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 }}