From Branch to Live URL — Worktree PR Workflow and Codeberg Pages Deploy

From Branch to Live URL — Worktree PR Workflow and Codeberg Pages Deploy

Article 10 · Series: Agentic Coding with Claude Code

For nine articles we unpacked the toolbox: memory, plan mode, skills, slash commands, subagents, worktrees, MCP, E2E tests, hooks. The demo project byhaushalt has three test layers, three active hooks, and a Vite SPA that builds cleanly locally. What it does not have: a public URL. Nobody except the developer sees the visualization. At the same time, the repo so far pushes directly to main — even for a solo project a workflow that quietly becomes a trap at the first moment of haste. Article 10 closes both gaps: a PR workflow as a convention that enforces discipline through tooling, and a deploy path to a subdomain under rotecodefraktion.de — without CI, without a VM, without our own server.

Why a PR Workflow on a Solo Project

A PR workflow means every change runs through a working branch, a pull request, and a merge into main. On a solo project that looks like overhead at first. Nobody reviews, nobody comments, the hand that commits is the same hand that merges. What does it buy us?

Three things discipline alone does not guarantee:

First: branch protection on main makes accidental direct pushes impossible. Without the prohibition, the git push origin main happens in every rushed moment — and that is exactly when accidents happen. A concrete example from this series itself: when publishing Article 7 the banner PNG accidentally went directly to master instead of through the preview branch. The error was caught by memory rules and after-the-fact correction, not by tooling. Branch protection would have prevented it from the start.

Second: PRs produce a reviewable diff. Even without a reviewer it is a different look at the code than the local git diff. The GitHub or Codeberg diff viewer shows changes in context, highlights unintended whitespace drift, lists affected files together. Anyone working solo who leaves PRs open and looks at the diff the next morning catches bugs they missed while writing live.

Third: PRs are the natural dock for CI. Even though Article 10 ships without CI, the PR workflow is the docking point where a pipeline can later attach. Anyone who introduces the workflow only when CI arrives has two migrations ahead instead of one.

The Hugo repo that hosts this series has lived this workflow since the prolog. Every single article went through a preview/agentic-NN-<slug> branch and a PR with banner check and build verification. For byhaushalt we now introduce the same convention.

Enabling Branch Protection in Codeberg

Branch protection is not in code but in the Codeberg UI:

  1. In the repo under Settings → Branches click Add Rule
  2. Branch pattern: main
  3. Enable Disable Push (no direct pushes possible anymore)
  4. Enable Merge Whitelist — only owner may merge
  5. Set Require pull request reviews before merging to 1 (you review yourself)
  6. Save

After that every git push origin main fails directly:

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

What remains: working branches with the convention feature/<topic>, fix/<topic>, docs/<topic>. Push to the working branch, the Codeberg UI automatically opens the PR template from .gitea/PULL_REQUEST_TEMPLATE.md, which in v1.0 contains a checklist: tests passing, hooks were active, docs updated.

Bootstrap Chicken-and-Egg

There is one step that breaks the convention: the very first commit that introduces the workflow. Activating branch protection on main would be no problem — but the deploy script that needs main for the build must itself land on main first. This one bootstrap commit goes through directly, with an explanation in the commit message. From the next commit on, the convention kicks in.

git worktree as an Accelerator

Before we look at the deploy script, a short flashback to git worktree from Article 6. Worktrees are the means by which a repository can have two or more branches checked out in the filesystem at the same time — without git checkout, without stash juggling, without losing sight of the running IDE session.

For deploys this is ideal. The pages branch has nothing to do with the main branch: it contains build output (index.html, assets, .domains), no source code. Anyone who switches via git checkout pages would replace their entire working tree, then run the build script, then check out back to main — five steps, three of them with pitfalls for open files in the IDE. With a worktree, the pages branch lives in /tmp/byhaushalt-pages, the main repo stays in /Users/.../byhaushalt. Both live in parallel, neither bothers the other.

Plan File for Article 10

Task 1: Introduce PR workflow.
  Branch protection in Codeberg UI (manual), .gitea/PULL_REQUEST_TEMPLATE.md,
  extend CLAUDE.md with branch convention.

Task 2: Write scripts/deploy.sh.
  Build via npm run build, worktree for pages branch, rsync, write .domains,
  commit + push, cleanup in trap.

Task 3: Set DNS CNAME.
  byhaushalt.rotecodefraktion.de → byhaushalt.rotecodefraktion.codeberg.page.
  (User step, documented.)

Task 4: Tag v1.0.

Task 1: PR Template and CLAUDE.md Update

The PR template (.gitea/PULL_REQUEST_TEMPLATE.md) has three sections: what changes, why, and a checklist. Codeberg reads it automatically and prefills the PR body on every new PR:

## What changes

<!-- Short description -->

## Reasoning

<!-- Reference to plan file, spec, or issue -->

## Checklist

- [ ] Tests passing (pytest, vitest, playwright as needed)
- [ ] Hooks were active while developing (format, commit guard, stop)
- [ ] Docs updated (CLAUDE.md, README, plan file)
- [ ] Plan file in `plans/` if new build output

## Test plan

<!-- How do I verify this change manually? -->

CLAUDE.md gets a new section that captures the convention:

## Branch and PR convention (from v1.0)

- `main` is protected — no direct pushes
- Working branches: `feature/<topic>`, `fix/<topic>`, `docs/<topic>`
- Every change via PR with `.gitea/PULL_REQUEST_TEMPLATE.md`
- Solo commits also go via branch + PR (the convention enforces discipline)
- The `pages` branch is the script's domain only — no manual commits there

The last point matters: the pages branch is not source, it is build output. Anyone who commits there manually collides with the next script run, because rsync --delete replaces all files.

Task 2: scripts/deploy.sh

The deploy script is the mechanism that turns web/dist/ into a runnable pages branch. Four core steps: check preconditions, build, sync into the worktree, push.

#!/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

Three places are not obvious. First the trap cleanup EXIT: without this line the worktree stays in /tmp/byhaushalt-pages after a script error, and the next run fails with “worktree already exists”. With the trap, cleanup runs even on error.

Second the case distinction between first and subsequent deploys: on the first run the pages branch does not exist yet, so it is created as an orphan branch — without history, without connection to main. The git rm -rf . immediately afterwards clears the tree of the current HEAD that git stages into the orphan. On later runs the branch is there and gets refreshed with git pull --rebase.

Third the rsync -a --delete --exclude '.domains': --delete removes from the worktree everything no longer in the build (e.g. an old JS bundle with a different hash). --exclude '.domains' prevents rsync from deleting the file, because it is not in web/dist/. Right after, .domains is rewritten — idempotent.

The first run shows what actually happens:

$ ./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, nine files (three of them web fonts), push successful. The pages branch now exists on Codeberg.

Task 3: DNS and Custom Domain

Up to here the site is on Codeberg’s infrastructure — but under a URL nobody knows. byhaushalt.rotecodefraktion.codeberg.page is the canonical Codeberg Pages URL for rotecodefraktion/byhaushalt. Technically works, but clumsy as a public URL.

For the own subdomain a DNS CNAME is enough. In the DNS panel for rotecodefraktion.de:

FieldValue
Namebyhaushalt
TypeCNAME
Databyhaushalt.rotecodefraktion.codeberg.page.
TTL60 (short for initial setup)

The trailing dot in the Data field is not optional. Without it most DNS providers interpret the value as a relative name and append the zone. Exactly that happened on the first configuration for this article:

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

Wrong. After the fix with trailing dot:

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

Correct.

The Order That Works

The individual steps depend on each other. Anyone debugging in the wrong order chases phantom problems. What to remember:

1. First push the pages branch with content + .domains. Codeberg Pages server decides based on the existence of the pages branch in the repo whether to serve anything at all. Before the first script run there is neither content nor an HTTPS cert for the subdomain — no matter what DNS says.

2. Allow cert wait time for <reponame>.<user>.codeberg.page. Immediately after the first push Codeberg requests a Let’s Encrypt cert for the canonical URL. That takes a few minutes. In the meantime the URL answers with TLS handshake error.

3. Set DNS CNAME. In parallel or afterwards, both work. Without the previous step the CNAME target does not respond either, so it is not wasted.

4. Wait for the custom domain cert. Once DNS resolves and Codeberg sees the Host header byhaushalt.rotecodefraktion.de, it matches it against the .domains file and requests a second cert for the subdomain. That also takes a few minutes.

In practice the HTTPS cert for the custom domain was available under one minute after the correct DNS entry on my setup — faster than the docs suggest. But edge cases with longer propagation exist, especially with providers that have long TTLs on the parent zone.

One detail missing from the docs: https://byhaushalt.rotecodefraktion.codeberg.page/ directly accessed still answers with TLS handshake error on my setup, even though the custom domain works fine. Codeberg seems to issue certs primarily for the hostnames declared in .domains, not for the canonical CNAME target URL. That is not a bug but a quirk: the Codeberg-internal URL is intended as a CNAME target, not as an end-user live URL.

Live State Verification

$ 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, valid TLS, correct HTML, assets served. The app is live at https://byhaushalt.rotecodefraktion.de/.

What a CI Pipeline Would Add

CI deliberately not in this article. Three reasons.

First: Codeberg’s Woodpecker CI needs a separate access approval via an issue in the Codeberg-e.V./requests repo. Wait time depends on the volunteer backlog — could be hours or days, depending on load. Hanging the series finale on that was not an option.

Second: what CI cannot do locally, it can very well do in a clean environment: run tests automatically on every push without requiring anyone to have local hooks active. Playwright in a clean container instead of the personal cache. Build reproducibility on a third machine. Status badge in the README. That deserves its own article, not a paragraph at the end of Article 10.

Third: for the series the lesson is complete even without CI. The hooks from Article 9 cover the local test run. Branch protection enforces the convention. The manual ./scripts/deploy.sh is transparent and debuggable. A bonus article on the Woodpecker pipeline will come later, once Codeberg access is granted.

Status at 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 at https://byhaushalt.rotecodefraktion.de/, full state at byhaushalt @ v1.0.

v1.0 adds over v0.9: the deploy script under scripts/deploy.sh, the PR template under .gitea/PULL_REQUEST_TEMPLATE.md, the pages branch with the first live build, an expanded CLAUDE.md with branch convention and deploy doc, and the plan file plans/v1.0-deploy.md. The test count is unchanged since v0.9.

Series Recap

Ten articles, ten tags, one demo project. What the toolbox adds up to:

Anyone who leaves out individual tools still has something — but the more of them mesh, the less the setup needs personal discipline. That is exactly the core: agentic coding does not get better through a better model, but through a better setup around the model.

Bonus: Validation Against a Second Source

The series would be incomplete without proof: that our parser produces the right numbers we have so far validated against the overview on pages 60–62 of the same PDF corpus — that is, against a different aggregate from our own source data. That is self-reference, not an audit. Only the comparison against a second, independent source makes the result robust. The Bavarian finance ministry provides this source with the interactive budget diagram at stmfh.bayern.de — the data behind it is accessible as XML.

The bonus article Three Sources, One Truth describes how we built this comparison: per individual plan a table with the PDF chapter close, the own live aggregation, and the STMFH diagram side by side. Σ revenues and Σ expenses match the PDF to the cent (84,647.4 Mio. € per side); the 195 Mio. residual difference against STMFH explains itself as data-vintage-related — a special fund (SVIK) set up in early 2026 that is not yet reflected in our PDFs dated May 9. The article also lists the five concrete parser bugfixes that were necessary to reach cent-precise agreement.

License

The data has been prepared to the best of our knowledge, and the repo stays open and usable for everyone — including STMFH. The project ships under MIT, with one exception:

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

Two clauses on top of the standard MIT terms:

  1. Use Restriction — Use by the party Alternative für Deutschland (AfD), its substructures (incl. Junge Alternative and its successor Generation Deutschland), parliamentary groups, foundations and elected representatives acting in a party capacity is excluded.
  2. Distribution Restriction — Hosting, embedding, distribution, or promotional linking of the software on AfD-owned platforms as well as on the identified right-wing populist media ecosystem (COMPACT, Junge Freiheit, Sezession/IfS, PI-News, Tichys Einblick, Apollo News, NIUS, Sächsische Allgemeine) is excluded.

What Comes Next

The series is done. A coherent design, an accessibility audit, an API and more than a year of data are still missing. The repo at byhaushalt stays active. Questions, forks and issues are welcome.

Disclaimer

The Bavarian finance ministry has refused for years to publish raw budget data. It does not even manage to release the budget in an accessible form — let alone version it. That is not digitalization. That this happens in a state whose minister for digital affairs, Fabian Mehring, publicly calls himself „European champion of digitalization" is a deliberate obstruction of the public — and raises the question of what the responsible digital ministry actually does about its own cabinet colleague. More on this in my note in the bonus article.

Should errors turn up in the numbers, I ask for forbearance in advance. The data had to be made available under makeshift conditions — something I have criticized at the finance ministry since the Söder years and asked for repeatedly over the past ten years.