Merge pull request #23 from KucharczykL/claude/optimistic-volta-dx6xhd
Django CI/CD / test (push) Successful in 4m35s
Django CI/CD / build-and-push (push) Successful in 2m39s

Harden staging and bring GitHub/Gitea CI to parity
This commit is contained in:
2026-06-14 16:46:54 +02:00
committed by GitHub
10 changed files with 293 additions and 38 deletions
+11
View File
@@ -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
+5
View File
@@ -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}" \
+98
View File
@@ -0,0 +1,98 @@
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
permissions:
pull-requests: write
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
+1 -1
View File
@@ -25,7 +25,7 @@ RUN uv run python manage.py gen_element_types
FROM node:22-bookworm-slim AS assets FROM node:22-bookworm-slim AS assets
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Corepack ships with Node and activates the pnpm version pinned in # Corepack ships with Node and activates the pnpm version pinned in
# package.json's "packageManager" field — no npm bootstrap needed. # package.json's "packageManager" field — no npm bootstrap needed.
RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts
+19
View File
@@ -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
+29
View File
@@ -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"
+55 -36
View File
@@ -1,71 +1,90 @@
- model: games.game
pk: 1
fields:
name: Nioh 2
wikidata: Q67482292
- model: games.game
pk: 2
fields:
name: Elden Ring
wikidata: Q64826862
- model: games.game
pk: 3
fields:
name: Cyberpunk 2077
wikidata: Q3182559
- model: games.purchase
pk: 1
fields:
game: 1
platform: 1
date_purchased: 2021-02-13
date_refunded: null
- model: games.purchase
pk: 2
fields:
game: 2
platform: 1
date_purchased: 2022-02-24
date_refunded: null
- model: games.purchase
pk: 3
fields:
game: 3
platform: 1
date_purchased: 2020-12-07
date_refunded: null
- model: games.platform - model: games.platform
pk: 1 pk: 1
fields: fields:
name: Steam name: Steam
group: PC group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 3 pk: 3
fields: fields:
name: Xbox Gamepass name: Xbox Gamepass
group: PC group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 4 pk: 4
fields: fields:
name: Epic Games Store name: Epic Games Store
group: PC group: PC
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 5 pk: 5
fields: fields:
name: Playstation 5 name: Playstation 5
group: Playstation group: Playstation
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 6 pk: 6
fields: fields:
name: Playstation 4 name: Playstation 4
group: Playstation group: Playstation
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 7 pk: 7
fields: fields:
name: Nintendo Switch name: Nintendo Switch
group: Nintendo group: Nintendo
created_at: "2020-01-01T00:00:00Z"
- model: games.platform - model: games.platform
pk: 8 pk: 8
fields: fields:
name: Nintendo 3DS name: Nintendo 3DS
group: Nintendo group: Nintendo
created_at: "2020-01-01T00:00:00Z"
- model: games.game
pk: 1
fields:
name: Nioh 2
wikidata: Q67482292
created_at: "2021-02-13T00:00:00Z"
updated_at: "2021-02-13T00:00:00Z"
- model: games.game
pk: 2
fields:
name: Elden Ring
wikidata: Q64826862
created_at: "2022-02-24T00:00:00Z"
updated_at: "2022-02-24T00:00:00Z"
- model: games.game
pk: 3
fields:
name: Cyberpunk 2077
wikidata: Q3182559
created_at: "2020-12-07T00:00:00Z"
updated_at: "2020-12-07T00:00:00Z"
- model: games.purchase
pk: 1
fields:
games: [1]
platform: 1
date_purchased: 2021-02-13
date_refunded: null
created_at: "2021-02-13T00:00:00Z"
updated_at: "2021-02-13T00:00:00Z"
- model: games.purchase
pk: 2
fields:
games: [2]
platform: 1
date_purchased: 2022-02-24
date_refunded: null
created_at: "2022-02-24T00:00:00Z"
updated_at: "2022-02-24T00:00:00Z"
- model: games.purchase
pk: 3
fields:
games: [3]
platform: 1
date_purchased: 2020-12-07
date_refunded: null
created_at: "2020-12-07T00:00:00Z"
updated_at: "2020-12-07T00:00:00Z"
@@ -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."
)
)
+41
View File
@@ -0,0 +1,41 @@
from datetime import timedelta
from django.contrib.sessions.models import Session as DjangoSession
from django.core.management import call_command
from django.test import TransactionTestCase
from django.utils.timezone import now
from django_q.models import Schedule
class ScrubStagingTest(TransactionTestCase):
# TransactionTestCase flushes the DB before each test instead of wrapping
# in a savepoint. Required here because scrub_staging deletes all sessions
# — a TestCase savepoint rollback would restore any sessions committed by
# earlier tests (e.g. force_login in test_paths_return_200) and leak state
# into the e2e live-server tests that follow.
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):
call_command("scrub_staging")
self.assertEqual(DjangoSession.objects.count(), 0)
self.assertEqual(Schedule.objects.count(), 0)
+6 -1
View File
@@ -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