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.

support

Found this useful? You can buy me a coffee.

☕ buy me a coffee