From 465671c5168d7cfaee3818b0f894b6439062e003 Mon Sep 17 00:00:00 2001 From: David Muto Date: Fri, 16 Oct 2026 13:16:06 -0400 Subject: [PATCH] [release]: Cut patches from per-minor release branches (#237) Starting with v1.0 we support the current minor and the two before it, so critical fixes need to ship on older lines. Releases could only be cut from main, and every release took latest on GitHub and Docker Hub, so a patch to an older line would have taken latest from a newer one. Each minor now gets a _releases/vX.Y.x_ branch, which the workflow creates when it releases vX.Y.0 from main. main cuts minor and major releases, release branches cut patches for their line only. `svu` picks the highest tag in the whole repo regardless of branch, so on a release branch it is limited to that line's tags with `--tag.pattern`. A release is marked latest only when it is the highest stable version. The workflow passes that to goreleaser as MAKE_LATEST, which controls both make_latest and the latest image tag. This also stops prereleases from taking the latest image tag. CI now runs for release branches so backport PRs are checked, and CONTRIBUTING covers the support window and how to backport, including fixes that need adapting rather than a clean cherry-pick. (cherry picked from commit a799dc2bf730a0161428fc37de462a099e1a5e8b) --- .github/CONTRIBUTING.md | 35 +++++++++++++--- .github/workflows/ci.yaml | 2 + .github/workflows/release.yaml | 76 +++++++++++++++++++++++++++++++--- .goreleaser.yaml | 4 +- 4 files changed, 105 insertions(+), 12 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6200157..50874b1 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -114,16 +114,39 @@ buf curl --protocol grpc --http2-prior-knowledge \ ## Releases +We support the current minor release and the two before it (for example, `v1.0`, `v0.9`, and `v0.8`). Each minor +version has a `releases/vX.Y.x` branch, and patches for that line are cut from it. New minor and major releases are cut +from `main`. + Releases are cut from the [release workflow](workflows/release.yaml) via manual dispatch; you'll need write access to the repository. The workflow computes the version, creates and pushes the tag, then runs goreleaser to publish the binaries, container image, and GitHub release. -To cut a release, open **Actions -> release -> Run workflow** in the GitHub UI, select `main` as the branch, and choose -either: +To cut a release, open **Actions -> release -> Run workflow** in the GitHub UI, pick the branch, and choose either: -- a **bump** (`patch`, `minor`, or `major`); the next version is computed from the latest tag with `svu`, or +- a **bump**; the next version is computed from the latest tag with `svu`. Use `minor` or `major` from `main`, and + `patch` from a release branch (scoped to that line's tags), or - an explicit **version** (with or without a leading `v`, e.g. `1.4.0` or `v0.2.0-alpha.20260722`), which overrides the - bump. + bump. On a release branch it must belong to that line. + +Releasing `vX.Y.0` from `main` also creates the `releases/vX.Y.x` branch from the new tag. + +The release is marked latest on GitHub, and the container image is tagged `latest`, only when it is the highest stable +version. A patch to an older line (say `v0.9.1` after `v1.0.1`) is published without touching `latest`. + +The run fails fast, without creating a tag, if it is dispatched from a branch other than `main` or `releases/vX.Y.x`, +if the bump doesn't match the branch, if an explicit version is not valid semver or doesn't belong to the release +branch, or if the resolved tag already exists. Only one release runs at a time. + +### Backporting fixes + +Fixes usually land on `main` first. To ship one on a supported line, open a PR against its `releases/vX.Y.x` branch: + +- If the fix applies cleanly, cherry-pick it with `git cherry-pick -x `. +- If it doesn't (for example, the code it touches doesn't exist on that line), adapt it by hand. + +Either way, link the corresponding `main` commit in the PR description. A fix that only applies to an older line is +fine too; say why there's no `main` counterpart. Once merged, run the release workflow from the release branch with a +`patch` bump. -The run fails fast, without creating a tag, if it is dispatched from a branch other than `main`, if an explicit version -is not valid semver, or if the resolved tag already exists. +Release branches for lines that fall out of the support window can be deleted by hand. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ed85804..2a550e2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,9 +4,11 @@ on: pull_request: branches: - main + - releases/** push: branches: - main + - releases/** permissions: contents: read diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5d38e97..6912761 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,21 +19,42 @@ on: permissions: contents: write +# One release at a time across all branches. Whether a release is marked latest +# depends on every other tag, so two lines releasing at once could both claim it. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }} + cancel-in-progress: false jobs: release: runs-on: ubuntu-latest steps: + # main cuts minor and major releases; releases/vX.Y.x cuts patches for that + # line. line is empty on main and vX.Y on a release branch. - name: Guard branch - if: github.ref != 'refs/heads/main' + id: line env: REF: ${{ github.ref }} run: | - echo "::error::Releases can only be run from main (got ${REF})" - exit 1 + set -euo pipefail + case "${REF}" in + refs/heads/main) + line="" + ;; + refs/heads/releases/v*.x) + line="${REF#refs/heads/releases/}" + line="${line%.x}" + if ! echo "${line}" | grep -Eq '^v[0-9]+\.[0-9]+$'; then + echo "::error::Release branches must be named releases/vX.Y.x (got ${REF})" + exit 1 + fi + ;; + *) + echo "::error::Releases can only be run from main or releases/vX.Y.x (got ${REF})" + exit 1 + ;; + esac + echo "line=${line}" >> "$GITHUB_OUTPUT" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -46,6 +67,7 @@ jobs: env: INPUT_VERSION: ${{ inputs.version }} INPUT_BUMP: ${{ inputs.bump }} + LINE: ${{ steps.line.outputs.line }} run: | set -euo pipefail if [ -n "${INPUT_VERSION}" ]; then @@ -54,7 +76,22 @@ jobs: echo "::error::Invalid version '${INPUT_VERSION}'. Expected semver like 1.2.3 or v1.2.3-rc1 (build metadata '+...' is not supported)" exit 1 fi + if [ -n "${LINE}" ] && [ "${tag#"${LINE}".}" = "${tag}" ]; then + echo "::error::Version ${tag} does not belong on releases/${LINE}.x" + exit 1 + fi + elif [ -n "${LINE}" ]; then + if [ "${INPUT_BUMP}" != "patch" ]; then + echo "::error::Release branches only cut patch releases (got '${INPUT_BUMP}'); run minor and major bumps from main" + exit 1 + fi + # svu picks the highest tag in the repo, so scope it to this line. + tag="$(svu patch --tag.pattern "${LINE}.*")" else + if [ "${INPUT_BUMP}" = "patch" ]; then + echo "::error::Patch releases are cut from their releases/vX.Y.x branch, not main" + exit 1 + fi tag="$(svu "${INPUT_BUMP}")" fi echo "tag=${tag}" >> "$GITHUB_OUTPUT" @@ -80,6 +117,35 @@ jobs: git tag -a "${TAG}" -m "Release ${TAG}" git push origin "${TAG}" + - name: Create release branch + if: steps.line.outputs.line == '' + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + if ! echo "${TAG}" | grep -Eq '^v[0-9]+\.[0-9]+\.0$'; then + exit 0 + fi + branch="releases/${TAG%.0}.x" + git push origin "${TAG}^{commit}:refs/heads/${branch}" + echo "Created ${branch} from ${TAG}" + + # Only the highest stable version is latest, so a patch to an older line + # doesn't take latest from a newer one. goreleaser reads MAKE_LATEST. + - name: Resolve latest + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + highest="$(git tag -l 'v*' --sort=-v:refname | grep -v -- '-' | head -n1)" + if [ "${TAG}" = "${highest}" ]; then + latest=true + else + latest=false + fi + echo "MAKE_LATEST=${latest}" >> "$GITHUB_ENV" + echo "Latest: ${latest} (highest stable tag is ${highest})" + - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d30a739..5ccd8f1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -33,7 +33,7 @@ dockers_v2: tags: - "v{{.Version }}" - "{{ if .IsNightly }}nightly{{ end }}" - - "{{ if not .IsNightly }}latest{{ end }}" + - '{{ if eq (envOrDefault "MAKE_LATEST" "false") "true" }}latest{{ end }}' labels: "org.opencontainers.image.title": "{{ .ProjectName }}" @@ -79,6 +79,8 @@ release: name: temporal-proxy draft: false prerelease: auto + # Set by the release workflow; only the highest stable version is latest. + make_latest: '{{ envOrDefault "MAKE_LATEST" "false" }}' name_template: "{{ .Version }} Release" header: | ## Temporal Proxy {{ .Version }}