fix: pin and serialize staging releases
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Executable
+203
@@ -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"
|
||||
Executable
+79
@@ -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'
|
||||
Executable
+17
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user