fix: pin and serialize staging releases

This commit is contained in:
Jesse_Chen
2026-07-21 07:45:14 +08:00
parent 53d9aaf846
commit 2f08940717
14 changed files with 965 additions and 1661 deletions
+40 -4
View File
@@ -2,6 +2,18 @@ name: Staging Backend Quality Gate
on:
pull_request:
paths:
- '.github/workflows/backend-quality-gate.yml'
- '.github/workflows/deploy-staging.yml'
- '.github/workflows/migrate-staging-database.yml'
- 'deploy/**'
- 'frontend/**'
- 'jyotish_vedic/**'
- 'scripts/**'
- 'tests/**'
- 'mcp_server.py'
- 'pyproject.toml'
- 'requirements*.txt'
push:
branches: [staging]
workflow_dispatch:
@@ -60,10 +72,7 @@ jobs:
name: quick-quality-gate-diagnostics
path: artifacts/quick-quality-gate.log
- name: Run database tests
run: npm run test:db --prefix frontend
- name: Validate frontend
- name: Validate frontend and database contracts
env:
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder
@@ -109,6 +118,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish API image
id: api_build
uses: docker/build-push-action@v6
with:
context: .
@@ -117,6 +127,7 @@ jobs:
tags: ghcr.io/jesse-ux/jyotisha-api:${{ github.sha }}
- name: Build and publish web image
id: web_build
uses: docker/build-push-action@v6
with:
context: .
@@ -126,3 +137,28 @@ jobs:
build-args: |
NEXT_PUBLIC_SUPABASE_URL=${{ vars.STAGING_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ vars.STAGING_SUPABASE_ANON_KEY }}
- name: Record immutable staging image manifest
env:
API_DIGEST: ${{ steps.api_build.outputs.digest }}
WEB_DIGEST: ${{ steps.web_build.outputs.digest }}
run: |
set -euo pipefail
[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$API_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
[[ "$WEB_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
install -d -m 700 artifacts/staging-images
umask 077
printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' \
"$GITHUB_SHA" "$API_DIGEST" "$WEB_DIGEST" \
> artifacts/staging-images/manifest.env
node frontend/scripts/staging-image-manifest.mjs \
artifacts/staging-images/manifest.env "$GITHUB_SHA" >/dev/null
- name: Upload immutable staging image manifest
uses: actions/upload-artifact@v4
with:
name: staging-image-manifest-${{ github.sha }}-${{ github.run_attempt }}
path: artifacts/staging-images/manifest.env
if-no-files-found: error
retention-days: 30
+152 -145
View File
@@ -7,9 +7,14 @@ on:
workflow_dispatch:
inputs:
deploy_sha:
description: Exact 40-character commit SHA from a successful backend quality gate
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
permissions:
contents: read
@@ -17,7 +22,7 @@ permissions:
packages: read
concurrency:
group: staging
group: staging-mutation
cancel-in-progress: false
jobs:
@@ -37,44 +42,103 @@ jobs:
STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }}
steps:
- name: Validate tested revision
- name: Validate tested revision and gate run
id: revision
env:
REQUESTED_SHA: ${{ github.event.workflow_run.head_sha || inputs.deploy_sha }}
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
WORKFLOW_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
REQUESTED_ROLLBACK: ${{ inputs.allow_rollback || 'false' }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
test "${#REQUESTED_SHA}" -eq 40
case "$REQUESTED_SHA" in
*[!0-9a-fA-F]*) echo "deploy_sha must be a full hexadecimal commit SHA" >&2; exit 1 ;;
esac
DEPLOY_GIT_SHA="$(printf '%s' "$REQUESTED_SHA" | tr '[:upper:]' '[:lower:]')"
set -euo pipefail
[[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "deploy_sha must be a lowercase full commit SHA" >&2
exit 1
}
allow_rollback=false
if [ "$REQUESTED_ROLLBACK" = "true" ]; then
[ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] || {
echo "rollback authorization is manual-only" >&2
exit 1
}
allow_rollback=true
fi
gate_run_id="$WORKFLOW_RUN_ID"
gate_run_attempt="$WORKFLOW_RUN_ATTEMPT"
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
TESTED_RUNS="$(curl --fail --silent --show-error \
runs="$(curl --fail --silent --show-error \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/backend-quality-gate.yml/runs?head_sha=$DEPLOY_GIT_SHA&branch=staging&event=push&status=success&per_page=100")"
MATCHING_RUNS="$(printf '%s' "$TESTED_RUNS" | jq --arg sha "$DEPLOY_GIT_SHA" \
'[.workflow_runs[] | select(.head_sha == $sha and .head_branch == "staging" and .event == "push" and .conclusion == "success")] | length')"
test "$MATCHING_RUNS" -ge 1 || {
echo "No successful Staging Backend Quality Gate run found for exact SHA $DEPLOY_GIT_SHA" >&2
exit 1
}
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/backend-quality-gate.yml/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&per_page=100")"
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
[.workflow_runs[] | select(
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.run_attempt) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
gate_run_attempt="$(jq -er '.run_attempt' <<<"$selected_run")"
fi
echo "sha=$DEPLOY_GIT_SHA" >> "$GITHUB_OUTPUT"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || {
echo "no successful exact-SHA staging quality gate run found" >&2
exit 1
}
[[ "$gate_run_attempt" =~ ^[1-9][0-9]*$ ]] || {
echo "invalid staging quality gate run attempt" >&2
exit 1
}
staging_head="$(curl --fail --silent --show-error \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/staging" |
jq -er '.object.sha')"
if [ "$allow_rollback" = "false" ] && [ "$REQUESTED_SHA" != "$staging_head" ]; then
echo "stale staging revision refused; use explicit manual rollback only when intended" >&2
exit 1
fi
{
echo "sha=$REQUESTED_SHA"
echo "gate_run_id=$gate_run_id"
echo "gate_run_attempt=$gate_run_attempt"
echo "allow_rollback=$allow_rollback"
} >>"$GITHUB_OUTPUT"
- name: Checkout tested revision
uses: actions/checkout@v4
with:
ref: ${{ steps.revision.outputs.sha }}
persist-credentials: false
- name: Verify checked-out revision
- name: Download gate-produced image manifest
uses: actions/download-artifact@v4
with:
name: staging-image-manifest-${{ steps.revision.outputs.sha }}-${{ steps.revision.outputs.gate_run_attempt }}
path: artifacts/staging-image
github-token: ${{ github.token }}
run-id: ${{ steps.revision.outputs.gate_run_id }}
- name: Validate immutable image manifest
id: images
env:
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
run: test "$(git rev-parse HEAD)" = "$DEPLOY_GIT_SHA"
- name: Validate staging target configuration
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
node frontend/scripts/staging-image-manifest.mjs \
artifacts/staging-image/manifest.env "$DEPLOY_SHA" >>"$GITHUB_OUTPUT"
- name: Verify revision and staging target
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$DEPLOY_SHA"
test "$DEPLOY_HOST" = "118.26.111.127"
test "$DEPLOY_PORT" = "22"
test "$DEPLOY_USER" = "deploy"
@@ -86,156 +150,99 @@ jobs:
env:
SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
run: |
set -euo pipefail
test -n "$SSH_PRIVATE_KEY"
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-staging
printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging
chmod 600 ~/.ssh/jyotisha-staging
printf '%s\n' "$STAGING_KNOWN_HOSTS" > ~/.ssh/known_hosts
printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Record previous staging images
- name: Verify forward-only deployed revision
id: previous
env:
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
PREVIOUS_HEALTH_SHA="$(curl --fail --silent --show-error --max-time 10 "$STAGING_URL/api/health" 2>/dev/null | jq -r '.deployment.gitCommit // empty' || true)"
PREVIOUS_API_IMAGE="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"container_id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=api' | head -n 1); if [ -n \"\$container_id\" ]; then docker inspect --format '{{.Config.Image}}' \"\$container_id\"; fi")"
PREVIOUS_WEB_IMAGE="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"container_id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$container_id\" ]; then docker inspect --format '{{.Config.Image}}' \"\$container_id\"; fi")"
PREVIOUS_SHA="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"container_id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$container_id\" ]; then docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$container_id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; fi")"
if [ -z "$PREVIOUS_SHA" ] && [[ "$PREVIOUS_HEALTH_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
PREVIOUS_SHA="$PREVIOUS_HEALTH_SHA"
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
previous_sha="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then value=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"\${value:-not-deployed}\"; else printf not-deployed; fi; fi")"
if [ "$previous_sha" != "not-deployed" ] && [[ ! "$previous_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "invalid deployed staging revision state" >&2
exit 1
fi
if [ -z "$PREVIOUS_SHA" ] && [[ "$PREVIOUS_WEB_IMAGE" =~ ^ghcr\.io/jesse-ux/jyotisha-web:([0-9a-f]{40})$ ]]; then
PREVIOUS_SHA="${BASH_REMATCH[1]}"
forward_verified=true
if [ "$ALLOW_ROLLBACK" = "false" ] &&
[ "$previous_sha" != "not-deployed" ] &&
[ "$previous_sha" != "$DEPLOY_SHA" ]; then
comparison="$(curl --fail --silent --show-error \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")"
jq -e --arg base "$previous_sha" '
.status == "ahead" and .merge_base_commit.sha == $base
' <<<"$comparison" >/dev/null || {
echo "automatic staging rollback or divergent deploy refused" >&2
exit 1
}
fi
if [ -n "$PREVIOUS_SHA" ]; then
test "${#PREVIOUS_SHA}" -eq 40
case "$PREVIOUS_SHA" in
*[!0-9a-fA-F]*) echo "Previous staging SHA is unsafe" >&2; exit 1 ;;
esac
PREVIOUS_SHA="$(printf '%s' "$PREVIOUS_SHA" | tr '[:upper:]' '[:lower:]')"
fi
for image in "$PREVIOUS_API_IMAGE" "$PREVIOUS_WEB_IMAGE"; do
case "$image" in
"") ;;
*[!A-Za-z0-9._/@:-]*) echo "Previous staging image reference is unsafe" >&2; exit 1 ;;
esac
done
{
echo "api_image=$PREVIOUS_API_IMAGE"
echo "web_image=$PREVIOUS_WEB_IMAGE"
echo "previous_sha=$PREVIOUS_SHA"
} >> "$GITHUB_OUTPUT"
{
echo "### Staging deployment state"
echo "- Previous verified SHA: \`${PREVIOUS_SHA:-not-deployed}\`"
echo "- Target SHA: \`$DEPLOY_GIT_SHA\`"
echo "- Previous API image: \`${PREVIOUS_API_IMAGE:-not-deployed}\`"
echo "- Previous web image: \`${PREVIOUS_WEB_IMAGE:-not-deployed}\`"
} >> "$GITHUB_STEP_SUMMARY"
echo "sha=$previous_sha"
echo "forward_verified=$forward_verified"
} >>"$GITHUB_OUTPUT"
- name: Sync tested staging sources
- name: Stage tested sources in an isolated incoming directory
id: incoming
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
RSYNC_SSH="ssh $SSH_OPTIONS"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 755 '$DEPLOY_PATH'"
incoming="$DEPLOY_PATH/.incoming/$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$incoming'"
rsync -az --delete \
--exclude='.git/' \
--exclude='.env*' \
--exclude='frontend/node_modules/' \
--exclude='frontend/.next/' \
-e "$RSYNC_SSH" \
./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
--exclude='/.git/' --exclude='/.env*' --exclude='/backups/' \
--exclude='/frontend/node_modules/' --exclude='/frontend/.next/' \
-e "$RSYNC_SSH" ./ "$DEPLOY_USER@$DEPLOY_HOST:$incoming/"
echo "path=$incoming" >>"$GITHUB_OUTPUT"
- name: Validate staging configuration
env:
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && bash deploy/validate-staging-env.sh .env.staging && bash deploy/validate-staging-database-env.sh .env.staging.database && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml config --quiet"
- name: Log in to GHCR
- name: Log in to GHCR with run-local Docker state
env:
GHCR_TOKEN: ${{ github.token }}
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
printf '%s' "$GHCR_TOKEN" | ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin"
- name: Pull exact staging images
env:
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml pull api web postgres"
- name: Start and wait for staging PostgreSQL
env:
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml up -d --no-build --wait postgres"
- name: Check staging migrations
id: migration_check
env:
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
set +e
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml --profile migration-check run --rm migration-checker"
CHECK_STATUS=$?
set -e
if [ "$CHECK_STATUS" -eq 3 ]; then
echo "Run the Migrate Staging Database workflow manually with exact SHA $DEPLOY_GIT_SHA; no API, web, or Caddy container was changed." >&2
exit 3
fi
if [ "$CHECK_STATUS" -ne 0 ]; then
echo "Staging migration check failed safely for exact SHA $DEPLOY_GIT_SHA" >&2
exit "$CHECK_STATUS"
fi
- name: Deploy exact staging images
env:
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml up -d --no-build --remove-orphans"
- name: Verify staging
env:
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
run: |
curl --fail --silent --show-error --retry 12 --retry-delay 5 "$STAGING_URL/login" >/dev/null
test "$(curl --silent --output /dev/null --write-out '%{http_code}' "$STAGING_URL/api/account")" = "401"
test "$(curl --fail --silent --show-error "$STAGING_URL/api/health" | jq -r '.deployment.gitCommit')" = "$DEPLOY_GIT_SHA"
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='ghcr.io/jesse-ux/jyotisha-api:$DEPLOY_GIT_SHA' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_GIT_SHA' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.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 "- Verified deployed SHA: \`$DEPLOY_GIT_SHA\`" >> "$GITHUB_STEP_SUMMARY"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$INCOMING_PATH/.docker'"
printf '%s' "$GHCR_TOKEN" | ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"DOCKER_CONFIG='$INCOMING_PATH/.docker' docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin"
- name: Roll back staging images
if: failure() && steps.migration_check.outcome == 'success' && steps.previous.outputs.api_image != '' && steps.previous.outputs.web_image != '' && steps.previous.outputs.previous_sha != ''
- name: Deploy and verify exact image digests under host lock
env:
PREVIOUS_API_IMAGE: ${{ steps.previous.outputs.api_image }}
PREVIOUS_WEB_IMAGE: ${{ steps.previous.outputs.web_image }}
PREVIOUS_SHA: ${{ steps.previous.outputs.previous_sha }}
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 }}
EXPECTED_PREVIOUS_SHA: ${{ steps.previous.outputs.sha }}
FORWARD_REVISION_VERIFIED: ${{ steps.previous.outputs.forward_verified }}
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' DATABASE_ENV_FILE='../.env.staging.database' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' API_IMAGE='$PREVIOUS_API_IMAGE' WEB_IMAGE='$PREVIOUS_WEB_IMAGE' GITHUB_SHA='$PREVIOUS_SHA' docker compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml up -d --no-build --remove-orphans api web caddy"
"INCOMING_PATH='$INCOMING_PATH' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$EXPECTED_PREVIOUS_SHA' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$FORWARD_REVISION_VERIFIED' DOCKER_CONFIG='$INCOMING_PATH/.docker' STAGING_URL='$STAGING_URL' bash '$INCOMING_PATH/deploy/run-staging-deploy.sh'" |
tee staging-deploy-result.txt
sed 's/^/- /' staging-deploy-result.txt >>"$GITHUB_STEP_SUMMARY"
- name: Log out of GHCR
if: always()
- name: Remove run-local staging files
if: always() && steps.incoming.outputs.path != ''
continue-on-error: true
env:
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "docker logout ghcr.io >/dev/null 2>&1 || true"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"DOCKER_CONFIG='$INCOMING_PATH/.docker' docker logout ghcr.io >/dev/null 2>&1 || true; rm -rf -- '$INCOMING_PATH'"
+127 -88
View File
@@ -4,12 +4,12 @@ on:
workflow_dispatch:
inputs:
deploy_sha:
description: Full tested commit SHA to migrate
description: Full tested staging commit SHA to migrate
required: true
type: string
concurrency:
group: staging-database-migration
group: staging-mutation
cancel-in-progress: false
permissions:
@@ -30,7 +30,7 @@ jobs:
STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }}
steps:
- name: Validate tested revision
- name: Validate current tested staging revision
id: revision
env:
REQUESTED_SHA: ${{ inputs.deploy_sha }}
@@ -38,45 +38,70 @@ jobs:
shell: bash
run: |
set -euo pipefail
if [[ ! "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "deploy_sha must be a lowercase full 40-character commit SHA" >&2
[[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "deploy_sha must be a lowercase full commit SHA" >&2
exit 1
fi
TESTED_RUNS="$(curl --fail --silent --show-error \
}
runs="$(curl --fail --silent --show-error \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/backend-quality-gate.yml/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&per_page=100")"
if ! jq -e --arg sha "$REQUESTED_SHA" '
(.workflow_runs | type == "array") and
any(.workflow_runs[];
.head_sha == $sha and
.head_branch == "staging" and
.event == "push" and
.conclusion == "success"
)
' <<< "$TESTED_RUNS" >/dev/null; then
echo "No successful Staging Backend Quality Gate run found for exact SHA $REQUESTED_SHA on staging" >&2
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
[.workflow_runs[] | select(
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.run_attempt) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
gate_run_attempt="$(jq -er '.run_attempt' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]]
[[ "$gate_run_attempt" =~ ^[1-9][0-9]*$ ]]
staging_head="$(curl --fail --silent --show-error \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/staging" |
jq -er '.object.sha')"
[ "$REQUESTED_SHA" = "$staging_head" ] || {
echo "stale staging migration refused; migrate the current staging head" >&2
exit 1
fi
printf 'deploy_sha=%s\n' "$REQUESTED_SHA" >> "$GITHUB_OUTPUT"
}
{
echo "sha=$REQUESTED_SHA"
echo "gate_run_id=$gate_run_id"
echo "gate_run_attempt=$gate_run_attempt"
} >>"$GITHUB_OUTPUT"
- name: Checkout tested revision
uses: actions/checkout@v4
with:
ref: ${{ steps.revision.outputs.deploy_sha }}
ref: ${{ steps.revision.outputs.sha }}
persist-credentials: false
- name: Verify checked-out revision
- name: Download gate-produced image manifest
uses: actions/download-artifact@v4
with:
name: staging-image-manifest-${{ steps.revision.outputs.sha }}-${{ steps.revision.outputs.gate_run_attempt }}
path: artifacts/staging-image
github-token: ${{ github.token }}
run-id: ${{ steps.revision.outputs.gate_run_id }}
- name: Validate immutable migration image
id: images
env:
DEPLOY_SHA: ${{ steps.revision.outputs.deploy_sha }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
node frontend/scripts/staging-image-manifest.mjs \
artifacts/staging-image/manifest.env "$DEPLOY_SHA" >>"$GITHUB_OUTPUT"
- name: Verify revision and staging target
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$DEPLOY_SHA"
- name: Validate staging target configuration
run: |
set -euo pipefail
test "$DEPLOY_HOST" = "118.26.111.127"
test "$DEPLOY_PORT" = "22"
test "$DEPLOY_USER" = "deploy"
@@ -90,100 +115,114 @@ jobs:
set -euo pipefail
test -n "$SSH_PRIVATE_KEY"
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-staging
printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging
chmod 600 ~/.ssh/jyotisha-staging
printf '%s\n' "$STAGING_KNOWN_HOSTS" > ~/.ssh/known_hosts
printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Sync tested staging sources
- name: Verify forward-only migration revision
id: previous
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
previous_sha="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then value=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"\${value:-not-deployed}\"; else printf not-deployed; fi; fi")"
if [ "$previous_sha" != "not-deployed" ] && [[ ! "$previous_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "invalid deployed staging revision state" >&2
exit 1
fi
if [ "$previous_sha" != "not-deployed" ] && [ "$previous_sha" != "$DEPLOY_SHA" ]; then
comparison="$(curl --fail --silent --show-error \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")"
jq -e --arg base "$previous_sha" '
.status == "ahead" and .merge_base_commit.sha == $base
' <<<"$comparison" >/dev/null || {
echo "stale or divergent staging migration refused" >&2
exit 1
}
fi
{
echo "sha=$previous_sha"
echo "forward_verified=true"
} >>"$GITHUB_OUTPUT"
- name: Stage tested sources in an isolated incoming directory
id: incoming
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
RSYNC_SSH="ssh $SSH_OPTIONS"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 755 '$DEPLOY_PATH'"
incoming="$DEPLOY_PATH/.incoming/$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$incoming'"
rsync -az --delete \
--exclude='.git/' \
--exclude='.env*' \
--exclude='frontend/node_modules/' \
--exclude='frontend/.next/' \
-e "$RSYNC_SSH" \
./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
--exclude='/.git/' --exclude='/.env*' --exclude='/backups/' \
--exclude='/frontend/node_modules/' --exclude='/frontend/.next/' \
-e "$RSYNC_SSH" ./ "$DEPLOY_USER@$DEPLOY_HOST:$incoming/"
echo "path=$incoming" >>"$GITHUB_OUTPUT"
- name: Validate staging environment files
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && bash deploy/validate-staging-env.sh .env.staging staging.jyotisha.chat deploy/Caddyfile.staging && bash deploy/validate-staging-database-env.sh .env.staging.database"
- name: Log in to GHCR
- name: Log in to GHCR with run-local Docker state
env:
GHCR_TOKEN: ${{ github.token }}
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
run: |
set -euo pipefail
test -n "$GHCR_TOKEN"
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
printf '%s' "$GHCR_TOKEN" | ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin"
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$INCOMING_PATH/.docker'"
printf '%s' "$GHCR_TOKEN" | ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"DOCKER_CONFIG='$INCOMING_PATH/.docker' docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin"
- name: Pull exact migration image
- name: Apply exact-image migrations under host lock
env:
DEPLOY_SHA: ${{ steps.revision.outputs.deploy_sha }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
WEB_IMAGE: ${{ steps.images.outputs.web_image }}
EXPECTED_PREVIOUS_SHA: ${{ steps.previous.outputs.sha }}
FORWARD_REVISION_VERIFIED: ${{ steps.previous.outputs.forward_verified }}
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"docker pull 'ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_SHA'"
"INCOMING_PATH='$INCOMING_PATH' DEPLOY_PATH='$DEPLOY_PATH' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$EXPECTED_PREVIOUS_SHA' FORWARD_REVISION_VERIFIED='$FORWARD_REVISION_VERIFIED' DOCKER_CONFIG='$INCOMING_PATH/.docker' bash '$INCOMING_PATH/deploy/run-staging-migration.sh'"
- name: Start and wait for staging PostgreSQL
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && DATABASE_ENV_FILE='../.env.staging.database' docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml up -d --wait postgres"
- name: Apply reviewed staging migrations
- name: Dispatch current exact-SHA staging deployment
env:
DEPLOY_SHA: ${{ steps.revision.outputs.deploy_sha }}
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && DATABASE_ENV_FILE='../.env.staging.database' WEB_IMAGE='ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_SHA' docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml --profile migration run --rm migrator"
- name: Print ordered migration ledger
run: |
set -euo pipefail
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '$DEPLOY_PATH' && DATABASE_ENV_FILE='../.env.staging.database' docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml exec -T postgres psql -U postgres -d jyotisha -Atc 'select filename from migration.schema_migrations order by filename'"
- name: Dispatch exact-SHA staging deployment
env:
DEPLOY_SHA: ${{ steps.revision.outputs.deploy_sha }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
if [[ ! "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "validated deploy SHA is unsafe" >&2
staging_head="$(curl --fail --silent --show-error \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/staging" |
jq -er '.object.sha')"
[ "$DEPLOY_SHA" = "$staging_head" ] || {
echo "staging advanced during migration; refusing stale deployment dispatch" >&2
exit 1
fi
PAYLOAD_FILE="$(mktemp)"
trap 'rm -f "$PAYLOAD_FILE"' EXIT
jq -n --arg deploy_sha "$DEPLOY_SHA" \
'{ref: "staging", inputs: {deploy_sha: $deploy_sha}}' > "$PAYLOAD_FILE"
curl --fail --silent --show-error \
--request POST \
}
payload="$(jq -cn --arg deploy_sha "$DEPLOY_SHA" \
'{ref:"main",inputs:{deploy_sha:$deploy_sha,allow_rollback:"false"}}')"
curl --fail --silent --show-error --request POST \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--header "Content-Type: application/json" \
--data-binary "@$PAYLOAD_FILE" \
--data "$payload" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/deploy-staging.yml/dispatches"
- name: Log out of GHCR
if: always()
- name: Remove run-local staging files
if: always() && steps.incoming.outputs.path != ''
continue-on-error: true
env:
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
run: |
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "docker logout ghcr.io >/dev/null 2>&1 || true"
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
"DOCKER_CONFIG='$INCOMING_PATH/.docker' docker logout ghcr.io >/dev/null 2>&1 || true; rm -rf -- '$INCOMING_PATH'"
+7 -7
View File
@@ -149,7 +149,7 @@ The GitHub `staging` Environment contains the secret `STAGING_SSH_PRIVATE_KEY` a
The repository-level public build inputs are configured at GitHub **Settings -> Secrets and variables -> Actions -> Variables** (the UI is also shown as **Settings → Secrets and variables → Actions → Variables**): `STAGING_SUPABASE_URL` and `STAGING_SUPABASE_ANON_KEY`. They are public build inputs, required for publish, and exposed to the browser; keep them staging-only and never print their values in workflow output, summaries, or support messages. The workflow passes them only as the `NEXT_PUBLIC_*` build arguments after non-empty/HTTPS validation.
`Staging Backend Quality Gate` runs for `pull_request`, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` can publish immutable full-SHA GHCR images. `.github/workflows/deploy-staging.yml` consumes the successful gate's exact SHA, and its manual `deploy_sha` input must identify a full 40-character commit with a successful `staging` gate run.
`Staging Backend Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound manifest containing their `sha256` digests. `.github/workflows/deploy-staging.yml` consumes that exact successful run, validates its manifest against the full 40-character commit, and deploys digest references rather than trusting the discoverability tags.
The staging env file must include these non-secret selectors so Compose cannot fall back to production paths:
@@ -165,13 +165,13 @@ After source sync and before `up`, the workflow validates `.env.staging` mode/se
1. Complete the server and GitHub bootstrap: create both mode-`0600` env files, configure the staging Environment variables/secrets, and configure the repository staging build variables.
2. Merge the reviewed change, then push the reviewed SHA to `staging`; do not rely on a `main` workflow dispatch to publish images.
3. The `Staging Backend Quality Gate` runs for that push and, when successful, publishes the SHA-tagged API/web images for that exact 40-character commit SHA.
4. The automatic `Deploy staging` workflow starts from that successful gate, syncs the exact SHA, and validates both `.env.staging` and `.env.staging.database` before any app change.
3. The `Staging Backend Quality Gate` runs for that push and, when successful, publishes API/web images plus an artifact binding the exact SHA to both immutable image digests.
4. The automatic `Deploy staging` workflow downloads that gate-run artifact, syncs the exact revision under the shared staging host lock, and validates both `.env.staging` and `.env.staging.database` before any app change.
5. If environment validation fails, fix the server-side env files without committing or copying secrets, then manually rerun `Deploy staging` from `main` with the same successful SHA in `deploy_sha`; the workflow rechecks a successful staging gate for that exact SHA.
6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually with the same full SHA; a successful migration re-dispatches `Deploy staging` with that same SHA.
7. Confirm `https://staging.jyotisha.chat/api/health` reports the exact SHA and private API health.
Application rollback uses the same workflow: manually dispatch `Deploy staging` from `main` with a previous known-good full SHA that has a successful `Staging Backend Quality Gate` run. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal.
Application rollback uses the same workflow: manually dispatch `Deploy staging` from the `main` controller with a previous known-good full SHA that has a successful `Staging Backend Quality Gate` run, and explicitly set `allow_rollback=true`. Normal and migration-triggered deployments reject stale, divergent, or backward revisions. Rollback still consumes the selected gate run's digest manifest; database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal.
Inspect staging without printing secrets:
@@ -229,14 +229,14 @@ PostgreSQL is private: `deploy/docker-compose.postgres.yml` has no `ports` mappi
Use this order for every staging revision:
1. Merge to `staging` after reviewing the change.
2. Wait for `Staging Backend Quality Gate` to pass and for that exact full SHA's API/web images to be published.
2. Wait for `Staging Backend Quality Gate` to pass and publish that exact full SHA's API/web digest manifest.
3. The automatic `Deploy staging` workflow checks the exact SHA in read-only migration-check mode before changing API, web, or Caddy. If it reports pending or drifted migrations, stop; do not retry the application deployment as if it were a migration.
4. Open **Migrate Staging Database -> Run workflow** and enter the reported full lowercase 40-character SHA in `deploy_sha`. The workflow validates that exact SHA against a successful `staging` gate, checks it out, starts only PostgreSQL, and runs the reviewed migrator.
5. A successful migration prints the ordered migration ledger and re-dispatches `Deploy staging` automatically with the same exact SHA. Do not substitute a branch name, a short SHA, or a newer commit.
5. A successful migration rechecks that `staging` still points at the same exact SHA, prints the ordered migration ledger, and dispatches the `main` controller for digest-pinned deployment with `allow_rollback=false`. If `staging` advanced during migration, it refuses the stale dispatch. Do not substitute a branch name, a short SHA, or a newer commit.
6. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2.
7. After health verification, create the local encrypted backup described below.
The read-only checker exits before app changes when a migration is pending. Its message includes the exact SHA and the `Migrate Staging Database` workflow name. A failed migration does not re-dispatch deployment. Application rollback restores a previously verified image/SHA only; it does not roll back database state.
The deploy and migration workflows share the `staging-mutation` Actions concurrency group, and their live-tree sync plus Compose work runs under `/opt/jyotisha-staging/.state/mutation.lock`. The synchronized tree explicitly preserves `/backups/`, `.env*`, `.state`, and `.incoming`. The read-only checker exits before app changes when a migration is pending. Its message includes the exact SHA and the `Migrate Staging Database` workflow name. A failed migration does not re-dispatch deployment. Application rollback restores previously recorded digest references and SHA only; it does not roll back database state.
### Local encrypted staging backups (three-copy limit)
+203
View File
@@ -0,0 +1,203 @@
#!/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 STAGING_URL
)
for key in "${required[@]}"; do
if [ -z "${!key:-}" ]; then
echo "required staging deployment input is missing: $key" >&2
exit 1
fi
done
sha_pattern='^[0-9a-f]{40}$'
digest_pattern='^ghcr\.io/jesse-ux/jyotisha-(api|web)@sha256:[0-9a-f]{64}$'
if [[ ! "$DEPLOY_SHA" =~ $sha_pattern ]] ||
[[ ! "$API_IMAGE" =~ $digest_pattern ]] ||
[[ ! "$WEB_IMAGE" =~ $digest_pattern ]]; then
echo "unsafe staging image identity" >&2
exit 1
fi
if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then
echo "invalid rollback authorization" >&2
exit 1
fi
case "$INCOMING_PATH" in
"$DEPLOY_PATH"/.incoming/*) ;;
*) echo "unsafe incoming staging 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 staging 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 ps -aq \
--filter 'label=com.docker.compose.project=jyotisha-staging' \
--filter 'label=com.docker.compose.service=web' | head -n 1)"
if [ -n "$existing_web" ]; then
discovered_sha="$(docker 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 staging revision state" >&2
exit 1
fi
if [ "$current_sha" != "$EXPECTED_PREVIOUS_SHA" ]; then
echo "staging 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 staging revision was not verified" >&2
exit 1
fi
container_id() {
docker ps -aq \
--filter 'label=com.docker.compose.project=jyotisha-staging' \
--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 inspect --format '{{.Image}}' "$id")"
docker 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 ghcr.io/jesse-ux/jyotisha-api)"
previous_web_image="$(repo_digest_for_container web ghcr.io/jesse-ux/jyotisha-web)"
previous_api_id=""
previous_web_id=""
if [ -n "$(container_id api)" ]; then
previous_api_id="$(docker inspect --format '{{.Image}}' "$(container_id api)")"
fi
if [ -n "$(container_id web)" ]; then
previous_web_id="$(docker inspect --format '{{.Image}}' "$(container_id web)")"
fi
bash "$INCOMING_PATH/deploy/sync-staging-tree.sh" \
"$INCOMING_PATH" "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
bash deploy/validate-staging-env.sh \
.env.staging staging.jyotisha.chat deploy/Caddyfile.staging
bash deploy/validate-staging-database-env.sh .env.staging.database
compose=(
docker compose -p jyotisha-staging --env-file .env.staging
-f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml
)
export APP_ENV_FILE='../.env.staging'
export DATABASE_ENV_FILE='../.env.staging.database'
export CADDYFILE_PATH='./Caddyfile.staging'
export SITE_ADDRESS='https://staging.jyotisha.chat'
export GITHUB_SHA="$DEPLOY_SHA"
"${compose[@]}" config --quiet
"${compose[@]}" pull api web postgres
"${compose[@]}" up -d --no-build --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 Staging Database for $DEPLOY_SHA" >&2
exit 3
fi
if [ "$check_status" -ne 0 ]; then
echo "staging migration check failed safely" >&2
exit "$check_status"
fi
switched=false
rollback() {
local status=$?
if [ "$switched" = "true" ] &&
[[ "$previous_api_image" =~ $digest_pattern ]] &&
[[ "$previous_web_image" =~ $digest_pattern ]] &&
[[ "$current_sha" =~ $sha_pattern ]]; then
echo "staging verification failed; restoring prior image digests" >&2
API_IMAGE="$previous_api_image" WEB_IMAGE="$previous_web_image" \
GITHUB_SHA="$current_sha" \
"${compose[@]}" up -d --no-build --remove-orphans api web caddy || true
fi
exit "$status"
}
trap rollback ERR
"${compose[@]}" up -d --no-build --remove-orphans
switched=true
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 image inspect --format '{{.Id}}' "$expected_ref")"
running_id="$(docker inspect --format '{{.Image}}' "$id")"
[ "$running_id" = "$expected_id" ]
repo_digests="$(docker 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 STAGING_URL="$STAGING_URL" \
web node --input-type=module <<'NODE'
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let login;
for (let attempt = 0; attempt < 12; attempt += 1) {
try {
login = await fetch(`${process.env.STAGING_URL}/login`);
if (login.ok) break;
} catch {}
await delay(5_000);
}
if (!login?.ok) process.exit(1);
const account = await fetch(`${process.env.STAGING_URL}/api/account`);
if (account.status !== 401) process.exit(1);
const publicHealth = await fetch(`${process.env.STAGING_URL}/api/health`);
const publicBody = await publicHealth.json();
if (!publicHealth.ok || publicBody.deployment?.gitCommit !== process.env.EXPECTED_SHA) {
process.exit(1);
}
const privateHealth = await fetch("http://api:5200/api/health");
const privateBody = await privateHealth.json();
if (!privateHealth.ok || privateBody.status !== "ok" || privateBody.swisseph_available !== true) {
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"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
set +x
required=(
INCOMING_PATH DEPLOY_PATH WEB_IMAGE DEPLOY_SHA EXPECTED_PREVIOUS_SHA
DOCKER_CONFIG
)
for key in "${required[@]}"; do
if [ -z "${!key:-}" ]; then
echo "required staging migration input is missing: $key" >&2
exit 1
fi
done
[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "unsafe staging migration revision" >&2
exit 1
}
[[ "$WEB_IMAGE" =~ ^ghcr\.io/jesse-ux/jyotisha-web@sha256:[0-9a-f]{64}$ ]] || {
echo "unsafe staging migration image" >&2
exit 1
}
case "$INCOMING_PATH" in
"$DEPLOY_PATH"/.incoming/*) ;;
*) echo "unsafe incoming staging 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 staging 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 ps -aq \
--filter 'label=com.docker.compose.project=jyotisha-staging' \
--filter 'label=com.docker.compose.service=web' | head -n 1)"
if [ -n "$existing_web" ]; then
discovered_sha="$(docker 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 staging revision state" >&2
exit 1
fi
[ "$current_sha" = "$EXPECTED_PREVIOUS_SHA" ] || {
echo "staging 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 staging revision was not verified" >&2
exit 1
}
bash "$INCOMING_PATH/deploy/sync-staging-tree.sh" \
"$INCOMING_PATH" "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
bash deploy/validate-staging-env.sh \
.env.staging staging.jyotisha.chat deploy/Caddyfile.staging
bash deploy/validate-staging-database-env.sh .env.staging.database
export DATABASE_ENV_FILE='../.env.staging.database'
compose=(docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml)
docker pull "$WEB_IMAGE"
"${compose[@]}" up -d --wait postgres
"${compose[@]}" --profile migration run --rm migrator
"${compose[@]}" exec -T postgres psql -U postgres -d jyotisha -Atc \
'select filename from migration.schema_migrations order by filename'
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 2 ] || [ ! -d "$1" ] || [ ! -d "$2" ]; then
echo "usage: sync-staging-tree.sh SOURCE_DIRECTORY DESTINATION_DIRECTORY" >&2
exit 1
fi
rsync -az --delete \
--exclude='/.git/' \
--exclude='/.env*' \
--exclude='/backups/' \
--exclude='/.state/' \
--exclude='/.incoming/' \
--exclude='/frontend/node_modules/' \
--exclude='/frontend/.next/' \
"$1/" "$2/"
@@ -4,7 +4,7 @@
**Goal:** Add the self-hosted PostgreSQL staging foundation, least-privilege roles, reviewed SQL migration runner, automatic backend quality gate, immutable GHCR images, and exact-SHA staging deploy path without moving authentication or business traffic off Supabase.
**Architecture:** PostgreSQL 17 runs on the private Compose network of the Hong Kong staging VPS. A separate deployment-user-owned database env file supplies bootstrap and schema credentials only to PostgreSQL and an opt-in migrator; normal web/API containers never receive them. Reviewed plain SQL is the schema source of truth, while `pg` and Drizzle provide the future runtime seam. PRs and `staging` pushes run database/backend/frontend/configuration tests; successful `staging` pushes publish SHA-tagged web/API images, and staging pulls that exact SHA. Migrations remain a separate manual workflow.
**Architecture:** PostgreSQL 17 runs on the private Compose network of the Hong Kong staging VPS. A separate deployment-user-owned database env file supplies bootstrap and schema credentials only to PostgreSQL and an opt-in migrator; normal web/API containers never receive them. Reviewed plain SQL is the schema source of truth, while `pg` and Drizzle provide the future runtime seam. PRs and `staging` pushes run database/backend/frontend/configuration tests; successful `staging` pushes publish web/API images and a run-bound SHA-to-digest manifest, and staging deploys only those exact digests. Migrations remain a separate manual workflow.
**Tech Stack:** PostgreSQL 17 Alpine, Docker Compose, Node.js 22, Next.js 16, TypeScript, `pg`, Drizzle ORM, Python 3.12, GitHub Actions, GHCR, Bash, OpenSSL.
@@ -20,7 +20,9 @@
- App deployment never runs schema migration. Migration is manual and separately serialized.
- Before changing app containers, staging deploy runs the exact SHA image in read-only migration-check mode. No pending migration means automatic continuation. Pending or checksum-drifted migration stops before app changes; a successful manual migration dispatches staging deploy again for the same full SHA.
- Production defaults remain manual-only and unchanged.
- Staging image tags are immutable full Git SHAs; never deploy `latest`.
- Staging publication uses full Git SHA tags for discovery, but deployment is
authorized and pinned by the build outputs' `sha256` manifest digests. Never
deploy a mutable tag such as `latest`, or treat a tag alone as image identity.
- Finish each task with the focused commit shown.
## Planned Files
@@ -742,7 +744,7 @@ Assert:
- It never invokes the applying `db:migrate` command, `migrator` service, or `--profile migration`.
- It runs the exact web image through `migration-checker`/`db:migrate:check` before changing any app container.
- Pending migrations stop before `api`, `web`, or `caddy` changes and print the manual workflow name plus exact SHA.
- Rollback uses recorded prior image names.
- Rollback uses recorded prior digest references, image IDs, and SHA.
- [ ] **Step 2: Confirm red**
@@ -776,15 +778,17 @@ APP_ENV_FILE=../.env.staging
DATABASE_ENV_FILE=../.env.staging.database
CADDYFILE_PATH=./Caddyfile.staging
SITE_ADDRESS=staging.jyotisha.chat
API_IMAGE=ghcr.io/jesse-ux/jyotisha-api:<full-sha>
WEB_IMAGE=ghcr.io/jesse-ux/jyotisha-web:<full-sha>
API_IMAGE=ghcr.io/jesse-ux/jyotisha-api@sha256:<manifest-digest>
WEB_IMAGE=ghcr.io/jesse-ux/jyotisha-web@sha256:<manifest-digest>
```
- Validate both env files and Compose config.
- Send GHCR token through `docker login --password-stdin`; never save it in either env file.
- Pull `api web postgres`, start/wait for PostgreSQL, then run the exact web image through `--profile migration-check run --rm migration-checker`.
- Continue to `up -d --no-build --remove-orphans` only after check exit `0`; treat exit `3` as a safe stop with no application changes.
- Record prior container image names before switching. Roll back with those exact image names and `--no-build`.
- Download and validate the successful gate run's SHA-to-digest manifest. Record
prior container digest references, image IDs, and SHA before switching. Roll
back with those exact digest references and `--no-build`.
- Log out in an always-running cleanup step.
- Never run migrations.
@@ -845,7 +849,7 @@ on:
required: true
type: string
concurrency:
group: staging-database-migration
group: staging-mutation
cancel-in-progress: false
permissions:
contents: read
@@ -883,7 +887,13 @@ docker compose -p jyotisha-staging \
'select filename from migration.schema_migrations order by filename'
```
Authenticate GHCR through stdin and log out in cleanup. Do not start/restart app services. After migration and ledger reporting succeed, call the GitHub workflow-dispatch API for `deploy-staging.yml` with `ref: staging` and `inputs.deploy_sha` equal to the validated full SHA.
Authenticate GHCR through stdin using run-local Docker state and remove it in
cleanup. Do not start/restart app services. Deployment and migration share the
`staging-mutation` concurrency group and the host mutation lock. After migration
and ledger reporting succeed, recheck that `staging` still points at the validated
SHA, then call the GitHub workflow-dispatch API for `deploy-staging.yml` with the
`main` controller ref, `inputs.deploy_sha` equal to that full SHA, and
`inputs.allow_rollback` set to `false`.
- [ ] **Step 4: Verify and commit**
@@ -946,7 +956,7 @@ Each secret uses independently generated 32 random bytes. URL password is percen
Document order:
1. Merge to `staging`.
2. Wait for backend quality gate and SHA images.
2. Wait for backend quality gate and its exact-SHA image digest manifest.
3. If automatic deploy reports pending migrations, manually run `Migrate Staging Database` with the reported full SHA.
4. The successful migration workflow re-dispatches exact-SHA staging deploy automatically.
5. Check `https://staging.jyotisha.chat/api/health`.
@@ -166,7 +166,7 @@ Sustained swap use, database saturation, disk above 70%, or unacceptable request
## Build and deployment
The VPS must not compile large application images while PostgreSQL is serving tests. GitHub Actions builds immutable web/API images, pushes SHA-addressed images to GitHub Container Registry, and the VPS only pulls and restarts them.
The VPS must not compile large application images while PostgreSQL is serving tests. GitHub Actions builds web/API images, publishes SHA tags only for discovery, and records the build outputs' immutable manifest digests in an artifact bound to the successful quality-gate run. The VPS validates that artifact and pulls digest references only.
Application deployment and database migration remain different operations:
@@ -182,7 +182,7 @@ touching the running application. After the operator runs the manual migration
workflow successfully, that workflow dispatches staging deployment again for the
same full SHA. The check may read the migration ledger but may never apply SQL.
Deployment records the previous application SHA and image digests. Public and private health checks must pass before a deployment is marked successful. Application rollback does not claim to roll back database state.
Deployment and migration share one Actions concurrency group and one host-side lock covering live-tree synchronization through their final database/application verification. Deployment records the previous application SHA, image digests, and image IDs. It rejects stale or backward automatic revisions, verifies running container image IDs/RepoDigests plus the application-reported SHA, and requires public and private health checks before updating deployed-revision state. An older application revision requires an explicit manual rollback authorization; application rollback does not claim to roll back database state.
## Automatic backend quality gate
+1 -1
View File
@@ -9,7 +9,7 @@
"start": "next start",
"test": "tsx --test tests/*.test.ts",
"test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts",
"test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts",
"test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts tests/staging-image-manifest.test.ts",
"db:migrate": "node scripts/db-migrate.mjs",
"db:migrate:check": "node scripts/db-migrate.mjs --check",
"lint": "eslint",
@@ -0,0 +1,74 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const shaPattern = /^[0-9a-f]{40}$/;
const digestPattern = /^sha256:[0-9a-f]{64}$/;
const expectedKeys = ["git_sha", "api_digest", "web_digest"];
export function parseStagingImageManifest(text, expectedSha) {
if (!shaPattern.test(expectedSha)) {
throw new Error("invalid expected staging revision");
}
const lines = text.endsWith("\n") ? text.slice(0, -1).split("\n") : text.split("\n");
if (lines.length !== expectedKeys.length) {
throw new Error("invalid staging image manifest");
}
const values = new Map();
for (const line of lines) {
const separator = line.indexOf("=");
if (separator <= 0) throw new Error("invalid staging image manifest");
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (!expectedKeys.includes(key) || values.has(key)) {
throw new Error("invalid staging image manifest");
}
values.set(key, value);
}
if (values.get("git_sha") !== expectedSha) {
throw new Error("staging image manifest revision mismatch");
}
for (const key of ["api_digest", "web_digest"]) {
if (!digestPattern.test(values.get(key) ?? "")) {
throw new Error("invalid staging image digest");
}
}
return {
gitSha: expectedSha,
apiDigest: values.get("api_digest"),
webDigest: values.get("web_digest"),
apiImage: `ghcr.io/jesse-ux/jyotisha-api@${values.get("api_digest")}`,
webImage: `ghcr.io/jesse-ux/jyotisha-web@${values.get("web_digest")}`,
};
}
const invokedPath = process.argv[1]
? pathToFileURL(resolve(process.argv[1])).href
: undefined;
if (invokedPath === import.meta.url) {
try {
const [manifestPath, expectedSha] = process.argv.slice(2);
if (!manifestPath || !expectedSha) {
throw new Error("manifest path and expected revision are required");
}
const manifest = parseStagingImageManifest(
await readFile(manifestPath, "utf8"),
expectedSha,
);
process.stdout.write(
[
`git_sha=${manifest.gitSha}`,
`api_image=${manifest.apiImage}`,
`web_image=${manifest.webImage}`,
].join("\n") + "\n",
);
} catch {
console.error("invalid staging image manifest");
process.exitCode = 1;
}
}
+6 -74
View File
@@ -1,8 +1,6 @@
import assert from "node:assert/strict";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
@@ -145,8 +143,7 @@ test("staging deploy consumes only the isolated staging environment and tested r
assert.match(workflow, /packages: read/);
assert.match(workflow, /environment:\s*\n\s*name: staging/);
assert.match(workflow, /deploy_sha:/);
assert.doesNotMatch(workflow, /default: staging/);
assert.match(workflow, /test "\$\{#REQUESTED_SHA\}" -eq 40/);
assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/);
assert.match(
workflow,
/actions\/workflows\/backend-quality-gate\.yml\/runs\?head_sha=/,
@@ -157,80 +154,15 @@ test("staging deploy consumes only the isolated staging environment and tested r
assert.match(workflow, /test "\$DEPLOY_HOST" = "118\.26\.111\.127"/);
assert.match(workflow, /test "\$DEPLOY_USER" = "deploy"/);
assert.match(workflow, /test "\$DEPLOY_PATH" = "\/opt\/jyotisha-staging"/);
assert.match(workflow, /--exclude='\.env\*'/);
assert.match(
workflow,
/docker compose -p jyotisha-staging --env-file \.env\.staging/,
);
assert.match(
workflow,
/bash deploy\/validate-staging-env\.sh \.env\.staging/,
);
assert.match(
workflow,
/bash deploy\/validate-staging-database-env\.sh \.env\.staging\.database/,
);
assert.match(workflow, /-f deploy\/docker-compose\.server\.yml/);
assert.match(workflow, /-f deploy\/docker-compose\.postgres\.yml/);
assert.match(workflow, /deployment\.gitCommit/);
assert.match(workflow, /--exclude='\/\.env\*'/);
assert.match(workflow, /--exclude='\/backups\/'/);
assert.match(workflow, /run-staging-deploy\.sh/);
assert.match(workflow, /steps\.images\.outputs\.api_image/);
assert.match(workflow, /steps\.images\.outputs\.web_image/);
assert.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/);
assert.doesNotMatch(workflow, /103\.117\.123\.53/);
});
test("staging rsync preserves every destination env variant during delete", () => {
const workflow = readFileSync(
new URL("../../.github/workflows/deploy-staging.yml", import.meta.url),
"utf8",
);
const envExclusion = workflow.match(/--exclude='([^']*\.env[^']*)'/)?.[1];
assert.equal(envExclusion, ".env*");
const root = mkdtempSync(join(tmpdir(), "jyotisha-staging-rsync-"));
const source = join(root, "source");
const destination = join(root, "destination");
mkdirSync(source);
mkdirSync(destination);
writeFileSync(join(source, "app.txt"), "new revision\n");
for (const name of [
".env",
".env.local",
".env.staging",
".env.staging.backup",
]) {
writeFileSync(join(destination, name), "preserve\n");
}
try {
const result = spawnSync(
"rsync",
[
"-a",
"--delete",
`--exclude=${envExclusion}`,
`${source}/`,
`${destination}/`,
],
{ encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr);
for (const name of [
".env",
".env.local",
".env.staging",
".env.staging.backup",
]) {
assert.equal(
existsSync(join(destination, name)),
true,
`${name} was deleted`,
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("staging env validator rejects selector drift, duplicates, and unsafe permissions", () => {
const validator = fileURLToPath(
new URL("../../deploy/validate-staging-env.sh", import.meta.url),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseStagingImageManifest } from "../scripts/staging-image-manifest.mjs";
const gitSha = "0123456789abcdef0123456789abcdef01234567";
const apiDigest = `sha256:${"a".repeat(64)}`;
const webDigest = `sha256:${"b".repeat(64)}`;
function validManifest(): string {
return [
`git_sha=${gitSha}`,
`api_digest=${apiDigest}`,
`web_digest=${webDigest}`,
"",
].join("\n");
}
test("manifest produces immutable GHCR digest references", () => {
assert.deepEqual(parseStagingImageManifest(validManifest(), gitSha), {
gitSha,
apiDigest,
webDigest,
apiImage: `ghcr.io/jesse-ux/jyotisha-api@${apiDigest}`,
webImage: `ghcr.io/jesse-ux/jyotisha-web@${webDigest}`,
});
});
test("manifest rejects revision drift, mutable tags, duplicates, extras, and malformed digests", () => {
const invalid = [
validManifest().replace(gitSha, "f".repeat(40)),
validManifest().replace(apiDigest, `${gitSha}`),
validManifest().replace(apiDigest, `sha256:${"A".repeat(64)}`),
validManifest().replace(
`web_digest=${webDigest}`,
`api_digest=${apiDigest}`,
),
`${validManifest()}extra=value\n`,
validManifest().replace("api_digest=", "api_image=ghcr.io/example:"),
];
for (const contents of invalid) {
assert.throws(() => parseStagingImageManifest(contents, gitSha));
}
});