For a long time this site deployed on every push to main. A blog post, a CSS tweak, a half-finished refactor — all the same path: merge, and it's live within minutes. The runner builds public/, uploads it to Netlify, done.
That's exactly right for content. It's wrong for code.
I wanted two things that sound contradictory:
- Blog posts should publish the moment they land. No version, no ceremony.
- Code — features, refactors — should go live only when I deliberately cut a release. Tagged
vMAJOR.MINOR.PATCH, with notes, on the Releases page.
This is the story of making both true at once, and the handful of things that broke on the way.
## The one constraint that shapes everything
A static site is built and deployed from a single branch's HEAD. When main deploys, it deploys everything on main at that instant. There's no "deploy these files but not those."
That single fact kills the obvious approach. If I let unreleased feature code sit on main "but not deploy it," the next blog post — which does deploy — would drag that code live with it. You can't gate code on a branch you also publish from.
So the rule writes itself: unreleased code must never touch main. Which is the textbook case for the release-branch model — develop stages code that isn't live, main is production, and code only crosses from one to the other through a release.
## The model: three lanes into main
Everything that reaches production arrives one of three ways. develop is the staging line where feature work accumulates, invisible to the world.
| Lane | Branch (off…) | Into main via |
Result |
|---|---|---|---|
| Blog | content/* (off main) |
direct MR | deploys immediately, no release |
| Hotfix | hotfix/* (off main) |
direct MR | deploys + auto patch release |
| Release | develop |
the release pipeline promotes it | deploys + minor/major release |
| (features) | feature/* (off develop) |
— | stays on develop, not live |
A blog post branches off main and merges straight back — it only touches content/, so there's no unreleased code riding along. A feature branches off develop and waits there. The post you're reading went through the blog lane: a content/* branch, a merge request into main, live.
## A release is a pipeline variable
I'd built a version of this before — a Gradle plugin for an Android project that bumped the version from a CI variable and tagged it. The shape carried over cleanly: the human picks the bump type; CI computes the number.
To cut a release I run a pipeline on main with one variable:
VERSION_TYPE = MAJOR | MINOR | PATCH
No deciding "is the next one 1.4.0 or 1.3.1" by hand — I decide whether it's a breaking change, a feature, or a fix, and the math is mechanical. That math is a tiny Node script with no dependencies:
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
const [, major, minor, patch] = current.match(SEMVER).map(Number);
switch (process.env.VERSION_TYPE) {
case 'MAJOR': next = `${major + 1}.0.0`; break;
case 'MINOR': next = `${major}.${minor + 1}.0`; break;
case 'PATCH': next = `${major}.${minor}.${patch + 1}`; break;
}
It reads the current version from package.json, writes back the bumped one, and prints it to stdout so the CI job can capture it as NEW_VERSION=$(node scripts/release/bump.mjs). package.json stays the single source of truth for the version.
## Release notes that skip the blog
A release should summarise what shipped — features and fixes — not "added three blog posts." Since I already write Conventional Commits (feat:, fix:, chore:), the changelog is a git log away. The trick is excluding content.
execFileSync('git', [
'log', range, '--no-merges', '--pretty=format:%H%x09%s',
'--', '.', ':(exclude)content',
]);
That -- . :(exclude)content pathspec keeps only commits that touched something outside content/. A commit that edited a blog post and nothing else is dropped — even if it's a feat(blog):. A commit that touched lib/ and a post stays. The remaining subjects get grouped by type into ### ✨ Features and ### 🐛 Fixes, and that Markdown becomes both the annotated tag message and the GitLab Release body.
GitLab has a native changelog generator, and I looked at it first. It turned out to be the wrong tool here: it keys off an explicit Changelog: git trailer rather than Conventional Commit prefixes, and — the dealbreaker — it has no path filtering. A 70-line script that excludes content by path fit the actual requirement better than the built-in.
## The pipeline: promote, bump, tag
The release job lives in .gitlab-ci.yml. Trimmed to its spine:
release:prepare:
stage: version
rules:
- if: '$VERSION_TYPE =~ /^(MAJOR|MINOR|PATCH)$/'
script:
- git checkout main && git pull --ff-only origin main
# promote the staged code — this is what makes a feature go live
- git merge --no-ff origin/develop -m "Merge develop into main for release"
- NEW_VERSION=$(node scripts/release/bump.mjs)
- PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
- node scripts/release/changelog.mjs "$PREV_TAG" HEAD > notes.md
- git commit -am "chore(release): v${NEW_VERSION}"
- git push origin main
- git tag -a "v${NEW_VERSION}" -F notes.md
- git push origin "v${NEW_VERSION}"
The order matters: merge develop first (that's the promotion — the staged features become part of main), then bump, generate notes, commit, tag. The push to main triggers the normal deploy; the tag push triggers the next stage.
## The tag becomes a GitLab Release
Pushing a v* tag fires a second pipeline. One job regenerates the notes for the tag range; another turns them into a Release using GitLab's release keyword and the official release-cli image:
release:publish:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
variables: { GIT_STRATEGY: none } # no clone needed; it's an API call
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
needs:
- job: release:notes
artifacts: true
script:
- echo "Publishing $CI_COMMIT_TAG"
release:
tag_name: '$CI_COMMIT_TAG'
name: 'Release $CI_COMMIT_TAG'
description: 'release_notes.md'
The Release is just a marker on an already-deployed main — the deploy happened when the bump commit landed. GIT_STRATEGY: none skips cloning the repo entirely, because creating a Release is a single API call against an existing tag.
## The hotfix lane
The model has a hole: what about an urgent fix you can't wait for a full release to ship? A hotfix branches off main (not develop — you're fixing what's live, not what's staged), merges straight back, and deploys on merge like any content change. The fix is live immediately.
The only thing left is to tag it. I didn't want to fumble with pipeline variables mid-incident, so a hotfix merge auto-cuts a patch release. The detection is a CI rule reading the merge commit's title:
autorelease:hotfix:
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_TITLE =~ /Merge branch .hotfix\//'
A Merge branch 'hotfix/csp-header' into 'main' matches; a content/* blog merge doesn't; neither does the chore(release): bump commit, so there's no loop. It bumps the patch, tags, and the same publish stage above turns it into a Release. One action during an incident: merge the MR.
## Things that broke
No build system survives contact with reality. Four that cost me time:
### 1. curl isn't in node:24-alpine
A nightly job fetches the live feed to decide whether anything actually changed. It silently "worked" by always deploying — because the image has no curl, every fetch failed, and the failure was being swallowed. The fix is the same apk add the other jobs use:
- apk add --no-cache curl > /dev/null 2>&1
The lesson wasn't "install curl." It was that a fetch failure was being treated as a content change. Failures should fail loudly, not fall through to the expensive default.
### 2. A colon turned a script line into a YAML map
This one passed every local check and still broke the pipeline:
# Breaks — GitLab: "script config should be a string or a nested array of strings"
- git commit -m "chore(release): ${TAG}"
The : after chore(release) is YAML's mapping separator. As a bare list item, the line parses as { 'git commit -m "chore(release)': '${TAG}"' } — a map, not a string. My local yaml.safe_load happily parsed it as a dict without error, which is exactly why my check missed it. The fix is to quote the whole line so the colon is literal:
- 'git commit -m "chore(release): ${TAG}"'
The same text is fine inside a | block elsewhere — block scalars don't interpret :. The real fix was to my verification: assert every script entry is a string, not just that the YAML parses.
### 3. Scheduling is by date, not time
Future-dated posts are excluded from the build until their day arrives — the date filter strips the time component entirely:
const normDate = (d) => d instanceof Date
? d.toISOString().slice(0, 10)
: String(d).slice(0, 10);
Note
So a post's timestamp is cosmetic for display; the gate is the date. A future-dated post sits dormant on main and a nightly build publishes it once it's due. Worth knowing before you expect "publish at 10 PM" to mean 10 PM.
### 4. No empty commits
Bumping the version produces a real one-line diff, so the chore(release) commit is never empty. But the very first release was a special case — the version was already 1.0.0, so there was nothing to bump. A guard skips the commit entirely rather than manufacturing an empty one:
if git diff --quiet -- package.json; then
echo "version unchanged — tagging current main, no commit"
else
git commit -am "chore(release): ${TAG}"
fi
The tag carries the marker when there's no diff to commit.
## Where it stands
main now moves on exactly three events: a blog merge, a hotfix merge, or a release. Features sit on develop until I decide they ship, and when they do, they ship together under one version with notes generated from the commits. Blogs — like this one — still go live the moment they merge.
The whole thing is a .gitlab-ci.yml, two small Node scripts, and a branch convention. No release framework, no new dependencies. That felt right for a site that's a hand-rolled static generator to begin with — the release process should weigh about as much as the thing it releases.