Vom Branch zur Live-URL — Worktree-PR-Workflow und Codeberg-Pages-Deploy

Vom Branch zur Live-URL — Worktree-PR-Workflow und Codeberg-Pages-Deploy

Artikel 10 · Serie: Agentic Coding mit Claude Code

Neun Artikel lang haben wir den Werkzeugkasten ausgepackt: Memory, Plan Mode, Skills, Slash Commands, Subagents, Worktrees, MCP, E2E-Tests, Hooks. Das Demo-Projekt byhaushalt hat drei Test-Layer, drei aktive Hooks und eine Vite-SPA die lokal sauber baut. Was es nicht hat: eine öffentliche URL. Niemand außer dem Entwickler sieht die Visualisierung. Gleichzeitig pusht das Repo bisher direkt auf main — auch als Solo-Projekt ein Workflow, der bei der erstbesten Eile leise zur Falle wird. Artikel 10 schließt beide Lücken: PR-Workflow als Konvention die Disziplin durch Tooling erzwingt, und ein Deploy-Pfad zu einer Subdomain unter rotecodefraktion.de — ohne CI, ohne VM, ohne eigenen Server.

Warum PR-Workflow bei einem Solo-Projekt

Ein PR-Workflow heißt: jede Änderung läuft über einen Arbeitsbranch, einen Pull Request und einen Merge in main. Beim Solo-Projekt ist das auf den ersten Blick Overhead. Niemand reviewt, niemand commentiert, die Hand die committet ist auch die Hand die merged. Was bringt das?

Drei Dinge, die Disziplin allein nicht garantiert:

Erstens: Branch-Protection auf main macht versehentliche Direkt-Pushes unmöglich. Ohne das Verbot wird in jedem Eil-Moment der git push origin main gemacht — und genau in diesen Momenten passieren die Unfälle. Ein konkretes Beispiel aus dieser Reihe selbst: beim Veröffentlichen von Artikel 7 ging das Banner-PNG versehentlich direkt auf master statt über den Preview-Branch. Aufgefangen wurde der Fehler durch Memory-Regeln und nachträgliche Korrektur, nicht durch das Tool. Branch-Protection hätte ihn von vornherein verhindert.

Zweitens: PRs erzeugen einen reviewbaren Diff. Auch ohne Reviewer ist es ein anderer Blick auf den Code als der lokale git diff. Der GitHub- oder Codeberg-Diff-Viewer zeigt Änderungen im Kontext, hebt unintended Whitespace-Drift hervor, listet betroffene Dateien gesammelt. Wer als Solo-Entwickler PRs offen lässt und am nächsten Morgen den Diff nochmal anschaut, fängt Bugs die er beim Live-Schreiben übersehen hat.

Drittens: PRs sind das natürliche Dock für CI. Auch wenn Artikel 10 ohne CI auskommt, ist der PR-Workflow der Andockpunkt, an dem später eine Pipeline lauffähig wird. Wer den Workflow erst dann einführt wenn die CI da ist, hat zwei Migrationen vor sich statt einer.

Das Hugo-Repo, das diese Reihe selbst hostet, lebt diesen Workflow seit dem Prolog. Jeder einzelne Artikel ging über einen preview/agentic-NN-<slug>-Branch durch einen PR mit Banner-Check und Build-Verifikation. Für byhaushalt führen wir die gleiche Konvention jetzt ein.

Branch-Protection in Codeberg aktivieren

Branch-Protection ist nicht im Code, sondern im Codeberg-UI:

  1. Im Repo unter Settings → Branches auf Add Rule klicken
  2. Branch Pattern: main
  3. Disable Push aktivieren (kein direkter Push mehr möglich)
  4. Enable Merge Whitelist — nur Owner darf mergen
  5. Require pull request reviews before merging auf 1 setzen (man reviewt sich selbst)
  6. Speichern

Danach scheitert jeder git push origin main direkt:

remote: error: GH006: Protected branch update failed for refs/heads/main.

Was bleibt: Arbeitsbranches mit Konvention feature/<thema>, fix/<thema>, docs/<thema>. Push auf den Arbeitsbranch, Codeberg-UI öffnet automatisch das PR-Template aus .gitea/PULL_REQUEST_TEMPLATE.md, das in v1.0 mit einer Checklist liegt: Tests grün, Hooks waren aktiv, Doku aktualisiert.

Bootstrap-Henne-Ei

Es gibt einen Schritt der Konvention bricht: der erste Commit der den Workflow einführt. Branch-Protection auf main aktivieren wäre kein Problem — aber das Deploy-Skript, das main für den Build braucht, muss erstmal selbst nach main. Dieser eine Bootstrap-Commit geht direkt durch, mit Erklärung im Commit-Message. Ab dem nächsten Commit greift die Konvention.

git worktree als Beschleuniger

Bevor wir das Deploy-Skript ansehen, ein kurzer Rückblick auf git worktree aus Artikel 6. Worktrees sind das Mittel, mit dem ein Repository zwei oder mehr Branches gleichzeitig im Filesystem haben kann — ohne git checkout, ohne stash-jonglieren, ohne die laufende IDE-Session aus den Augen zu verlieren.

Für Deploys ist das ideal. Der pages-Branch hat nichts mit dem main-Branch zu tun: er enthält Build-Output (index.html, Assets, .domains), keinen Source-Code. Wer ihn via git checkout pages wechselt, würde sein gesamtes Working-Tree austauschen, danach das Build-Skript laufen lassen, danach zurück zu main checkout — fünf Schritte, drei davon mit Stolperfallen für offene Dateien in der IDE. Mit Worktree hängt der pages-Branch in /tmp/byhaushalt-pages, das main-Repo bleibt in /Users/.../byhaushalt. Beide leben parallel, keiner stört den anderen.

Plan-File für Art 10

Task 1: PR-Workflow einführen.
  Branch-Protection in Codeberg-UI (manuell), .gitea/PULL_REQUEST_TEMPLATE.md,
  CLAUDE.md um Branch-Konvention erweitern.

Task 2: scripts/deploy.sh schreiben.
  Build via npm run build, Worktree für pages-Branch, rsync, .domains schreiben,
  Commit + Push, Cleanup im trap.

Task 3: DNS-CNAME setzen.
  byhaushalt.rotecodefraktion.de → byhaushalt.rotecodefraktion.codeberg.page.
  (User-Schritt, dokumentiert.)

Task 4: Tag v1.0.

Task 1: PR-Template und CLAUDE.md-Update

Das PR-Template (.gitea/PULL_REQUEST_TEMPLATE.md) hat drei Sektionen: was sich ändert, warum, und eine Checklist. Codeberg liest es automatisch und füllt damit den PR-Body bei jedem neuen PR vor:

## Was ändert sich

<!-- Kurze Beschreibung -->

## Begründung

<!-- Bezug zu Plan-File, Spec oder Issue -->

## Checklist

- [ ] Tests grün (pytest, vitest, playwright nach Bedarf)
- [ ] Hooks waren aktiv beim Entwickeln (Format, Commit-Guard, Stop)
- [ ] Doku aktualisiert (CLAUDE.md, README, Plan-File)
- [ ] Plan-File in `plans/` falls neuer Build-Output

## Test-Plan

<!-- Wie verifiziere ich diese Änderung manuell? -->

Die CLAUDE.md bekommt einen neuen Abschnitt der die Konvention festhält:

## Branch- und PR-Konvention (ab v1.0)

- `main` ist geschützt — keine direkten Pushes
- Arbeitsbranches: `feature/<thema>`, `fix/<thema>`, `docs/<thema>`
- Jede Änderung über PR mit `.gitea/PULL_REQUEST_TEMPLATE.md`
- Auch Solo-Commits gehen über Branch + PR (Konvention erzwingt Disziplin)
- `pages`-Branch ist ausschließlich Skript-Domäne — keine manuellen Commits dort

Der letzte Punkt ist wichtig: der pages-Branch ist kein Source, sondern Build-Output. Wer da von Hand committet, kollidiert beim nächsten Skript-Lauf, weil rsync --delete alle Files ersetzt.

Task 2: scripts/deploy.sh

Das Deploy-Skript ist die Mechanik die aus web/dist/ einen lauffähigen pages-Branch macht. Vier Kernschritte: Vorbedingungen prüfen, bauen, in den Worktree synchronisieren, pushen.

#!/usr/bin/env bash
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORKTREE="${TMPDIR:-/tmp}/byhaushalt-pages"
DOMAIN="byhaushalt.rotecodefraktion.de"

cd "$REPO_ROOT"

# preconditions: clean tree, on main (or user confirms)
[[ -n "$(git status --porcelain)" ]] && { echo "Uncommitted changes" >&2; exit 1; }
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$CURRENT_BRANCH" != "main" ]]; then
  echo "WARN: deploying from '$CURRENT_BRANCH'. Continue? [y/N]"
  read -r answer; [[ "$answer" == "y" ]] || exit 1
fi
SHA_SHORT="$(git rev-parse --short HEAD)"

# cleanup on exit, also on error
cleanup() {
  if git worktree list --porcelain | grep -q "$WORKTREE"; then
    git worktree remove --force "$WORKTREE" 2>/dev/null || true
  fi
  [[ -d "$WORKTREE" ]] && rm -rf "$WORKTREE"
}
trap cleanup EXIT

# build
(cd web && npm run build)

# pages worktree: orphan branch on first run, normal on re-runs
git fetch origin pages 2>/dev/null || true
if git show-ref --verify --quiet refs/remotes/origin/pages; then
  git worktree add "$WORKTREE" pages
  (cd "$WORKTREE" && git pull --rebase origin pages)
else
  git worktree add --orphan -b pages "$WORKTREE"
  (cd "$WORKTREE" && git rm -rf . 2>/dev/null || true)
fi

# sync build, write .domains
rsync -a --delete --exclude '.git' --exclude '.domains' web/dist/ "$WORKTREE/"
echo "$DOMAIN" > "$WORKTREE/.domains"

# commit + push (no-op if no change)
cd "$WORKTREE"
git add -A
if git diff --cached --quiet; then
  echo "==> No changes to deploy."
  exit 0
fi
git commit -m "deploy: $(date -u +%Y-%m-%dT%H:%M:%SZ) from main@${SHA_SHORT}"
git push origin pages

Drei Stellen sind nicht offensichtlich. Erstens das trap cleanup EXIT: ohne diese Zeile bleibt der Worktree nach einem Skript-Fehler in /tmp/byhaushalt-pages stehen, und der nächste Lauf scheitert mit „worktree already exists". Mit dem trap läuft Cleanup auch im Fehlerfall.

Zweitens die Fallunterscheidung zwischen erstem und folgenden Deploys: beim ersten Lauf existiert der pages-Branch noch nicht, also wird er als Orphan-Branch angelegt — also ohne History, ohne Verbindung zu main. Der git rm -rf .-Schritt direkt danach räumt das Tree des aktuellen HEAD weg, das git in den Orphan stagt. Bei späteren Läufen ist der Branch da, wird einfach mit git pull --rebase aktualisiert.

Drittens das rsync -a --delete --exclude '.domains': --delete entfernt aus dem Worktree alles, was nicht mehr im Build ist (z.B. ein altes JS-Bundle mit anderem Hash). --exclude '.domains' verhindert, dass die Datei vom rsync gelöscht wird, weil sie nicht in web/dist/ liegt. Direkt danach wird .domains neu geschrieben — idempotent.

Der erste Lauf zeigt was tatsächlich passiert:

$ ./scripts/deploy.sh
==> Building web/ ...
✓ built in 853ms
==> Creating orphan pages branch.
==> Syncing web/dist/ -> pages worktree ...
[pages (root-commit) 548d177] deploy: 2026-05-19T20:21:15Z from main@b1b93f6
 9 files changed, 58 insertions(+)
 create mode 100644 .domains
 create mode 100644 assets/index-BiATo7hK.css
 create mode 100644 assets/index-CrqzRUPD.js
 create mode 100644 data/haushalt.json
 create mode 100644 data/manifest.json
 create mode 100644 index.html
 ...
✓ Pushed pages branch.

Build 853 ms, neun Files (drei davon Web-Fonts), Push erfolgreich. Der pages-Branch existiert jetzt auf Codeberg.

Task 3: DNS und Custom Domain

Bis hier ist die Site auf Codebergs Infrastruktur — aber unter einer URL die niemand kennt. byhaushalt.rotecodefraktion.codeberg.page ist die kanonische Codeberg-Pages-URL für rotecodefraktion/byhaushalt. Funktioniert technisch, ist aber als öffentliche URL ungelenk.

Für die eigene Subdomain reicht ein DNS-CNAME. Im DNS-Panel für rotecodefraktion.de:

FieldValue
Namebyhaushalt
TypeCNAME
Databyhaushalt.rotecodefraktion.codeberg.page.
TTL60 (kurz für initiales Setup)

Der Punkt am Ende des Data-Feldes ist nicht optional. Ohne ihn interpretieren die meisten DNS-Provider den Wert als relativen Namen und hängen die Zone an. Genau das ist bei der ersten Konfiguration für diesen Artikel passiert:

$ dig +short CNAME byhaushalt.rotecodefraktion.de
byhaushalt.rotecodefraktion.codeberg.page.rotecodefraktion.de.

Falsch. Nach dem Fix mit trailing dot:

$ dig +short CNAME byhaushalt.rotecodefraktion.de
byhaushalt.rotecodefraktion.codeberg.page.

Korrekt.

Reihenfolge die funktioniert

Die einzelnen Schritte sind voneinander abhängig. Wer in der falschen Reihenfolge debuggt, jagt Phantom-Probleme. Was du dir merken solltest:

1. Erst pages-Branch mit Inhalt + .domains pushen. Codeberg-Pages-Server entscheidet anhand der Existenz des pages-Branchs im Repo, ob er irgendetwas ausliefert. Vor dem ersten Skript-Lauf gibt es weder Inhalt noch HTTPS-Cert für die Subdomain — egal was DNS sagt.

2. Cert-Wartezeit für <reponame>.<user>.codeberg.page einplanen. Direkt nach dem ersten Push beantragt Codeberg via Let’s Encrypt einen Cert für die kanonische URL. Das dauert ein paar Minuten. In der Zwischenzeit antwortet die URL mit TLS handshake error.

3. DNS-CNAME setzen. Parallel oder danach, beides geht. Ohne den vorherigen Schritt antwortet das CNAME-Ziel auch noch nicht, das ist also keine Verschwendung.

4. Cert für Custom-Domain abwarten. Sobald DNS aufgelöst wird und Codeberg den Host-Header byhaushalt.rotecodefraktion.de sieht, gleicht er ihn mit der .domains-Datei ab und beantragt einen zweiten Cert für die Subdomain. Auch das dauert wenige Minuten.

In der Praxis war das HTTPS-Cert für die Custom-Domain bei meinem Setup unter einer Minute nach dem korrekten DNS-Eintrag verfügbar — schneller als die Doku andeutet. Aber Edge-Cases mit längerer Propagation existieren, vor allem bei Providern mit langen TTLs auf der Parent-Zone.

Ein Detail das in der Doku fehlt: https://byhaushalt.rotecodefraktion.codeberg.page/ direkt aufgerufen antwortet bei meinem Setup weiterhin mit TLS handshake error, obwohl die Custom-Domain einwandfrei läuft. Codeberg scheint Certs primär für die im .domains deklarierten Hostnames auszustellen, nicht für die kanonische CNAME-Target-URL. Das ist kein Bug, sondern eine Eigenheit: die Codeberg-internal-URL ist als CNAME-Ziel gedacht, nicht als End-User-Live-URL.

Verifikation Live-Stand

$ curl -sI https://byhaushalt.rotecodefraktion.de/ | head -3
HTTP/2 200
allow: GET, HEAD, OPTIONS
alt-svc: h3=":443"; ma=2592000

$ curl -s https://byhaushalt.rotecodefraktion.de/ | grep -oE '<title>[^<]+</title>'
<title>byhaushalt — Bayerischer Haushalt 2026/27</title>

200 OK, gültiges TLS, HTML korrekt, Assets ausgeliefert. Die App ist live unter https://byhaushalt.rotecodefraktion.de/.

Was eine CI-Pipeline ergänzen würde

CI bewusst nicht in diesem Artikel. Drei Gründe.

Erstens: Codebergs Woodpecker-CI braucht eine separate Access-Freischaltung über ein Issue im Codeberg-e.V./requests-Repo. Wartezeit liegt im Volunteer-Backlog — kann Stunden oder Tage dauern, je nach Auslastung. Den Reihen-Abschluss daran zu hängen war keine Option.

Zweitens: Was CI lokal nicht kann, kann sie sehr wohl in einer sauberen Umgebung: Tests auf jeden Push automatisch ausführen, ohne dass jemand die lokalen Hooks aktiv haben muss. Playwright im sauberen Container statt im persönlichen Cache. Build-Reproduzierbarkeit auf einer dritten Maschine. Status-Badge im README. Das verdient einen eigenen Artikel, nicht einen Absatz am Ende von Artikel 10.

Drittens: Für die Reihe ist die Lehre vollständig auch ohne CI. Die Hooks aus Artikel 9 decken den lokalen Test-Lauf. Branch-Protection erzwingt die Konvention. Der manuelle ./scripts/deploy.sh ist transparent und debuggbar. Ein Bonus-Artikel zur Woodpecker-Pipeline kommt später wenn der Codeberg-Access da ist.

Stand bei v1.0

git clone https://codeberg.org/rotecodefraktion/byhaushalt.git
cd byhaushalt
git checkout v1.0
ls scripts/
# deploy.sh
cat .gitea/PULL_REQUEST_TEMPLATE.md | head -3
ls .claude/hooks/
# post-edit-format.sh
# pre-commit-guard.sh
# stop-quick-tests.sh

Live unter https://byhaushalt.rotecodefraktion.de/, vollständiger Stand bei byhaushalt @ v1.0.

v1.0 enthält gegenüber v0.9: das Deploy-Skript unter scripts/deploy.sh, das PR-Template unter .gitea/PULL_REQUEST_TEMPLATE.md, den pages-Branch mit dem ersten Live-Build, eine erweiterte CLAUDE.md mit Branch-Konvention und Deploy-Doku, und das Plan-File plans/v1.0-deploy.md. Die Test-Bilanz ist unverändert seit v0.9.

Rückblick auf die Reihe

Zehn Artikel, zehn Tags, ein Demo-Projekt. Was sich als Werkzeugkasten ergibt:

Wer einzelne Werkzeuge weglässt, hat trotzdem etwas — aber je mehr davon greift, desto weniger braucht das Setup persönliche Disziplin. Genau das ist der Kern: Agentic Coding wird nicht durch ein besseres Modell besser, sondern durch ein besseres Setup um das Modell herum.

Bonus: Validierung gegen eine zweite Quelle

Die Reihe wäre ohne Beleg unvollständig: dass unser Parser die richtigen Zahlen liefert, haben wir bisher gegen die Übersicht auf Seite 60–62 desselben PDF-Korpus geprüft — also gegen ein anderes Aggregat aus den eigenen Quelldaten. Das ist Selbstreferenz, kein Audit. Erst der Vergleich gegen eine zweite, unabhängige Quelle macht das Ergebnis belastbar. Das bayerische Finanzministerium liefert diese Quelle mit dem interaktiven Haushalts-Diagramm auf stmfh.bayern.de — die Daten dahinter sind als XML zugänglich.

Der Bonus-Artikel Drei Quellen, eine Wahrheit beschreibt, wie wir diesen Abgleich gebaut haben: pro Einzelplan eine Tabelle mit PDF-Kap-Abschluss, eigener Live-Aggregation und STMFH-Diagramm nebeneinander. Σ Einnahmen und Σ Ausgaben treffen das PDF auf den Cent genau (84.647,4 Mio. € je Seite), die 195 Mio. Restdifferenz gegen STMFH lassen sich als datenstandsbedingt erklären — ein im Frühjahr 2026 eingerichtetes Sondervermögen (SVIK), das in unseren PDFs vom 9. Mai noch nicht abgebildet ist. Der Artikel listet außerdem die fünf konkreten Parser-Bugfixes, die für die Cent-genaue Übereinstimmung nötig waren.

Lizenz

Die Daten sind nach bestem Wissen und Gewissen aufgearbeitet, das Repo bleibt offen und nutzbar für alle — auch für das STMFH. Das Projekt läuft unter MIT-Lizenz, mit einer Ausnahme:

It works like magic… and boy, have we patented it.

Zwei Klauseln über die Standard-MIT-Bedingungen hinaus:

  1. Use Restriction — Nutzung durch die Partei Alternative für Deutschland (AfD), ihre Untergliederungen (inkl. Junge Alternative und deren Nachfolger Generation Deutschland), Fraktionen, Stiftungen und Mandatsträger im Parteiauftrag ist ausgeschlossen.
  2. Distribution Restriction — Hosting, Einbettung, Verbreitung oder bewerbende Verlinkung der Software auf AfD-eigenen Plattformen sowie auf dem identifizierten rechtspopulistischen Medien-Ökosystem (COMPACT, Junge Freiheit, Sezession/IfS, PI-News, Tichys Einblick, Apollo News, NIUS, Sächsische Allgemeine) ist ausgeschlossen.

Was als nächstes kommt

Die Reihe ist abgeschlossen. Es fehlt noch ein stimmiges Design, eine Überprüfung auf Barrierefreiheit, eine API und mehr als ein Jahr Daten. Das Repo unter byhaushalt bleibt aktiv. Fragen, Forks und Issues sind willkommen.

Disclaimer

Seit Jahren weigert sich das Finanzministerium, die Rohdaten zu publizieren. Es schafft es nicht einmal, den Haushalt barrierefrei zu veröffentlichen — von einer Versionierung ganz zu schweigen. Das ist keine Digitalisierung. Dass das in einem Freistaat passiert, in dem sich der Staatsminister für Digitales, Fabian Mehring, selbst zum „Europameister in der Digitalisierung" erklärt, ist bewusste Behinderung der Öffentlichkeit — und wirft die Frage auf, was das zuständige Digital-Ressort eigentlich gegen den eigenen Kabinettskollegen unternimmt. Mehr dazu in meiner Einlassung im Bonus-Artikel.

Sollten in den Zahlen Fehler auftauchen, bitte ich schon jetzt um Nachsicht. Ich musste die Daten unter erschwerten Bedingungen verfügbar machen — etwas, das ich beim Finanzministerium bereits zu Söders Zeiten kritisiert und in den letzten zehn Jahren wiederholt angefordert habe.