Chaining a release to CI's own success, not a raw push

Local Fitness · No. 025

Shipped

v0.12.0 moved this repo onto a feature → dev → main branch model and wired the release so it fires automatically the moment CI turns green on main, gated so a promotion that doesn’t bump the version is a no-op. The same release cleared a backlog of dependency bumps, including moving the web build’s base image from Node 22 to Node 26. The branch model itself is just two long-lived branches and a habit of starting work from dev. The part worth a full walkthrough is the release workflow: chaining it to CI’s own success instead of a plain push, making it idempotent, and the trap that broke the container build the moment the base image moved.

Setup: what the release needs to exist first

Two GitHub Actions events look interchangeable here and aren’t. Triggering the release on push: branches: [main] fires the instant the commit lands, before you know whether that commit actually passes CI. Triggering it on workflow_run instead means the release job only runs after a specific named workflow finishes, and only checks whether that run’s conclusion was a success. A release built this way can never ship on top of a red build, because it doesn’t exist until the green one reports in.

That trigger needs three things already in place:

  • A CI workflow with a stable name, since workflow_run refers to it by name rather than by file path.
  • A single source of truth for the version, something a one-line command can read back out. This project uses pyproject.toml; a package.json version field works the same way with a different one-liner.
  • A changelog with a heading per version (## [x.y.z]), so the release notes can be pulled straight out of it instead of written by hand at release time.

Build it: a CI workflow the release can key off

Nothing about this job is release-specific yet. It just needs to run on both the integration branch and the deploy branch, and it needs a name the next workflow can reference.

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main, dev]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: echo "run your real build and test steps here"

Running it on both branches matters for the chain: the same job has to have already gone green on main for the release workflow below to have anything to listen for.

Build it: chain the release off that workflow’s success

The release workflow does four things: wait for CI to finish on main, bail unless it succeeded, read the version, and create the release only if that version hasn’t shipped yet.

# .github/workflows/release.yml
name: Release
on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]
    branches: [main]

jobs:
  release:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.workflow_run.head_sha }}

      - name: Read version
        id: ver
        run: |
          version=$(grep -m1 '^version' pyproject.toml | sed -E 's/.*"(.*)".*/\1/')
          echo "version=$version" >> "$GITHUB_OUTPUT"

      - name: Create release if this version is new
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          tag="v${{ steps.ver.outputs.version }}"
          if gh release view "$tag" >/dev/null 2>&1; then
            echo "$tag already released, nothing to do."
            exit 0
          fi
          notes=$(awk -v v="${{ steps.ver.outputs.version }}" '
            $0 ~ "^## \\[" v "\\]" {f=1; next}
            /^## \[/ {f=0}
            f {print}
          ' CHANGELOG.md)
          gh release create "$tag" --target "${{ github.event.workflow_run.head_sha }}" \
            --title "$tag" --notes "$notes"

gh release create does the shipping: --target pins the release to the exact commit CI validated, and --notes takes the changelog section the awk block pulled out. gh release view is the whole guard. If a tag already has a release, checking it returns success and the job exits without touching anything; if it comes back empty, the job cuts a new one. That’s what makes the workflow safe to fire on every single CI success: most of those runs will find nothing new and do nothing.

Use it: what a reader can check without a live repo

The version read and the changelog extraction are cheap to try before you ever push a workflow. Drop a pyproject.toml and a CHANGELOG.md with a version heading in a scratch directory and run the same two commands the workflow runs:

version=$(grep -m1 '^version' pyproject.toml | sed -E 's/.*"(.*)".*/\1/')
echo "version=$version"

notes=$(awk -v v="$version" '
  $0 ~ "^## \\[" v "\\]" {f=1; next}
  /^## \[/ {f=0}
  f {print}
' CHANGELOG.md)
echo "$notes"

Against a CHANGELOG.md with a ## [0.2.0] - 2026-07-11 heading followed by an ### Added bullet, that produces:

version=0.2.0

### Added
- Automatic release on a green CI run against main.

That’s the exact text gh release create will use for the release body once the workflow runs for real. The part you can’t test locally is the trigger chain itself: push the two workflows, land a commit on main that bumps the version and adds a changelog heading, and watch the Actions tab. CI should go green first, then Release should start right after with workflow_run as its trigger, and gh release view v<version> afterward should show the release it just created. Promote a second commit that doesn’t touch the version and the same workflow should run, find the tag already released, and exit without creating anything.

Gotchas

Editing the trigger doesn’t take effect where you’d expect. workflow_run only evaluates the copy of the workflow file that lives on the repository’s default branch, never the one on the branch that triggered it (per GitHub’s own docs). Change the branches: filter or add a new workflow to listen for on a feature branch, and nothing happens until that change actually lands on main. The first promotion that introduces or edits this trigger has to be the one that puts it on main; there’s no way to test the new trigger from a branch first.

A base image bump can break something CI never sees. This release also bumped the web build’s base image from node:22 to node:26, and that broke corepack enable inside the Docker build with a plain “command not found.” Corepack stopped shipping bundled with Node as of Node 25 (nodejs/corepack), so a container that used to get it for free now needs npm install -g corepack first. CI didn’t catch it because CI ran the frontend build directly on the runner, not inside the image actually being shipped; the two environments only diverged once the base image changed. If your CI validates on the host but ships a container, a runtime bump in the Dockerfile is the one class of change host-only CI structurally can’t catch, so it needs a manual container build and boot as part of the check, not just a green CI run.

Dependency bot PRs pile up and conflict with each other. Several open bumps against the same package manifest and lockfile were fine individually, but once the first one merged, the rest no longer applied cleanly, and the bot’s own rebase automation didn’t pick up the slack. The fix wasn’t waiting; it was pulling the remaining version bumps into one manual commit against the current lockfile and verifying it with a real build before closing out the stale PRs. A weekly bot cadence is only free until two of its own PRs land on the same file in the same week.

Sources

Changelog

  • chore: release 0.12.0 — feature->dev->main branch model + auto-tag (#36) (#37) (94426f5)
  • fix: install corepack explicitly for node:26 web-builder (1e4e80d)
  • ci: adopt feature->dev->main branch model (plumbing, pre-protection) (df36aa6)
  • chore(deps): bump react-router-dom, lucide-react, vite in /web (9255a52)
  • chore(deps): Bump react and @types/react in /web (#15) (d8e6a97)
  • chore(deps): bump node from 22-bookworm-slim to 26-bookworm-slim (#29) (6502956)
  • chore(deps): bump actions/checkout from 4 to 7 (#30) (9e1c09b)