From 076a4841f7f3ed5a50f39398ff7172f3536af90c Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 15:58:22 +0800 Subject: [PATCH] ci: deploy tested revisions to staging --- .github/workflows/ci.yml | 2 + .github/workflows/deploy-staging.yml | 99 ++++++++++++++++++++++++ frontend/tests/health-deployment.test.ts | 21 +++++ 3 files changed, 122 insertions(+) create mode 100644 .github/workflows/deploy-staging.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bae554e4..1ca5930f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: Jyotish Skill CI on: + push: + branches: [staging] workflow_dispatch: jobs: diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 00000000..5b7a18ab --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,99 @@ +name: Deploy staging + +on: + workflow_run: + workflows: ["Jyotish Skill CI"] + types: [completed] + workflow_dispatch: + inputs: + git_ref: + description: Tested branch, tag, or commit SHA to deploy + required: true + default: staging + +permissions: + contents: read + +concurrency: + group: staging + cancel-in-progress: false + +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') + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: staging + url: ${{ vars.STAGING_URL }} + env: + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_URL: ${{ vars.STAGING_URL }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + + steps: + - name: Checkout tested revision + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || inputs.git_ref }} + + - name: Resolve deployment SHA + id: revision + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate staging target configuration + run: | + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" + test "$STAGING_URL" = "https://staging.jyotisha.chat" + test -n "$STAGING_KNOWN_HOSTS" + + - name: Configure pinned staging SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + run: | + test -n "$SSH_PRIVATE_KEY" + install -m 700 -d ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-staging + chmod 600 ~/.ssh/jyotisha-staging + printf '%s\n' "$STAGING_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Sync and rebuild staging + 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" + RSYNC_SSH="ssh $SSH_OPTIONS" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 755 '$DEPLOY_PATH'" + rsync -az --delete \ + --exclude='.git/' \ + --exclude='.env.production' \ + --exclude='.env.staging' \ + --exclude='frontend/node_modules/' \ + --exclude='frontend/.next/' \ + -e "$RSYNC_SSH" \ + ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "cd '$DEPLOY_PATH' && test -f .env.staging && GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml up -d --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" + ssh -i ~/.ssh/jyotisha-staging -p "$DEPLOY_PORT" \ + -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \ + "$DEPLOY_USER@$DEPLOY_HOST" \ + "cd '$DEPLOY_PATH' && 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)); })'" diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 81473c35..bf9b14c6 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -42,3 +42,24 @@ test("staging Caddy configuration serves only the configured staging address", ( assert.match(caddy, /reverse_proxy web:3000/); assert.doesNotMatch(caddy, /www\.jyotisha\.chat/); }); + +test("staging deploy consumes only the isolated staging environment and tested revision", () => { + const ci = readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"); + const workflow = readFileSync(new URL("../../.github/workflows/deploy-staging.yml", import.meta.url), "utf8"); + + assert.match(ci, /push:\s*\n\s*branches: \[staging\]/); + assert.match(workflow, /workflows: \["Jyotish Skill CI"\]/); + assert.match(workflow, /github\.event\.workflow_run\.head_branch == 'staging'/); + assert.match(workflow, /environment:\s*\n\s*name: staging/); + assert.match(workflow, /STAGING_SSH_PRIVATE_KEY/); + assert.match(workflow, /vars\.STAGING_HOST/); + assert.match(workflow, /vars\.STAGING_KNOWN_HOSTS/); + 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\.staging'/); + assert.match(workflow, /docker compose --env-file \.env\.staging/); + assert.match(workflow, /deployment\.gitCommit/); + assert.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/); + assert.doesNotMatch(workflow, /103\.117\.123\.53/); +});