diff --git a/.github/workflows/changelog-preview.yaml b/.github/workflows/changelog-preview.yaml new file mode 100644 index 00000000..ba021ce7 --- /dev/null +++ b/.github/workflows/changelog-preview.yaml @@ -0,0 +1,87 @@ +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) + # 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)")) or .breaking) ] | 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 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..dab7cad3 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,416 @@ +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. Uncheck dry_run.' + 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 + + # .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)")) 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 }} + 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: + # 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: + 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/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/cliff.toml b/cliff.toml new file mode 100644 index 00000000..d14f03bd --- /dev/null +++ b/cliff.toml @@ -0,0 +1,78 @@ +# 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/docs/developer-guide/releasing.md b/docs/developer-guide/releasing.md index 7023ae88..a4be3ca6 100644 --- a/docs/developer-guide/releasing.md +++ b/docs/developer-guide/releasing.md @@ -1,39 +1,72 @@ # 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!:` (any type with `!`) | 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. + +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`.** 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. -**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. +## One pending draft at a time + +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, 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. ## 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). 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 } - ] - } - } -}