VPS part 5: The review and release workflow
Part 5 of 5 in a series about building a small self-hosted web server with an AI agent. Part 4 covers the blog itself.
The agent writes the code, and a person decides what ships. This post is about the process between those two, which grew one rule at a time over the first five releases. This series went through it too: each part was its own pull request.
Who does what
| Agent (Claude) | Admin (bajdev) | |
|---|---|---|
| Plan | proposes, asks questions | decides scope |
| Build | writes code, tests, docs | |
| Check | runs tests, runs the branch locally, takes screenshots | |
| Review | sends a compare link and the PR text | opens the PR, reviews it |
| Merge | merges | |
| Ship | deletes the branch, tags, deploys, checks the live site |
The loop
┌──────────────────────────────────────────────────────────────┐
│ 1. branch off main feature/<name> │
│ 2. build + test go vet, go test -race │
│ 3. run the branch locally PORT=180xx ./posts │
│ 4. screenshots desktop.png, mobile.png │
│ 5. release record pull-releases/NNN-<name>/ │
│ 6. push, send compare link ───────────▶ admin opens the PR │
│ reviews, merges │
│ 7. told "merged" ◀─────────── │
│ 8. verify the merge, delete the branch on Gogs │
│ 9. tag v<version> on the merge commit, push the tag │
│10. deploy from the tag, check the live site │
└──────────────────────────────────────────────────────────────┘
Steps 1–6 happen on the agent's side. Nothing reaches main without the
admin's merge, and nothing reaches the live site without coming from
main.
Release records: the PR description, in the repo
Gogs has no API for opening pull requests (part 3), so the agent can't write the PR description into Gogs. Instead, each pull request adds its own record to the repo:
pull-releases/
├── README.md how it works + index of every PR
├── 001-initial/
│ ├── README.md title on line 1, then what changed,
│ ├── desktop.png notes, testing and screenshots
│ └── mobile.png
├── 002-header-about-config/
├── 003-css-cache-busting/
├── 004-release-notes-page/
└── 005-post-author/
To open the PR, the admin pastes the record's first line as the title and the rest as the description. Because the record is committed on the branch, it's reviewed along with the change, and the history of what changed and why lives next to the code permanently.
Screenshots from the branch, not the live site
The screenshots show the branch's own build, running locally before it's deployed, so they show exactly what the merge will put live. They're taken with headless Chromium by a short script:
#!/usr/bin/env bash
# screenshot-site <url> <out-dir> [prefix]
# Saves <prefix>desktop.png (1280x900) and <prefix>mobile.png (390x844 at 2x).
set -euo pipefail
url=$1; out=$(realpath -m "$2"); prefix=${3:-}
mkdir -p "$out"
profile=$(mktemp -d); trap 'rm -rf "$profile"' EXIT
shot() { # <file> <width,height> [chromium flags...]
local file=$out/$1 size=$2; shift 2
rm -f "$file"
timeout 60 chromium --headless --disable-gpu --hide-scrollbars --no-first-run \
--user-data-dir="$profile" --window-size="$size" "$@" \
--screenshot="$file" "$url" >/dev/null 2>&1 || true
[[ -s $file ]] || { echo "screenshot failed: $file" >&2; return 1; }
echo "$file"
}
shot "${prefix}desktop.png" 1280,900
shot "${prefix}mobile.png" 390,844 --force-device-scale-factor=2
The agent can read images, so it looks at every screenshot before pushing. That's caught real problems: the first version of the release notes page showed each version twice, and squeezed the desktop screenshot into a thumbnail next to a very tall mobile one. Both were fixed before the admin ever saw them.
The records for the first two releases were written after the fact. Their
screenshots came from rebuilding each release's final commit in a
temporary git worktree and running it on a spare port.
Release notes on the site
Since the records are Markdown with screenshots, the site publishes them.
pull-releases/ is embedded in the binary, so the notes at
/releases always match the code that's running. Reaching them
from the About page takes one click. A test checks every folder: a record
that's misnamed, missing a screenshot or missing its title fails the build
instead of quietly disappearing from the site.
Versions and tags
Each code release bumps a constant in version.go. After the merge, the
merge commit gets an annotated tag:
git tag -a v0.4.0 cd75a2b -m "v0.4.0: Add author to post header" \
-m "Release 005. Notes: pull-releases/005-post-author/"
git push origin v0.4.0
| Tag | Release |
|---|---|
| v0.1.0 | Add Markdown blog server |
| v0.2.0 | Add About page, configurable Home link, RSS footer, MIT license |
| v0.2.1 | Add content hash to stylesheet URL; start pull-releases records |
| v0.3.0 | Add release notes pages |
| v0.4.0 | Add author to post header |
The first release had no version number when it shipped. Its tag was assigned later, when tags were backfilled, and the tag message says so. The agent checks before tagging: if the version wasn't bumped or the tag already exists, it reports that instead of tagging, so a version can never point at two different commits.
Content is different from code
Posts like this one go through the same review: a branch per post, a compare link, a merge. But they don't change the app, so they get no version bump, no tag and no release record. The change log stays a log of code changes.
Making it stick
None of this works if the agent forgets it between sessions. Two places keep it:
CLAUDE.md, the instruction file read at the start of every session: the server conventions from part 2, plus each step of this workflow as it was agreed.- The agent's memory: short notes about decisions and the reasons for them, such as "content-only PRs get no record" or "delete merged branches without asking".
Most rules started as a correction or a request in conversation: take screenshots, add a release notes page, delete branches after merging, tag releases. Each became one line in one of those two places.
What's next
So far the stack is one server, a git host, a test host and this blog. The port table still has a slot reserved for a deploy receiver, which could let a merge trigger the deploy without the agent being involved. If it gets built, it'll get a post of its own.