diff --git a/.github/actions/release-train-bump/action.yml b/.github/actions/release-train-bump/action.yml deleted file mode 100644 index 88c3f60..0000000 --- a/.github/actions/release-train-bump/action.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Release train bump -description: Bump package.json version using release-train strategy - -inputs: - bump_strategy: - description: Bump strategy from release-train-detect - required: true - version_bump: - description: patch | minor | major (used for open-cycle) - required: false - default: minor - -outputs: - version: - description: New bare semver version - value: ${{ steps.bump.outputs.version }} - tag: - description: New git tag with v prefix - value: ${{ steps.bump.outputs.tag }} - -runs: - using: composite - steps: - - name: Bump version - id: bump - shell: bash - run: | - bash "${{ github.action_path }}/bump.sh" "${{ inputs.bump_strategy }}" "${{ inputs.version_bump }}" diff --git a/.github/actions/release-train-bump/bump.sh b/.github/actions/release-train-bump/bump.sh deleted file mode 100755 index 44f6e33..0000000 --- a/.github/actions/release-train-bump/bump.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -STRATEGY="${1:?bump strategy required}" -VERSION_BUMP="${2:-minor}" - -case "$STRATEGY" in - open-cycle) - NEW_VERSION=$(npm version "pre${VERSION_BUMP}" --preid=alpha --no-git-tag-version) - ;; - prerelease-alpha) - NEW_VERSION=$(npm version prerelease --preid=alpha --no-git-tag-version) - ;; - promote-beta | prerelease-beta) - NEW_VERSION=$(npm version prerelease --preid=beta --no-git-tag-version) - ;; - graduate) - NEW_VERSION=$(npm version patch --no-git-tag-version) - ;; - *) - echo "::error::Unknown bump strategy: ${STRATEGY}" - exit 1 - ;; -esac - -if [ -n "${GITHUB_OUTPUT:-}" ]; then - echo "version=${NEW_VERSION#v}" >> "$GITHUB_OUTPUT" - echo "tag=${NEW_VERSION}" >> "$GITHUB_OUTPUT" -else - echo "version=${NEW_VERSION#v}" - echo "tag=${NEW_VERSION}" -fi diff --git a/.github/actions/release-train-detect/action.yml b/.github/actions/release-train-detect/action.yml index 9a76ad2..2812b90 100644 --- a/.github/actions/release-train-detect/action.yml +++ b/.github/actions/release-train-detect/action.yml @@ -1,5 +1,5 @@ name: Release train detect -description: Detect whether a release-train publish is needed and which bump strategy to apply +description: Detect whether a release-train publish is needed and compute the exact next version from git tags inputs: branch: @@ -11,17 +11,23 @@ outputs: description: Whether a new release should be published value: ${{ steps.detect.outputs.should_release }} bump_strategy: - description: Bump strategy (open-cycle | prerelease-alpha | promote-beta | prerelease-beta | graduate | skip) + description: Strategy applied (open-cycle | prerelease-alpha | promote-beta | prerelease-beta | open-cycle-beta | graduate | hotfix-stable | skip) value: ${{ steps.detect.outputs.bump_strategy }} release_type: description: Release channel (alpha | beta | stable | none) value: ${{ steps.detect.outputs.release_type }} version_bump: - description: Semver bump for open-cycle (patch | minor | major) + description: Semver bump applied when opening a new cycle (patch | minor | major) value: ${{ steps.detect.outputs.version_bump }} current_version: - description: Current version derived from git tags (base for bump.sh) + description: Latest version of this channel derived from git tags (informational) value: ${{ steps.detect.outputs.current_version }} + next_version: + description: Exact next version to publish (e.g. 0.15.2-beta.0). Empty when should_release=false + value: ${{ steps.detect.outputs.next_version }} + next_tag: + description: Exact next git tag to publish (e.g. v0.15.2-beta.0). Empty when should_release=false + value: ${{ steps.detect.outputs.next_tag }} runs: using: composite diff --git a/.github/actions/release-train-detect/detect.sh b/.github/actions/release-train-detect/detect.sh index 82ca7dd..e84201b 100755 --- a/.github/actions/release-train-detect/detect.sh +++ b/.github/actions/release-train-detect/detect.sh @@ -1,146 +1,97 @@ #!/usr/bin/env bash set -euo pipefail +# Release-train version detection. +# +# Git tags are the SINGLE SOURCE OF TRUTH. package.json is never read to +# derive a version: it drifts between branches and caused duplicate/stale +# releases in the past (e.g. staging publishing 0.15.1-beta.N after v0.15.1 +# had already shipped stable on main). +# +# Channels: +# develop -> alpha staging -> beta main -> stable +# +# Invariants: +# * Every computed version is strictly greater than the latest stable +# release. A pre-release cycle whose base version has already graduated +# (or been surpassed) is considered closed and is never continued. +# * staging promotes the newest alpha cycle to beta when that cycle is +# ahead of both the stable line and the current beta cycle. +# * The EXACT next version is computed here, in one place. Downstream +# steps apply it verbatim — no relative "npm version prerelease" math. + BRANCH="${1:?branch required}" case "$BRANCH" in develop) CHANNEL="alpha" ;; staging) CHANNEL="beta" ;; - main) CHANNEL="stable" ;; + main) CHANNEL="stable" ;; *) - echo "::error::Unsupported release-train branch: ${BRANCH}" + echo "::error::Unsupported release-train branch: ${BRANCH}" >&2 exit 1 ;; esac -find_last_channel_tag() { - local current="$1" - local channel="$2" - - if [[ "$channel" == "alpha" && "$current" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-alpha\. ]]; then - git describe --tags --abbrev=0 --match "v${BASH_REMATCH[1]}-alpha.*" 2>/dev/null || true - elif [[ "$channel" == "beta" && "$current" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-beta\. ]]; then - git describe --tags --abbrev=0 --match "v${BASH_REMATCH[1]}-beta.*" 2>/dev/null || true - elif [[ "$channel" == "beta" && "$current" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-alpha\. ]]; then - git describe --tags --abbrev=0 --match "v${BASH_REMATCH[1]}-alpha.*" 2>/dev/null || true - elif [[ "$channel" == "stable" && "$current" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-beta\. ]]; then - git describe --tags --abbrev=0 --match "v${BASH_REMATCH[1]}-beta.*" 2>/dev/null || true - elif [[ ! "$current" =~ - ]]; then - if git rev-parse -q --verify "refs/tags/v${current}" >/dev/null 2>&1; then - echo "v${current}" - else - git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true - fi - else - git describe --tags --abbrev=0 2>/dev/null || true - fi -} +# ---------- helpers ---------- -find_last_stable_tag() { - git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true +latest_stable_tag() { + git tag -l 'v[0-9]*' --sort=-v:refname \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true } -# When a stable release graduates ahead of an older pre-release cycle (e.g. v0.15.1 -# stable while v0.15.0-alpha.10 still exists), develop must open a new alpha cycle -# from the stable tag instead of continuing the stale pre-release chain. -prefer_stable_over_stale_prerelease() { - local prerelease_tag="$1" - local stable_tag prerelease_ver stable_ver prerelease_base - - stable_tag=$(find_last_stable_tag) - - if [ -z "$prerelease_tag" ]; then - echo "${stable_tag:-v0.0.0}" - return - fi - - if [ -z "$stable_tag" ]; then - echo "$prerelease_tag" - return - fi - - prerelease_ver="${prerelease_tag#v}" - stable_ver="${stable_tag#v}" +latest_prerelease_tag() { # $1 = alpha | beta + git tag -l "v[0-9]*-$1.*" --sort=-v:refname \ + | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+-$1\.[0-9]+$" | head -1 || true +} - if [[ "$prerelease_ver" =~ ^([0-9]+\.[0-9]+\.[0-9]+)- ]]; then - prerelease_base="${BASH_REMATCH[1]}" - if [ "$(printf '%s\n' "$prerelease_base" "$stable_ver" | sort -V | tail -1)" = "$stable_ver" ] \ - && [ "$stable_ver" != "$prerelease_base" ]; then - echo "$stable_tag" - return - fi - fi +base_of() { # vX.Y.Z[-pre.N] -> X.Y.Z + local v="${1#v}" + echo "${v%%-*}" +} - echo "$prerelease_tag" +base_gt() { # true when base $1 > base $2 + [ "$1" != "$2" ] && [ "$(printf '%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ] } -derive_current() { - local channel="$1" tag="" - case "$channel" in - alpha) - tag=$(prefer_stable_over_stale_prerelease "$(git tag -l 'v[0-9]*-alpha.*' --sort=-v:refname | head -1)") - ;; - beta) tag=$(git tag -l 'v[0-9]*-beta.*' --sort=-v:refname | head -1) - [ -z "$tag" ] && tag=$(git tag -l 'v[0-9]*-alpha.*' --sort=-v:refname | head -1) ;; - stable) tag=$(git tag -l 'v[0-9]*-beta.*' --sort=-v:refname | head -1) - [ -z "$tag" ] && { echo "::error::No beta tag found on main" >&2; exit 1; } ;; +bump_base() { # $1 = X.Y.Z, $2 = major | minor | patch + local major minor patch + IFS=. read -r major minor patch <<<"$1" + case "$2" in + major) echo "$((major + 1)).0.0" ;; + minor) echo "${major}.$((minor + 1)).0" ;; + patch) echo "${major}.${minor}.$((patch + 1))" ;; + *) echo "::error::Unknown bump type: $2" >&2; exit 1 ;; esac - [ -z "$tag" ] && tag=$(find_last_stable_tag) - [ -z "$tag" ] && tag="v0.0.0" - echo "${tag#v}" } -CURRENT=$(derive_current "$CHANNEL") -resolve_version_bump() { - local since_tag="$1" - if [ -z "$since_tag" ]; then - echo "minor" - return +next_prerelease_number() { # $1 = base, $2 = preid -> next free N for vBASE-preid.N + local last + last=$(git tag -l "v$1-$2.*" --sort=-v:refname \ + | grep -E "^v$1-$2\.[0-9]+$" | head -1 || true) + if [ -z "$last" ]; then + echo 0 + else + echo "$(( ${last##*.} + 1 ))" fi +} - if git log "${since_tag}..HEAD" --pretty=%s | grep -qiE '^feat(\(.+\))?!?:'; then +# Conventional-commit driven semver bump for opening a new cycle. +resolve_version_bump() { # $1 = since ref (empty = whole history) + local range log + if [ -n "$1" ]; then range="$1..HEAD"; else range="HEAD"; fi + log=$(git log "$range" --pretty='%s%n%b' 2>/dev/null || true) + if echo "$log" | grep -qiE '^[a-z]+(\([^)]*\))?!:' \ + || echo "$log" | grep -qE '^BREAKING[ -]CHANGE:'; then + echo "major" + elif echo "$log" | grep -qiE '^feat(\([^)]*\))?:'; then echo "minor" else echo "patch" fi } -resolve_bump_strategy() { - case "$BRANCH" in - develop) - if [[ "$CURRENT" =~ -alpha\. ]]; then - echo "prerelease-alpha" - elif [[ ! "$CURRENT" =~ - ]]; then - echo "open-cycle" - else - echo "::error::Unexpected version ${CURRENT} on develop (expected stable or alpha pre-release)" >&2 - exit 1 - fi - ;; - staging) - if [[ "$CURRENT" =~ -alpha\. ]]; then - echo "promote-beta" - elif [[ "$CURRENT" =~ -beta\. ]]; then - echo "prerelease-beta" - else - echo "::error::Unexpected version ${CURRENT} on staging (expected alpha or beta pre-release)" >&2 - exit 1 - fi - ;; - main) - if [[ "$CURRENT" =~ -beta\. ]]; then - echo "graduate" - else - echo "::error::Unexpected version ${CURRENT} on main (expected beta pre-release before stable)" >&2 - exit 1 - fi - ;; - esac -} - write_github_output() { - local key="$1" - local value="$2" + local key="$1" value="$2" if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "${key}=${value}" >> "$GITHUB_OUTPUT" else @@ -148,41 +99,139 @@ write_github_output() { fi } -LAST_TAG=$(find_last_channel_tag "$CURRENT" "$CHANNEL") +# ---------- gather state from tags ---------- + +STABLE_TAG=$(latest_stable_tag) +STABLE_BASE="${STABLE_TAG#v}" +[ -z "$STABLE_BASE" ] && STABLE_BASE="0.0.0" + +ALPHA_TAG=$(latest_prerelease_tag alpha) +BETA_TAG=$(latest_prerelease_tag beta) +ALPHA_BASE="" +BETA_BASE="" +[ -n "$ALPHA_TAG" ] && ALPHA_BASE=$(base_of "$ALPHA_TAG") +[ -n "$BETA_TAG" ] && BETA_BASE=$(base_of "$BETA_TAG") + +# ---------- should we release at all? ---------- + +case "$CHANNEL" in + alpha) CHANNEL_TAG="$ALPHA_TAG" ;; + beta) CHANNEL_TAG="$BETA_TAG" ;; + stable) CHANNEL_TAG="$STABLE_TAG" ;; +esac SHOULD_RELEASE=false -if [ -z "$LAST_TAG" ]; then +if [ -z "$CHANNEL_TAG" ]; then SHOULD_RELEASE=true -elif [ "$(git rev-parse HEAD)" != "$(git rev-parse "$LAST_TAG")" ]; then - COMMIT_COUNT=$(git rev-list "${LAST_TAG}..HEAD" --count) - if [ "$COMMIT_COUNT" -gt 0 ]; then +elif [ "$(git rev-parse HEAD)" != "$(git rev-parse "${CHANNEL_TAG}^{commit}")" ]; then + if [ "$(git rev-list "${CHANNEL_TAG}..HEAD" --count)" -gt 0 ]; then SHOULD_RELEASE=true fi fi -if [ "$SHOULD_RELEASE" = "true" ]; then - BUMP_STRATEGY=$(resolve_bump_strategy) - write_github_output "should_release" "true" - write_github_output "bump_strategy" "$BUMP_STRATEGY" - write_github_output "current_version" "$CURRENT" - - case "$BRANCH" in - develop) write_github_output "release_type" "alpha" ;; - staging) write_github_output "release_type" "beta" ;; - main) write_github_output "release_type" "stable" ;; - esac - - if [ "$BUMP_STRATEGY" = "open-cycle" ]; then - write_github_output "version_bump" "$(resolve_version_bump "$(find_last_stable_tag)")" - else - write_github_output "version_bump" "patch" - fi - - echo "Release train: will release on ${BRANCH} (${BUMP_STRATEGY})" -else +if [ "$SHOULD_RELEASE" != "true" ]; then write_github_output "should_release" "false" write_github_output "bump_strategy" "skip" write_github_output "release_type" "none" write_github_output "version_bump" "patch" - echo "Release train: no integrated changes since ${LAST_TAG:-}, skipping release" + write_github_output "current_version" "${CHANNEL_TAG#v}" + write_github_output "next_version" "" + write_github_output "next_tag" "" + echo "Release train: no integrated changes since ${CHANNEL_TAG:-}, skipping release" + exit 0 +fi + +# ---------- compute the exact next version ---------- + +NEXT_VERSION="" +BUMP_STRATEGY="" +VERSION_BUMP="patch" + +case "$CHANNEL" in + alpha) + if [ -n "$ALPHA_BASE" ] && base_gt "$ALPHA_BASE" "$STABLE_BASE"; then + # The current alpha cycle is still ahead of stable: continue it. + BUMP_STRATEGY="prerelease-alpha" + NEXT_VERSION="${ALPHA_BASE}-alpha.$(next_prerelease_number "$ALPHA_BASE" alpha)" + else + # The previous cycle graduated (or never existed): open a new one. + BUMP_STRATEGY="open-cycle" + VERSION_BUMP=$(resolve_version_bump "$STABLE_TAG") + FLOOR="$STABLE_BASE" + # Stay ahead of any beta cycle staging may have opened independently. + if [ -n "$BETA_BASE" ] && base_gt "$BETA_BASE" "$FLOOR"; then + FLOOR="$BETA_BASE" + fi + NEW_BASE=$(bump_base "$FLOOR" "$VERSION_BUMP") + NEXT_VERSION="${NEW_BASE}-alpha.$(next_prerelease_number "$NEW_BASE" alpha)" + fi + ;; + + beta) + if [ -n "$BETA_BASE" ] && base_gt "$BETA_BASE" "$STABLE_BASE" \ + && { [ -z "$ALPHA_BASE" ] || ! base_gt "$ALPHA_BASE" "$BETA_BASE"; }; then + # Current beta cycle has not graduated and no newer alpha cycle exists: + # iterate it (e.g. a fix merged into staging). + BUMP_STRATEGY="prerelease-beta" + NEXT_VERSION="${BETA_BASE}-beta.$(next_prerelease_number "$BETA_BASE" beta)" + elif [ -n "$ALPHA_BASE" ] && base_gt "$ALPHA_BASE" "$STABLE_BASE"; then + # Promote the newest alpha cycle to beta. This is the path that fixes + # the historical bug: a stale beta of an already-released base is + # abandoned in favour of the live alpha cycle. + BUMP_STRATEGY="promote-beta" + NEXT_VERSION="${ALPHA_BASE}-beta.$(next_prerelease_number "$ALPHA_BASE" beta)" + else + # Nothing upstream is ahead of stable (e.g. hotfix merged straight to + # staging): open a fresh cycle from the stable line. + BUMP_STRATEGY="open-cycle-beta" + VERSION_BUMP=$(resolve_version_bump "$STABLE_TAG") + NEW_BASE=$(bump_base "$STABLE_BASE" "$VERSION_BUMP") + NEXT_VERSION="${NEW_BASE}-beta.$(next_prerelease_number "$NEW_BASE" beta)" + fi + ;; + + stable) + if [ -n "$BETA_BASE" ] && base_gt "$BETA_BASE" "$STABLE_BASE"; then + BUMP_STRATEGY="graduate" + NEXT_VERSION="$BETA_BASE" + elif [ -n "$ALPHA_BASE" ] && base_gt "$ALPHA_BASE" "$STABLE_BASE"; then + # develop merged straight to main, skipping staging. + BUMP_STRATEGY="graduate" + NEXT_VERSION="$ALPHA_BASE" + else + # Hotfix merged straight to main. + BUMP_STRATEGY="hotfix-stable" + VERSION_BUMP=$(resolve_version_bump "$STABLE_TAG") + NEXT_VERSION=$(bump_base "$STABLE_BASE" "$VERSION_BUMP") + fi + ;; +esac + +# ---------- safety net ---------- + +if git rev-parse -q --verify "refs/tags/v${NEXT_VERSION}" >/dev/null; then + echo "::error::Computed tag v${NEXT_VERSION} already exists. Refusing to overwrite an existing release." >&2 + exit 1 +fi + +NEXT_BASE=$(base_of "$NEXT_VERSION") +if ! base_gt "$NEXT_BASE" "$STABLE_BASE"; then + echo "::error::Computed version ${NEXT_VERSION} does not advance past latest stable ${STABLE_BASE}. Aborting." >&2 + exit 1 fi + +case "$CHANNEL" in + alpha) RELEASE_TYPE="alpha" ;; + beta) RELEASE_TYPE="beta" ;; + stable) RELEASE_TYPE="stable" ;; +esac + +write_github_output "should_release" "true" +write_github_output "bump_strategy" "$BUMP_STRATEGY" +write_github_output "release_type" "$RELEASE_TYPE" +write_github_output "version_bump" "$VERSION_BUMP" +write_github_output "current_version" "${CHANNEL_TAG#v}" +write_github_output "next_version" "$NEXT_VERSION" +write_github_output "next_tag" "v${NEXT_VERSION}" + +echo "Release train: ${BRANCH} (${BUMP_STRATEGY}) -> v${NEXT_VERSION} [stable=${STABLE_BASE} beta=${BETA_TAG:-none} alpha=${ALPHA_TAG:-none}]" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 9673c03..85f6249 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -8,9 +8,10 @@ on: required: true type: string version: - description: "Version bump type (patch | minor | major)" - required: true + description: "Version bump type for legacy/manual mode (patch | minor | major)" + required: false type: string + default: patch release_type: description: "Release type (stable | alpha | beta | rc)" required: false @@ -61,13 +62,8 @@ on: required: false type: string default: legacy - bump_strategy: - description: "Release-train bump strategy (open-cycle | prerelease-alpha | promote-beta | prerelease-beta | graduate)" - required: false - type: string - default: "" - current_version: - description: "Current version from git tags (synced to package.json before bump, required when bump_mode=release-train)" + next_version: + description: "Exact version to publish (required when bump_mode=release-train, computed by release-train-detect)" required: false type: string default: "" @@ -99,11 +95,16 @@ jobs: runs-on: ubuntu-latest steps: - - name: Validate GHCR inputs - if: inputs.push_ghcr && inputs.ghcr_image_name == '' + - name: Validate inputs run: | - echo "::error::push_ghcr=true requires a non-empty ghcr_image_name input." - exit 1 + if [ "${{ inputs.push_ghcr }}" = "true" ] && [ -z "${{ inputs.ghcr_image_name }}" ]; then + echo "::error::push_ghcr=true requires a non-empty ghcr_image_name input." + exit 1 + fi + if [ "${{ inputs.bump_mode }}" = "release-train" ] && [ -z "${{ inputs.next_version }}" ]; then + echo "::error::bump_mode=release-train requires a non-empty next_version input." + exit 1 + fi - name: Checkout uses: actions/checkout@v4 @@ -145,18 +146,19 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Sync package.json to tag-derived version - if: inputs.bump_mode == 'release-train' && inputs.current_version != '' - run: npm version ${{ inputs.current_version }} --no-git-tag-version --allow-same-version - - - name: Bump version (release train) + # Release-train mode: the exact version was computed from git tags by + # release-train-detect. Apply it verbatim — package.json is a mirror of + # the tag state, never the source of truth. + - name: Set version (release train) if: inputs.bump_mode == 'release-train' id: bump_train - uses: sisques-labs/workflows/.github/actions/release-train-bump@main - with: - bump_strategy: ${{ inputs.bump_strategy }} - version_bump: ${{ inputs.version }} + run: | + NEW_VERSION=$(npm version "${{ inputs.next_version }}" --no-git-tag-version --allow-same-version) + echo "version=${NEW_VERSION#v}" >> "$GITHUB_OUTPUT" + echo "tag=${NEW_VERSION}" >> "$GITHUB_OUTPUT" + # Legacy mode: manual workflow_dispatch releases bump relative to the + # version currently in package.json. - name: Bump version (legacy) if: inputs.bump_mode != 'release-train' id: bump_legacy @@ -183,8 +185,9 @@ jobs: - name: Guard against duplicate tag run: | TAG="${{ steps.bump.outputs.tag }}" + git fetch origin "refs/tags/${TAG}" 2>/dev/null || true if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then - echo "::error::Git tag ${TAG} already exists on origin. Refusing to publish a duplicate release." + echo "::error::Git tag ${TAG} already exists. Refusing to publish a duplicate release." exit 1 fi @@ -266,31 +269,65 @@ jobs: username: ${{ github.actor }} password: ${{ github.token }} - - name: Build and push + # Build first WITHOUT pushing: if the image cannot be built, nothing is + # published anywhere (no git tag, no registry tag, no GitHub Release). + - name: Build image (no push) uses: docker/build-push-action@v6 with: context: ${{ inputs.context }} file: ${{ inputs.dockerfile }} platforms: ${{ inputs.platforms }} - push: true + push: false tags: ${{ steps.tags.outputs.list }} secrets: | node_auth_token=${{ secrets.NODE_AUTH_TOKEN }} cache-from: type=gha cache-to: type=gha,mode=max + # Publish to git BEFORE the registries: the git tag is the source of + # truth for the next version computation, so it must never lag behind + # a published image. The atomic push guarantees the release commit and + # its tag land together or not at all. - name: Commit, tag & push env: HUSKY: "0" run: | - git tag ${{ steps.bump.outputs.tag }} + TAG="${{ steps.bump.outputs.tag }}" if [ "${{ github.ref_name }}" != "develop" ]; then git add package.json pnpm-lock.yaml if [ -f package-lock.json ]; then git add package-lock.json; fi if [ -f CHANGELOG.md ] && [ -f cliff.toml ]; then git add CHANGELOG.md; fi - git diff --staged --quiet || git commit -m "chore: release ${{ steps.bump.outputs.tag }}" + git diff --staged --quiet || git commit -m "chore(release): ${TAG}" fi - git push origin HEAD --follow-tags + git tag "${TAG}" + for attempt in 1 2 3; do + if git push --atomic origin "HEAD:${{ github.ref_name }}" "refs/tags/${TAG}"; then + exit 0 + fi + echo "git push failed (attempt ${attempt}), retrying in $((attempt * 5))s..." + sleep $((attempt * 5)) + git fetch origin "${{ github.ref_name }}" + if ! git merge-base --is-ancestor "origin/${{ github.ref_name }}" HEAD; then + echo "::error::Branch ${{ github.ref_name }} moved during the release. Aborting; the next push will release these changes." + exit 1 + fi + done + echo "::error::Failed to push release commit and tag after 3 attempts." + exit 1 + + # The registry push reuses the buildx cache from the build step, so this + # is a near-instant publish of the exact image that was just built. + - name: Push image + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ steps.tags.outputs.list }} + secrets: | + node_auth_token=${{ secrets.NODE_AUTH_TOKEN }} + cache-from: type=gha - name: Sync stable to develop if: inputs.sync_develop_after_stable @@ -303,7 +340,7 @@ jobs: exit 0 fi git checkout develop - git merge "${{ steps.bump.outputs.tag }}" -m "chore: sync stable ${{ steps.bump.outputs.tag }} from main" + git merge "${{ steps.bump.outputs.tag }}" -m "chore(release): sync stable ${{ steps.bump.outputs.tag }} from main" git push origin develop - name: Create GitHub Release (git-cliff) diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index 9b204a5..388b7bd 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -64,10 +64,6 @@ permissions: contents: write packages: write -concurrency: - group: release-train-${{ github.repository }}-${{ github.ref_name }} - cancel-in-progress: false - jobs: detect: name: Detect release @@ -76,8 +72,8 @@ jobs: should_release: ${{ steps.detect.outputs.should_release }} bump_strategy: ${{ steps.detect.outputs.bump_strategy }} release_type: ${{ steps.detect.outputs.release_type }} - version_bump: ${{ steps.detect.outputs.version_bump }} - current_version: ${{ steps.detect.outputs.current_version }} + next_version: ${{ steps.detect.outputs.next_version }} + next_tag: ${{ steps.detect.outputs.next_tag }} steps: - name: Checkout uses: actions/checkout@v4 @@ -90,6 +86,19 @@ jobs: with: branch: ${{ github.ref_name }} + - name: Summary + run: | + { + echo "### Release train — ${{ github.ref_name }}" + if [ "${{ steps.detect.outputs.should_release }}" = "true" ]; then + echo "- **Next version:** \`${{ steps.detect.outputs.next_tag }}\`" + echo "- **Strategy:** ${{ steps.detect.outputs.bump_strategy }}" + echo "- **Channel:** ${{ steps.detect.outputs.release_type }}" + else + echo "- No integrated changes since \`${{ steps.detect.outputs.current_version }}\` — skipping release." + fi + } >> "$GITHUB_STEP_SUMMARY" + release: name: Build & publish needs: detect @@ -106,10 +115,8 @@ jobs: run_test: ${{ inputs.run_test }} platforms: ${{ inputs.platforms }} bump_mode: release-train - bump_strategy: ${{ needs.detect.outputs.bump_strategy }} release_type: ${{ needs.detect.outputs.release_type }} - version: ${{ needs.detect.outputs.version_bump }} - current_version: ${{ needs.detect.outputs.current_version }} + next_version: ${{ needs.detect.outputs.next_version }} sync_develop_after_stable: ${{ inputs.sync_develop_after_stable && github.ref_name == 'main' }} secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..faaabcc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: test-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + release-train-detect: + name: Release train detect tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run tests + run: bash tests/release-train-detect.test.sh + + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Lint shell scripts + run: | + shopt -s globstar nullglob + shellcheck .github/actions/**/*.sh tests/*.sh diff --git a/README.md b/README.md index bb4b4b8..ddd0fae 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,18 @@ This repository contains reusable GitHub Actions workflows and composite actions ``` .github/ -├── workflows/ # Reusable workflows -│ ├── web-build.yml # Web application build workflow -│ ├── api-build.yml # API build workflow -│ └── node-release.yml # Node.js release workflow with semantic-release -└── actions/ # Composite actions - ├── setup/ # Common setup (Node.js, pnpm, checkout) - └── install/ # Install dependencies with pnpm +├── workflows/ # Reusable workflows +│ ├── web-build.yml # Web application build workflow +│ ├── api-build.yml # API build workflow +│ ├── node-release.yml # Node.js release workflow with semantic-release +│ ├── release-train.yml # Automatic alpha/beta/stable release train +│ ├── docker-release.yml # Version bump + Docker build & publish +│ └── test.yml # CI for this repository (detect tests + shellcheck) +├── actions/ # Composite actions +│ ├── setup/ # Common setup (Node.js, pnpm, checkout) +│ ├── install/ # Install dependencies with pnpm +│ └── release-train-detect/ # Compute the exact next version from git tags +└── tests/ # Test suites for the scripts in this repo ``` ## How to Use in Other Projects @@ -278,6 +283,88 @@ jobs: use_filter: true ``` +### Release Train + +Fully automatic semver pipeline driven by branch merges. Every push to a train +branch publishes a Docker image, a git tag, and a GitHub Release for the +corresponding channel: + +| Branch | Channel | Version produced | Docker tags | +| --------- | ------- | --------------------------------- | ------------------------------------ | +| `develop` | alpha | `X.Y.Z-alpha.N` | `:X.Y.Z-alpha.N`, `:alpha` | +| `staging` | beta | `X.Y.Z-beta.N` | `:X.Y.Z-beta.N`, `:beta` | +| `main` | stable | `X.Y.Z` | `:X.Y.Z`, `:latest` | + +**How versions are computed** + +Git tags are the single source of truth — `package.json` is overwritten with +the computed version at release time and is never read to derive one. The +`release-train-detect` action computes the exact next version in one place: + +- `develop` continues the open alpha cycle (`0.16.0-alpha.3` → `0.16.0-alpha.4`) + or opens a new one from the latest stable when the previous cycle graduated. + The bump for a new cycle follows conventional commits: `feat:` → minor, + breaking change (`!` or `BREAKING CHANGE:`) → major, anything else → patch. +- `staging` promotes the newest alpha cycle to `beta.0`, or iterates the + current beta cycle when a fix is merged into staging directly. +- `main` graduates the leading beta cycle to stable, or cuts a patch release + for hotfixes merged straight to main. + +A computed version is **always strictly greater than the latest stable +release**: a stale pre-release cycle whose base already shipped (e.g. +`0.15.1-beta.N` after `v0.15.1` went stable) is abandoned, never continued. +Duplicate tags are rejected before anything is published. + +**Publish ordering (atomic releases)** + +1. Lint, test, and **build** the Docker image without pushing. +2. Commit the version bump + changelog, tag the release commit, and push both + atomically. If this fails, nothing has been published anywhere. +3. Push the image (instant — reuses the buildx cache) and create the GitHub + Release. + +This guarantees a git tag can never lag behind a published image, which is +what previously allowed version reuse. + +**Usage (consumer repository):** + +```yaml +name: Release Train + +on: + push: + branches: [develop, staging, main] + +permissions: + contents: write + packages: write + +# One group for the whole repo: develop/staging/main releases are serialized +# so two channels never read/write tags concurrently. +concurrency: + group: release-train + cancel-in-progress: false + +jobs: + release: + uses: sisques-labs/workflows/.github/workflows/release-train.yml@main + with: + image_name: sisqueslabs/my-app + ghcr_image_name: ghcr.io/sisques-labs/my-app + push_ghcr: true + node_version: "22" + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + permissions: + contents: write + packages: write +``` + +**Testing:** the version-computation logic is covered by +`tests/release-train-detect.test.sh`, which runs on every PR to this +repository (including a regression test for the stale-beta bug). + ## Composite Actions ### Setup diff --git a/tests/release-train-detect.test.sh b/tests/release-train-detect.test.sh new file mode 100644 index 0000000..11dc433 --- /dev/null +++ b/tests/release-train-detect.test.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tests for .github/actions/release-train-detect/detect.sh +# +# Each scenario builds a throwaway git repository with a tag layout and +# asserts the computed next version. Run locally or in CI: +# bash tests/release-train-detect.test.sh + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DETECT="${REPO_ROOT}/.github/actions/release-train-detect/detect.sh" + +PASS=0 +FAIL=0 +CURRENT_TEST="" +WORKDIR="" + +cleanup() { + if [ -n "$WORKDIR" ]; then rm -rf "$WORKDIR"; fi +} +trap cleanup EXIT + +new_repo() { + cleanup + WORKDIR=$(mktemp -d) + cd "$WORKDIR" + git init -q -b main + git config user.email "test@test" + git config user.name "test" + git config commit.gpgsign false + git config tag.gpgsign false + git config gpg.format openpgp + commit "chore: initial commit" +} + +commit() { + echo "$RANDOM" >> file.txt + git add file.txt + git commit -qm "$1" +} + +tag() { git tag "$1"; } + +run_detect() { # $1 = branch + OUTPUT_FILE=$(mktemp) + DETECT_EXIT=0 + GITHUB_OUTPUT="$OUTPUT_FILE" bash "$DETECT" "$1" >/dev/null 2>&1 || DETECT_EXIT=$? +} + +out() { grep "^$1=" "$OUTPUT_FILE" | head -1 | cut -d= -f2- || true; } + +assert_eq() { # $1 = description, $2 = expected, $3 = actual + if [ "$2" = "$3" ]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL [${CURRENT_TEST}] $1: expected '$2', got '$3'" + fi +} + +test_case() { CURRENT_TEST="$1"; echo "--- $1"; } + +# --------------------------------------------------------------------------- +test_case "first ever release on develop opens 0.1.0-alpha.0" +new_repo +commit "feat: first feature" +run_detect develop +assert_eq "should_release" "true" "$(out should_release)" +assert_eq "strategy" "open-cycle" "$(out bump_strategy)" +assert_eq "next_version" "0.1.0-alpha.0" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "develop continues an open alpha cycle" +new_repo +tag "v0.15.1" +commit "feat: start cycle" +tag "v0.15.2-alpha.0" +commit "fix: more work" +run_detect develop +assert_eq "strategy" "prerelease-alpha" "$(out bump_strategy)" +assert_eq "next_version" "0.15.2-alpha.1" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "REGRESSION: staging abandons stale beta of an already-stable base and promotes the live alpha cycle" +# Exact production state that broke: v0.15.1 shipped stable on main while +# staging kept iterating v0.15.1-beta.N and develop was on v0.15.2-alpha.N. +new_repo +tag "v0.15.0-alpha.9" +tag "v0.15.1-beta.3" +tag "v0.15.1" +commit "feat: alpha cycle work" +tag "v0.15.2-alpha.3" +commit "Merge develop into staging" +run_detect staging +assert_eq "should_release" "true" "$(out should_release)" +assert_eq "strategy" "promote-beta" "$(out bump_strategy)" +assert_eq "next_version" "0.15.2-beta.0" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "staging iterates the current beta cycle when it still leads" +new_repo +tag "v0.15.1" +tag "v0.16.0-alpha.2" +commit "fix: promoted work" +tag "v0.16.0-beta.0" +commit "fix: staging hotfix" +run_detect staging +assert_eq "strategy" "prerelease-beta" "$(out bump_strategy)" +assert_eq "next_version" "0.16.0-beta.1" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "staging promotes a newer alpha cycle over an older live beta" +new_repo +tag "v0.15.1" +tag "v0.16.0-beta.1" +commit "feat: new cycle" +tag "v0.17.0-alpha.4" +commit "Merge develop into staging" +run_detect staging +assert_eq "strategy" "promote-beta" "$(out bump_strategy)" +assert_eq "next_version" "0.17.0-beta.0" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "staging hotfix with nothing ahead of stable opens a fresh beta cycle" +new_repo +tag "v0.15.1" +tag "v0.15.1-beta.3" +commit "fix: urgent staging hotfix" +run_detect staging +assert_eq "strategy" "open-cycle-beta" "$(out bump_strategy)" +assert_eq "next_version" "0.15.2-beta.0" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "main graduates the leading beta cycle" +new_repo +tag "v0.15.1" +commit "feat: cycle work" +tag "v0.16.0-beta.2" +commit "Merge staging into main" +run_detect main +assert_eq "strategy" "graduate" "$(out bump_strategy)" +assert_eq "next_version" "0.16.0" "$(out next_version)" +assert_eq "release_type" "stable" "$(out release_type)" + +# --------------------------------------------------------------------------- +test_case "main never re-releases an already-graduated beta base" +new_repo +tag "v0.15.1-beta.3" +tag "v0.15.1" +commit "fix: hotfix straight to main" +run_detect main +assert_eq "strategy" "hotfix-stable" "$(out bump_strategy)" +assert_eq "next_version" "0.15.2" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "main graduates an alpha cycle when develop merged straight to main" +new_repo +tag "v0.15.1" +commit "feat: fast-tracked work" +tag "v0.16.0-alpha.1" +commit "Merge develop into main" +run_detect main +assert_eq "strategy" "graduate" "$(out bump_strategy)" +assert_eq "next_version" "0.16.0" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "develop opens a new cycle after its previous cycle graduated (feat -> minor)" +new_repo +tag "v0.15.2-alpha.3" +tag "v0.15.2" +commit "feat: new work after release" +run_detect develop +assert_eq "strategy" "open-cycle" "$(out bump_strategy)" +assert_eq "next_version" "0.16.0-alpha.0" "$(out next_version)" +assert_eq "version_bump" "minor" "$(out version_bump)" + +# --------------------------------------------------------------------------- +test_case "develop opens a new cycle with fix-only commits (patch)" +new_repo +tag "v0.15.2-alpha.3" +tag "v0.15.2" +commit "fix: small fix after release" +run_detect develop +assert_eq "next_version" "0.15.3-alpha.0" "$(out next_version)" +assert_eq "version_bump" "patch" "$(out version_bump)" + +# --------------------------------------------------------------------------- +test_case "develop opens a major cycle on breaking change" +new_repo +tag "v0.15.2" +commit "feat!: breaking api change" +run_detect develop +assert_eq "next_version" "1.0.0-alpha.0" "$(out next_version)" +assert_eq "version_bump" "major" "$(out version_bump)" + +# --------------------------------------------------------------------------- +test_case "develop stays ahead of a beta cycle opened independently on staging" +new_repo +tag "v0.15.1" +tag "v0.15.2-beta.0" +commit "fix: develop work" +run_detect develop +assert_eq "strategy" "open-cycle" "$(out bump_strategy)" +assert_eq "next_version" "0.15.3-alpha.0" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "no release when HEAD is already tagged for the channel" +new_repo +commit "feat: work" +tag "v0.16.0-alpha.0" +run_detect develop +assert_eq "should_release" "false" "$(out should_release)" +assert_eq "strategy" "skip" "$(out bump_strategy)" + +# --------------------------------------------------------------------------- +test_case "no release on staging when HEAD is already tagged beta" +new_repo +commit "feat: work" +tag "v0.16.0-beta.1" +run_detect staging +assert_eq "should_release" "false" "$(out should_release)" + +# --------------------------------------------------------------------------- +test_case "first ever stable release on main without any pre-release" +new_repo +commit "fix: tiny project" +run_detect main +assert_eq "should_release" "true" "$(out should_release)" +assert_eq "next_version" "0.0.1" "$(out next_version)" + +# --------------------------------------------------------------------------- +test_case "unsupported branch fails" +new_repo +commit "feat: work" +run_detect feature/foo +assert_eq "exit code" "1" "$DETECT_EXIT" + +# --------------------------------------------------------------------------- +echo +echo "passed: $PASS failed: $FAIL" +[ "$FAIL" -eq 0 ]