ci(release): build the bump commit, add a minor tag, make the bump an explicit choice

docker.yml built and published whatever `main` pointed to when the build
and merge jobs started, not the commit it had just bumped and tagged, so
anything that landed in between shipped under a version that did not
contain it. build, merge and release-helm now check out the bump commit,
the way docker-dev.yml already pinned its prerelease builds. The push of
that commit fast-forwards main from the commit the run started on, so a
race fails the release instead of skipping over it.

Nothing tied a release to a green CI run. Both release workflows now
refuse a commit unless every ci-ok check run on it has finished green,
and docker.yml refuses to run on anything but main.

bump=auto fell back to a patch whenever no prerelease was in flight,
which would have published the ORM release, the largest change the
project has made, as 4.3.4. auto now only finalizes a prerelease above
the last stable tag and otherwise stops, asking for patch, minor or
major. The inputs are unchanged. A patch that adds migrations gets a
warning, not a refusal, since patch releases have carried them before.

Stable releases also publish :MAJOR.MINOR, so an install can take the
fixes of one minor line without being moved onto the next on day one.
The tag tables in the wiki list it and the prerelease tag, and the root
CLAUDE.md describes the release and prerelease flow.
This commit is contained in:
Maurice
2026-10-08 11:42:45 +02:00
parent 4cfe6b59ba
commit d88dc17e8e
5 changed files with 141 additions and 43 deletions
+39 -8
View File
@@ -1,5 +1,11 @@
name: Build & Push Docker Image (Prerelease)
# The canary. Dispatched by hand, usually on dev, it builds the dispatched
# commit as <next minor or major>-pre.N, publishes :VERSION, :MAJOR-pre and
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
# release it refuses a commit whose ci-ok is not green. docker.yml with
# bump=auto later finalizes the line once main carries it.
on:
workflow_dispatch:
inputs:
@@ -11,6 +17,7 @@ on:
permissions:
contents: write
checks: read
concurrency:
group: prerelease-build
@@ -30,13 +37,37 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# Every check run named ci-ok on this commit has to have finished green.
# A run still going counts as not green: wait for it and dispatch again.
- name: Require a green ci-ok on the commit being built
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
conclusions="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&per_page=100" \
--jq '[.check_runs[] | .conclusion // "pending"] | join(" ")')"
echo "ci-ok on $sha: ${conclusions:-none}"
if [ -z "$conclusions" ]; then
echo "::error::ci-ok never ran on $sha. Run Tests & SonarQube on this branch and dispatch again."
exit 1
fi
for c in $conclusions; do
if [ "$c" != "success" ]; then
echo "::error::ci-ok on $sha is not green ($conclusions)."
exit 1
fi
done
- name: Determine prerelease version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
run: |
git fetch --tags
# Capture the exact commit we're building so build/merge jobs are pinned to it
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Get latest stable tag (exclude prerelease tags)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
@@ -44,14 +75,12 @@ jobs:
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
IFS='.' read -r MAJOR MINOR _ <<< "$STABLE"
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
NEXT_MAJOR="$((MAJOR + 1)).0.0"
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
TARGET="$NEXT_MAJOR"
else
@@ -64,8 +93,8 @@ jobs:
N=$(( ${LAST_N:-0} + 1 ))
NEW_VERSION="${TARGET}-pre.${N}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION"
build:
runs-on: ${{ matrix.runner }}
@@ -81,7 +110,9 @@ jobs:
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@v6
with:
@@ -203,7 +234,7 @@ jobs:
run: |
KEEP=20
VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
BASE_VERSION="${VERSION%%-pre.*}"
git fetch --tags
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
+96 -33
View File
@@ -1,10 +1,24 @@
name: Build & Push Docker Image
# The stable release. Dispatched by hand on main, it:
# 1. refuses to run unless ci-ok passed on the main commit it releases,
# 2. works out the version from the tags and the bump input, commits the
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
# 3. builds both platforms from exactly that bump commit (never from
# whatever main points to by then) and publishes :VERSION,
# :MAJOR.MINOR, :MAJOR and :latest,
# 4. publishes the Helm chart from the same commit.
#
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
# Without a prerelease in flight there is no default: the run stops until
# someone picks patch, minor or major.
on:
workflow_dispatch:
inputs:
bump:
description: 'Force bump line (auto = patch/finalize as today)'
description: 'auto finalizes the prerelease in flight; otherwise pick patch, minor or major'
type: choice
options: [auto, patch, minor, major]
default: auto
@@ -15,6 +29,7 @@ on:
permissions:
contents: write
checks: read
concurrency:
group: stable-build
@@ -27,7 +42,14 @@ jobs:
timeout-minutes: 120
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- name: Release from main only
if: github.ref != 'refs/heads/main'
run: |
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
exit 1
- uses: actions/create-github-app-token@v3
id: app-token
with:
@@ -40,23 +62,49 @@ jobs:
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
# Every check run named ci-ok on this commit (the dev push and the main
# push each leave one) has to have finished green. A run still going
# counts as not green: wait for it and dispatch again.
- name: Require a green ci-ok on the commit being released
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
conclusions="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&per_page=100" \
--jq '[.check_runs[] | .conclusion // "pending"] | join(" ")')"
echo "ci-ok on $sha: ${conclusions:-none}"
if [ -z "$conclusions" ]; then
echo "::error::ci-ok never ran on $sha. Run Tests & SonarQube on main and dispatch again."
exit 1
fi
for c in $conclusions; do
if [ "$c" != "success" ]; then
echo "::error::ci-ok on $sha is not green ($conclusions)."
exit 1
fi
done
- name: Determine bump type and update version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
run: |
set -eu
git fetch --tags
# Derive version from git tags — no package.json dependency
# Derive version from git tags, no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
if [ "$BUMP_INPUT" = "major" ]; then
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
if [ "$CONFIRM_MAJOR" != "MAJOR" ]; then
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
exit 1
fi
@@ -69,49 +117,57 @@ jobs:
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
BUMP="patch"
else
# auto: finalize in-flight prerelease if one exists, else patch
# auto finalizes the in-flight prerelease and nothing else. It used
# to fall back to a patch, which would have shipped any change,
# however big, as a patch nobody chose.
PRE_BASE=""
if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}"
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump — it bypasses confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
# If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ -z "$PRE_BASE" ] || [ "$HIGHEST" != "$PRE_BASE" ] || [ "$PRE_BASE" = "$STABLE" ]; then
echo "::error::No prerelease above $STABLE is in flight, so bump=auto has nothing to finalize. Dispatch again with bump=patch, minor or major."
exit 1
fi
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump, it would bypass confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE to $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
fi
# Patch releases have carried migrations before, so this only warns:
# whoever pinned :MAJOR.MINOR gets this schema change too.
if [ "$BUMP" = "patch" ] && [ -n "$STABLE_TAG" ] \
&& [ -n "$(git diff --name-only --diff-filter=A "$STABLE_TAG" HEAD -- server/src/db/migrations/)" ]; then
echo "::warning::$NEW_VERSION is a patch release that adds database migrations."
fi
echo "Bump type: $BUMP"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION ($BUMP)"
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
# Commit and tag. The push fast-forwards main from the commit ci-ok
# was checked on, so it fails rather than release past anything that
# landed on main in the meantime.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin main --follow-tags
git push origin HEAD:main --follow-tags
# build, merge and release-helm check out exactly this commit.
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
build:
runs-on: ${{ matrix.runner }}
@@ -127,11 +183,13 @@ jobs:
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
@@ -181,7 +239,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- name: Download build digests
uses: actions/download-artifact@v7
@@ -204,6 +262,10 @@ jobs:
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
# the conservative pin for an install that wants to wait out a
# release that migrates.
MINOR_TAG="$(echo "$VERSION" | cut -d. -f1-2)"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
@@ -227,6 +289,7 @@ jobs:
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$MINOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
@@ -241,7 +304,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0