From c1dea2ac6794af48c9eb27ccf7524998b74d0371 Mon Sep 17 00:00:00 2001 From: Jose Armesto Date: Wed, 3 Jun 2026 17:41:14 +0200 Subject: [PATCH] ci: migrate to push-based release flow Replaces the legacy `create_release_pr` + `create_release` branch-name trigger workflow with a push-based auto-release flow: - `.github/workflows/auto-release.yaml`: on push to main / release-*, git-cliff inspects unreleased conventional commits, computes the bumped version, and `gh release create` produces the tag + GitHub Release atomically. Notes come from cliff's GitHub-aware template (PR link + author per bullet, Full Changelog footer). - `cliff.toml`: bump rules (feat -> minor, breaking -> major), Keep-a- Changelog group mapping, squash-merge `(#N)` preprocessor, repo coordinates for PR-link rendering. Removes: - `zz_generated.create_release_pr.yaml` (legacy release-PR opener) - `zz_generated.create_release.yaml` (legacy tag/release publisher) - `zz_generated.validate_changelog.yaml` (manual-CHANGELOG.md enforcement; incompatible with conventional-commit-driven flow) Existing CHANGELOG.md content is preserved verbatim. Future release notes are published only on the GitHub Releases page. --- .github/workflows/auto-release.yaml | 146 ++++++++++++++++++ .../zz_generated.create_release.yaml | 30 ---- .../zz_generated.create_release_pr.yaml | 43 ------ .../zz_generated.validate_changelog.yaml | 22 --- cliff.toml | 107 +++++++++++++ 5 files changed, 253 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/auto-release.yaml delete mode 100644 .github/workflows/zz_generated.create_release.yaml delete mode 100644 .github/workflows/zz_generated.create_release_pr.yaml delete mode 100644 .github/workflows/zz_generated.validate_changelog.yaml create mode 100644 cliff.toml diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml new file mode 100644 index 0000000..c951ca3 --- /dev/null +++ b/.github/workflows/auto-release.yaml @@ -0,0 +1,146 @@ +--- +# yamllint disable rule:truthy +# Push-based release tagger + release-page publisher. +# +# Runs on push to main (normal releases) and to release-* branches (backports +# / maintenance releases). Inspects conventional commits reachable from the +# pushed ref since the latest reachable v*.*.* tag via git-cliff, and: +# +# 1. creates a new vX.Y.Z git tag if a bump is warranted +# 2. creates the matching GitHub Release with notes generated by git-cliff +# +# The tag push then triggers downstream publishing (for chart/service/CLI +# repos, this is the CircleCI architect pipeline firing on the same /^v.*/ +# tag filter — go-build + push-to-registries + push-to-app-catalog + +# upload-release-assets as appropriate for the repo type). Architect appends +# any binary artifacts to the release we just created here. +# +# No release PR. No human approval step. The mechanism that protects against +# bad releases is the pre-merge CI on the feature PRs that landed these +# commits — once they're on the branch, they ship. +name: Auto-release + +on: + push: + branches: + - main + - 'release-*' # maintenance branches for backports, e.g. release-2.x + +permissions: + contents: write # push tags and create the GitHub Release + # git-cliff's GitHub API lookups (commit.remote.pr_number / username + # in release notes) need PR read access via GITHUB_TOKEN. + pull-requests: read + +# Scope the concurrency lock per branch so a backport tag on release-2.x +# doesn't block (or get blocked by) a main release, and vice versa. A second +# push to the same branch while a tag is being computed still waits. +concurrency: + group: auto-release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + tag: + name: Tag + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 # full history so git-cliff sees every commit + fetch-tags: true # ensure tags are present (shallow runners drop them by default) + + # Install git-cliff once and call it from shell so we can do all the + # expensive work (commits walk + GitHub API PR lookups) in a single + # invocation, then cheaply re-render from the cached JSON context. + # Replaces two separate orhun/git-cliff-action invocations that each + # re-installed git-cliff and re-paid the API-lookup cost. + - name: Install git-cliff + uses: giantswarm/install-binary-action@5bef88f65012037dd836117c8d344b21bb559854 # v4.1.0 + with: + binary: git-cliff + version: "2.13.1" + # yamllint disable-line rule:line-length + download_url: 'https://github.com/orhun/git-cliff/releases/download/v${version}/git-cliff-${version}-x86_64-unknown-linux-gnu.tar.gz' + tarball_binary_path: 'git-cliff-${version}/git-cliff' + smoke_test: '${binary} --version' + + - name: Compute next version and render release notes + id: cliff + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Single expensive call: walks commits, queries the GitHub API for + # per-commit PR lookups, emits the JSON context for ONLY the bumped + # release. + # + # Flag choice matters here. Both `--latest` and `--unreleased` + # *look* like single-release filters, but only one composes with + # `--bump`: + # --latest --bump : returns the latest EXISTING tag — drops the + # bump silently. We don't want this (it caused + # #15 to never produce a new tag). + # --unreleased --bump : filters to commits that aren't in any tag + # yet, computes the bumped version from + # them, returns JSON for that bumped + # release only. This is what we want. + # + # Without either filter, --bump --context returns the full release + # history in JSON and the render below would concatenate every + # past release into each new release's notes (the #13 → #15 bug). + git-cliff --unreleased --bump --context > cliff-context.json + NEXT=$(jq -r '.[0].version // empty' cliff-context.json) + echo "next computed: ${NEXT:-}" + echo "version=${NEXT}" >> "$GITHUB_OUTPUT" + # Cheap render from the cached context — no API calls, no git walk. + # `--strip all` drops header/footer; body-only is what gh release + # create --notes-file expects. + git-cliff --from-context cliff-context.json --strip all --output release-notes.md + + - name: Decide whether to tag + id: decide + env: + NEXT: ${{ steps.cliff.outputs.version }} + run: | + set -euo pipefail + # `git describe --tags --abbrev=0` returns the closest tag reachable + # from HEAD — NOT the highest tag in the repo overall. This matters + # for backports: on release-2.x, HEAD's reachable history terminates + # before v3.0.0 was tagged, so describe correctly returns v2.3.5 + # (the baseline of the 2.x line) and we end up tagging v2.3.6 rather + # than something nonsensical relative to v3.0.0. + last=$(git describe --tags --abbrev=0 --match='v*.*.*' 2>/dev/null || echo "") + echo "branch: ${GITHUB_REF_NAME}" + echo "last reachable tag: ${last:-}" + if [ -z "${NEXT}" ] || [ "${NEXT}" = "${last}" ]; then + echo "No releasable commits since ${last:-inception}; skipping tag." + echo "tag=" >> "$GITHUB_OUTPUT" + else + echo "tag=${NEXT}" >> "$GITHUB_OUTPUT" + fi + + # Create the tag AND the GitHub Release in one atomic API call. GitHub's + # release-create endpoint accepts a tag_name + target_commitish and, if + # the tag doesn't already exist, creates it pointing at that commitish + # as part of the same operation. Either both exist or neither does — + # no risk of a tag-without-release "stuck" state, no race window with + # CircleCI's `upload-release-assets` job that starts polling the + # release the instant it sees the tag. + # + # Trade-off: the tag created this way is **lightweight** (just a ref), + # not annotated. For our use — git describe, architect's tag filter, + # gitsemver, gh release view — both behave identically. Nothing in the + # giantswarm stack distinguishes them. + - name: Create release (and the tag, atomically) + if: steps.decide.outputs.tag != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.decide.outputs.tag }} + run: | + set -euo pipefail + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file release-notes.md \ + --target "$GITHUB_SHA" diff --git a/.github/workflows/zz_generated.create_release.yaml b/.github/workflows/zz_generated.create_release.yaml deleted file mode 100644 index 60ddf39..0000000 --- a/.github/workflows/zz_generated.create_release.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# DO NOT EDIT. Generated with: -# -# devctl -# -# https://github.com/giantswarm/devctl/blob/0df8c5e633bb1e4fd17c35525a7b382395526f22/pkg/gen/input/workflows/internal/file/create_release.yaml.template -# -name: Create Release - -on: - push: - branches: - - 'legacy' - - 'main' - - 'master' - - 'release-v*.*.x' - # "!" negates previous positive patterns so it has to be at the end. - - '!release-v*.x.x' - -permissions: {} - -jobs: - create-release: - uses: giantswarm/github-workflows/.github/workflows/create-release.yaml@main - with: - build-release-artifacts: false - fetch-deep-gitlog-for-build: true - secrets: - TAYLORBOT_GITHUB_ACTION: ${{ secrets.TAYLORBOT_GITHUB_ACTION }} - permissions: - contents: write diff --git a/.github/workflows/zz_generated.create_release_pr.yaml b/.github/workflows/zz_generated.create_release_pr.yaml deleted file mode 100644 index 1a19e88..0000000 --- a/.github/workflows/zz_generated.create_release_pr.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# DO NOT EDIT. Generated with: -# -# devctl -# -# https://github.com/giantswarm/devctl/blob/87f30fd3b955a0daf6017834a776c222d93a207c/pkg/gen/input/workflows/internal/file/create_release_pr.yaml.template -# -name: Create Release PR -on: - push: - branches: - - 'legacy#release#v*.*.*' - - 'main#release#v*.*.*' - - 'main#release#major' - - 'main#release#minor' - - 'main#release#patch' - - 'master#release#v*.*.*' - - 'master#release#major' - - 'master#release#minor' - - 'master#release#patch' - - 'release#v*.*.*' - - 'release#major' - - 'release#minor' - - 'release#patch' - - 'release-v*.*.x#release#v*.*.*' - # "!" negates previous positive patterns so it has to be at the end. - - '!release-v*.x.x#release#v*.*.*' - workflow_call: - inputs: - branch: - required: true - type: string - -permissions: {} - -jobs: - publish: - uses: giantswarm/github-workflows/.github/workflows/create-release-pr.yaml@main - permissions: - contents: read - with: - branch: ${{ inputs.branch }} - secrets: - TAYLORBOT_GITHUB_ACTION: ${{ secrets.TAYLORBOT_GITHUB_ACTION }} diff --git a/.github/workflows/zz_generated.validate_changelog.yaml b/.github/workflows/zz_generated.validate_changelog.yaml deleted file mode 100644 index 108bbc0..0000000 --- a/.github/workflows/zz_generated.validate_changelog.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# DO NOT EDIT. Generated with: -# -# devctl -# -# https://github.com/giantswarm/devctl/blob/87f30fd3b955a0daf6017834a776c222d93a207c/pkg/gen/input/workflows/internal/file/validate_changelog.yaml.template -# -name: Validate changelog - -on: - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'CHANGELOG.md' - -permissions: {} - -jobs: - validate-changelog: - uses: giantswarm/github-workflows/.github/workflows/validate-changelog.yaml@main - permissions: - contents: read - pull-requests: write diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..3c93c5b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,107 @@ +# git-cliff config for the push-based PoC. +# +# Two jobs: +# 1. Decide the next semver from conventional commits (`--bumped-version`, +# used by .github/workflows/auto-tag.yaml). +# 2. Render the release notes posted on the GitHub Release (`--latest`, +# also used by auto-tag.yaml after the tag is pushed). + +[bump] +# `feat:` always bumps minor (not patch). Matches conventional-commits spec. +features_always_bump_minor = true +# `feat!:`/`BREAKING CHANGE` always bumps major. +breaking_always_bump_major = true + +[git] +conventional_commits = true +# Drop non-conventional commits silently rather than counting them as +# unclassified work — the bump-decider should only act on intent-bearing +# commits. +filter_unconventional = true +# Highest-version tag (not creation date) is the baseline when computing what +# changed since last release. +topo_order = false +sort_commits = "oldest" + +# Strip the trailing " (#N)" that GitHub appends to squash-merge commit +# messages. Our template renders the PR link explicitly via cliff's GitHub +# integration (commit.remote.pr_number — see [changelog].body), so the +# auto-appended reference would duplicate it. +# +# `(?m)` enables multi-line mode so `$` matches end-of-line (the squash +# `(#N)` lives at the end of the subject line, not the end of the full +# commit text — git-cliff's default regex mode is single-line). This +# anchoring keeps `(#N)` references that appear inside commit bodies +# untouched. +commit_preprocessors = [ + { pattern = '(?m) \(#\d+\)$', replace = "" }, +] + +# Map commit types to changelog groups. Mirrors the section structure from +# the old release-please-config.json (Keep-a-Changelog convention): feat → +# Added, fix → Fixed, all "internal change" types → Changed, security → +# Security. +# +# `style` is the only type marked skip — release-please-config.json didn't +# include it anywhere either. Bump decision is unaffected by these group +# names: git-cliff only bumps on feat / fix / breaking regardless. +commit_parsers = [ + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^refactor", group = "Changed" }, + { message = "^perf", group = "Changed" }, + { message = "^docs", group = "Changed" }, + { message = "^chore", group = "Changed" }, + { message = "^test", group = "Changed" }, + { message = "^build", group = "Changed" }, + { message = "^ci", group = "Changed" }, + { message = "^security", group = "Security" }, + { message = "^style", skip = true }, +] + +# GitHub repo coordinates — required for the API lookups that populate +# `commit.github.pr_number` and `commit.github.username` in the template +# below. The token is passed automatically by git-cliff-action from the +# workflow's GITHUB_TOKEN. +# +# Note for devctl when templating this file: owner / repo need to come from +# the consuming repo's identity, not be hard-coded. +[remote.github] +owner = "giantswarm" +repo = "agentic-platform" + +# Template for the rendered release notes. Body is what gets posted to the +# GitHub Release; header and footer are empty so the release page shows only +# the section list. +# +# Each bullet ends with " in [#N](pr-url) by [@user](user-url)" when +# git-cliff successfully resolves the PR for the commit via the GitHub API. +# `commit.remote` is the post-2.x replacement for the per-platform fields +# (commit.github / commit.gitlab / etc.) — using it future-proofs the +# template against git-cliff dropping the old names. +# Backslashes at line ends escape the newline so the bullet stays on one +# line in the rendered Markdown. +[changelog] +header = "" +# `{%-` / `-%}` strip the surrounding newline so the rendered Markdown is +# tight: one blank line before each "### " header, no leading blank, no +# double-blank between header and bullets. Without the whitespace control +# the output picks up two extra blank lines per loop iteration. +body = """ +{% for group, commits in commits | group_by(attribute="group") -%} +### {{ group }} + +{% for commit in commits -%} +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ +{% if commit.breaking %}[**breaking**] {% endif %}\ +{{ commit.message | split(pat="\n") | first | upper_first }}\ +{% if commit.remote.pr_number %} in [#{{ commit.remote.pr_number }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/pull/{{ commit.remote.pr_number }}){% endif %}\ +{% if commit.remote.username %} by [@{{ commit.remote.username }}](https://github.com/{{ commit.remote.username }}){% endif %} +{% endfor %} +{% endfor -%} +{% if previous.version %} +**Full Changelog**: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/compare/{{ previous.version }}...{{ version }} +{% endif -%} +""" +trim = true +footer = ""