Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
95a638a
Add 90-day trial
vcarl Dec 12, 2025
3519984
Simplify logging now that Error objects are correctly handled
vcarl Dec 12, 2025
8774286
Improve CD reliability with health probes, rollback, and atomic secrets
vcarl Dec 12, 2025
137024b
Simplify secrets injection with envFrom
vcarl Dec 12, 2025
efabca4
Add per-PR preview environment infrastructure
vcarl Dec 12, 2025
49b7876
Switch preview environments to per-PR HTTP-01 certs
vcarl Dec 12, 2025
5bea164
Fix payment e2e test
vcarl Dec 12, 2025
871e582
Add debug output for DigitalOcean token in preview workflow
vcarl Dec 12, 2025
877efd3
Use DIGITAL_OCEAN_K8S token for preview workflow
vcarl Dec 12, 2025
f384599
Target staging namespace
vcarl Dec 12, 2025
303c1ec
Loosen startup timeout
vcarl Dec 12, 2025
627e9ea
Fix healthcheck SQL query missing select clause
vcarl Dec 12, 2025
2f46cc1
Fix preview deployments not updating on push
vcarl Dec 13, 2025
161c697
Fix reverse proxy issue with detecting host
vcarl Dec 13, 2025
acaed58
Convert preview to StatefulSet with persistent storage
vcarl Dec 13, 2025
0021f3e
Increase preview CPU limit to 500m
vcarl Dec 13, 2025
1e72eaf
Add e2e tests against PR preview deployments
vcarl Dec 13, 2025
3670349
Add fixture generation system for staging/local environments
vcarl Dec 15, 2025
f3a2a4a
Remove debug step
vcarl Dec 15, 2025
d9dd99f
CI/CD architecture review notes
vcarl Dec 15, 2025
95cd9e9
Split preview deploy and e2e tests into separate jobs
vcarl Dec 15, 2025
42d7c1e
Consolidate CI and preview workflows for e2e testing
vcarl Dec 15, 2025
055cb36
Consolidate CI/CD: single artifact from CD, remove tsx
vcarl Dec 15, 2025
7e698c6
Simplify CI/CD: move all deploys into cd.yml
vcarl Dec 15, 2025
efc634f
Fix casing
vcarl Dec 15, 2025
d0be1c4
Use full commit sha
vcarl Dec 15, 2025
43eca6d
Add an .nvmrc
vcarl Dec 15, 2025
ea3b56f
Consolidate December infrastructure notes
vcarl Dec 15, 2025
04dd4f7
Add E2E tests after deployment in CD workflow
vcarl Dec 15, 2025
0825a9c
Remove --with-deps from playwright install
vcarl Dec 15, 2025
3c9e5b9
Delete preview database on each deploy
vcarl Dec 15, 2025
c988ca3
Document PR preview environments in CONTRIBUTING.md
vcarl Dec 16, 2025
7c16358
Move "wait for service to start working" later in the workflow
vcarl Dec 16, 2025
f659e63
Notes
vcarl Dec 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
332 changes: 314 additions & 18 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ on: push
jobs:
build:
runs-on: ubuntu-latest

environment: CI
outputs:
image_sha: sha-${{ github.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -35,32 +37,62 @@ jobs:
tags: |
type=ref,event=pr
type=ref,event=branch
type=sha
type=sha,format=long
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}

- name: Build and push Docker images
uses: docker/build-push-action@v6
with:
push: ${{github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/feature/actions'}}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

deployment:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: CI
outputs:
pr_number: ${{ steps.get-pr.outputs.result }}
preview_url: ${{ steps.set-outputs.outputs.preview_url }}
is_production: ${{ steps.set-outputs.outputs.is_production }}

steps:
- name: Checkout to branch
- name: Checkout
uses: actions/checkout@v4

- name: Get PR number
id: get-pr
uses: actions/github-script@v7
with:
script: |
const branch = context.ref.replace('refs/heads/', '');
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${branch}`
});
if (prs.data.length > 0 && !prs.data[0].draft) {
const pr = prs.data[0];
const hasNoPreview = pr.labels.some(l => l.name === 'no-preview');
if (!hasNoPreview) {
console.log(`Found PR #${pr.number} for branch ${branch}`);
return pr.number;
}
}
console.log(`No eligible PR for branch ${branch}`);
return '';
result-encoding: string

- name: Tag Build
uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=sha,format=long

- name: Create build context for k8s deployment
# There should only be 1 tag, so 'join' will just produce a simple string
Expand All @@ -72,14 +104,68 @@ jobs:
- name: Set up kubectl
uses: matootie/dokube@v1.4.1
with:
personalAccessToken: ${{ secrets.DIGITALOCEAN_TOKEN }}
personalAccessToken: ${{ secrets.DIGITAL_OCEAN_K8S }}
clusterName: k8s-rf

- name: Deploy app
# --- Production deployment (main branch only) ---
- name: Create production secret manifest
if: github.ref == 'refs/heads/main'
run: |
kubectl diff -k . || echo \n
kubectl delete secret modbot-env || echo \n
kubectl create secret generic modbot-env \
cat <<EOF > secret-values.yaml
apiVersion: v1
kind: Secret
metadata:
name: modbot-env
namespace: default
type: Opaque
stringData:
SESSION_SECRET: "${{ secrets.SESSION_SECRET }}"
DISCORD_PUBLIC_KEY: "${{ secrets.DISCORD_PUBLIC_KEY }}"
DISCORD_APP_ID: "${{ secrets.DISCORD_APP_ID }}"
DISCORD_SECRET: "${{ secrets.DISCORD_SECRET }}"
DISCORD_HASH: "${{ secrets.DISCORD_HASH }}"
DISCORD_TEST_GUILD: "${{ secrets.DISCORD_TEST_GUILD }}"
SENTRY_INGEST: "${{ secrets.SENTRY_INGEST }}"
SENTRY_RELEASES: "${{ secrets.SENTRY_RELEASES }}"
STRIPE_SECRET_KEY: "${{ secrets.STRIPE_SECRET_KEY }}"
STRIPE_PUBLISHABLE_KEY: "${{ secrets.STRIPE_PUBLISHABLE_KEY }}"
STRIPE_WEBHOOK_SECRET: "${{ secrets.STRIPE_WEBHOOK_SECRET }}"
VITE_PUBLIC_POSTHOG_KEY: "${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}"
VITE_PUBLIC_POSTHOG_HOST: "${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}"
DATABASE_URL: "${{ secrets.DATABASE_URL }}"
EOF

- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: |
kubectl diff -k . || true
kubectl apply -f secret-values.yaml
kubectl apply -k .
if ! kubectl rollout status statefulset/mod-bot-set --timeout=5m; then
echo "Deployment failed, rolling back..."
kubectl rollout undo statefulset/mod-bot-set
exit 1
fi

- name: Set Sentry release
if: github.ref == 'refs/heads/main'
run: |
curl ${{secrets.SENTRY_RELEASES}} \
-X POST \
-H 'Content-Type: application/json' \
-d '{"version": "${{github.sha}}"}'

# --- Preview deployment (PR branches only) ---
- name: Deploy preview
if: github.ref != 'refs/heads/main' && steps.get-pr.outputs.result != ''
run: |
PR_NUMBER=${{ steps.get-pr.outputs.result }}
echo "Deploying preview for PR #${PR_NUMBER}"

kubectl config set-context --current --namespace=staging

# Ensure staging secret exists
kubectl create secret generic modbot-staging-env \
--from-literal=SESSION_SECRET=${{ secrets.SESSION_SECRET }} \
--from-literal=DISCORD_PUBLIC_KEY=${{ secrets.DISCORD_PUBLIC_KEY }} \
--from-literal=DISCORD_APP_ID=${{ secrets.DISCORD_APP_ID }} \
Expand All @@ -93,12 +179,222 @@ jobs:
--from-literal=STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }} \
--from-literal=VITE_PUBLIC_POSTHOG_KEY=${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} \
--from-literal=VITE_PUBLIC_POSTHOG_HOST=${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} \
--from-literal=DATABASE_URL=${{ secrets.DATABASE_URL }}
kubectl apply -k .
--from-literal=DATABASE_URL=/data/mod-bot.sqlite3 \
--dry-run=client -o yaml | kubectl apply -f -

- name: Set Sentry release
# Deploy preview environment
export PR_NUMBER
export COMMIT_SHA=${{ github.sha }}

# Delete database to start fresh (ignore errors if pod doesn't exist yet)
kubectl exec statefulset/mod-bot-pr-${PR_NUMBER} -- rm -f /data/mod-bot.sqlite3 || true

envsubst < cluster/preview/deployment.yaml | kubectl apply -f -

kubectl rollout restart statefulset/mod-bot-pr-${PR_NUMBER}

echo "Preview deployed at https://${PR_NUMBER}.euno-staging.reactiflux.com"

- name: Set deployment outputs
id: set-outputs
run: |
curl ${{secrets.SENTRY_RELEASES}} \
-X POST \
-H 'Content-Type: application/json' \
-d '{"version": "${{github.sha}}"}'
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "is_production=true" >> $GITHUB_OUTPUT
echo "preview_url=" >> $GITHUB_OUTPUT
elif [[ -n "${{ steps.get-pr.outputs.result }}" ]]; then
echo "is_production=false" >> $GITHUB_OUTPUT
echo "preview_url=https://${{ steps.get-pr.outputs.result }}.euno-staging.reactiflux.com" >> $GITHUB_OUTPUT
else
echo "is_production=false" >> $GITHUB_OUTPUT
echo "preview_url=" >> $GITHUB_OUTPUT
fi

- name: Comment preview URL on PR
if: github.ref != 'refs/heads/main' && steps.get-pr.outputs.result != ''
uses: actions/github-script@v7
with:
script: |
const prNumber = parseInt('${{ steps.get-pr.outputs.result }}');
const previewUrl = `https://${prNumber}.euno-staging.reactiflux.com`;
const commitSha = '${{ github.sha }}';

const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});

const botComment = comments.data.find(c =>
c.user.type === 'Bot' && c.body.includes('Preview deployed')
);

const body = `### Preview deployed

It may take a few minutes before the service becomes available.

| Environment | URL |
|-------------|-----|
| Preview | ${previewUrl} |

Deployed commit: \`${commitSha.substring(0, 7)}\`

This preview will be updated on each push and deleted when the PR is closed.`;

if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body
});
}

# --- E2E Tests after deployment ---
e2e:
needs: deployment
if: needs.deployment.outputs.preview_url != '' || needs.deployment.outputs.is_production == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TARGET_URL: ${{ needs.deployment.outputs.preview_url || 'https://euno.reactiflux.com' }}
PR_NUMBER: ${{ needs.deployment.outputs.pr_number }}
steps:
- name: Checkout repo
uses: actions/checkout@v4

- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 24

- run: npm ci

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

- name: Install Playwright browsers
run: npx playwright install chromium

- name: Wait for service to be ready
run: |
for i in {1..30}; do
if curl -sf "$TARGET_URL" > /dev/null; then
echo "Service is ready"
exit 0
fi
echo "Waiting for service... ($i/30)"
sleep 10
done
echo "Service did not become ready in time"
exit 1

- name: Run Playwright tests
run: npm run test:e2e
env:
E2E_PREVIEW_URL: ${{ env.TARGET_URL }}

- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ github.run_id }}
path: |
playwright-report/
test-results/
retention-days: 30

- name: Deploy test report to GitHub Pages
if: always()
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./playwright-report
destination_dir: reports/${{ github.run_number }}
keep_files: true

- name: Comment PR with test results
if: ${{ always() && env.PR_NUMBER != '' }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const prNumber = parseInt('${{ env.PR_NUMBER }}');
const targetUrl = '${{ env.TARGET_URL }}';
const reportUrl = `https://reactiflux.github.io/mod-bot/reports/${{ github.run_number }}`;
const runUrl = '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}';

// Parse test results
let stats = { passed: 0, failed: 0, flaky: 0, skipped: 0 };
try {
const results = JSON.parse(fs.readFileSync('test-results/results.json', 'utf8'));
const countTests = (suites) => {
for (const suite of suites) {
for (const spec of suite.specs || []) {
for (const test of spec.tests || []) {
if (test.status === 'expected') stats.passed++;
else if (test.status === 'unexpected') stats.failed++;
else if (test.status === 'flaky') stats.flaky++;
else if (test.status === 'skipped') stats.skipped++;
}
}
if (suite.suites) countTests(suite.suites);
}
};
countTests(results.suites || []);
} catch (e) {
console.log('Could not parse test results:', e.message);
}

const emoji = stats.failed > 0 ? '❌' : stats.flaky > 0 ? '⚠️' : '✅';
const status = stats.failed > 0 ? 'Failed' : stats.flaky > 0 ? 'Flaky' : 'Passed';
const statsParts = [
stats.passed > 0 && `**${stats.passed}** passed`,
stats.flaky > 0 && `**${stats.flaky}** flaky`,
stats.failed > 0 && `**${stats.failed}** failed`,
stats.skipped > 0 && `**${stats.skipped}** skipped`,
].filter(Boolean).join(' · ');

const body = `## ${emoji} E2E Tests ${status}

${statsParts}

[View Report](${reportUrl}) · [View Run](${runUrl})

Tested against: ${targetUrl}`;

// Find existing E2E comment to update
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});

const existingComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('E2E Tests')
);

if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body
});
}
Loading
Loading