Skip to content

WIP: refactor track and modlog helpers #151

WIP: refactor track and modlog helpers

WIP: refactor track and modlog helpers #151

Workflow file for this run

name: CD
concurrency:
group: cd-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
HUSKY: 0
on: push
jobs:
build:
runs-on: ubuntu-latest
environment: CI
outputs:
image_sha: sha-${{ github.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag Build
uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
# Only tag with latest if we're on main
tags: |
type=ref,event=pr
type=ref,event=branch
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: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deployment:
needs: build
runs-on: ubuntu-latest
environment: ${{ github.ref == 'refs/heads/main' && 'Main branch' || '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
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,format=long
- name: Create build context for k8s deployment
# There should only be 1 tag, so 'join' will just produce a simple string
run: |
touch k8s-context
echo IMAGE=${{ join(steps.meta.outputs.tags, '') }} > k8s-context
cat k8s-context
- name: Set up kubectl
uses: matootie/[email protected]
with:
personalAccessToken: ${{ secrets.DIGITAL_OCEAN_K8S }}
clusterName: k8s-rf
# --- Production deployment (main branch only) ---
- name: Create production secret manifest
if: github.ref == 'refs/heads/main'
run: |
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 }}"
POSTHOG_KEY: "${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}"
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 }} \
--from-literal=DISCORD_SECRET=${{ secrets.DISCORD_SECRET }} \
--from-literal=DISCORD_HASH=${{ secrets.DISCORD_HASH }} \
--from-literal=DISCORD_TEST_GUILD=${{ secrets.DISCORD_TEST_GUILD }} \
--from-literal=SENTRY_INGEST=${{ secrets.SENTRY_INGEST }} \
--from-literal=SENTRY_RELEASES=${{ secrets.SENTRY_RELEASES }} \
--from-literal=STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }} \
--from-literal=STRIPE_PUBLISHABLE_KEY=${{ secrets.STRIPE_PUBLISHABLE_KEY }} \
--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=POSTHOG_KEY=${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} \
--from-literal=POSTHOG_HOST=${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} \
--from-literal=DATABASE_URL=/data/mod-bot.sqlite3 \
--dry-run=client -o yaml | kubectl apply -f -
# 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: |
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
# });
# }