diff --git a/.github/workflows/cleanup-previews.yml b/.github/workflows/cleanup-previews.yml new file mode 100644 index 0000000..940f529 --- /dev/null +++ b/.github/workflows/cleanup-previews.yml @@ -0,0 +1,66 @@ +name: Cleanup Expired Previews + +on: + schedule: + - cron: '0 */6 * * *' # every 6 hours + workflow_dispatch: + +permissions: + pull-requests: write + +jobs: + cleanup: + runs-on: ubuntu-latest + env: + CAPROVER_URL: ${{ secrets.CAPROVER_URL }} + CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + EXPIRY_HOURS: 6 + steps: + - name: Expire old PR previews + run: | + ADMIN_TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \ + -H "Content-Type: application/json" \ + -d "{\"password\": \"$CAPROVER_PASSWORD\"}" \ + | jq -r '.data.token') + + NOW_TS=$(date +%s) + REDEPLOY_URL="https://github.com/$REPO/actions/workflows/preview.yml" + + gh pr list --repo "$REPO" --state open --json number --jq '.[].number' | while read PR_NUM; do + APP_NAME="pr-$PR_NUM" + + # Find the active (non-expired) preview comment + COMMENT=$(gh api "repos/$REPO/issues/$PR_NUM/comments" \ + --jq '[.[] | select(.body | contains("")) | select(.body | contains("expired") | not)] | first') + + [ "$COMMENT" = "null" ] || [ -z "$COMMENT" ] && continue + + COMMENT_ID=$(echo "$COMMENT" | jq -r '.id') + UPDATED_AT=$(echo "$COMMENT" | jq -r '.updated_at') + UPDATED_TS=$(date -d "$UPDATED_AT" +%s) + AGE_HOURS=$(( (NOW_TS - UPDATED_TS) / 3600 )) + + [ "$AGE_HOURS" -lt "$EXPIRY_HOURS" ] && continue + + echo "Expiring preview for PR #$PR_NUM (age: ${AGE_HOURS}h)" + + # Delete CapRover app + curl -s -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/delete" \ + -H "Content-Type: application/json" \ + -H "x-captain-auth: $ADMIN_TOKEN" \ + -d "{\"appName\": \"$APP_NAME\"}" || true + + # Update comment to show expired state with re-deploy link + gh api "repos/$REPO/issues/comments/$COMMENT_ID" \ + -X PATCH \ + --field body=" +## Preview deployment _(expired)_ + +Removed after ${EXPIRY_HOURS}h of inactivity. + +[Re-deploy preview]($REDEPLOY_URL) — click **Run workflow** and enter PR number \`$PR_NUM\`. + +_Expired: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')_" + done diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..665ad03 --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,66 @@ +name: Deploy Staging + +on: + push: + branches: ["main"] + +permissions: + packages: write + +jobs: + deploy: + runs-on: ubuntu-latest + env: + APP_NAME: ${{ secrets.CAPROVER_STAGING_APP }} + CAPROVER_URL: ${{ secrets.CAPROVER_URL }} + CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + + - name: Set image URL + run: echo "IMAGE=$(echo ghcr.io/${{ github.repository_owner }}/devx-staging:$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to GHCR + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.IMAGE }} + + - name: Deploy image to CapRover + run: | + ADMIN_TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \ + -H "Content-Type: application/json" \ + -d "{\"password\": \"$CAPROVER_PASSWORD\"}" \ + | jq -r '.data.token') + + curl -sf -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/update" \ + -H "Content-Type: application/json" \ + -H "x-captain-auth: $ADMIN_TOKEN" \ + -d "{\"appName\": \"$APP_NAME\", \"imageName\": \"$IMAGE\", \"instanceCount\": 1}" diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..117b1ed --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,142 @@ +name: Preview + +on: + pull_request: + branches: ["main"] + types: [opened, reopened, synchronize, closed] + workflow_dispatch: + inputs: + pr_number: + description: PR number to (re-)deploy + required: true + +concurrency: + group: "preview-${{ github.event.number || inputs.pr_number }}" + cancel-in-progress: true + +permissions: + pull-requests: write + packages: write + +jobs: + deploy-preview: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + env: + PR_NUMBER: ${{ github.event.number || inputs.pr_number }} + APP_NAME: pr-${{ github.event.number || inputs.pr_number }} + CAPROVER_URL: ${{ secrets.CAPROVER_URL }} + CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + + - name: Set image URL + run: echo "IMAGE=$(echo ghcr.io/${{ github.repository_owner }}/devx-preview:pr-${PR_NUMBER}-$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to GHCR + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.IMAGE }} + + - name: Create app and deploy image via CapRover API + run: | + ADMIN_TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \ + -H "Content-Type: application/json" \ + -d "{\"password\": \"$CAPROVER_PASSWORD\"}" \ + | jq -r '.data.token') + + # Create app if it doesn't exist + curl -s -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/register" \ + -H "Content-Type: application/json" \ + -H "x-captain-auth: $ADMIN_TOKEN" \ + -d "{\"appName\": \"$APP_NAME\", \"hasPersistentData\": false}" || true + + # Point app at the pre-built image and trigger deploy + curl -sf -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/update" \ + -H "Content-Type: application/json" \ + -H "x-captain-auth: $ADMIN_TOKEN" \ + -d "{\"appName\": \"$APP_NAME\", \"imageName\": \"$IMAGE\", \"instanceCount\": 1}" + + - name: Comment preview URL on PR + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + CAPROVER_APP_DOMAIN: ${{ secrets.CAPROVER_APP_DOMAIN }} + with: + script: | + const prNumber = process.env.PR_NUMBER; + const appName = `pr-${prNumber}`; + const url = `https://${appName}.${process.env.CAPROVER_APP_DOMAIN}`; + const marker = ''; + const body = `${marker}\n## Preview deployment\n\n${url}\n\n_Updated: ${new Date().toUTCString()} — expires after 6h of inactivity._`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(prNumber), + }); + + const existing = comments.find(c => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(prNumber), + body, + }); + } + + cleanup-preview: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + env: + APP_NAME: pr-${{ github.event.number }} + CAPROVER_URL: ${{ secrets.CAPROVER_URL }} + CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} + steps: + - name: Delete CapRover app + run: | + TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \ + -H "Content-Type: application/json" \ + -d "{\"password\": \"$CAPROVER_PASSWORD\"}" \ + | jq -r '.data.token') + + curl -s -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/delete" \ + -H "Content-Type: application/json" \ + -H "x-captain-auth: $TOKEN" \ + -d "{\"appName\": \"$APP_NAME\"}" || true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3308cac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY out/ /usr/share/nginx/html +EXPOSE 80