Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .github/workflows/auto-release.yaml
Original file line number Diff line number Diff line change
@@ -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:-<empty>}"
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:-<none>}"
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"
30 changes: 0 additions & 30 deletions .github/workflows/zz_generated.create_release.yaml

This file was deleted.

43 changes: 0 additions & 43 deletions .github/workflows/zz_generated.create_release_pr.yaml

This file was deleted.

22 changes: 0 additions & 22 deletions .github/workflows/zz_generated.validate_changelog.yaml

This file was deleted.

107 changes: 107 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
@@ -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 = ""