diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f48f5e1..295e16c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,7 +13,10 @@ docs: update MODULE.bazel usage example ci: bump Bazel version in test matrix - Breaking changes: use feat! or fix! and fill out the BREAKING CHANGE section below. + Breaking changes: use feat! or fix! in the PR title and fill out the + BREAKING CHANGE section below. The PR description becomes the squash commit + body, so do not mention that footer (with its colon) for non-breaking changes; + it forces a major release. See CONTRIBUTING.md#major-releases. Delete this comment block before submitting. --> diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index 555b305..48b2599 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -79,6 +79,40 @@ jobs: exit 1 fi + # Show which commits ccv treats as breaking, so a maintainer can tell an + # intended major from accidental `BREAKING CHANGE: ` text in a commit body + # (squash merges include the PR description). Matching body lines are + # printed under each subject. The grep is per line, so it may list a few + # more commits than ccv counts. Control characters and backticks are + # stripped so commit text cannot break out of the summary code block. + - name: List breaking commits + id: list + if: steps.ccv.outputs.new-tag-version-type == 'major' + continue-on-error: true # Informational only + run: | + sanitize() { LC_ALL=C tr -d '\000-\010\013-\037\177' | tr '`' "'"; } + # Same pattern as ccv v0.10.0; `^` anchors only the first alternative. + breaking='^(fix|feat)(\(.+\))?!: |BREAKING CHANGE: ' + last_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*')" + commits="$( + git log --format='%H' -E --grep="${breaking}" "${last_tag}..HEAD" | + while read -r sha; do + git log -1 --format='%h %s' "${sha}" + git log -1 --format='%b' "${sha}" | { grep -E -- "${breaking}" || true; } | sed 's/^/ /' + done | sanitize + )" + { + echo "### Likely breaking commits" + echo + echo "Best effort: commits since the closest version tag that match ccv's breaking-change pattern." + echo "An empty list is inconclusive; inspect \`git log\` in that case." + echo + echo '```text' + echo "Since $(sanitize <<<"${last_tag}"):" + echo "${commits:-(none found)}" + echo '```' + } >> "${GITHUB_STEP_SUMMARY}" + - name: Report dry run if: steps.ccv.outputs.new-tag == 'true' && inputs.dry_run env: @@ -89,7 +123,8 @@ jobs: echo "Dry run: next version would be \`${TAG}\` (${TYPE}) at \`${GITHUB_SHA}\`. No tag pushed." if [[ "${TYPE}" == "major" ]]; then echo - echo "A non-dry run would fail: major releases must be tagged by hand (see CONTRIBUTING.md#major-releases)." + echo "A non-dry run would fail: major releases must be tagged by hand. If the commits listed above" + echo "show the bump is accidental, tag the intended version instead (see CONTRIBUTING.md#major-releases)." fi } >> "${GITHUB_STEP_SUMMARY}" @@ -97,24 +132,49 @@ jobs: if: steps.ccv.outputs.new-tag == 'true' && steps.ccv.outputs.new-tag-version-type == 'major' && !inputs.dry_run env: TAG: ${{ steps.ccv.outputs.new-tag-version }} + LIST_OK: ${{ steps.list.outcome == 'success' }} run: | echo "::error title=Major version bump::Commits since the last tag require a major bump to ${TAG}. Major releases are not tagged automatically." + # Next patch after the highest release tag, offered for an accidental bump. + latest="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" + patch_tag="" + if [[ "${latest}" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + patch_tag="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))" + fi + print_commands() { + echo '```bash' + echo "git fetch origin" + echo "git -c tag.gpgSign=false tag $1 ${GITHUB_SHA}" + echo "git push origin refs/tags/$1" + echo '```' + } { echo "## Major version bump blocked" echo - echo "Commits since the last tag (\`feat!:\`, \`fix!:\` or \`BREAKING CHANGE:\`) require a major bump to \`${TAG}\`." - echo "No tag was pushed. Every later run will fail the same way until a major release is tagged by hand." + echo "Commits since the last tag require a major bump to \`${TAG}\` at \`${GITHUB_SHA}\`. No tag was pushed." + echo "Scheduled runs and dispatches fail the same way until a release tag is pushed by hand on this commit." echo - echo "To release this commit, tag it explicitly and push with your own credentials." - echo "Tags cannot be deleted or moved, so double-check the commit first:" + if [[ "${LIST_OK}" == "true" ]]; then + echo "Check the breaking commits listed above first." + else + echo "Listing the breaking commits failed. Inspect \`git log\` since the last tag first." + fi echo - echo '```bash' - echo "git fetch origin" - echo "git tag ${TAG} ${GITHUB_SHA}" - echo "git push origin refs/tags/${TAG}" - echo '```' + echo "Push the tag with your own credentials (a \`GITHUB_TOKEN\` push does not trigger \`release.yaml\`)." + echo "The push releases that version. Tags cannot be deleted or moved." echo - echo "\`release.yaml\` runs on the tag push. See CONTRIBUTING.md#major-releases." + echo "**Intended breaking change:** release \`${TAG}\`:" + echo + print_commands "${TAG}" + echo + if [[ -n "${patch_tag}" ]]; then + echo "**Accidental bump** (for example the text \`BREAKING CHANGE: \` in a PR description): release the next patch \`${patch_tag}\` instead." + echo "Use a minor version if the commits contain \`feat:\`. Later runs count only commits after this tag." + echo + print_commands "${patch_tag}" + echo + fi + echo "The tag must be lightweight (not annotated or signed) and on this commit. See CONTRIBUTING.md#major-releases." } >> "${GITHUB_STEP_SUMMARY}" exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5017055..c9d0cd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,23 +65,43 @@ such as `docs:`, `ci:`, `chore:` or `refactor:` do not. ### Major releases A breaking change requires a major bump. The versioning tool treats only these -as breaking: `feat!:`, `fix!:`, or a `BREAKING CHANGE: ` footer (with a space, +as breaking: `feat!:`, `fix!:`, or the text `BREAKING CHANGE: ` (with a space, not a hyphen). Other breaking forms, such as `refactor!:` or `BREAKING-CHANGE:`, are not detected. Mark breaking changes with one of the three supported forms. -The `Tag` workflow never pushes a major tag. It fails and prints the version -and the commit SHA it computed. Every later run fails the same way until a -maintainer tags that commit by hand with their own credentials. A tag pushed -by `GITHUB_TOKEN` would not trigger the release: +`BREAKING CHANGE: ` is matched **anywhere** in the commit message, not only in +the footer. Squash-merge commits include the PR description, so do not write +that text in a PR description unless the change is breaking. Prefer `!` in the +PR title (for example `feat!: ...`) to mark a breaking change. + +The `Tag` workflow never pushes a major tag. On a major bump it fails, and the +run summary shows the computed version, the commit SHA, and a best-effort list +of the commits that look breaking, with the matching lines from their +messages. Scheduled runs and dispatches keep failing until a maintainer pushes +a release tag on that commit by hand, with their own credentials (a tag pushed +by `GITHUB_TOKEN` would not trigger the release). The summary prints the exact +commands. For an intended breaking change: ```bash git fetch origin -git tag vX.0.0 +git -c tag.gpgSign=false tag vX.0.0 git push origin refs/tags/vX.0.0 ``` -Version tags cannot be deleted or moved, so double-check the commit before -pushing. Pushing the tag triggers -[`release.yaml`](.github/workflows/release.yaml), which creates the GitHub -Release and submits a BCR PR. +If the listed commits show the bump is accidental, push the intended version +instead: the next patch after the latest release tag (the summary prints it), +or the next minor if there are `feat:` commits. The version must be higher +than every existing release tag. + +Either way: + +- The tag must be **lightweight** (not annotated or signed) and on the commit + from the summary. The versioning tool ignores annotated tags, so the bump + would not be cleared. `-c tag.gpgSign=false` prevents signing from your git + config. +- Pushing the tag triggers [`release.yaml`](.github/workflows/release.yaml), + which creates the GitHub Release and submits a BCR PR. Version tags cannot be + deleted or moved, so double-check the commit and version first. +- Later runs count only the commits after this tag, so an accidental breaking + commit no longer forces a major bump.