From d88dc17e8e9842580f5667b7ec2d0e1f7f5038c0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 11:42:45 +0200 Subject: [PATCH] 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. --- .github/workflows/docker-dev.yml | 47 +++++++++-- .github/workflows/docker.yml | 129 +++++++++++++++++++++++-------- CLAUDE.md | 1 + wiki/Install-Docker.md | 3 +- wiki/Updating.md | 4 +- 5 files changed, 141 insertions(+), 43 deletions(-) diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index b6d54262b..ccda72479 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -1,5 +1,11 @@ name: Build & Push Docker Image (Prerelease) +# The canary. Dispatched by hand, usually on dev, it builds the dispatched +# commit as -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}') diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 436accbf1..1c038200a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 :-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 diff --git a/CLAUDE.md b/CLAUDE.md index 77b697518..32e3cf399 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,7 @@ These principles come out of a full-repo audit and shape all new code: - **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there. - **Runners and Node**: jobs run on `ubuntu-24.04`, not `ubuntu-latest`, and every `setup-node` reads `.nvmrc`. Third-party actions (anything outside `actions/*`) are pinned by commit SHA with the tag in a comment; Dependabot keeps both current. - **Security scans are advisory.** `security.yml` runs `npm audit` (what the image installs, plus `plugin-sdk`) on every PR and push into dev and main, and Docker Scout on the built image for main. Neither is required: a newly published advisory would turn every open PR red at once. Fixes are held as floors in the root `package.json` `overrides`; npm ignores an `overrides` block in a workspace `package.json`, so never put one there. +- **Releases** are dispatched by hand and refuse a commit whose `ci-ok` is not green. `docker-dev.yml` (any branch, usually dev) is the canary: it builds the dispatched commit as the next minor or major `-pre.N` and moves only `:latest-pre` and `:-pre`. `docker.yml` (main only) bumps the version, commits and tags it, builds both platforms from exactly that commit and moves `:latest`, `:`, `:.` and `:`. Its `bump=auto` only finalizes a prerelease in flight; without one it stops and asks for patch, minor or major. Ship a release that migrates or changes a lot as a prerelease first, and as a minor, so installs pinned to `:.` can wait it out. **SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it: diff --git a/wiki/Install-Docker.md b/wiki/Install-Docker.md index 78c038c2f..f0b3b7da5 100644 --- a/wiki/Install-Docker.md +++ b/wiki/Install-Docker.md @@ -40,9 +40,10 @@ See [Environment-Variables](Environment-Variables) for the full list. |---|--------------------------|---| | `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions | | Major version | `mauriceboe/trek:4` | Latest release pinned to that major version | +| Minor version | `mauriceboe/trek:4.3` | Latest patch of that minor version; never moves to the next minor (published for releases after 4.3.3) | | Full version | `mauriceboe/trek:4.0.0` | Exact release; never changes | -Replace `mauriceboe/trek:latest` in the run command with your chosen tag to pin to a major version or exact release. +Replace `mauriceboe/trek:latest` in the run command with your chosen tag to pin to a major version, a minor version or an exact release. Test builds of the next version are tagged `latest-pre`; see [Updating](Updating#image-tags). Every image carries the standard `org.opencontainers.image.*` labels (title, version, source, documentation, licence), so an update tool such as Renovate links an image update to its release notes. `docker inspect mauriceboe/trek:latest --format '{{json .Config.Labels}}'` shows them. diff --git a/wiki/Updating.md b/wiki/Updating.md index bfd1307ac..952d88fb6 100644 --- a/wiki/Updating.md +++ b/wiki/Updating.md @@ -12,9 +12,11 @@ Back up your data first. Go to Admin Panel → **Backup** and create a manual ba |---|---|---| | `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions | | Major version | `mauriceboe/trek:4` | Latest release pinned to that major version | +| Minor version | `mauriceboe/trek:4.3` | Latest patch of that minor version; never moves to the next minor (published for releases after 4.3.3) | | Full version | `mauriceboe/trek:4.0.0` | Exact release; never changes | +| Prerelease | `mauriceboe/trek:latest-pre` | The newest test build of the next version. For trying it out on a copy of your data, not for the instance you rely on | -Use `latest` or a major-version tag if you want updates on each redeploy. Use a full version tag for explicit control — update by changing the tag, not by re-pulling. +Use `latest` or a major-version tag if you want updates on each redeploy. A minor-version tag still brings fixes but waits for you to move to the next minor, which is where larger changes and database upgrades usually land. Use a full version tag for explicit control: update by changing the tag, not by re-pulling. ## Docker Compose (Recommended)