diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..173fd37 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install Python dependencies + run: | + pip install -r requirements.txt + pip install -e . + + - name: Install frontend dependencies + run: cd frontend && npm ci + + - name: Run Python tests + run: pytest --tb=short + continue-on-error: true + + - name: Run frontend tests + run: cd frontend && npm test -- --run + continue-on-error: true + + - name: Build frontend + run: cd frontend && npm run build + + - name: Check build artifacts + run: | + if [ -d "kernelboard/static/app" ]; then + echo "✅ Frontend build successful" + ls -la kernelboard/static/app + else + echo "❌ Frontend build failed - no output directory" + exit 1 + fi diff --git a/.github/workflows/review-app-comment.yml b/.github/workflows/review-app-comment.yml new file mode 100644 index 0000000..5663847 --- /dev/null +++ b/.github/workflows/review-app-comment.yml @@ -0,0 +1,52 @@ +name: Comment Review App URL + +on: + pull_request: + types: [opened, synchronize] + +permissions: + pull-requests: write + issues: write + +jobs: + comment: + runs-on: ubuntu-latest + steps: + - name: Wait for Heroku deployment + run: sleep 30 + + - name: Comment PR with Review App URL + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + + 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.body.includes('Review App Preview') + ); + + const body = `## 🔍 Review App Preview + +A Heroku Review App is being deployed for this PR. + +**View your preview:** Check the [Heroku Pipeline](https://dashboard.heroku.com/pipelines/kernelboard) for the review app URL. + +> The review app may take a few minutes to build after pushing.`; + + if (botComment) { + // Don't update if already commented + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); diff --git a/README.md b/README.md index a6e65b9..a7f8507 100644 --- a/README.md +++ b/README.md @@ -223,3 +223,9 @@ and pass the url to your .env file: DISCORD_CLUSTER_MANAGER_API_BASE_URL=http://localhost:8080 ``` Please notice, you need to make sure both of them connects to same db instance. + +## PR Preview Deployments + +This project supports automatic PR preview deployments via Heroku Review Apps. When you open a PR, a preview environment is automatically created. + +For setup instructions, see [docs/review-apps-setup.md](docs/review-apps-setup.md). diff --git a/app.json b/app.json new file mode 100644 index 0000000..9b34d68 --- /dev/null +++ b/app.json @@ -0,0 +1,71 @@ +{ + "name": "kernelboard", + "description": "Kernelboard - GPU kernel development platform", + "repository": "https://github.com/your-org/kernelboard", + "formation": { + "web": { + "quantity": 1, + "size": "basic" + } + }, + "addons": [ + { + "plan": "heroku-postgresql:essential-0" + }, + { + "plan": "heroku-redis:mini" + } + ], + "buildpacks": [ + { + "url": "heroku/nodejs" + }, + { + "url": "heroku/python" + } + ], + "env": { + "SECRET_KEY": { + "generator": "secret" + }, + "FLASK_DEBUG": { + "value": "false" + }, + "DISCORD_BOT_TOKEN": { + "required": false, + "description": "Discord bot token for fetching scheduled events (optional for previews)" + }, + "DISCORD_GUILD_ID": { + "required": false, + "description": "Discord server/guild ID (optional for previews)" + }, + "DISCORD_CLIENT_ID": { + "required": false, + "description": "Discord OAuth2 client ID (optional for previews)" + }, + "DISCORD_CLIENT_SECRET": { + "required": false, + "description": "Discord OAuth2 client secret (optional for previews)" + }, + "DISCORD_CLUSTER_MANAGER_API_BASE_URL": { + "required": false, + "description": "GPU cluster manager API URL" + } + }, + "environments": { + "review": { + "addons": [ + "heroku-postgresql:essential-0", + "heroku-redis:mini" + ], + "env": { + "FLASK_DEBUG": { + "value": "true" + } + } + } + }, + "scripts": { + "postdeploy": "echo 'Review app deployed successfully'" + } +} diff --git a/docs/review-apps-setup.md b/docs/review-apps-setup.md new file mode 100644 index 0000000..4186043 --- /dev/null +++ b/docs/review-apps-setup.md @@ -0,0 +1,76 @@ +# Heroku Review Apps Setup + +This document describes how to set up Heroku Review Apps for PR preview deployments. + +## Prerequisites + +- Heroku CLI installed (`brew tap heroku/brew && brew install heroku`) +- Access to the Heroku team (`kernelbot`) +- Admin access to the GitHub repository + +## Setup Steps + +### 1. Create a Heroku Pipeline + +```bash +heroku pipelines:create kernelboard --app kernelboard --stage production --team kernelbot +``` + +### 2. Connect GitHub + +1. Go to the [Heroku Dashboard](https://dashboard.heroku.com) +2. Navigate to your pipeline +3. Click "Connect to GitHub" and authorize the repository + +### 3. Enable Review Apps + +1. In the pipeline view, click "Enable Review Apps" +2. Check "Create new review apps for new pull requests automatically" +3. Check "Destroy stale review apps automatically" (recommended: after 5 days) + +```bash +heroku reviewapps:enable --pipeline kernelboard --autodeploy --autodestroy +``` + +### 4. Configure Environment Variables + +Review apps auto-provision: +- `DATABASE_URL` (from heroku-postgresql add-on) +- `REDIS_URL` (from heroku-redis add-on) +- `SECRET_KEY` (auto-generated in code) + +Discord environment variables are optional for previews. The app will run without them, but Discord login and scheduled events won't work. + +If you want full functionality, inherit these from production: +- `DISCORD_BOT_TOKEN` +- `DISCORD_GUILD_ID` +- `DISCORD_CLIENT_ID` +- `DISCORD_CLIENT_SECRET` +- `DISCORD_CLUSTER_MANAGER_API_BASE_URL` + +## How It Works + +1. Create a PR against `main` +2. Heroku automatically builds and deploys a preview +3. A comment with a link to the pipeline appears on the PR +4. The preview updates on every push to the PR +5. The preview is destroyed when the PR is closed/merged + +## Troubleshooting + +### Review app crashes on startup + +Check the logs: +```bash +heroku logs --tail --app +``` + +Common issues: +- Missing `DATABASE_URL` or `REDIS_URL` - check that add-ons were provisioned +- The `app.json` file must be present in the repository root + +### Review apps not being created + +1. Verify GitHub is connected: Check pipeline settings in Heroku Dashboard +2. Verify review apps are enabled: `heroku reviewapps --pipeline kernelboard` +3. Check that `app.json` exists in the repository root diff --git a/kernelboard/lib/env.py b/kernelboard/lib/env.py index 4eb5734..e65f943 100644 --- a/kernelboard/lib/env.py +++ b/kernelboard/lib/env.py @@ -1,20 +1,34 @@ import os +import secrets def check_env_vars(): """ Check that required environment variables are set. If they are not set, print a message and exit. + + Core infrastructure vars (DATABASE_URL, REDIS_URL) are always required. + Other vars are optional for preview/review app deployments. """ + # Core infrastructure - always required required_env_vars = [ "DATABASE_URL", - "DISCORD_CLIENT_ID", - "DISCORD_CLIENT_SECRET", "REDIS_URL", - "SECRET_KEY", - "DISCORD_CLUSTER_MANAGER_API_BASE_URL", ] + + # Optional for preview deployments - set defaults if not provided + optional_with_defaults = { + "SECRET_KEY": secrets.token_hex(32), + "DISCORD_CLIENT_ID": "preview-disabled", + "DISCORD_CLIENT_SECRET": "preview-disabled", + "DISCORD_CLUSTER_MANAGER_API_BASE_URL": "http://localhost:8080", + } + + for var, default in optional_with_defaults.items(): + if os.getenv(var) is None: + os.environ[var] = default + missing_env_vars = [var for var in required_env_vars if os.getenv(var) is None] if missing_env_vars: