From 8b1712eb6ab649f49fa60b66c12bb223a13f943c Mon Sep 17 00:00:00 2001 From: Chris Bargmann Date: Tue, 25 Aug 2026 11:15:33 +0200 Subject: [PATCH 1/8] feat(ci): replace release-please with git-cliff release-please collects commits by walking history in commit-date order and stopping at the sha of the last release. That boundary holds with squash merges, since main is linear, but not with merge commits. The v0.3.0 notes we shipped show what it costs: 64 feat/fix entries for 36 merged PRs, the same change listed twice, and #729 and #713 with no PR link at all. git-cliff asks git for a real tag range instead, so commit dates cannot move the boundary. Notes are built from the merge commits on main and everything on the branch side is dropped, which is what stops every WIP commit inside a PR appearing next to the PR itself. Replaying v0.3.0-rc2..v0.3.0 through this config gives 12 features and 24 bug fixes against the 12 feat and 24 fix PRs that actually merged. Releases are cut by dispatching this workflow. It computes the version, renders the notes, creates the tag and opens a draft; publishing the draft stays a separate human decision and still fires docker, helm and docs through `release: published`. Nothing writes to main, so the GitHub App, the RELEASE_PLEASE_APP_ID and _PRIVATE_KEY credentials and the "allow Actions to create and approve pull requests" setting are all unused now and can be revoked. The non-obvious parts, each of which is there for a verified reason: - `--use-branch-tags`, because git-cliff otherwise takes the newest tag in the repository even when it is not an ancestor of HEAD. A stray v0.9.9 on an unmerged branch makes main report previous=v0.9.9 and silently shifts the notes range. - the default-branch guard, because dispatching from a feature branch would tag and sign unreviewed code and leave exactly that stray tag. - two content guards, because a commit only reaches the notes if it is a merge AND its subject parses as conventional. GitHub's default "Merge pull request #1 from ..." subject satisfies neither the eye nor the parser and used to vanish without a trace. - the releasable-commits guard, because `no_increment_regex` is a no-op in git-cliff 2.13.1. The example from its own docs, a lone chore after 0.1.0 documented to stay at 0.1.0, returns 0.1.1. - `protect_breaking_commits` with the merge filter moved into the template, so a branch-side `feat!` still drives the bump when the PR title dropped the "!" without also rendering a duplicate section with no PR link. - `--prerelease` for versions containing a hyphen, so an rc does not publish as a normal release and take over the Latest marker. - the cliff.toml existence check, because a missing config makes git-cliff warn, exit 0 and fall back to its own defaults, which bump a breaking change straight to 1.0.0. The asset build is unchanged from the release-please workflow it came from and runs as a second job. `retry_assets_for` re-runs just that job against an existing draft when an upload failed. --- .github/workflows/release-please.yaml | 194 ------------ .github/workflows/release.yaml | 405 ++++++++++++++++++++++++++ .release-please-manifest.json | 3 - cliff.toml | 63 ++++ release-please-config.json | 23 -- 5 files changed, 468 insertions(+), 220 deletions(-) delete mode 100644 .github/workflows/release-please.yaml create mode 100644 .github/workflows/release.yaml delete mode 100644 .release-please-manifest.json create mode 100644 cliff.toml delete mode 100644 release-please-config.json diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml deleted file mode 100644 index 11de1f53..00000000 --- a/.github/workflows/release-please.yaml +++ /dev/null @@ -1,194 +0,0 @@ -name: Release Please - -on: - push: - branches: - - main - # Manual re-run of release-assets against an existing draft release. The push - # path only fires that job on the release commit itself, so a failed asset - # upload is otherwise unrecoverable without deleting the tag and release. - workflow_dispatch: - inputs: - tag: - description: Existing draft release tag to (re)build and attach assets for, e.g. v0.3.0 - required: true - -permissions: - contents: read - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - release-please: - if: github.event_name == 'push' - runs-on: ubuntu-24.04 - permissions: - # write permission is required to create the release and tag - contents: write - # write permission is required to label the release PR (labels go - # through the Issues API) - issues: write - # write permission is required to maintain the release PR - pull-requests: write - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - sha: ${{ steps.release.outputs.sha }} - steps: - # Mint a short-lived installation token from the release-please GitHub App - # so the Release PR is opened by the App and not the default GITHUB_TOKEN. - # PRs opened with GITHUB_TOKEN do not trigger CI, so required checks would - # never run on the Release PR. The App must be installed on this repo with - # contents + pull-requests + issues (labels) write. Configure the App ID as - # the RELEASE_PLEASE_APP_ID repo variable and the private key as the - # RELEASE_PLEASE_APP_PRIVATE_KEY secret. - # - # Gate on BOTH credentials: secrets cannot be used in `if:`, so surface a - # boolean from an env-backed check. If either is missing we skip the App - # and fall back to GITHUB_TOKEN, rather than running create-github-app-token - # with an empty private-key (which fails the whole job). - - name: Check release-please app config - id: app-config - env: - APP_ID: ${{ vars.RELEASE_PLEASE_APP_ID }} - APP_KEY: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} - run: | - if [ -n "$APP_ID" ] && [ -n "$APP_KEY" ]; then - echo "configured=true" >> "$GITHUB_OUTPUT" - else - echo "configured=false" >> "$GITHUB_OUTPUT" - fi - - - name: Generate release-please app token - id: app-token - if: ${{ steps.app-config.outputs.configured == 'true' }} - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ vars.RELEASE_PLEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} - - - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 - id: release - with: - token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} - - release-assets: - name: Build and attach release assets - needs: release-please - # always(): on workflow_dispatch the release-please job is skipped, which - # would otherwise skip this one too. - if: ${{ always() && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') }} - runs-on: ubuntu-24.04 - permissions: - # write permission is required to upload assets to the draft release - contents: write - id-token: write # for keyless cosign sign-blob - attestations: write # for build-provenance attestation - env: - VERSION: ${{ inputs.tag || needs.release-please.outputs.tag_name }} - steps: - - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # Immutable releases forbid uploading assets to an already-published - # release, so release-please creates a draft release (with its tag - # already pushed via force-tag-creation). Check out the release - # commit by SHA to avoid racing on tag propagation. Publishing the - # draft (explicit human decision) makes the release immutable and - # triggers the docker/helm/docs release workflows. - ref: ${{ inputs.tag || needs.release-please.outputs.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Extract go version from flake.nix - id: get-go-version - run: | - GO_VERSION="$(sed -nE 's/^[[:space:]]*goVersion[[:space:]]*=[[:space:]]*"([0-9]+\.[0-9]+\.[0-9]+)";[[:space:]]*$/\1/p' flake.nix)" - if [ "$(printf '%s\n' "$GO_VERSION" | sed '/^$/d' | wc -l)" -ne 1 ]; then - echo "::error::Expected exactly one goVersion assignment in flake.nix" - exit 1 - fi - echo "version=$GO_VERSION" >> $GITHUB_OUTPUT - - - name: Setup Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - with: - go-version: ${{ steps.get-go-version.outputs.version }} - cache: true - - - name: Run tests - run: make test - - - name: Build binaries - run: | - COMMIT=$(git rev-parse --short HEAD) - BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') - LDFLAGS="-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildTime=${BUILD_TIME}" - - # Linux amd64 - GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-apiserver-linux-amd64 ./cmd/solar-apiserver - GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-controller-manager-linux-amd64 ./cmd/solar-controller-manager - GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-discovery-linux-amd64 ./cmd/solar-discovery - GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-renderer-linux-amd64 ./cmd/solar-renderer - - # Linux arm64 - GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-apiserver-linux-arm64 ./cmd/solar-apiserver - GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-controller-manager-linux-arm64 ./cmd/solar-controller-manager - GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-discovery-linux-arm64 ./cmd/solar-discovery - GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-renderer-linux-arm64 ./cmd/solar-renderer - - # Darwin amd64 - GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-apiserver-darwin-amd64 ./cmd/solar-apiserver - GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-controller-manager-darwin-amd64 ./cmd/solar-controller-manager - GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-discovery-darwin-amd64 ./cmd/solar-discovery - GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-renderer-darwin-amd64 ./cmd/solar-renderer - - # Darwin arm64 - GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-apiserver-darwin-arm64 ./cmd/solar-apiserver - GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-controller-manager-darwin-arm64 ./cmd/solar-controller-manager - GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-discovery-darwin-arm64 ./cmd/solar-discovery - GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-renderer-darwin-arm64 ./cmd/solar-renderer - - - name: Create checksums - run: | - # bin/ is also the Makefile's LOCALBIN, so it holds tool binaries and an - # envtest bin/k8s/ directory after `make test`. Only ship solar--. - find bin -maxdepth 1 -type f -name 'solar-*-*' | sort | xargs sha256sum > bin/checksums.txt - - - name: Attest build provenance - # actions/attest defaults to SLSA build provenance when no sbom-path or - # predicate input is given. - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 - with: - subject-path: 'bin/solar-*-*' # binaries only; runs before signing so .sig/.pem are not yet present - - - name: Install cosign - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - - - name: Sign release artefacts (keyless) - env: - COSIGN_EXPERIMENTAL: 1 - run: | - cd bin - for f in solar-*-* checksums.txt; do - # Emit a Sigstore bundle (signature + cert + Rekor proof), named - # *.sigstore.json so OpenSSF Scorecard's Signed-Releases check - # detects it. Don't revert to --output-signature/--output-certificate: - # those are deprecated and silently ignored by newer cosign, which - # defaults to the bundle format and requires --bundle. - cosign sign-blob --yes \ - --new-bundle-format=true \ - --bundle "${f}.sigstore.json" \ - "$f" - done - - - name: Upload assets to draft release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # --clobber keeps re-runs idempotent when assets were already - # partially uploaded - gh release upload "${VERSION}" bin/solar-*-* bin/checksums.txt* \ - --repo "${GITHUB_REPOSITORY}" --clobber diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..b21a4a95 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,405 @@ +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)' || '') }} + +# Decision 1 of 2: this creates the tag and the draft release. +# Decision 2 of 2: you publish the draft, which fires docker / helm / docs. +# Nothing here writes to main. + +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 + 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.' + required: false + type: string + +permissions: + contents: read + +concurrency: + # Two releases at once would race to create the same tag. Never cancel a run + # in flight: it may already have tagged and drafted. + group: release + cancel-in-progress: false + +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: Guard - releases are cut from the default branch + env: + REF_NAME: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # Dispatching from a feature branch would tag and sign unreviewed + # code, and the stray tag then becomes the newest in the repo, which + # silently moves the changelog boundary for every later release. + if [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then + echo "::error::Releases are cut from ${DEFAULT_BRANCH}, not ${REF_NAME}." + exit 1 + fi + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install git-cliff + uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7 + 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') + + # A draft only blocks a release if its tag exists, because that tag is + # what moves the changelog boundary. Untagged drafts are leftovers. + 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 + + - name: Read release context + id: ctx + run: | + set -euo pipefail + + # Without this git-cliff warns, exits 0 and uses its built-in config, + # which bumps a breaking change straight to 1.0.0. + if [ ! -f cliff.toml ]; then + echo "::error::cliff.toml not found." + exit 1 + fi + + # One call gives the version, the previous tag and the filtered commit + # set. Every step below reads from this file, so the guards can never + # disagree with the notes. + git cliff --config cliff.toml --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 - everything on main landed as a merge + env: + RANGE: ${{ steps.ctx.outputs.previous }}..HEAD + run: | + set -euo pipefail + + non_merge_commits=$(git log --first-parent --no-merges "$RANGE" --format='%h %s') + + if [ -n "$non_merge_commits" ]; then + echo "::error::These landed on main without being merge commits, so they are not in the notes. Squash and rebase merges are not supported here." + printf '%s\n' "$non_merge_commits" | sed 's/^/ /' + exit 1 + fi + + # A merge kept by the filter but not parsed as conventional (GitHub's + # default "Merge pull request #1 from ..." subject, or a Revert) is + # dropped from the notes without any other signal. + unparsed_merges=$(git log --first-parent --merges "$RANGE" --format='%h %s' \ + | grep -vE '^[0-9a-f]+ (feat|fix|perf|refactor|revert|docs|ci|chore|test|style|build)(\(.+\))?!?:' || true) + + if [ -n "$unparsed_merges" ]; then + echo "::error::These merges have no conventional subject, so they would be missing from the notes. The merge commit title must be the PR title." + printf '%s\n' "$unparsed_merges" | sed 's/^/ /' + exit 1 + fi + + - 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)")) ] | length + ' ctx.json) + + if [ "$releasable_count" -eq 0 ]; then + echo "::error::No feat/fix/perf 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 }} + run: | + set -euo pipefail + + git cliff --config cliff.toml --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=() + # Without this an rc publishes as a normal release and takes over + # the Latest marker, plus the docs and chart aliases downstream. + 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[@]}" + } + + # The tag has to exist first: a draft release does not create its own + # tag, and assets can only be attached while it is still a draft. + 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" + + assets: + name: Build and attach release assets + needs: prepare + # always(): prepare is skipped on the recovery path, which would otherwise + # skip this job too. + if: ${{ always() && (needs.prepare.outputs.created == 'true' || inputs.retry_assets_for != '') }} + runs-on: ubuntu-24.04 + permissions: + contents: write # upload assets to the draft release + id-token: write # keyless cosign sign-blob + attestations: write # build-provenance attestation + env: + VERSION: ${{ inputs.retry_assets_for || needs.prepare.outputs.version }} + steps: + - name: Check the draft release exists + if: ${{ inputs.retry_assets_for != '' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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 + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # By SHA to avoid racing on tag propagation; the manual path supplies + # no SHA and falls back to the tag, which exists by then. + ref: ${{ needs.prepare.outputs.sha || inputs.retry_assets_for }} + fetch-depth: 0 + persist-credentials: false + + - name: Extract go version from flake.nix + id: get-go-version + run: | + GO_VERSION="$(sed -nE 's/^[[:space:]]*goVersion[[:space:]]*=[[:space:]]*"([0-9]+\.[0-9]+\.[0-9]+)";[[:space:]]*$/\1/p' flake.nix)" + if [ "$(printf '%s\n' "$GO_VERSION" | sed '/^$/d' | wc -l)" -ne 1 ]; then + echo "::error::Expected exactly one goVersion assignment in flake.nix" + exit 1 + fi + echo "version=$GO_VERSION" >> "$GITHUB_OUTPUT" + + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: ${{ steps.get-go-version.outputs.version }} + cache: true + + - name: Run tests + run: make test + + - name: Build binaries + run: | + COMMIT=$(git rev-parse --short HEAD) + BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + LDFLAGS="-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildTime=${BUILD_TIME}" + + # Linux amd64 + GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-apiserver-linux-amd64 ./cmd/solar-apiserver + GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-controller-manager-linux-amd64 ./cmd/solar-controller-manager + GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-discovery-linux-amd64 ./cmd/solar-discovery + GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-renderer-linux-amd64 ./cmd/solar-renderer + + # Linux arm64 + GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-apiserver-linux-arm64 ./cmd/solar-apiserver + GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-controller-manager-linux-arm64 ./cmd/solar-controller-manager + GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-discovery-linux-arm64 ./cmd/solar-discovery + GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-renderer-linux-arm64 ./cmd/solar-renderer + + # Darwin amd64 + GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-apiserver-darwin-amd64 ./cmd/solar-apiserver + GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-controller-manager-darwin-amd64 ./cmd/solar-controller-manager + GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-discovery-darwin-amd64 ./cmd/solar-discovery + GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/solar-renderer-darwin-amd64 ./cmd/solar-renderer + + # Darwin arm64 + GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-apiserver-darwin-arm64 ./cmd/solar-apiserver + GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-controller-manager-darwin-arm64 ./cmd/solar-controller-manager + GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-discovery-darwin-arm64 ./cmd/solar-discovery + GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/solar-renderer-darwin-arm64 ./cmd/solar-renderer + + - name: Create checksums + run: | + # bin/ is also the Makefile's LOCALBIN, so it holds tool binaries and an + # envtest bin/k8s/ directory after `make test`. Only ship solar--. + find bin -maxdepth 1 -type f -name 'solar-*-*' | sort | xargs sha256sum > bin/checksums.txt + + - name: Attest build provenance + # actions/attest defaults to SLSA build provenance when no sbom-path or + # predicate input is given. + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: 'bin/solar-*-*' # binaries only; runs before signing so .sig/.pem are not yet present + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sign release artefacts (keyless) + env: + COSIGN_EXPERIMENTAL: 1 + run: | + cd bin + for f in solar-*-* checksums.txt; do + # Emit a Sigstore bundle (signature + cert + Rekor proof), named + # *.sigstore.json so OpenSSF Scorecard's Signed-Releases check + # detects it. Don't revert to --output-signature/--output-certificate: + # those are deprecated and silently ignored by newer cosign, which + # defaults to the bundle format and requires --bundle. + cosign sign-blob --yes \ + --new-bundle-format=true \ + --bundle "${f}.sigstore.json" \ + "$f" + done + + - name: Upload assets to draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # --clobber keeps re-runs idempotent when assets were already + # partially uploaded + gh release upload "${VERSION}" bin/solar-*-* bin/checksums.txt* \ + --repo "${GITHUB_REPOSITORY}" --clobber diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index 0ee8c012..00000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "0.3.0" -} diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..22a708a5 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,63 @@ +# 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 +tag_pattern = "v[0-9].*" +sort_commits = "newest" + +# Merge commit bodies are the PR description verbatim, so machine-written prose +# ends up in footer position. Strip CodeRabbit's block before parsing, or a +# generated "BREAKING CHANGE:" line would silently bump the version. +commit_preprocessors = [ + { pattern = '(?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/release-please-config.json b/release-please-config.json deleted file mode 100644 index 6cf085be..00000000 --- a/release-please-config.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "packages": { - ".": { - "release-type": "go", - "draft": true, - "force-tag-creation": true, - "changelog-sections": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "chore", "section": "Miscellaneous Chores" }, - { "type": "docs", "hidden": true }, - { "type": "ci", "hidden": true }, - { "type": "build", "hidden": true }, - { "type": "test", "hidden": true }, - { "type": "refactor", "hidden": true }, - { "type": "style", "hidden": true }, - { "type": "perf", "hidden": true }, - { "type": "revert", "hidden": true } - ] - } - } -} From 1e7e73d6a9c7115d4ecee4a85533836bd984837b Mon Sep 17 00:00:00 2001 From: Chris Bargmann Date: Tue, 25 Aug 2026 11:15:48 +0200 Subject: [PATCH 2/8] feat(ci): preview pending release notes on push to main Renders the next version and its notes into the job summary of every push to main, so what the next release would contain is always visible without cutting anything. This is the part of a rolling draft release that is actually useful, without its cost. Keeping a real draft current would mean either rebuilding and re-signing every artefact on each merge, or leaving stale binaries attached to a draft whose attestations point at an older commit. It also warns as soon as something lands on main that would be missing from the notes, so that is found on the merge that caused it rather than at release time. --- .github/workflows/changelog-preview.yaml | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/changelog-preview.yaml diff --git a/.github/workflows/changelog-preview.yaml b/.github/workflows/changelog-preview.yaml new file mode 100644 index 00000000..de5e4579 --- /dev/null +++ b/.github/workflows/changelog-preview.yaml @@ -0,0 +1,85 @@ +name: Changelog Preview + +# Shows what the next release would contain, in the job summary of every push +# to main. Same visibility a rolling draft release would give, without the tag, +# the draft or rebuilding artefacts per merge. + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: changelog-preview + cancel-in-progress: true + +jobs: + preview: + name: Pending release notes + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install git-cliff + uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7 + with: + tool: git-cliff@2.13.1 + + - name: Render pending notes + id: ctx + run: | + set -euo pipefail + + git cliff --config cliff.toml --use-branch-tags --unreleased --bump --context > ctx.json + + previous_tag=$(jq -r '.[0].previous.version // empty' ctx.json) + next_version=$(jq -r '.[0].version // empty' ctx.json) + releasable_count=$(jq ' + [ (.[0].commits // [])[] | select(.raw_message | test("^(feat|fix|perf)")) ] | length + ' ctx.json) + + echo "previous=${previous_tag}" >> "$GITHUB_OUTPUT" + + if [ "$releasable_count" -eq 0 ] || [ -z "$next_version" ]; then + echo "Nothing releasable since ${previous_tag}." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + { + echo "## Pending release: \`${next_version}\`" + echo + echo "Changes since \`${previous_tag}\`. Run the **Release** workflow to cut it." + echo + } >> "$GITHUB_STEP_SUMMARY" + git cliff --config cliff.toml --use-branch-tags --unreleased --tag "$next_version" >> "$GITHUB_STEP_SUMMARY" + + - name: Warn about commits that are not merges + env: + PREVIOUS_TAG: ${{ steps.ctx.outputs.previous }} + run: | + set -euo pipefail + + [ -n "$PREVIOUS_TAG" ] || exit 0 + + non_merge_commits=$(git log --first-parent --no-merges "${PREVIOUS_TAG}..HEAD" --format='%h %s') + unparsed_merges=$(git log --first-parent --merges "${PREVIOUS_TAG}..HEAD" --format='%h %s' \ + | grep -vE '^[0-9a-f]+ (feat|fix|perf|refactor|revert|docs|ci|chore|test|style|build)(\(.+\))?!?:' || true) + missing=$(printf '%s\n%s\n' "$non_merge_commits" "$unparsed_merges" | sed '/^$/d') + + if [ -n "$missing" ]; then + echo "::warning::These will be missing from the release notes:" + printf '%s\n' "$missing" | sed 's/^/ /' + { + echo + echo "> [!WARNING]" + echo "> Missing from the notes. A commit must be a merge whose subject is the conventional PR title:" + printf '%s\n' "$missing" | sed 's/^/> - /' + } >> "$GITHUB_STEP_SUMMARY" + fi From a2287f7d12d334a873bce5f2632963f1a72b4eac Mon Sep 17 00:00:00 2001 From: Chris Bargmann Date: Tue, 25 Aug 2026 11:15:48 +0200 Subject: [PATCH 3/8] docs: document the git-cliff release flow Both human decisions are unchanged: cut the release, then publish the draft. Records the things that are easy to get wrong. Breaking changes bump the minor only while the major is 0, which is git-cliff's default and needs no config change at 1.0. Prereleases work but are a one-way door, since after an rc tag the auto-computed version never returns to a final one and the final notes cover only what landed after the rc. Three repository settings are load-bearing for the notes and are listed as prerequisites, because "none" was not true. CHANGELOG.md is frozen at v0.3.0 with a pointer to the Releases page. Deleting published history would be worse than leaving a stale file. --- CHANGELOG.md | 3 ++ docs/developer-guide/releasing.md | 61 +++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 009dd907..dcfb3ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +Frozen at `v0.3.0`. From `v0.4.0` on, release notes are generated by git-cliff +and live on the [Releases page](https://github.com/opendefensecloud/solution-arsenal/releases). + ## [0.3.0](https://github.com/opendefensecloud/solution-arsenal/compare/v0.3.0-rc2...v0.3.0) (2026-08-24) - gate release-please app token on both app id and private key ([2c5d19e](https://github.com/opendefensecloud/solution-arsenal/commit/2c5d19ef738eacbce168d5f392d8d7d472f0f8fc)) diff --git a/docs/developer-guide/releasing.md b/docs/developer-guide/releasing.md index 7023ae88..cb7e86f1 100644 --- a/docs/developer-guide/releasing.md +++ b/docs/developer-guide/releasing.md @@ -1,39 +1,68 @@ # Releasing -Releases are automated with [release-please](https://github.com/googleapis/release-please), driven entirely by [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) messages on `main`. There are no release labels and no manual tagging. +Releases are cut with the [Release workflow](https://github.com/opendefensecloud/solution-arsenal/blob/main/.github/workflows/release.yaml), which generates notes from [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) messages using [git-cliff](https://git-cliff.org). There is no release PR and no manual tagging. ## Version resolution | Commit type | Version bump | |---|---| -| `fix:` | patch | +| `fix:`, `perf:` | patch | | `feat:` | minor | -| `feat!:` / `BREAKING CHANGE:` footer | major | -| `chore:`, `docs:`, `ci:`, … | none | +| `feat!:` / `BREAKING CHANGE:` footer | minor while the major is `0` | +| `chore:`, `docs:`, `ci:`, … | none on their own | -To force a specific version, add a `Release-As: x.y.z` footer to a commit on `main`. +Breaking changes bump the minor for as long as the major is `0`. This is git-cliff's default behaviour and needs no configuration: from `1.0.0` onward a breaking change bumps the major on its own. Reaching `1.0.0` means passing `v1.0.0` as the `version` input once. Nothing in `cliff.toml` has to change. -The first release-please run graduates from the `0.3.0-rc2` bootstrap to a stable `0.3.0`: the migration's merge commit carries a `Release-As: 0.3.0` footer so the first Release PR targets `0.3.0`. After that, versions follow semver from the commit types above. Release candidates and other prereleases are no longer cut automatically. +To release a specific version, pass it as the `version` input instead of letting git-cliff compute one. + +Prereleases work but are a one-way door for the notes. Once `v0.4.0-rc1` is tagged, the auto-computed version becomes `v0.4.0-rc1.1` rather than returning to `v0.4.0`, and the final `v0.4.0` notes cover only what landed *after* rc1. Cutting an rc means passing every subsequent version by hand and editing the final draft. + +## Notes are built from merge commits + +Every PR lands on `main` as a merge commit whose subject is the PR title. The notes are built from those merge commits alone; everything on the branch side of a merge is dropped, or every WIP commit inside a PR would appear next to the PR itself. + +Two things have to hold, and both are checked. A commit on `main` has to *be* a merge, and its subject has to parse as a conventional commit, which is the PR title. Squash and rebase merges leave no merge commit; GitHub's default `Merge pull request #1 from ...` subject parses as nothing. Either way the commit would be missing from the notes, so the Changelog Preview warns when one lands and the Release workflow refuses to run. + +`test:`, `style:` and `build:` merges are dropped from the notes on purpose. ## What happens when -1. **Commits land on `main`.** On every push, the [Release Please workflow](https://github.com/opendefensecloud/solution-arsenal/blob/main/.github/workflows/release-please.yaml) scans commits since the last release. If at least one releasable commit (`feat`/`fix`/breaking) exists, it opens or updates a **Release PR** that bumps the version and updates `CHANGELOG.md`. `chore`-only batches never produce a release. -2. **The Release PR is merged.** First human decision. release-please creates a **draft** GitHub release with the generated changelog and pushes the `v*` tag immediately (`force-tag-creation` — without it, drafts get no tag until publication and release-please loses the previous-release boundary on subsequent runs). The `release-assets` job then runs the test suite, builds the binaries for all platforms, writes checksums, attaches build-provenance attestations, signs everything with cosign (keyless), and uploads the artefacts to the draft. Nothing is published yet. -3. **The draft is published.** Second human decision — via the GitHub UI or `gh release edit --draft=false`. This is the single ship moment: it makes the release immutable and fires every publish-triggered workflow. -4. **Publish-triggered workflows fire.** On the `release: published` event, Docker images, Helm charts (stamped with the tag version), and versioned docs are built and published by their respective workflows. These workflows do not trigger on the tag push, so nothing ships until you publish the draft. +1. **Commits land on `main`.** The [Changelog Preview workflow](https://github.com/opendefensecloud/solution-arsenal/blob/main/.github/workflows/changelog-preview.yaml) renders the pending version and its notes into the run's job summary. Nothing is tagged, committed or built. +2. **You run the Release workflow.** First human decision. Leave `dry_run` checked to see the computed version and the rendered notes without creating anything. Re-run with it unchecked to push the tag and create a **draft** release, after which the `assets` job runs the test suite, builds the binaries for all platforms, writes checksums, attaches build-provenance attestations, signs everything with cosign (keyless), and uploads the artefacts to the draft. Nothing is published yet. +3. **You publish the draft.** Second human decision, via the GitHub UI or `gh release edit --draft=false`. This is the single ship moment: it makes the release immutable and fires every publish-triggered workflow. +4. **Publish-triggered workflows fire.** On `release: published`, Docker images, Helm charts (stamped with the tag version) and versioned docs are built and published by their respective workflows. They do not trigger on the tag push, so nothing ships until you publish the draft. The draft step exists because releases are immutable: GitHub rejects asset uploads to an already-published release, so the signed artefacts must be attached while the release is still a draft. -**If `release-assets` fails** (a flaky test, a bad upload), the draft is left with partial or no artefacts. Don't delete the tag: re-run the job against the existing draft with `gh workflow run release-please.yaml -f tag=`. It rebuilds, re-signs, and re-uploads everything with `--clobber`, replacing whatever landed on the first attempt. +The tag is created explicitly before the draft, because a draft release does not create its own tag until it is published. Tags are not covered by the `protect-main` ruleset, which targets branches only. + +**Commits landing on `main` between the tag and publication** do not touch the pending draft. They accumulate into the next release. Publishing the draft ships exactly the version that was in it. + +## One pending draft at a time -**Commits landing on `main` between merge and publish** do not touch the pending draft. They accumulate into the next Release PR. Publishing the draft ships exactly the version that was in it. +Re-running **Release** while a draft is pending is refused. It does not roll the existing draft forward, and it cannot: the tag pins the changelog boundary, so a second run would cut a second version, and the artefacts already attached are signed and attested against the commit the draft was cut from. + +If something landed that you want included, delete the draft and its tag, then run **Release** again. It will pick up everything since the last published release. Otherwise publish the draft and let the new commits go into the next one. + +## Recovering a failed asset upload + +If the draft was created but the artefacts failed to upload, run **Release** again with `retry_assets_for` set to the draft's tag. That skips version resolution and the notes entirely and only rebuilds and re-attaches the artefacts. It refuses if the tag has no release, or if the release is already published. Uploads use `--clobber`, so re-runs are idempotent and there is no need to delete the tag and the release. + +Leave `retry_assets_for` empty for every normal release. ## Prerequisites -- **A GitHub App for release-please.** The Release PR must be opened by an App token, not the default `GITHUB_TOKEN` — PRs opened with `GITHUB_TOKEN` do not trigger CI, so required checks never run on the Release PR. Install a GitHub App on this repo with `contents: write`, `pull requests: write`, and `issues: write` (release-please's labels go through the Issues API), then set its App ID as the `RELEASE_PLEASE_APP_ID` repository variable and its private key as the `RELEASE_PLEASE_APP_PRIVATE_KEY` secret. The workflow mints a short-lived installation token from these via [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token). If the variable is unset, it falls back to `GITHUB_TOKEN` (release-please still works, but the Release PR gets no CI checks). -- **Repository setting** — Settings → Actions → General → enable *Allow GitHub Actions to create and approve pull requests*, or release-please cannot open the Release PR. +No credentials beyond the default `GITHUB_TOKEN`. The workflow never writes to `main`, so it needs no GitHub App, no PAT, and no *Allow GitHub Actions to create and approve pull requests* setting. + +Three repository settings are load-bearing, though: + +- **Merge commits enabled**, and squash and rebase merging disabled. The notes are built from merge commits. +- **Default merge commit message set to *Pull request title***, under Settings → General. The message is what the notes are made of. +- **The PR title check kept as a required status check** (`.github/workflows/conventional-commits.yml`). It is what guarantees every merge subject is a conventional commit. + +Releases are only ever cut from the default branch; the workflow refuses to run anywhere else. ## Configuration -- [`release-please-config.json`](https://github.com/opendefensecloud/solution-arsenal/blob/main/release-please-config.json) — release type, draft mode, changelog sections. -- [`.release-please-manifest.json`](https://github.com/opendefensecloud/solution-arsenal/blob/main/.release-please-manifest.json) — the currently released version; maintained by release-please, do not edit by hand. +- [`cliff.toml`](https://github.com/opendefensecloud/solution-arsenal/blob/main/cliff.toml) — commit grouping, the merge-commit filter and the bump rules. +- [`CHANGELOG.md`](https://github.com/opendefensecloud/solution-arsenal/blob/main/CHANGELOG.md) — frozen at `v0.3.0`. From `v0.4.0` on, the notes live on the [Releases page](https://github.com/opendefensecloud/solution-arsenal/releases). From bc6dadf10f13909af18c1f93500d2adc4847988e Mon Sep 17 00:00:00 2001 From: Chris Bargmann Date: Tue, 25 Aug 2026 11:44:07 +0200 Subject: [PATCH 4/8] fix(ci): anchor the release tag pattern "v[0-9].*" matches any tag that merely contains v, so a foreign tag like chart-v1.2.3 would become the changelog boundary and leak its prefix into the computed version. Anchor it to the same shape the release workflow validates. --- cliff.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cliff.toml b/cliff.toml index 22a708a5..de9f436c 100644 --- a/cliff.toml +++ b/cliff.toml @@ -25,7 +25,9 @@ filter_commits = true # protected commit out of the rendered notes. protect_breaking_commits = true topo_order_commits = true -tag_pattern = "v[0-9].*" +# 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 machine-written prose From 3a1cc39282c5bad5d50d06c064c4dacd7dfad17e Mon Sep 17 00:00:00 2001 From: Chris Bargmann Date: Tue, 25 Aug 2026 11:44:07 +0200 Subject: [PATCH 5/8] fix(ci): stop PR descriptions from marking a release breaking Merge bodies are the PR description verbatim, so a BREAKING CHANGE: line in template boilerplate, bot output or quoted text would silently bump the major. Strip every message to its subject before parsing; breaking is the ! in the checked PR title or a branch-side feat! subject, which protect_breaking_commits keeps in play. Replaces the CodeRabbit-specific preprocessor, whose block this also covers. --- cliff.toml | 10 ++++++---- docs/developer-guide/releasing.md | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cliff.toml b/cliff.toml index de9f436c..2810fc1e 100644 --- a/cliff.toml +++ b/cliff.toml @@ -30,11 +30,13 @@ topo_order_commits = true tag_pattern = '^v[0-9]+\.[0-9]+\.[0-9]+' sort_commits = "newest" -# Merge commit bodies are the PR description verbatim, so machine-written prose -# ends up in footer position. Strip CodeRabbit's block before parsing, or a -# generated "BREAKING CHANGE:" line would silently bump the version. +# 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. commit_preprocessors = [ - { pattern = '(?s)', replace = "" }, + { pattern = '(?s)\n.*', replace = "" }, ] # The first rule is the whole filter: keep merge commits, drop the branch side. diff --git a/docs/developer-guide/releasing.md b/docs/developer-guide/releasing.md index cb7e86f1..4974b4e3 100644 --- a/docs/developer-guide/releasing.md +++ b/docs/developer-guide/releasing.md @@ -8,7 +8,7 @@ Releases are cut with the [Release workflow](https://github.com/opendefensecloud |---|---| | `fix:`, `perf:` | patch | | `feat:` | minor | -| `feat!:` / `BREAKING CHANGE:` footer | minor while the major is `0` | +| `feat!:` (any type with `!`) | minor while the major is `0` | | `chore:`, `docs:`, `ci:`, … | none on their own | Breaking changes bump the minor for as long as the major is `0`. This is git-cliff's default behaviour and needs no configuration: from `1.0.0` onward a breaking change bumps the major on its own. Reaching `1.0.0` means passing `v1.0.0` as the `version` input once. Nothing in `cliff.toml` has to change. @@ -25,6 +25,8 @@ Two things have to hold, and both are checked. A commit on `main` has to *be* a `test:`, `style:` and `build:` merges are dropped from the notes on purpose. +Commit messages are stripped to their subject line before parsing. Merge bodies are the PR description verbatim, so without this any `BREAKING CHANGE:` line in a PR body (template boilerplate, bot output, quoted text) would silently bump the major. A breaking change is signalled with `!` in the PR title, or in a branch-side commit subject like `feat!:`; `BREAKING CHANGE:` footers are ignored. + ## What happens when 1. **Commits land on `main`.** The [Changelog Preview workflow](https://github.com/opendefensecloud/solution-arsenal/blob/main/.github/workflows/changelog-preview.yaml) renders the pending version and its notes into the run's job summary. Nothing is tagged, committed or built. From e0b8cc1aa6a2f1d1c84dc66842ee1deece8cc0d5 Mon Sep 17 00:00:00 2001 From: Chris Bargmann Date: Tue, 25 Aug 2026 11:44:31 +0200 Subject: [PATCH 6/8] fix(ci): count breaking commits of any type as releasable The guard only counted feat/fix/perf, but a lone refactor! bumps the version, so Release refused a pending breaking release and the preview reported nothing releasable. Select on .breaking too, in both places. --- .github/workflows/changelog-preview.yaml | 4 +++- .github/workflows/release.yaml | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/changelog-preview.yaml b/.github/workflows/changelog-preview.yaml index de5e4579..ba021ce7 100644 --- a/.github/workflows/changelog-preview.yaml +++ b/.github/workflows/changelog-preview.yaml @@ -41,8 +41,10 @@ jobs: previous_tag=$(jq -r '.[0].previous.version // empty' ctx.json) next_version=$(jq -r '.[0].version // empty' ctx.json) + # Same rule as the release guard: .breaking covers types outside the + # list, a lone "refactor!" bumps the version and counts as releasable. releasable_count=$(jq ' - [ (.[0].commits // [])[] | select(.raw_message | test("^(feat|fix|perf)")) ] | length + [ (.[0].commits // [])[] | select((.raw_message | test("^(feat|fix|perf)")) or .breaking) ] | length ' ctx.json) echo "previous=${previous_tag}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b21a4a95..cb8e6825 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -156,12 +156,14 @@ jobs: run: | set -euo pipefail + # .breaking covers types outside the list: a lone "refactor!" bumps + # the version, so it must count as releasable too. releasable_count=$(jq ' - [ .[0].commits[] | select(.raw_message | test("^(feat|fix|perf)")) ] | length + [ .[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 commits since ${PREVIOUS_TAG}. Pass an explicit version to override." + echo "::error::No feat/fix/perf or breaking commits since ${PREVIOUS_TAG}. Pass an explicit version to override." exit 1 fi From 3bc733a6b3a62cfd0f62a9c973004f2ba3b25f94 Mon Sep 17 00:00:00 2001 From: Chris Bargmann Date: Tue, 25 Aug 2026 11:44:31 +0200 Subject: [PATCH 7/8] fix(ci): refuse a dry-run asset retry dry_run defaults to checked and the recovery path ignored it, so a dispatch the form presents as a dry run rebuilt, re-signed and clobbered the draft's assets. Hard-fail the combination instead of surprising. --- .github/workflows/release.yaml | 11 ++++++++++- docs/developer-guide/releasing.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cb8e6825..dab7cad3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,7 +18,7 @@ on: type: boolean default: true 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.' + 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 @@ -283,6 +283,15 @@ jobs: env: VERSION: ${{ inputs.retry_assets_for || needs.prepare.outputs.version }} steps: + # dry_run defaults to checked, and there is nothing to dry-run on the + # recovery path: it would rebuild, re-sign and clobber the draft's assets + # under a label that promises a no-op. Refuse instead of surprising. + - name: Guard - retry is not a dry run + if: ${{ inputs.retry_assets_for != '' && 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 if: ${{ inputs.retry_assets_for != '' }} env: diff --git a/docs/developer-guide/releasing.md b/docs/developer-guide/releasing.md index 4974b4e3..294026f0 100644 --- a/docs/developer-guide/releasing.md +++ b/docs/developer-guide/releasing.md @@ -48,7 +48,7 @@ If something landed that you want included, delete the draft and its tag, then r ## Recovering a failed asset upload -If the draft was created but the artefacts failed to upload, run **Release** again with `retry_assets_for` set to the draft's tag. That skips version resolution and the notes entirely and only rebuilds and re-attaches the artefacts. It refuses if the tag has no release, or if the release is already published. Uploads use `--clobber`, so re-runs are idempotent and there is no need to delete the tag and the release. +If the draft was created but the artefacts failed to upload, run **Release** again with `retry_assets_for` set to the draft's tag, and uncheck `dry_run`. There is no dry run on the recovery path (it would rebuild and overwrite the draft's assets under a label that promises a no-op), so the workflow refuses the combination. The retry skips version resolution and the notes entirely and only rebuilds and re-attaches the artefacts. It refuses if the tag has no release, or if the release is already published. Uploads use `--clobber`, so re-runs are idempotent and there is no need to delete the tag and the release. Leave `retry_assets_for` empty for every normal release. From dbc213cfa838363b2c24827f599db19cb524e630 Mon Sep 17 00:00:00 2001 From: Chris Bargmann Date: Wed, 26 Aug 2026 14:59:30 +0200 Subject: [PATCH 8/8] refactor: replace any emojis in generated changelog output --- cliff.toml | 11 +++++++++++ docs/developer-guide/releasing.md | 2 ++ 2 files changed, 13 insertions(+) diff --git a/cliff.toml b/cliff.toml index 2810fc1e..d14f03bd 100644 --- a/cliff.toml +++ b/cliff.toml @@ -35,8 +35,19 @@ sort_commits = "newest" # 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. diff --git a/docs/developer-guide/releasing.md b/docs/developer-guide/releasing.md index 294026f0..a4be3ca6 100644 --- a/docs/developer-guide/releasing.md +++ b/docs/developer-guide/releasing.md @@ -27,6 +27,8 @@ Two things have to hold, and both are checked. A commit on `main` has to *be* a Commit messages are stripped to their subject line before parsing. Merge bodies are the PR description verbatim, so without this any `BREAKING CHANGE:` line in a PR body (template boilerplate, bot output, quoted text) would silently bump the major. A breaking change is signalled with `!` in the PR title, or in a branch-side commit subject like `feat!:`; `BREAKING CHANGE:` footers are ignored. +Emoji are removed from the subject as well, so a decorated PR title still reads plainly in the notes. This matters most for a title that opens with one: the conventional parser rejects any subject that does not start with the type, so without the stripping a gitmoji-prefixed PR would go missing from the notes entirely. + ## What happens when 1. **Commits land on `main`.** The [Changelog Preview workflow](https://github.com/opendefensecloud/solution-arsenal/blob/main/.github/workflows/changelog-preview.yaml) renders the pending version and its notes into the run's job summary. Nothing is tagged, committed or built.