From 1efb1b29d72eaa167107d7eb85448e568b335f93 Mon Sep 17 00:00:00 2001 From: Alexander Olzem Date: Fri, 23 Oct 2026 12:33:21 +0200 Subject: [PATCH 1/5] feat: add reusable release workflow --- .../actions/check-release-notes/action.yml | 24 ++ .../check-release-notes.sh | 88 +++++ .../test-check-release-notes.sh | 145 ++++++++ .github/release-drafter.yml | 58 --- .github/workflows/release-drafter.yaml | 44 --- .github/workflows/release-self.yml | 36 ++ .github/workflows/release.yml | 329 ++++++++++++++++++ .github/workflows/test-actions.yml | 12 +- README.md | 88 +++++ cliff.toml | 85 +++++ renovate.json | 12 + 11 files changed, 815 insertions(+), 106 deletions(-) create mode 100644 .github/actions/check-release-notes/action.yml create mode 100755 .github/actions/check-release-notes/check-release-notes.sh create mode 100755 .github/actions/check-release-notes/test-check-release-notes.sh delete mode 100644 .github/release-drafter.yml delete mode 100644 .github/workflows/release-drafter.yaml create mode 100644 .github/workflows/release-self.yml create mode 100644 .github/workflows/release.yml create mode 100644 cliff.toml diff --git a/.github/actions/check-release-notes/action.yml b/.github/actions/check-release-notes/action.yml new file mode 100644 index 0000000..33e9f32 --- /dev/null +++ b/.github/actions/check-release-notes/action.yml @@ -0,0 +1,24 @@ +name: Check release notes +description: Fail when commits in a range would be missing from the git-cliff notes. +inputs: + range: + description: Git range to check, for example v0.3.0..HEAD. + required: true + force: + description: Warn instead of failing when commits are missing. + required: false + default: 'false' + context: + description: Path to the git-cliff --context JSON. + required: false + default: ctx.json +runs: + using: composite + steps: + - name: Check release notes + shell: bash + env: + RANGE: ${{ inputs.range }} + FORCE: ${{ inputs.force }} + CLIFF_CONTEXT: ${{ inputs.context }} + run: bash "$GITHUB_ACTION_PATH/check-release-notes.sh" "$RANGE" diff --git a/.github/actions/check-release-notes/check-release-notes.sh b/.github/actions/check-release-notes/check-release-notes.sh new file mode 100755 index 0000000..8751aa3 --- /dev/null +++ b/.github/actions/check-release-notes/check-release-notes.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# Lists the commits in a range that will not appear in the generated release +# notes, and fails unless FORCE is true. Prints nothing and exits 0 when the +# range is fully representable. +# +# check-release-notes.sh v0.3.0..HEAD +# +# Expects the git-cliff context in $CLIFF_CONTEXT (default: ctx.json), produced +# by: +# +# git cliff --config --use-branch-tags --unreleased --bump --context +# +# A commit reaches the notes only if both of these hold: +# +# 1. it is a merge commit, because the shared cliff.toml filters on merge_commit +# 2. its subject parses as a conventional commit, which is what picks a group +# +# The context check keeps a config that groups extra types from stopping the +# release: if git-cliff renders the merge, the notes contain it. +# +# Squash and rebase merges fail the first. GitHub's default +# "Merge pull request #1 from ..." subject and the Revert button's default +# title fail the second. + +set -euo pipefail + +RANGE="${1:?usage: $0 , e.g. v0.3.0..HEAD}" +CLIFF_CONTEXT="${CLIFF_CONTEXT:-ctx.json}" +FORCE="${FORCE:-false}" + +# The conventional types the shared cliff.toml groups or deliberately skips. +# Anything else has no section to go into and is dropped. +CONVENTIONAL_TYPES='feat|fix|perf|refactor|revert|docs|ci|chore|test|style|build' + +# git-cliff strips these before parsing and trims the subject, so they have to +# go here too, otherwise a subject like "✨ feat: thing" looks unparseable when +# git-cliff handles it fine. Keep in sync with commit_preprocessors in the +# shared cliff.toml at the repository root. A caller that overrides +# `cliff_config` gets a best-effort check; the rendered context above remains +# authoritative for merges it actually renders. +EMOJI_RANGES='\x{1F000}-\x{1FAFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{FE0E}\x{FE0F}\x{200D}' + +normalise_subjects() { + perl -CSD -pe "s/[${EMOJI_RANGES}]//g; s/[ \\t]{2,}/ /g; s/^[ \\t]+//; s/[ \\t]+$//" +} + +# Commits git-cliff will render. The notes template keeps merge commits only, +# so a non-merge that protect_breaking_commits puts in the context is not +# rendered and must still be reported. +rendered_ids=$(jq -r '.[0].commits[] | select(.merge_commit == true) | .id' "$CLIFF_CONTEXT") + +missing=$( + # \x1f is not IFS whitespace, so an empty %P (root commit) stays a field + # instead of collapsing and shifting the subject into parents. + git log --first-parent "$RANGE" --format='%H%x1f%h%x1f%P%x1f%s' | + while IFS=$'\x1f' read -r sha short parents subject; do + # Already represented in the notes. + if grep -qxF "$sha" <<< "$rendered_ids"; then + continue + fi + + # A merge whose type the notes deliberately skip is not a missing + # commit. Non-merges never reach the notes, so they are reported + # whatever their type. + if [[ "$parents" == *" "* ]]; then + normalised=$(printf '%s' "$subject" | normalise_subjects) + if [[ "$normalised" =~ ^(${CONVENTIONAL_TYPES})(\(.+\))?!?: ]]; then + continue + fi + fi + + printf '%s %s\n' "$short" "$subject" + done +) + +if [ -z "$missing" ]; then + exit 0 +fi + +printf '%s\n' "$missing" + +if [ "$FORCE" != "true" ]; then + echo "::error::These commits would be missing from the release notes. Fix the history, or re-run with force to release without them." >&2 + exit 1 +fi + +echo "::warning::Releasing without the commits above in the notes, because force is set." >&2 diff --git a/.github/actions/check-release-notes/test-check-release-notes.sh b/.github/actions/check-release-notes/test-check-release-notes.sh new file mode 100755 index 0000000..c345ccb --- /dev/null +++ b/.github/actions/check-release-notes/test-check-release-notes.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +TESTDIR="$(mktemp -d)" +trap 'rm -rf "$TESTDIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +new_repo() { + local dir="$1" + mkdir -p "$dir" + cd "$dir" + git init -q -b main + git config user.email test@example.com + git config user.name Test + git config commit.gpgsign false + git config tag.gpgsign false + git commit -q --allow-empty -m "chore: init" + git tag v0.1.0 +} + +merge_commit() { + local branch="$1" + local subject="$2" + local title="$3" + git checkout -q -b "$branch" main + git commit -q --allow-empty -m "$subject" + git checkout -q main + git merge -q --no-ff "$branch" -m "$title" + git rev-parse HEAD +} + +# Mixed history: rendered and unrendered merges, a deliberately skipped type, +# and direct commits on main. +new_repo "$TESTDIR/mixed" + +feat_merge=$(merge_commit feat "feat: real feature" "feat: real feature (#1)") +merge_commit test "test: thing" "test: skipped merge (#2)" > /dev/null +weird_merge=$(merge_commit weird "whatever" "Weird title (#3)") +deps_merge=$(merge_commit deps "deps: bump" "deps: bump (#4)") +breaking_merge=$(merge_commit breaking "feat!: breaking" "feat!: breaking merge (#5)") + +# Conventional subject with emoji and extra whitespace, absent from the +# context: normalization must trim it so the type filter still skips it. +merge_commit spaced "feat: spaced" " ✨ feat: spaced feature " > /dev/null + +git commit -q --allow-empty -m "fix: direct commit" +fix_direct=$(git rev-parse HEAD) +git commit -q --allow-empty -m "feat!: direct breaking commit" +breaking_direct=$(git rev-parse HEAD) + +# The context also carries protected breaking non-merges. The notes template +# renders merges only, so those must still be reported. +jq -n \ + --arg feat "$feat_merge" \ + --arg deps "$deps_merge" \ + --arg breaking "$breaking_merge" \ + --arg direct_breaking "$breaking_direct" \ + '[{commits: [ + {id: $feat, merge_commit: true}, + {id: $deps, merge_commit: true}, + {id: $breaking, merge_commit: true}, + {id: $direct_breaking, merge_commit: false} + ]}]' > ctx.json + +expected=$( + printf '%s %s\n' "$(git rev-parse --short "$breaking_direct")" "feat!: direct breaking commit" + printf '%s %s\n' "$(git rev-parse --short "$fix_direct")" "fix: direct commit" + printf '%s %s\n' "$(git rev-parse --short "$weird_merge")" "Weird title (#3)" +) + +echo "=== Test 1: mixed history ===" +set +e +actual=$(CLIFF_CONTEXT=ctx.json bash "$ROOT/check-release-notes.sh" v0.1.0..HEAD 2> /dev/null) +rc=$? +set -e +[ "$rc" -eq 1 ] || fail "expected exit 1, got $rc" +[ "$actual" = "$expected" ] || fail "unexpected output: +$actual" +echo "PASS" + +echo "=== Test 2: force ===" +set +e +actual=$(FORCE=true CLIFF_CONTEXT=ctx.json bash "$ROOT/check-release-notes.sh" v0.1.0..HEAD 2> /dev/null) +rc=$? +set -e +[ "$rc" -eq 0 ] || fail "expected exit 0, got $rc" +[ "$actual" = "$expected" ] || fail "unexpected output: +$actual" +echo "PASS" + +# Clean history: the only commit since the tag is rendered. +new_repo "$TESTDIR/clean" +clean_merge=$(merge_commit feat "feat: only feature" "feat: only feature (#1)") +jq -n --arg id "$clean_merge" '[{commits: [{id: $id, merge_commit: true}]}]' > ctx.json + +echo "=== Test 3: nothing missing ===" +set +e +actual=$(CLIFF_CONTEXT=ctx.json bash "$ROOT/check-release-notes.sh" v0.1.0..HEAD 2> /dev/null) +rc=$? +set -e +[ "$rc" -eq 0 ] || fail "expected exit 0, got $rc" +[ -z "$actual" ] || fail "expected no output, got: +$actual" +echo "PASS" + +# Root commit: an empty %P field must not shift the subject into parents. +new_repo "$TESTDIR/root" +jq -n '[{commits: []}]' > ctx.json + +echo "=== Test 4: root commit keeps its subject ===" +set +e +actual=$(CLIFF_CONTEXT=ctx.json bash "$ROOT/check-release-notes.sh" HEAD 2> /dev/null) +rc=$? +set -e +[ "$rc" -eq 1 ] || fail "expected exit 1, got $rc" +expected="$(git rev-parse --short HEAD) chore: init" +[ "$actual" = "$expected" ] || fail "unexpected output: +$actual" +echo "PASS" + +# The shared config is the contract for the guard's subject normalisation and +# type list. Assert that the two have not drifted apart. +echo "=== Test 5: guard constants match the shared cliff.toml ===" +config="$ROOT/../../../cliff.toml" +[ -f "$config" ] || fail "shared cliff.toml not found at $config" + +guard_emoji=$(awk -F"'" '/^EMOJI_RANGES=/{print $2}' "$ROOT/check-release-notes.sh") +guard_types=$(awk -F"'" '/^CONVENTIONAL_TYPES=/{print $2}' "$ROOT/check-release-notes.sh") +[ -n "$guard_emoji" ] || fail "could not read EMOJI_RANGES from check-release-notes.sh" +[ -n "$guard_types" ] || fail "could not read CONVENTIONAL_TYPES from check-release-notes.sh" + +config_emoji=$(awk -F'[][]' '/1F000/{print $2}' "$config") +[ "$config_emoji" = "$guard_emoji" ] || fail "EMOJI_RANGES drifted from the shared cliff.toml" + +config_types=$(grep -oE "message = '\^[a-z]+" "$config" | sed "s/message = '\^//" | sort -u | paste -sd'|') +expected_types=$(printf '%s' "$guard_types" | tr '|' '\n' | sort -u | paste -sd'|') +[ "$config_types" = "$expected_types" ] || fail "CONVENTIONAL_TYPES drifted from the shared cliff.toml (config: ${config_types})" +echo "PASS" + +echo "✅ All tests passed" diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index 1d00390..0000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,58 +0,0 @@ -name-template: "$RESOLVED_VERSION" -tag-template: "$RESOLVED_VERSION" -categories: - - title: "Features" - labels: - - "feat" - - "feature" - - "enhancement" - - title: "Bug Fixes" - labels: - - "fix" - - "bugfix" - - "bug" - - title: "Maintenance" - labels: - - "docs" - - "doc" - - "chore" -change-template: "- $TITLE @$AUTHOR (#$NUMBER)" -change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. -version-template: "v$MAJOR.$MINOR.$PATCH" -version-resolver: - major: - labels: - - "major" - minor: - labels: - - "minor" - patch: - labels: - - "patch" - default: patch -exclude-labels: - - "skip-changelog" -autolabeler: - - label: "chore" - files: - - "*.md" - branch: - - '/docs{0,1}\/.+/' - - label: "bug" - branch: - - '/fix\/.+/' - - '/bug\/.+/' - - '/bugfix\/.+/' - title: - - "/fix/i" - - "/bug/i" - - "/bugfix/i" - - label: "feature" - branch: - - '/feat\/.+/' - - '/feature\/.+/' - - '/enhancement\/.+/' -template: | - ## Changes - - $CHANGES diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml deleted file mode 100644 index 7aaefe6..0000000 --- a/.github/workflows/release-drafter.yaml +++ /dev/null @@ -1,44 +0,0 @@ -name: Release Drafter - -on: - push: - # branches to consider in the event; optional, defaults to all - branches: - - main - - # pull_request event is required only for autolabeler. - # pull_request_target is required for the autolabeler to support PRs from forks - # pull_request_target: - # types: [opened, reopened, synchronize] - pull_request: - # Only following types are handled by the action, but one can default to all as well - types: [opened, reopened, synchronize] - -permissions: - contents: read - -jobs: - update_release_draft: - permissions: - # write permission is required to create a github release - contents: write - # write permission is required for autolabeler - # otherwise, read permission is required at least - pull-requests: write - runs-on: ubuntu-26.04 - steps: - # (Optional) GitHub Enterprise requires GHE_HOST variable set - #- name: Set GHE_HOST - # run: | - # echo "GHE_HOST=${GITHUB_SERVER_URL##https:\/\/}" >> $GITHUB_ENV - - # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0 - # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml - # with: - # config-name: my-config.yml - # disable-autolabeler: true - with: - commitish: main - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-self.yml b/.github/workflows/release-self.yml new file mode 100644 index 0000000..d88d2ce --- /dev/null +++ b/.github/workflows/release-self.yml @@ -0,0 +1,36 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release, e.g. v1.0.0. Leave empty and it is computed from the commits.' + required: false + type: string + dry_run: + description: 'Render the version and notes only. Do not tag or create a release.' + required: false + type: boolean + default: true + force: + description: 'Release even though commits are missing from the notes. Recovery only.' + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + uses: ./.github/workflows/release.yml + permissions: + contents: write + with: + version: ${{ inputs.version }} + dry_run: ${{ inputs.dry_run }} + force: ${{ inputs.force }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e72f856 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,329 @@ +name: Release + +# Reusable workflow that computes version and notes with git-cliff, +# creates a tag and a draft release. Publishing the draft triggers +# repo-specific build jobs (Docker, Helm, docs, assets). +# +# The caller's stub carries the triggers (workflow_dispatch). See "Release +# workflow" in the README. + +on: + workflow_call: + inputs: + version: + description: 'Version to release, e.g. v0.4.0. Leave empty and it is computed from the commits.' + required: false + type: string + default: '' + dry_run: + description: 'Render the version and notes only. Do not tag or create a release.' + required: false + type: boolean + default: true + force: + description: 'Release even though commits are missing from the notes. Recovery only.' + required: false + type: boolean + default: false + retry_assets_for: + description: 'Recovery only. Tag of an existing DRAFT release whose asset upload failed. Skips version and notes.' + required: false + type: string + default: '' + cliff_config: + description: 'Path to a git-cliff config. Defaults to the shared config from the workflow repository.' + required: false + type: string + default: '.release-tooling/cliff.toml' + outputs: + version: + description: 'The computed/released version.' + value: ${{ jobs.prepare.outputs.version }} + sha: + description: 'The commit SHA being released.' + value: ${{ jobs.prepare.outputs.sha }} + created: + description: 'Whether a tag and draft release were created.' + value: ${{ jobs.prepare.outputs.created }} + +permissions: + contents: read + +jobs: + prepare: + name: Compute version and notes + if: ${{ inputs.retry_assets_for == '' }} + runs-on: ubuntu-24.04 + permissions: + contents: write # create the tag ref and the draft release + outputs: + version: ${{ steps.version.outputs.version }} + sha: ${{ steps.version.outputs.sha }} + created: ${{ steps.release.outputs.created }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Guard - releases are cut from the default branch + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + case "$REF_TYPE" in + branch) + if [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then + echo "::error::Releases are cut from ${DEFAULT_BRANCH}, not ${REF_NAME}." + exit 1 + fi + ;; + tag) + # A tag carries the release only if it points at code that landed + # on the default branch. fetch-depth: 0 puts the branch tip in + # refs/remotes/origin/. + default_ref="refs/remotes/origin/${DEFAULT_BRANCH}" + if ! git rev-parse -q --verify "$default_ref" >/dev/null; then + echo "::error::Could not resolve ${default_ref}; a release needs full history." + exit 1 + fi + if ! git merge-base --is-ancestor HEAD "$default_ref"; then + echo "::error::Tag ${REF_NAME} does not point at a commit on ${DEFAULT_BRANCH}." + exit 1 + fi + ;; + *) + echo "::error::Releases must be cut from ${DEFAULT_BRANCH}; got ${REF_TYPE:-unknown} ${REF_NAME}." + exit 1 + ;; + esac + + - name: Install git-cliff + uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28 + with: + tool: git-cliff@2.13.1 + + - name: Guard - no draft release already pending + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + draft_tags=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 30 \ + --json tagName,isDraft --jq '.[] | select(.isDraft) | .tagName') + + blocking_drafts=() + for tag in $draft_tags; do + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + blocking_drafts+=("$tag") + fi + done + + if [ ${#blocking_drafts[@]} -gt 0 ]; then + echo "::error::A draft release is already pending: ${blocking_drafts[*]}. Publish it to ship, or delete the draft and its tag to cut a fresh one that includes what landed since." + exit 1 + fi + + # Check out the tooling from the same commit as the reusable workflow, so + # consumers get the shared cliff.toml and the notes guard without keeping + # their own copies. job.workflow_* is github.com only. + - name: Check out release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-tooling + persist-credentials: false + + - name: Read release context + id: ctx + env: + CLIFF_CONFIG: ${{ inputs.cliff_config }} + run: | + set -euo pipefail + + if [ ! -f "$CLIFF_CONFIG" ]; then + echo "::error::${CLIFF_CONFIG} not found." + exit 1 + fi + + git cliff --config "$CLIFF_CONFIG" --use-branch-tags --unreleased --bump --context > ctx.json + + previous_tag=$(jq -r '.[0].previous.version // empty' ctx.json) + computed_version=$(jq -r '.[0].version // empty' ctx.json) + + if [ -z "$previous_tag" ]; then + echo "::error::Could not work out the previous release tag from git-cliff." + exit 1 + fi + + echo "Releasing everything since ${previous_tag}." + { + echo "previous=${previous_tag}" + echo "computed=${computed_version}" + } >> "$GITHUB_OUTPUT" + + - name: Guard - nothing is missing from the notes + uses: ./.release-tooling/.github/actions/check-release-notes + with: + range: ${{ steps.ctx.outputs.previous }}..HEAD + force: ${{ inputs.force }} + + - name: Guard - something releasable landed + if: ${{ inputs.version == '' }} + env: + PREVIOUS_TAG: ${{ steps.ctx.outputs.previous }} + run: | + set -euo pipefail + + releasable_count=$(jq ' + [ .[0].commits[] | select((.raw_message | test("^(feat|fix|perf)")) or .breaking) ] | length + ' ctx.json) + + if [ "$releasable_count" -eq 0 ]; then + echo "::error::No feat/fix/perf or breaking commits since ${PREVIOUS_TAG}. Pass an explicit version to override." + exit 1 + fi + + - name: Compute version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + COMPUTED_VERSION: ${{ steps.ctx.outputs.computed }} + run: | + set -euo pipefail + + version="${INPUT_VERSION:-$COMPUTED_VERSION}" + release_sha=$(git rev-parse HEAD) + + if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]]; then + echo "::error::Version must look like v1.2.3 or v1.2.3-rc1, got '${version}'." + exit 1 + fi + + if git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then + echo "::error::Tag ${version} already exists." + exit 1 + fi + + echo "Releasing ${version} from ${release_sha}." + { + echo "version=${version}" + echo "sha=${release_sha}" + } >> "$GITHUB_OUTPUT" + + - name: Render release notes + env: + VERSION: ${{ steps.version.outputs.version }} + SHA: ${{ steps.version.outputs.sha }} + CLIFF_CONFIG: ${{ inputs.cliff_config }} + run: | + set -euo pipefail + + git cliff --config "$CLIFF_CONFIG" --use-branch-tags --unreleased --tag "$VERSION" --output notes.md + + if [ -z "$(tr -d '[:space:]' < notes.md)" ]; then + echo "::error::Rendered notes are empty." + exit 1 + fi + + { + echo "## $VERSION" + echo + echo "commit \`${SHA}\`" + echo + cat notes.md + } >> "$GITHUB_STEP_SUMMARY" + + - name: Create tag and draft release + id: release + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + SHA: ${{ steps.version.outputs.sha }} + run: | + set -euo pipefail + + create_tag() { + gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f "ref=refs/tags/${VERSION}" \ + -f "sha=${SHA}" >/dev/null + } + + delete_tag() { + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${VERSION}" || true + } + + create_draft_release() { + local prerelease_flag=() + if [[ "$VERSION" == *-* ]]; then + prerelease_flag=(--prerelease) + fi + gh release create "$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --draft \ + --title "$VERSION" \ + --notes-file notes.md \ + --target "$SHA" \ + "${prerelease_flag[@]}" + } + + create_tag + + if ! create_draft_release; then + echo "::error::Creating the release failed, rolling the tag back." + delete_tag + exit 1 + fi + + echo "created=true" >> "$GITHUB_OUTPUT" + + - name: Dry run notice + if: ${{ inputs.dry_run }} + run: | + echo "Dry run: nothing tagged or created. Re-run with dry_run unchecked to release." >> "$GITHUB_STEP_SUMMARY" + + validate-retry: + name: Validate asset retry + # The recovery path skips prepare, so nothing else would check the target. + # Asset jobs live in the calling repository; gate them on this job's caller + # succeeding (needs..result == 'success'), not on retry being set. + if: ${{ inputs.retry_assets_for != '' }} + runs-on: ubuntu-24.04 + permissions: + # Draft releases are not visible to a read-only token. + contents: write + steps: + - name: Guard - retry is not a dry run + if: ${{ inputs.dry_run }} + run: | + echo "::error::retry_assets_for rebuilds and re-uploads assets; there is no dry run for it. Uncheck dry_run to retry." + exit 1 + + - name: Check the draft release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.retry_assets_for }} + run: | + set -euo pipefail + + release_state=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null || echo "missing") + + case "$release_state" in + draft) + ;; + published) + echo "::error::${VERSION} is already published and immutable. Cut a new version with the Release workflow." + exit 1 + ;; + missing) + echo "::error::No release found for ${VERSION}. This workflow only retries a failed upload. Use the Release workflow to cut a release." + exit 1 + ;; + esac diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 13db033..3dffa58 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -3,15 +3,16 @@ name: Test Actions # Consuming repositories pin this repo by SHA, so anything shipped from here # reaches their CI unchanged. The triggers therefore cover what is shipped — # the composite actions and their pins, common.mk, the scripts, the flake, the -# reusable workflows, and the README caller stubs consumers copy — not just the -# shell scripts. A pin bump in an action.yml used to change what every consumer -# runs without a single job executing here, and a permissions change in a -# reusable workflow can break every caller at startup. +# reusable workflows, the shared cliff.toml, and the README caller stubs +# consumers copy — not just the shell scripts. A pin bump in an action.yml used +# to change what every consumer runs without a single job executing here, and a +# permissions change in a reusable workflow can break every caller at startup. on: pull_request: paths: - '.github/actions/**' - '.github/workflows/**' + - 'cliff.toml' - 'README.md' - 'common.mk' - 'flake.lock' @@ -22,6 +23,7 @@ on: paths: - '.github/actions/**' - '.github/workflows/**' + - 'cliff.toml' - 'README.md' - 'common.mk' - 'flake.lock' @@ -58,6 +60,8 @@ jobs: run: scripts/test-check-dev-kit-pins.sh - name: Test renovate-relock.sh run: scripts/test-renovate-relock.sh + - name: Test check-release-notes.sh + run: .github/actions/check-release-notes/test-check-release-notes.sh # yq is not on the runner image but is in this repository's dev shell, so diff --git a/README.md b/README.md index d66f679..e2e5e56 100644 --- a/README.md +++ b/README.md @@ -603,6 +603,94 @@ Modify `flake.nix` to adjust Go version, packages, and pre-commit hooks: } ``` +### Release workflow + +The shared release workflow lives in `opendefensecloud/dev-kit/.github/workflows/release.yml` and computes version and notes with git-cliff, creates a tag and a draft release. Publishing the draft triggers repo-specific build jobs (Docker, Helm, docs, assets). Notes are rendered from merge commits and grouped by conventional type using the shared `cliff.toml` in dev-kit, so consumers do not keep their own copy. + +#### Using the reusable workflow + +Add a caller workflow in your repo (e.g. `.github/workflows/release.yml`): + +```yaml +name: Release +run-name: ${{ inputs.retry_assets_for && format('Retry assets {0}', inputs.retry_assets_for) || format('Release {0}{1}', inputs.version || '(auto)', inputs.dry_run && ' (dry run)' || '') }} + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release, e.g. v0.4.0. Leave empty and it is computed from the commits. The tag is created for you.' + required: false + type: string + dry_run: + description: 'Render the version and notes only. Do not tag or create a release.' + required: false + type: boolean + default: true + force: + description: 'Release even though commits are missing from the notes. Recovery only.' + required: false + type: boolean + default: false + retry_assets_for: + description: 'Recovery only. Tag of an existing DRAFT release whose asset upload failed. Skips version and notes, just rebuilds and re-attaches. Uncheck dry_run.' + required: false + type: string + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + prepare: + uses: opendefensecloud/dev-kit/.github/workflows/release.yml@<40-char-sha> # + permissions: + contents: write + with: + version: ${{ inputs.version }} + dry_run: ${{ inputs.dry_run }} + force: ${{ inputs.force }} + retry_assets_for: ${{ inputs.retry_assets_for }} + # cliff_config: cliff.toml # optional override; default is the shared config + + assets: + name: Build and attach release assets + needs: prepare + # result == 'success' matters: validation of a retry target happens in the + # called workflow, and always() alone would still start this job when it + # failed. created is empty on the retry path, so retry covers that case. + if: ${{ always() && needs.prepare.result == 'success' && (needs.prepare.outputs.created == 'true' || inputs.retry_assets_for != '') }} + runs-on: ubuntu-24.04 + permissions: + contents: write + id-token: write + attestations: write + env: + VERSION: ${{ inputs.retry_assets_for || needs.prepare.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@<40-char-sha> # v7 + with: + ref: ${{ needs.prepare.outputs.sha || inputs.retry_assets_for }} + fetch-depth: 0 + persist-credentials: false + # ... build, sign, attest, upload assets to the draft release ... +``` + +#### Requirements + +- **Shared `cliff.toml`**: No config file is needed by default. The workflow checks out `cliff.toml` from dev-kit at the same commit as the reusable workflow and uses it. Pass `cliff_config` to use a repository-specific file instead; the bundled notes guard then falls back to best-effort checks for that override. +- **Release-notes check**: The workflow checks out its own tooling at the commit that defines it (`job.workflow_sha`) and runs the bundled `check-release-notes` action, so consumers do not need to copy the check. `job.workflow_*` is available on github.com, not GHES. +- **Permissions**: The caller must grant `contents: write` to the `prepare` job (and to any jobs that create tags/releases or upload assets). +- **Default branch only**: Releases must be cut from the repository's default branch. A branch trigger must name the default branch; a tag trigger is accepted only when the tag points at a commit on it. Other triggers are rejected. +- **Merge commits**: The shared config renders the merge commits on the default branch. A commit pushed directly to the branch is reported by the notes guard and fails the release unless `force` is set. Squash and rebase merges are treated the same way. +- **Prior release tag**: The repository must already have a release tag. git-cliff derives the changelog boundary from the previous tag, and the workflow fails if there is none. Passing an explicit `version` does not bypass this. +- **Publishing the draft**: After assets are attached to the draft release, publish it to trigger downstream workflows (Docker, Helm, docs). Prerelease versions (containing `-`) are marked as prereleases automatically. +- **Asset retry**: `retry_assets_for` skips version and notes and must name an existing **draft** release. The workflow rejects a published or missing release, and rejects the combination with `dry_run`. Asset jobs must gate on `needs..result == 'success'` (see the stub above), otherwise `always()` would run them after a failed validation. + ## Design Decisions ### Why Nix? diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..3044210 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,85 @@ +# Shared by every repository that uses dev-kit's release workflow. The workflow +# checks this file out at the same commit as the reusable workflow and passes it +# to git-cliff unless the caller overrides `cliff_config`. +# +# The release-notes guard mirrors the parsers and preprocessors below; its test +# asserts that the shared constants have not drifted. + +# Notes are built from the merge commits on main. Everything on the branch side +# of a merge is dropped, otherwise every WIP commit inside a PR shows up next to +# the PR itself. test/style/build merges are dropped on purpose; anything else +# that would go missing fails the release workflow rather than passing quietly. + +[changelog] +header = "" +body = """ +{% for group, commits in commits | filter(attribute="merge_commit", value=true) | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} +{% for commit in commits %} +- {% if commit.breaking %}**breaking:** {% endif %}{% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message }} +{%- endfor %} +{% endfor %}""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +filter_commits = true +# Keeps a branch-side "feat!" in play when the PR title dropped the "!", so a +# breaking change can never be filtered out of the bump. It bypasses the skip +# rule below, so the template filters to merge commits again to keep the +# protected commit out of the rendered notes. +protect_breaking_commits = true +topo_order_commits = true +# Anchored so a foreign tag that merely contains v (chart-v1.2.3) can +# never become the changelog boundary or leak its prefix into the bump. +tag_pattern = '^v[0-9]+\.[0-9]+\.[0-9]+' +sort_commits = "newest" + +# Merge commit bodies are the PR description verbatim, so any "BREAKING CHANGE:" +# line in there (template boilerplate, bot output, quoted text) would silently +# mark the release breaking. Strip every message down to its subject before +# parsing: breaking detection then rests on the "!" in the checked PR title plus +# branch-side "feat!" subjects, which protect_breaking_commits keeps in play. +# +# Emoji are then dropped from the subject, so a decorated PR title reads plainly +# in the notes. Stripping also rescues a gitmoji-prefixed title: the conventional +# parser rejects any subject that does not open with the type, so a title led by +# an emoji would otherwise go missing from the notes entirely. The ranges cover +# the pictographic blocks alone, leaving arrows, fractions and the copyright and +# registered signs intact. The last two rules tidy up the whitespace the removal +# leaves behind. +commit_preprocessors = [ + { pattern = '(?s)\n.*', replace = "" }, + { pattern = '[\x{1F000}-\x{1FAFF}\x{2600}-\x{27BF}\x{2B00}-\x{2BFF}\x{FE0E}\x{FE0F}\x{200D}]', replace = "" }, + { pattern = '[ \t]{2,}', replace = " " }, + { pattern = '^\s+|\s+$', replace = "" }, +] + +# The first rule is the whole filter: keep merge commits, drop the branch side. +# This keys on git topology rather than on the merge commit title, so it does +# not depend on a repo setting. +commit_parsers = [ + { field = "merge_commit", pattern = "false", skip = true }, + { message = '^feat', group = "Features" }, + { message = '^fix', group = "Bug Fixes" }, + { message = '^perf', group = "Performance" }, + { message = '^refactor', group = "Refactor" }, + { message = '^revert', group = "Reverts" }, + { message = '^docs', group = "Documentation" }, + { message = '^ci', group = "CI" }, + { message = '^chore', group = "Miscellaneous Chores" }, + { message = '^test', skip = true }, + { message = '^style', skip = true }, + { message = '^build', skip = true }, + { message = '.*', skip = true }, +] + +[bump] +features_always_bump_minor = true +# Breaking bumps the minor while pre-1.0; reaching 1.0.0 stays deliberate. +breaking_always_bump_major = false +# `no_increment_regex` is deliberately unset: it is a no-op in 2.13.1 (the +# upstream doc example returns 0.1.1 instead of 0.1.0). The release workflow +# refuses a chore-only release instead. diff --git a/renovate.json b/renovate.json index d769d9f..4d95785 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,17 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "github>opendefensecloud/renovate-config" + ], + "regexManagers": [ + { + "fileMatch": [ + "^\\.github/workflows/.*\\.ya?ml$" + ], + "matchStrings": [ + "git-cliff@(?[0-9]+\\.[0-9]+\\.[0-9]+)" + ], + "depNameTemplate": "git-cliff/git-cliff", + "datasourceTemplate": "github-releases" + } ] } From 439185893def06711a523498e708481b55508905 Mon Sep 17 00:00:00 2001 From: Alexander Olzem Date: Fri, 23 Oct 2026 17:02:22 +0200 Subject: [PATCH 2/5] feat: enforce pr title to indicate breaking changes --- .github/workflows/conventional-commits.yml | 42 ++++++++++++++++++++++ README.md | 6 ++++ 2 files changed, 48 insertions(+) diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml index 317d1d3..9903963 100644 --- a/.github/workflows/conventional-commits.yml +++ b/.github/workflows/conventional-commits.yml @@ -29,6 +29,48 @@ jobs: perf revert requireScope: false + - name: Breaking commits are marked in the PR title + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + # gh api --paginate gets a maximum of 250 commits + count="${{ github.event.pull_request.commits }}" + if [ "$count" -gt 250 ]; then + echo "::error::This PR has $count commits, which exceeds the limit of 250 commits. Please squash your commits." + exit 1 + fi + + breaking_regex='^[a-z]+(\([^)]*\))?!:' + + # Ignore merge commits: a merge from the base branch must not force + # this PR's title to be breaking. Only the subject's "!" counts, + # because the release config strips commit bodies before parsing. + breaking=$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/commits" \ + --jq '.[] | select((.parents | length) < 2) + | (.commit.message | split("\n")[0]) as $subject + | select($subject | test("^[a-z]+(\\([^)]*\\))?!:")) + | (.sha | .[0:7]) as $sha + | "\($sha) \($subject)"') + + if [ -z "$breaking" ]; then + exit 0 + fi + + if [[ "$PR_TITLE" =~ $breaking_regex ]]; then + exit 0 + fi + + echo "::error::This PR contains breaking commit(s), but the PR title is not marked breaking." + printf '%s\n' "$breaking" + echo "Rename the PR title to a breaking conventional title, e.g. 'feat!:' or 'fix!:'." + echo "Editing the title reruns this check." + exit 1 commitlint: name: Commit Messages diff --git a/README.md b/README.md index e2e5e56..9e1f1d6 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,12 @@ jobs: uses: opendefensecloud/dev-kit/.github/workflows/conventional-commits.yml@<40-char-sha> # ``` +On pull requests, it also fails when any non-merge commit is a conventional +breaking change (`type!` or `type(scope)!`) but the PR title is not breaking. +The title becomes the merge commit, so this keeps the release notes and version +bump aligned. Merge commits are ignored, so a merge from the base branch cannot +force the title to change. + `.github/workflows/osv-scanner.yml`: ```yaml From 3f77d002240def874bf701ff0afbe2d212633652 Mon Sep 17 00:00:00 2001 From: Alexander Olzem Date: Mon, 26 Oct 2026 12:22:38 +0100 Subject: [PATCH 3/5] feat: ensure default merge commit title and body match PR title and body --- scripts/repo-settings.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/repo-settings.sh b/scripts/repo-settings.sh index c96e1b4..7fb1993 100755 --- a/scripts/repo-settings.sh +++ b/scripts/repo-settings.sh @@ -158,4 +158,9 @@ else echo " Created new ruleset" fi +echo " Configuring merge commit settings" +${GH} api --method PATCH "repos/$REPO" \ + -f merge_commit_title=PR_TITLE \ + -f merge_commit_message=PR_BODY + echo "Done." From 497960ab6461b7d08aa3e66452c635783972d411 Mon Sep 17 00:00:00 2001 From: Alexander Olzem Date: Wed, 28 Oct 2026 08:52:49 +0100 Subject: [PATCH 4/5] fix: refactor release workflow to not delete tags --- .github/workflows/release.yml | 37 ++++++++++++++++++++--------------- README.md | 4 +++- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e72f856..e8cd013 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release # Reusable workflow that computes version and notes with git-cliff, -# creates a tag and a draft release. Publishing the draft triggers +# creates a draft release and its tag. Publishing the draft triggers # repo-specific build jobs (Docker, Helm, docs, assets). # # The caller's stub carries the triggers (workflow_dispatch). See "Release @@ -124,7 +124,7 @@ jobs: done if [ ${#blocking_drafts[@]} -gt 0 ]; then - echo "::error::A draft release is already pending: ${blocking_drafts[*]}. Publish it to ship, or delete the draft and its tag to cut a fresh one that includes what landed since." + echo "::error::A draft release is already pending: ${blocking_drafts[*]}. Publish it to ship, or delete the draft to abandon it. Release tags cannot be deleted where protect-release-tags is active, so if its tag exists that version is burned: cut the next patch." exit 1 fi @@ -239,7 +239,7 @@ jobs: cat notes.md } >> "$GITHUB_STEP_SUMMARY" - - name: Create tag and draft release + - name: Create draft release and tag id: release if: ${{ !inputs.dry_run }} env: @@ -249,16 +249,6 @@ jobs: run: | set -euo pipefail - create_tag() { - gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f "ref=refs/tags/${VERSION}" \ - -f "sha=${SHA}" >/dev/null - } - - delete_tag() { - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${VERSION}" || true - } - create_draft_release() { local prerelease_flag=() if [[ "$VERSION" == *-* ]]; then @@ -273,11 +263,26 @@ jobs: "${prerelease_flag[@]}" } - create_tag + create_tag() { + gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f "ref=refs/tags/${VERSION}" \ + -f "sha=${SHA}" > /dev/null + } + # A draft release does not create its tag; GitHub materialises it on + # publish. Create the draft first, then the tag, so a failure never + # leaves a tag without a release and this workflow never has to delete + # a tag. if ! create_draft_release; then - echo "::error::Creating the release failed, rolling the tag back." - delete_tag + echo "::error::Creating the draft release failed. No tag was created; if a draft for ${VERSION} exists, delete it before retrying." + exit 1 + fi + + if ! create_tag; then + echo "::error::Creating tag ${VERSION} failed; deleting the draft release. This workflow never deletes tags." + if ! gh release delete "$VERSION" --repo "$GITHUB_REPOSITORY" --yes; then + echo "::error::Could not delete draft release ${VERSION}. Delete it manually before retrying; if ${VERSION} is already tagged, cut the next patch instead." + fi exit 1 fi diff --git a/README.md b/README.md index 9e1f1d6..2da7d05 100644 --- a/README.md +++ b/README.md @@ -611,7 +611,7 @@ Modify `flake.nix` to adjust Go version, packages, and pre-commit hooks: ### Release workflow -The shared release workflow lives in `opendefensecloud/dev-kit/.github/workflows/release.yml` and computes version and notes with git-cliff, creates a tag and a draft release. Publishing the draft triggers repo-specific build jobs (Docker, Helm, docs, assets). Notes are rendered from merge commits and grouped by conventional type using the shared `cliff.toml` in dev-kit, so consumers do not keep their own copy. +The shared release workflow lives in `opendefensecloud/dev-kit/.github/workflows/release.yml` and computes version and notes with git-cliff, creates a draft release and its tag. Publishing the draft triggers repo-specific build jobs (Docker, Helm, docs, assets). Notes are rendered from merge commits and grouped by conventional type using the shared `cliff.toml` in dev-kit, so consumers do not keep their own copy. #### Using the reusable workflow @@ -694,6 +694,8 @@ jobs: - **Default branch only**: Releases must be cut from the repository's default branch. A branch trigger must name the default branch; a tag trigger is accepted only when the tag points at a commit on it. Other triggers are rejected. - **Merge commits**: The shared config renders the merge commits on the default branch. A commit pushed directly to the branch is reported by the notes guard and fails the release unless `force` is set. Squash and rebase merges are treated the same way. - **Prior release tag**: The repository must already have a release tag. git-cliff derives the changelog boundary from the previous tag, and the workflow fails if there is none. Passing an explicit `version` does not bypass this. +- **Tag creation**: The draft release is created first, then its tag. GitHub creates a draft's tag only when the release is published, so the workflow creates it explicitly; if that fails, it deletes the draft. A failed release therefore leaves no tag behind, and the workflow never deletes a tag. +- **Tag immutability**: Where a `protect-release-tags` ruleset is active, a `v*` tag cannot be moved or deleted. Publish a pending draft to ship it; abandoning a draft whose tag exists burns that version, so cut the next patch. - **Publishing the draft**: After assets are attached to the draft release, publish it to trigger downstream workflows (Docker, Helm, docs). Prerelease versions (containing `-`) are marked as prereleases automatically. - **Asset retry**: `retry_assets_for` skips version and notes and must name an existing **draft** release. The workflow rejects a published or missing release, and rejects the combination with `dry_run`. Asset jobs must gate on `needs..result == 'success'` (see the stub above), otherwise `always()` would run them after a failed validation. From 28896e56558e777ff0ce54d6405b0aeb20a5af83 Mon Sep 17 00:00:00 2001 From: Alexander Olzem Date: Wed, 28 Oct 2026 08:54:27 +0100 Subject: [PATCH 5/5] fix: modernize renovate.json Co-authored-by: AlexHardAtWork <259883306+AlexHardAtWork@users.noreply.github.com> --- renovate.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/renovate.json b/renovate.json index 4d95785..8d28af9 100644 --- a/renovate.json +++ b/renovate.json @@ -3,10 +3,11 @@ "extends": [ "github>opendefensecloud/renovate-config" ], - "regexManagers": [ + "customManagers": [ { - "fileMatch": [ - "^\\.github/workflows/.*\\.ya?ml$" + "customType": "regex", + "managerFilePatterns": [ + "/^\\.github/workflows/.*\\.ya?ml$/" ], "matchStrings": [ "git-cliff@(?[0-9]+\\.[0-9]+\\.[0-9]+)"