Harden staging and bring GitHub/Gitea CI to parity
Address issue #20 and the CI divergence between Gitea and GitHub. Issue #20 (staging seeded from a prod snapshot): - Read SECRET_KEY from the environment with the insecure dev key as fallback, so each deployment can have its own key. - Add a `scrub_staging` management command that clears django_session and the django-q schedule/queue/results, removing copied prod sessions and the inherited convert_prices() schedule. - Run the scrub from entrypoint.sh when STAGING=true, and wire STAGING plus a per-branch SECRET_KEY into the Gitea staging deploy. CI parity (both systems kept, independent): - Add the Node/pnpm/TypeScript build steps to the Gitea build workflow to match the GitHub test job. - Add a GitHub staging workflow that deploys per-branch ephemeral instances to Fly.io (*.fly.dev) with a fresh database seeded from sample fixtures and its own SECRET_KEY, never production data. Tears the app down on branch delete and comments the URL on the open PR via github-script. - Add fly.staging.toml and a LOAD_SAMPLE_DATA entrypoint hook for the fresh-database public staging. https://claude.ai/code/session_01KYjUcNjLfZ8Hq1GAC8J4oZ
This commit is contained in:
@@ -19,6 +19,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --frozen
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install pnpm and JS dependencies
|
||||||
|
run: npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
- name: Build TypeScript
|
||||||
|
run: make ts
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: uv run playwright install --with-deps chromium
|
run: uv run playwright install --with-deps chromium
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ jobs:
|
|||||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
|
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
|
||||||
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
|
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
|
||||||
echo "HOST=tracker-${SLUG}.home.arpa" >> "$GITHUB_ENV"
|
echo "HOST=tracker-${SLUG}.home.arpa" >> "$GITHUB_ENV"
|
||||||
|
# Per-staging secret so each instance has its own key, decoupling it
|
||||||
|
# from prod even though the database is seeded from a prod snapshot.
|
||||||
|
echo "STAGING_SECRET_KEY=staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: docker build -t "timetracker:staging-${SLUG}" .
|
run: docker build -t "timetracker:staging-${SLUG}" .
|
||||||
@@ -32,6 +35,8 @@ jobs:
|
|||||||
-e PUID=1000 \
|
-e PUID=1000 \
|
||||||
-e PGID=100 \
|
-e PGID=100 \
|
||||||
-e DATA_DIR=/home/timetracker/app/data \
|
-e DATA_DIR=/home/timetracker/app/data \
|
||||||
|
-e STAGING=true \
|
||||||
|
-e "SECRET_KEY=${STAGING_SECRET_KEY}" \
|
||||||
-e "CSRF_TRUSTED_ORIGINS=https://${HOST}" \
|
-e "CSRF_TRUSTED_ORIGINS=https://${HOST}" \
|
||||||
-v "timetracker-staging-${SLUG}:/home/timetracker/app/data" \
|
-v "timetracker-staging-${SLUG}:/home/timetracker/app/data" \
|
||||||
-l "caddy=${HOST}" \
|
-l "caddy=${HOST}" \
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
name: Staging deployment
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore: [main]
|
||||||
|
delete:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: staging-${{ github.event.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute staging name
|
||||||
|
run: |
|
||||||
|
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30)
|
||||||
|
APP="timetracker-staging-${SLUG}"
|
||||||
|
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
|
||||||
|
echo "APP=${APP}" >> "$GITHUB_ENV"
|
||||||
|
echo "HOST=${APP}.fly.dev" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Set up flyctl
|
||||||
|
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
|
||||||
|
- name: Create app if missing
|
||||||
|
run: |
|
||||||
|
if ! flyctl status --app "$APP" >/dev/null 2>&1; then
|
||||||
|
flyctl apps create "$APP" --org personal
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set staging secrets
|
||||||
|
run: |
|
||||||
|
# Per-app SECRET_KEY so each staging instance is independent and no
|
||||||
|
# session cookie is shared across instances or with production.
|
||||||
|
SECRET_KEY="staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')"
|
||||||
|
flyctl secrets set --app "$APP" --stage \
|
||||||
|
"SECRET_KEY=${SECRET_KEY}" \
|
||||||
|
"CSRF_TRUSTED_ORIGINS=https://${HOST}"
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: flyctl deploy --app "$APP" --config fly.staging.toml --remote-only --yes
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Comment staging URL on PR
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const host = process.env.HOST;
|
||||||
|
const branch = process.env.BRANCH;
|
||||||
|
const body = `Staging deployment: https://${host}`;
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const pulls = await github.rest.pulls.list({
|
||||||
|
owner, repo, state: "open", head: `${owner}:${branch}`,
|
||||||
|
});
|
||||||
|
const pr = pulls.data[0];
|
||||||
|
if (!pr) {
|
||||||
|
core.info(`No open PR for branch '${branch}', skipping comment`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||||
|
owner, repo, issue_number: pr.number,
|
||||||
|
});
|
||||||
|
if (comments.some((comment) => comment.body === body)) {
|
||||||
|
core.info(`Staging URL already commented on PR #${pr.number}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner, repo, issue_number: pr.number, body,
|
||||||
|
});
|
||||||
|
core.info(`Commented staging URL on PR #${pr.number}`);
|
||||||
|
|
||||||
|
teardown:
|
||||||
|
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ github.event.ref }}
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Set up flyctl
|
||||||
|
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
|
||||||
|
- name: Destroy staging app
|
||||||
|
run: |
|
||||||
|
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30)
|
||||||
|
APP="timetracker-staging-${SLUG}"
|
||||||
|
flyctl apps destroy "$APP" --yes 2>/dev/null || true
|
||||||
@@ -20,6 +20,25 @@ chown "$PUID:$PGID" /var/log/supervisor
|
|||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
python manage.py collectstatic --clear --no-input
|
python manage.py collectstatic --clear --no-input
|
||||||
|
|
||||||
|
# Staging seeded from a production snapshot: remove copied sessions and the
|
||||||
|
# inherited django-q schedule/queue so staging neither shares prod's session
|
||||||
|
# cookies nor independently runs scheduled tasks (see issue #20).
|
||||||
|
if [ "${STAGING:-false}" = "true" ]; then
|
||||||
|
python manage.py scrub_staging
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Public staging with a fresh database (e.g. Fly.io): load demo data instead
|
||||||
|
# of any production snapshot. Runs once while the games table is empty.
|
||||||
|
if [ "${LOAD_SAMPLE_DATA:-false}" = "true" ]; then
|
||||||
|
python manage.py shell -c "
|
||||||
|
from games.models import Game
|
||||||
|
from django.core.management import call_command
|
||||||
|
if not Game.objects.exists():
|
||||||
|
call_command('loaddata', 'sample.yaml')
|
||||||
|
print('Loaded sample data.')
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "${CREATE_DEFAULT_SUPERUSER:-false}" = "true" ]; then
|
if [ "${CREATE_DEFAULT_SUPERUSER:-false}" = "true" ]; then
|
||||||
python manage.py shell -c "
|
python manage.py shell -c "
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Shared Fly.io configuration for ephemeral, per-branch GitHub staging deploys.
|
||||||
|
#
|
||||||
|
# The app name is NOT set here on purpose; each branch supplies its own via
|
||||||
|
# `flyctl deploy --app timetracker-staging-<slug>`. These instances run with a
|
||||||
|
# fresh database seeded from sample fixtures (never production data) and their
|
||||||
|
# own SECRET_KEY, so they are safe to expose on a public *.fly.dev hostname.
|
||||||
|
|
||||||
|
primary_region = "ams"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
PROD = "1"
|
||||||
|
TZ = "Europe/Prague"
|
||||||
|
DATA_DIR = "/home/timetracker/app/data"
|
||||||
|
LOAD_SAMPLE_DATA = "true"
|
||||||
|
CREATE_DEFAULT_SUPERUSER = "true"
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = 8000
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = "stop"
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 0
|
||||||
|
|
||||||
|
[[vm]]
|
||||||
|
size = "shared-cpu-1x"
|
||||||
|
memory = "512mb"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from django.contrib.sessions.models import Session
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django_q.models import OrmQ, Schedule, Task
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Remove copied production artifacts from a staging database seeded "
|
||||||
|
"from a production snapshot: clears authenticated sessions and the "
|
||||||
|
"django-q schedule/queue/results so staging does not share prod's "
|
||||||
|
"session cookies or independently run scheduled tasks."
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
sessions_deleted, _ = Session.objects.all().delete()
|
||||||
|
schedules_deleted, _ = Schedule.objects.all().delete()
|
||||||
|
tasks_deleted, _ = Task.objects.all().delete()
|
||||||
|
queued_deleted, _ = OrmQ.objects.all().delete()
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
"Scrubbed staging database: "
|
||||||
|
f"{sessions_deleted} session(s), "
|
||||||
|
f"{schedules_deleted} schedule(s), "
|
||||||
|
f"{tasks_deleted} task result(s), "
|
||||||
|
f"{queued_deleted} queued task(s) removed."
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.sessions.models import Session as DjangoSession
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from django_q.models import Schedule
|
||||||
|
|
||||||
|
|
||||||
|
class ScrubStagingTest(TestCase):
|
||||||
|
def test_scrub_removes_sessions_and_schedules(self):
|
||||||
|
DjangoSession.objects.create(
|
||||||
|
session_key="copied-from-prod",
|
||||||
|
session_data="",
|
||||||
|
expire_date=now() + timedelta(days=1),
|
||||||
|
)
|
||||||
|
Schedule.objects.create(
|
||||||
|
func="games.tasks.convert_prices",
|
||||||
|
name="Update converted prices",
|
||||||
|
schedule_type=Schedule.MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(DjangoSession.objects.count(), 1)
|
||||||
|
self.assertEqual(Schedule.objects.count(), 1)
|
||||||
|
|
||||||
|
call_command("scrub_staging")
|
||||||
|
|
||||||
|
self.assertEqual(DjangoSession.objects.count(), 0)
|
||||||
|
self.assertEqual(Schedule.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_scrub_is_safe_on_empty_database(self):
|
||||||
|
# Should not raise when there is nothing to remove (fresh staging DB).
|
||||||
|
call_command("scrub_staging")
|
||||||
|
|
||||||
|
self.assertEqual(DjangoSession.objects.count(), 0)
|
||||||
|
self.assertEqual(Schedule.objects.count(), 0)
|
||||||
@@ -21,7 +21,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = "django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@="
|
# Read from the environment so each deployment (prod, staging) can supply its
|
||||||
|
# own key; falls back to an insecure default for local development and tests.
|
||||||
|
SECRET_KEY = os.environ.get(
|
||||||
|
"SECRET_KEY",
|
||||||
|
"django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@=",
|
||||||
|
)
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = False if os.environ.get("PROD") else True
|
DEBUG = False if os.environ.get("PROD") else True
|
||||||
|
|||||||
Reference in New Issue
Block a user