ci: deploy immutable staging images
This commit is contained in:
@@ -2,17 +2,19 @@ name: Deploy staging
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Jyotish Skill CI"]
|
||||
workflows: ["Staging Backend Quality Gate"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
git_sha:
|
||||
description: Exact 40-character commit SHA from a successful CI run
|
||||
deploy_sha:
|
||||
description: Exact 40-character commit SHA from a successful backend quality gate
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: staging
|
||||
@@ -20,11 +22,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'staging')
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
@@ -42,12 +40,12 @@ jobs:
|
||||
- name: Validate tested revision
|
||||
id: revision
|
||||
env:
|
||||
REQUESTED_SHA: ${{ github.event.workflow_run.head_sha || inputs.git_sha }}
|
||||
REQUESTED_SHA: ${{ github.event.workflow_run.head_sha || inputs.deploy_sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
test "${#REQUESTED_SHA}" -eq 40
|
||||
case "$REQUESTED_SHA" in
|
||||
*[!0-9a-fA-F]*) echo "git_sha must be a full hexadecimal commit SHA" >&2; exit 1 ;;
|
||||
*[!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:]')"
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
@@ -55,9 +53,11 @@ jobs:
|
||||
--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/ci.yml/runs?head_sha=$DEPLOY_GIT_SHA&status=success&per_page=1")"
|
||||
test "$(printf '%s' "$TESTED_RUNS" | jq -r '.total_count')" -ge 1 || {
|
||||
echo "No successful Jyotish Skill CI run found for $DEPLOY_GIT_SHA" >&2
|
||||
"$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
|
||||
}
|
||||
fi
|
||||
@@ -93,29 +93,52 @@ jobs:
|
||||
printf '%s\n' "$STAGING_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
|
||||
- name: Record previous staging state
|
||||
- name: Record previous staging images
|
||||
id: previous
|
||||
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"
|
||||
PREVIOUS_SHA="$(curl --fail --silent --show-error --max-time 10 "$STAGING_URL/api/health" 2>/dev/null | jq -r '.deployment.gitCommit // empty' || true)"
|
||||
test -n "$PREVIOUS_SHA" || PREVIOUS_SHA="not-deployed"
|
||||
PREVIOUS_IMAGES="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"if [ -f '$DEPLOY_PATH/.env.staging' ] && [ -f '$DEPLOY_PATH/deploy/docker-compose.server.yml' ]; then cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml images --quiet; else echo not-deployed; fi")"
|
||||
test -n "$PREVIOUS_IMAGES" || PREVIOUS_IMAGES="not-deployed"
|
||||
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"
|
||||
fi
|
||||
if [ -z "$PREVIOUS_SHA" ] && [[ "$PREVIOUS_WEB_IMAGE" =~ ^ghcr\.io/jesse-ux/jyotisha-web:([0-9a-f]{40})$ ]]; then
|
||||
PREVIOUS_SHA="${BASH_REMATCH[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\`"
|
||||
echo "- Previous verified SHA: \`${PREVIOUS_SHA:-not-deployed}\`"
|
||||
echo "- Target SHA: \`$DEPLOY_GIT_SHA\`"
|
||||
echo "- Previous image IDs:"
|
||||
echo '```text'
|
||||
printf '%s\n' "$PREVIOUS_IMAGES"
|
||||
echo '```'
|
||||
echo "- Previous API image: \`${PREVIOUS_API_IMAGE:-not-deployed}\`"
|
||||
echo "- Previous web image: \`${PREVIOUS_WEB_IMAGE:-not-deployed}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Sync and rebuild staging
|
||||
env:
|
||||
DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }}
|
||||
- name: Sync tested staging sources
|
||||
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"
|
||||
RSYNC_SSH="ssh $SSH_OPTIONS"
|
||||
@@ -127,8 +150,65 @@ jobs:
|
||||
--exclude='frontend/.next/' \
|
||||
-e "$RSYNC_SSH" \
|
||||
./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
|
||||
|
||||
- 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 && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml up -d --build --remove-orphans"
|
||||
"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
|
||||
env:
|
||||
GHCR_TOKEN: ${{ github.token }}
|
||||
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:
|
||||
@@ -137,8 +217,25 @@ jobs:
|
||||
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"
|
||||
ssh -i ~/.ssh/jyotisha-staging -p "$DEPLOY_PORT" \
|
||||
-o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd '$DEPLOY_PATH' && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml exec -T web node -e 'fetch(\"http://api:5200/api/health\").then(async r => { const body = await r.json(); if (!r.ok || body.status !== \"ok\" || body.swisseph_available !== true) process.exit(1); console.log(JSON.stringify(body)); })'"
|
||||
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"
|
||||
|
||||
- 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 != ''
|
||||
env:
|
||||
PREVIOUS_API_IMAGE: ${{ steps.previous.outputs.api_image }}
|
||||
PREVIOUS_WEB_IMAGE: ${{ steps.previous.outputs.web_image }}
|
||||
PREVIOUS_SHA: ${{ steps.previous.outputs.previous_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='$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"
|
||||
|
||||
- name: Log out of GHCR
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
services:
|
||||
api:
|
||||
image: ${API_IMAGE:-jyotisha-api:local}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/railway-api.Dockerfile
|
||||
@@ -18,6 +19,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
image: ${WEB_IMAGE:-jyotisha-web:local}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/railway-web.Dockerfile
|
||||
|
||||
@@ -68,6 +68,52 @@ test("server compose accepts staging paths while preserving production defaults"
|
||||
);
|
||||
});
|
||||
|
||||
test("server compose defaults to local images without removing either build", () => {
|
||||
const composeFile = fileURLToPath(
|
||||
new URL("../../deploy/docker-compose.server.yml", import.meta.url),
|
||||
);
|
||||
const compose = readFileSync(composeFile, "utf8");
|
||||
|
||||
assert.match(compose, /^\s+image: \$\{API_IMAGE:-jyotisha-api:local\}$/m);
|
||||
assert.match(compose, /^\s+image: \$\{WEB_IMAGE:-jyotisha-web:local\}$/m);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "jyotisha-server-compose-"));
|
||||
const appEnvFile = join(root, ".env.production");
|
||||
writeFileSync(appEnvFile, "RUNTIME_FIXTURE=1\n");
|
||||
chmodSync(appEnvFile, 0o600);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
APP_ENV_FILE: appEnvFile,
|
||||
CADDYFILE_PATH: fileURLToPath(
|
||||
new URL("../../deploy/Caddyfile", import.meta.url),
|
||||
),
|
||||
GITHUB_SHA: "0000000000000000000000000000000000000000",
|
||||
NEXT_PUBLIC_SUPABASE_URL: "https://placeholder.supabase.co",
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: "placeholder",
|
||||
};
|
||||
delete env.API_IMAGE;
|
||||
delete env.WEB_IMAGE;
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
"docker",
|
||||
["compose", "-f", composeFile, "config", "--format", "json"],
|
||||
{ encoding: "utf8", env },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const rendered = JSON.parse(result.stdout) as {
|
||||
services: Record<string, { build?: unknown; image?: string }>;
|
||||
};
|
||||
assert.equal(rendered.services.api.image, "jyotisha-api:local");
|
||||
assert.equal(rendered.services.web.image, "jyotisha-web:local");
|
||||
assert.ok(rendered.services.api.build, "api build definition was removed");
|
||||
assert.ok(rendered.services.web.build, "web build definition was removed");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("staging Caddy configuration serves only the configured staging address", () => {
|
||||
const caddy = readFileSync(
|
||||
new URL("../../deploy/Caddyfile.staging", import.meta.url),
|
||||
@@ -80,8 +126,8 @@ test("staging Caddy configuration serves only the configured staging address", (
|
||||
});
|
||||
|
||||
test("staging deploy consumes only the isolated staging environment and tested revision", () => {
|
||||
const ci = readFileSync(
|
||||
new URL("../../.github/workflows/ci.yml", import.meta.url),
|
||||
const qualityGate = readFileSync(
|
||||
new URL("../../.github/workflows/backend-quality-gate.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const workflow = readFileSync(
|
||||
@@ -89,18 +135,22 @@ test("staging deploy consumes only the isolated staging environment and tested r
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(ci, /push:\s*\n\s*branches: \[staging\]/);
|
||||
assert.match(workflow, /workflows: \["Jyotish Skill CI"\]/);
|
||||
assert.match(qualityGate, /push:\s*\n\s*branches: \[staging\]/);
|
||||
assert.match(workflow, /workflows: \["Staging Backend Quality Gate"\]/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/github\.event\.workflow_run\.head_branch == 'staging'/,
|
||||
);
|
||||
assert.match(workflow, /actions: read/);
|
||||
assert.match(workflow, /packages: read/);
|
||||
assert.match(workflow, /environment:\s*\n\s*name: staging/);
|
||||
assert.match(workflow, /git_sha:/);
|
||||
assert.match(workflow, /deploy_sha:/);
|
||||
assert.doesNotMatch(workflow, /default: staging/);
|
||||
assert.match(workflow, /test "\$\{#REQUESTED_SHA\}" -eq 40/);
|
||||
assert.match(workflow, /actions\/workflows\/ci\.yml\/runs\?head_sha=/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/actions\/workflows\/backend-quality-gate\.yml\/runs\?head_sha=/,
|
||||
);
|
||||
assert.match(workflow, /STAGING_SSH_PRIVATE_KEY/);
|
||||
assert.match(workflow, /vars\.STAGING_HOST/);
|
||||
assert.match(workflow, /vars\.STAGING_KNOWN_HOSTS/);
|
||||
@@ -108,29 +158,23 @@ test("staging deploy consumes only the isolated staging environment and tested r
|
||||
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 --env-file \.env\.staging/);
|
||||
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,
|
||||
/docker compose --env-file \.env\.staging -f deploy\/docker-compose\.server\.yml config --quiet/,
|
||||
/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.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/);
|
||||
assert.doesNotMatch(workflow, /103\.117\.123\.53/);
|
||||
|
||||
const composeLines = workflow
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("docker compose"));
|
||||
assert.equal(workflow.match(/docker compose/g)?.length, 4);
|
||||
assert.equal(composeLines.length, 3);
|
||||
for (const line of composeLines) {
|
||||
assert.match(line, /APP_ENV_FILE='\.\.\/\.env\.staging'/);
|
||||
assert.match(line, /CADDYFILE_PATH='\.\/Caddyfile\.staging'/);
|
||||
assert.match(line, /SITE_ADDRESS='https:\/\/staging\.jyotisha\.chat'/);
|
||||
}
|
||||
});
|
||||
|
||||
test("staging rsync preserves every destination env variant during delete", () => {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
type YamlNode = {
|
||||
@@ -20,10 +30,14 @@ type WorkflowStep = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const workflowUrl = new URL(
|
||||
const backendWorkflowUrl = new URL(
|
||||
"../../.github/workflows/backend-quality-gate.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
const deploymentWorkflowUrl = new URL(
|
||||
"../../.github/workflows/deploy-staging.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
function indentation(line: string): number {
|
||||
return line.match(/^ */)?.[0].length ?? 0;
|
||||
@@ -60,8 +74,8 @@ function mappingNodes(
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function parseWorkflow(): WorkflowDocument {
|
||||
const lines = readFileSync(workflowUrl, "utf8").split("\n");
|
||||
function parseWorkflow(url = backendWorkflowUrl): WorkflowDocument {
|
||||
const lines = readFileSync(url, "utf8").split("\n");
|
||||
return { lines, root: mappingNodes(lines, 0) };
|
||||
}
|
||||
|
||||
@@ -104,10 +118,7 @@ function blockScalar(document: WorkflowDocument, node: YamlNode): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function job(
|
||||
document: WorkflowDocument,
|
||||
name: "validate" | "publish",
|
||||
): YamlNode {
|
||||
function job(document: WorkflowDocument, name: string): YamlNode {
|
||||
return child(document, requiredNode(document.root, "jobs"), name);
|
||||
}
|
||||
|
||||
@@ -174,6 +185,19 @@ function stepField(
|
||||
);
|
||||
}
|
||||
|
||||
function stepRun(document: WorkflowDocument, step: WorkflowStep): string {
|
||||
const run = stepField(document, step, "run");
|
||||
return run.value === "|" ? blockScalar(document, run) : run.value;
|
||||
}
|
||||
|
||||
function logicalShellLines(script: string): string[] {
|
||||
return script
|
||||
.replace(/\\\n\s*/g, " ")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
test("backend quality gate has structured staging triggers and concurrency", () => {
|
||||
const document = parseWorkflow();
|
||||
|
||||
@@ -414,3 +438,213 @@ test("deployment test script covers health and backend workflow contracts", () =
|
||||
"tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("staging deploy structurally follows the backend gate and validates an exact manual SHA", () => {
|
||||
const document = parseWorkflow(deploymentWorkflowUrl);
|
||||
|
||||
assert.equal(requiredNode(document.root, "name").value, "Deploy staging");
|
||||
const triggers = requiredNode(document.root, "on");
|
||||
const workflowRun = child(document, triggers, "workflow_run");
|
||||
assert.equal(
|
||||
child(document, workflowRun, "workflows").value,
|
||||
'["Staging Backend Quality Gate"]',
|
||||
);
|
||||
assert.equal(child(document, workflowRun, "types").value, "[completed]");
|
||||
const dispatch = child(document, triggers, "workflow_dispatch");
|
||||
const deploySha = child(document, child(document, dispatch, "inputs"), "deploy_sha");
|
||||
assert.equal(child(document, deploySha, "required").value, "true");
|
||||
assert.equal(child(document, deploySha, "type").value, "string");
|
||||
|
||||
assert.deepEqual(
|
||||
children(document, requiredNode(document.root, "permissions")).map(
|
||||
({ key, value }) => [key, value],
|
||||
),
|
||||
[
|
||||
["contents", "read"],
|
||||
["actions", "read"],
|
||||
["packages", "read"],
|
||||
],
|
||||
);
|
||||
const deploy = job(document, "deploy");
|
||||
assert.match(child(document, deploy, "if").value, /conclusion == 'success'/);
|
||||
const revision = requiredStep(document, deploy, "Validate tested revision");
|
||||
assert.equal(
|
||||
child(document, stepField(document, revision, "env"), "REQUESTED_SHA").value,
|
||||
"${{ github.event.workflow_run.head_sha || inputs.deploy_sha }}",
|
||||
);
|
||||
const validation = stepRun(document, revision);
|
||||
assert.match(validation, /test "\$\{#REQUESTED_SHA\}" -eq 40/);
|
||||
assert.match(
|
||||
validation,
|
||||
/actions\/workflows\/backend-quality-gate\.yml\/runs\?head_sha=\$DEPLOY_GIT_SHA/,
|
||||
);
|
||||
assert.match(validation, /head_branch == "staging"/);
|
||||
assert.match(validation, /conclusion == "success"/);
|
||||
});
|
||||
|
||||
test("staging deploy pins every Compose call and gates app changes on the read-only checker", () => {
|
||||
const document = parseWorkflow(deploymentWorkflowUrl);
|
||||
const deploy = job(document, "deploy");
|
||||
const deploySteps = steps(document, deploy);
|
||||
const names = deploySteps.map(({ name }) => name);
|
||||
const index = (name: string) => {
|
||||
const found = names.indexOf(name);
|
||||
assert.notEqual(found, -1, `missing deployment step: ${name}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
assert.ok(index("Pull exact staging images") < index("Start and wait for staging PostgreSQL"));
|
||||
assert.ok(index("Start and wait for staging PostgreSQL") < index("Check staging migrations"));
|
||||
assert.ok(index("Check staging migrations") < index("Deploy exact staging images"));
|
||||
assert.ok(index("Deploy exact staging images") < index("Verify staging"));
|
||||
assert.ok(index("Verify staging") < index("Roll back staging images"));
|
||||
assert.ok(index("Roll back staging images") < index("Log out of GHCR"));
|
||||
|
||||
const scripts = deploySteps
|
||||
.map((step) => {
|
||||
const fields = mappingNodes(
|
||||
document.lines,
|
||||
step.node.indent + 2,
|
||||
step.node.start + 1,
|
||||
step.node.end,
|
||||
);
|
||||
const run = fields.find(({ key }) => key === "run");
|
||||
return run ? (run.value === "|" ? blockScalar(document, run) : run.value) : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
const composeCommands = scripts
|
||||
.flatMap(logicalShellLines)
|
||||
.filter((line) => line.includes("docker compose"));
|
||||
assert.equal(composeCommands.length, 7);
|
||||
for (const command of composeCommands) {
|
||||
assert.match(command, /ssh /);
|
||||
assert.match(command, /APP_ENV_FILE='\.\.\/\.env\.staging'/);
|
||||
assert.match(command, /DATABASE_ENV_FILE='\.\.\/\.env\.staging\.database'/);
|
||||
assert.match(command, /CADDYFILE_PATH='\.\/Caddyfile\.staging'/);
|
||||
assert.match(command, /SITE_ADDRESS='https:\/\/staging\.jyotisha\.chat'/);
|
||||
assert.match(command, /API_IMAGE=/);
|
||||
assert.match(command, /WEB_IMAGE=/);
|
||||
assert.match(command, /--env-file \.env\.staging/);
|
||||
assert.match(command, /-f deploy\/docker-compose\.server\.yml/);
|
||||
assert.match(command, /-f deploy\/docker-compose\.postgres\.yml/);
|
||||
}
|
||||
|
||||
const login = stepRun(document, requiredStep(document, deploy, "Log in to GHCR"));
|
||||
assert.match(login, /printf '%s' "\$GHCR_TOKEN" \| ssh /);
|
||||
assert.match(login, /docker login ghcr\.io .*--password-stdin/);
|
||||
assert.doesNotMatch(login, /--password(?:\s|=)/);
|
||||
|
||||
assert.match(
|
||||
stepRun(document, requiredStep(document, deploy, "Pull exact staging images")),
|
||||
/pull api web postgres/,
|
||||
);
|
||||
assert.match(
|
||||
stepRun(
|
||||
document,
|
||||
requiredStep(document, deploy, "Start and wait for staging PostgreSQL"),
|
||||
),
|
||||
/up -d --no-build --wait postgres/,
|
||||
);
|
||||
|
||||
const check = requiredStep(document, deploy, "Check staging migrations");
|
||||
assert.equal(stepField(document, check, "id").value, "migration_check");
|
||||
const checkScript = stepRun(document, check);
|
||||
assert.match(checkScript, /--profile migration-check run --rm migration-checker/);
|
||||
assert.match(checkScript, /"\$CHECK_STATUS" -eq 3/);
|
||||
assert.match(checkScript, /Migrate Staging Database/);
|
||||
assert.match(checkScript, /\$DEPLOY_GIT_SHA/);
|
||||
assert.match(checkScript, /exit 3/);
|
||||
assert.match(
|
||||
readFileSync(
|
||||
new URL("../../deploy/docker-compose.postgres.yml", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
/command: \["npm", "run", "db:migrate:check"\]/,
|
||||
);
|
||||
|
||||
const workflow = readFileSync(deploymentWorkflowUrl, "utf8");
|
||||
assert.doesNotMatch(workflow, /npm\s+run\s+db:migrate(?!:check)/);
|
||||
assert.doesNotMatch(workflow, /run\s+--rm\s+migrator/);
|
||||
assert.doesNotMatch(workflow, /--profile\s+migration(?:\s|["'])/);
|
||||
assert.doesNotMatch(workflow, /(?:up -d[^\n]*--build|docker compose build)/);
|
||||
|
||||
const applicationDeploy = stepRun(
|
||||
document,
|
||||
requiredStep(document, deploy, "Deploy exact staging images"),
|
||||
);
|
||||
assert.match(applicationDeploy, /up -d --no-build --remove-orphans/);
|
||||
const previous = requiredStep(document, deploy, "Record previous staging images");
|
||||
assert.equal(stepField(document, previous, "id").value, "previous");
|
||||
assert.match(stepRun(document, previous), /docker inspect --format '\{\{\.Config\.Image\}\}'/);
|
||||
assert.match(stepRun(document, previous), /api_image=\$PREVIOUS_API_IMAGE/);
|
||||
assert.match(stepRun(document, previous), /web_image=\$PREVIOUS_WEB_IMAGE/);
|
||||
assert.match(stepRun(document, previous), /previous_sha=\$PREVIOUS_SHA/);
|
||||
|
||||
const rollback = requiredStep(document, deploy, "Roll back staging images");
|
||||
assert.match(stepField(document, rollback, "if").value, /steps\.migration_check\.outcome == 'success'/);
|
||||
const rollbackEnv = stepField(document, rollback, "env");
|
||||
assert.equal(
|
||||
child(document, rollbackEnv, "PREVIOUS_API_IMAGE").value,
|
||||
"${{ steps.previous.outputs.api_image }}",
|
||||
);
|
||||
assert.equal(
|
||||
child(document, rollbackEnv, "PREVIOUS_WEB_IMAGE").value,
|
||||
"${{ steps.previous.outputs.web_image }}",
|
||||
);
|
||||
assert.equal(
|
||||
child(document, rollbackEnv, "PREVIOUS_SHA").value,
|
||||
"${{ steps.previous.outputs.previous_sha }}",
|
||||
);
|
||||
assert.match(stepRun(document, rollback), /API_IMAGE='\$PREVIOUS_API_IMAGE'/);
|
||||
assert.match(stepRun(document, rollback), /WEB_IMAGE='\$PREVIOUS_WEB_IMAGE'/);
|
||||
assert.match(stepRun(document, rollback), /GITHUB_SHA='\$PREVIOUS_SHA'/);
|
||||
assert.match(stepRun(document, rollback), /up -d --no-build/);
|
||||
|
||||
const logout = requiredStep(document, deploy, "Log out of GHCR");
|
||||
assert.equal(stepField(document, logout, "if").value, "always()");
|
||||
assert.equal(stepField(document, logout, "continue-on-error").value, "true");
|
||||
assert.match(stepRun(document, logout), /docker logout ghcr\.io/);
|
||||
});
|
||||
|
||||
test("pending migration status exits 3 with the manual workflow and exact SHA", () => {
|
||||
const document = parseWorkflow(deploymentWorkflowUrl);
|
||||
const check = requiredStep(
|
||||
document,
|
||||
job(document, "deploy"),
|
||||
"Check staging migrations",
|
||||
);
|
||||
const script = stepRun(document, check);
|
||||
const root = mkdtempSync(join(tmpdir(), "jyotisha-migration-check-"));
|
||||
const fakeBin = join(root, "bin");
|
||||
const fakeSsh = join(fakeBin, "ssh");
|
||||
const exactSha = "0123456789abcdef0123456789abcdef01234567";
|
||||
mkdirSync(fakeBin);
|
||||
writeFileSync(fakeSsh, '#!/usr/bin/env bash\nexit "${FAKE_SSH_STATUS:?}"\n');
|
||||
chmodSync(fakeSsh, 0o700);
|
||||
|
||||
const run = (status: number) =>
|
||||
spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
DEPLOY_GIT_SHA: exactSha,
|
||||
DEPLOY_HOST: "staging.example.invalid",
|
||||
DEPLOY_PORT: "22",
|
||||
DEPLOY_USER: "deploy",
|
||||
DEPLOY_PATH: "/opt/jyotisha-staging",
|
||||
FAKE_SSH_STATUS: String(status),
|
||||
HOME: root,
|
||||
PATH: `${fakeBin}:${process.env.PATH}`,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const pending = run(3);
|
||||
assert.equal(pending.status, 3, pending.stderr);
|
||||
assert.match(`${pending.stdout}\n${pending.stderr}`, /Migrate Staging Database/);
|
||||
assert.match(`${pending.stdout}\n${pending.stderr}`, new RegExp(exactSha));
|
||||
assert.equal(run(0).status, 0);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user