Deploy Server #23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy Server | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| IMAGE: ghcr.io/vicplusplus/arrow-thing-api | |
| on: | |
| release: | |
| types: [published] | |
| workflow_dispatch: | |
| concurrency: | |
| group: deploy-server | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| packages: write | |
| jobs: | |
| # Gate: ensure CI passed for this commit before deploying | |
| ci-check: | |
| runs-on: ubuntu-latest | |
| # Skip CI gate for manual triggers — CI only runs on PRs | |
| if: github.event_name != 'workflow_dispatch' | |
| steps: | |
| - name: Verify CI passed | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| SHA=${{ github.sha }} | |
| echo "Checking CI status for $SHA..." | |
| # Wait up to 5 minutes for CI to report | |
| for i in $(seq 1 10); do | |
| STATUS=$(gh api "repos/${{ github.repository }}/commits/$SHA/status" --jq '.state') | |
| if [ "$STATUS" = "success" ]; then | |
| echo "CI passed" | |
| exit 0 | |
| elif [ "$STATUS" = "failure" ] || [ "$STATUS" = "error" ]; then | |
| echo "CI failed (state: $STATUS) — aborting deploy" | |
| exit 1 | |
| fi | |
| echo "Attempt $i: CI state is '$STATUS' — waiting 30s..." | |
| sleep 30 | |
| done | |
| echo "CI did not report success within timeout — aborting deploy" | |
| exit 1 | |
| build-and-push: | |
| runs-on: ubuntu-latest | |
| needs: ci-check | |
| if: ${{ !cancelled() && (needs.ci-check.result == 'success' || needs.ci-check.result == 'skipped') }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Log in to GitHub Container Registry | |
| uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push | |
| uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 | |
| with: | |
| context: . | |
| file: server/Dockerfile | |
| push: true | |
| tags: | | |
| ${{ env.IMAGE }}:latest | |
| ${{ env.IMAGE }}:${{ github.sha }} | |
| deploy: | |
| runs-on: ubuntu-latest | |
| needs: build-and-push | |
| if: ${{ !cancelled() && needs.build-and-push.result == 'success' }} | |
| environment: production | |
| steps: | |
| - name: Deploy to VPS | |
| run: | | |
| mkdir -p ~/.ssh | |
| echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_ed25519 | |
| chmod 600 ~/.ssh/id_ed25519 | |
| echo "${{ secrets.VPS_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts | |
| ssh -i ~/.ssh/id_ed25519 deploy@${{ secrets.VPS_HOST }} \ | |
| "cd /home/deploy/arrow-thing/repo && \ | |
| git fetch origin main && \ | |
| git reset --hard origin/main && \ | |
| ./server/deploy/setup.sh && \ | |
| cd /home/deploy/arrow-thing && \ | |
| docker compose pull && \ | |
| docker compose up -d --remove-orphans" | |
| - name: Health check | |
| run: | | |
| echo "Waiting for container to be ready..." | |
| sleep 5 | |
| for i in $(seq 1 6); do | |
| STATUS=$(curl -o /dev/null -s -w "%{http_code}" https://api.arrow-thing.com/health) | |
| if [ "$STATUS" = "200" ]; then | |
| echo "Health check passed (HTTP $STATUS)" | |
| exit 0 | |
| fi | |
| echo "Attempt $i: HTTP $STATUS — retrying in 10s..." | |
| sleep 10 | |
| done | |
| echo "Health check failed after all retries" | |
| exit 1 | |
| - name: Smoke tests | |
| run: | | |
| API="https://api.arrow-thing.com" | |
| FAILURES=0 | |
| smoke() { | |
| local METHOD=$1 ENDPOINT=$2 EXPECTED=$3 BODY=$4 | |
| if [ -n "$BODY" ]; then | |
| RESP=$(curl -s -o /tmp/smoke_body -w "%{http_code}" -X "$METHOD" "$API$ENDPOINT" \ | |
| -H "Content-Type: application/json" -d "$BODY") | |
| else | |
| RESP=$(curl -s -o /tmp/smoke_body -w "%{http_code}" -X "$METHOD" "$API$ENDPOINT") | |
| fi | |
| if [ "$RESP" = "$EXPECTED" ]; then | |
| echo "PASS: $METHOD $ENDPOINT → $RESP" | |
| else | |
| echo "FAIL: $METHOD $ENDPOINT → $RESP (expected $EXPECTED)" | |
| cat /tmp/smoke_body | |
| echo | |
| FAILURES=$((FAILURES + 1)) | |
| fi | |
| } | |
| # Unauthenticated endpoints return expected status codes | |
| smoke GET /api/auth/me 401 | |
| smoke POST /api/auth/login 401 '{"email":"smoke-nonexistent@test.invalid","password":"smoke12345678"}' | |
| smoke POST /api/auth/forgot-password 200 '{"email":"smoke-nonexistent@test.invalid"}' | |
| # Registration validation rejects bad input | |
| smoke POST /api/auth/register 400 '{"email":"not-an-email","password":"short","displayName":""}' | |
| # Admin endpoints reject missing key | |
| smoke POST /api/admin/lock-account 401 '{"email":"anyone@test.invalid"}' | |
| smoke POST /api/admin/unlock-account 401 '{"email":"anyone@test.invalid"}' | |
| if [ "$FAILURES" -gt 0 ]; then | |
| echo "Smoke tests failed: $FAILURES failure(s)" | |
| exit 1 | |
| fi | |
| echo "All smoke tests passed" |