From db440201ab462b5ee1ef1c6172c532bbc3e1ad09 Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Tue, 6 Oct 2026 00:19:51 +0200 Subject: [PATCH 1/2] Release through a pull request Signed-off-by: Thomas Darimont --- CONTRIBUTING.md | 46 ++++++++++----- etc/release.sh | 152 +++++++++++++++++++++++++++++++----------------- 2 files changed, 128 insertions(+), 70 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1986112..7117f1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,8 +16,8 @@ exception. `main` is protected by a [ruleset](.github/rulesets/main.json) (Settings > Rules): every change arrives through a pull request whose `status` check of the CI is green, force pushes and deletion are blocked, and the head branch is deleted on merge. No review is required while the project has one -maintainer. Repository admins may bypass the ruleset, which the release steps below use for the -version commits; everything else goes through a pull request as well. The JSON file in the +maintainer. Repository admins may bypass the ruleset; releases used that for their version commits +until 0.2.0, since 0.3.0 they go through a pull request too, so the bypass is an emergency exit only. The JSON file in the repository is the documentation of the ruleset, update it when the rules change (the Rules page can import it). @@ -113,20 +113,34 @@ generated on the portal (account menu, "Generate User Token"), and `GPG_PRIVATE_ `GPG_PASSPHRASE`, the ASCII-armored private key of `oss@easyssf.org` and its passphrase. The public key is on `keys.openpgp.org` and `keyserver.ubuntu.com`. -To release version `X.Y.Z` from `main`: - -1. Set the version in every POM and in the README and website snippets, and update - `project.build.outputTimestamp` in the root POM to the release date: - `./mvnw versions:set -DnewVersion=X.Y.Z -DgenerateBackupPoms=false`. Turn the `Unreleased` - section of `CHANGELOG.md` into the section of `X.Y.Z` with the date; the workflow publishes - that section as the release notes and fails without it. -2. Commit, tag `vX.Y.Z` and push the tag: `git tag -s vX.Y.Z -m "X.Y.Z" && git push origin vX.Y.Z`. - The push to `main` uses the admin bypass of the ruleset. - The workflow checks that the tag matches the POM version, runs the tests, signs, publishes, and - creates the GitHub release with generated notes and the SBOMs attached. The artifacts are on - Central a few minutes after the workflow finishes; search indexes them within hours. -3. Set the next development version, `./mvnw versions:set -DnewVersion=X.Y+1.0-SNAPSHOT - -DgenerateBackupPoms=false`, commit and push. +A release goes through a pull request like every other change to `main`; only the tag is pushed +directly, and tags are not covered by the ruleset. [`etc/release.sh`](etc/release.sh) runs the steps: + +1. `etc/release.sh prepare X.Y.Z`, on an up-to-date `main`, creates the branch `release/X.Y.Z` and + sets the version in every POM and in the dependency snippets of the READMEs, sets + `project.build.outputTimestamp` to now, and turns the `Unreleased` section of `CHANGELOG.md` into + the section of `X.Y.Z` with today's date (the workflow publishes that section as the release notes + and fails without it). Nothing is committed: review the diff, write the lead paragraph of the + section, and check the dependency snippets of other projects the script lists, such as the Quarkus + extension's. +2. `etc/release.sh propose [X.Y+1.0]` commits `Release X.Y.Z`, sets the next development version + (the next minor by default) and commits `Start X.Y+1.0`, pushes the branch and opens the pull + request with the release notes in its description. The release pull request is, like Dependabot's, + the exception to the rule that a pull request refers to an issue. **Merge it with a merge commit or + a rebase, not a squash**: the commit `Release X.Y.Z` has to reach `main` as it is, because the tag + goes on it. +3. `etc/release.sh release`, on `main` after the merge, finds the commit `Release X.Y.Z`, checks its + POM version and changelog section, creates the signed tag `vX.Y.Z` on it and pushes the tag, then + watches the release workflow, which checks that the tag matches the POM version, runs the tests, + signs, publishes, and creates the GitHub release with the changelog section and the SBOMs + attached. The artifacts are on Central a few minutes after the workflow finishes; search indexes + them within hours. If the pull request was squashed, the script says so: `main` then has no commit + with the release version, and the two commits have to be brought back with a new pull request. + +The script refuses to run on the wrong branch, on a `main` that differs from `origin/main`, on a dirty +tree, or for a tag that exists. Between the merge and the next snapshot commit, `main` carries the +release version for one commit, which the snapshot publishing job recognizes and skips. The admin +bypass of the ruleset is not needed for releases. Snapshots of `main` are published by `ci.yml` to the [Central snapshot repository](https://central.sonatype.com/repository/maven-snapshots/) while the diff --git a/etc/release.sh b/etc/release.sh index 794f0b0..97c82f3 100755 --- a/etc/release.sh +++ b/etc/release.sh @@ -1,14 +1,19 @@ #!/usr/bin/env bash -# The release steps of CONTRIBUTING.md, one subcommand each. Run from the root of the repository, on main. +# The release steps of CONTRIBUTING.md, one subcommand each. Releases go through a pull request like every +# other change to main; only the tag is pushed directly, and tags are not covered by the ruleset. # -# etc/release.sh prepare 0.3.0 set the version in the POMs and README snippets, the release timestamp, -# and turn the Unreleased section of CHANGELOG.md into the release's; nothing -# is committed, review the diff -# etc/release.sh release commit "Release X.Y.Z", create the signed tag vX.Y.Z and push both together; -# the release workflow does the rest (asks before pushing, --yes skips that) -# etc/release.sh next 0.4.0 set the next development version X.Y.Z-SNAPSHOT, commit "Start X.Y.Z", push +# etc/release.sh prepare 0.3.0 from an up-to-date main: create the branch release/0.3.0 and set the version +# in the POMs and README snippets, the release timestamp, and turn the +# Unreleased section of CHANGELOG.md into the release's. Nothing is committed: +# review the diff and write the lead paragraph of the section +# etc/release.sh propose [0.4.0] [--dry-run] on that branch: commit "Release 0.3.0", then set the next development version +# (0.4.0-SNAPSHOT, by default the next minor) and commit "Start 0.4.0", push +# the branch and open the pull request. Merge it with a merge commit or a +# rebase, not a squash: the release commit has to reach main as it is +# etc/release.sh release [0.3.0] on main after the merge: create the signed tag v0.3.0 on the commit +# "Release 0.3.0" and push the tag, which starts the release workflow # -# Needs git, the Maven wrapper, python3 (for the changelog) and, for watching the workflow, gh. +# Needs git, the Maven wrapper, python3 (for the changelog) and gh (for the pull request and the workflow). set -euo pipefail cd "$(dirname "$0")/.." @@ -19,37 +24,46 @@ version_of_pom() { ./mvnw -q -N org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout } -require_main() { - [ "$(git branch --show-current)" = "main" ] || fail "run this on main (currently on $(git branch --show-current))" - git fetch -q origin - [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || fail "main differs from origin/main, pull or push first" +semver() { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "'$1' is not a version of the form X.Y.Z" +} + +next_minor() { + local major minor + IFS=. read -r major minor _ <<< "$1" + echo "$major.$((minor + 1)).0" } require_clean() { [ -z "$(git status --porcelain --untracked-files=no)" ] || fail "the working tree has changes, commit or stash them first" } -semver() { - [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "'$1' is not a version of the form X.Y.Z" +require_up_to_date_main() { + [ "$(git branch --show-current)" = "main" ] || fail "run this on main (currently on $(git branch --show-current))" + git fetch -q origin + [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || fail "main differs from origin/main, pull or push first" } prepare() { local version=$1 semver "$version" - require_main + require_up_to_date_main require_clean local current; current=$(version_of_pom) - [[ "$current" == *-SNAPSHOT ]] || fail "the POMs are at $current, not at a snapshot; is a release already prepared?" + [[ "$current" == *-SNAPSHOT ]] || fail "the POMs are at $current, not at a snapshot" grep -q '^## \[Unreleased\]' CHANGELOG.md || fail "CHANGELOG.md has no Unreleased section" + local branch="release/$version" + git rev-parse -q --verify "$branch" >/dev/null && fail "the branch $branch exists already" + git rev-parse -q --verify "refs/tags/v$version" >/dev/null && fail "the tag v$version exists already" - echo "setting the version $version (from $current)" + git checkout -q -b "$branch" + echo "setting the version $version (from $current) on $branch" ./mvnw -q versions:set -DnewVersion="$version" -DgenerateBackupPoms=false local stamp; stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) sed -i.bak "s|[^<]*|$stamp|" pom.xml && rm pom.xml.bak - # the dependency snippets of the easyssf artifacts; other artifacts (the Quarkus extension) keep theirs - local previous=${current%-SNAPSHOT} - # the changelog carries the local date of the release, the POM timestamp UTC + # the dependency snippets of the easyssf artifacts; other artifacts (the Quarkus extension) keep theirs. + # The changelog carries the local date of the release, the POM timestamp UTC. python3 - "$version" "$(date +%Y-%m-%d)" <<'PY' import re, sys, pathlib version, day = sys.argv[1], sys.argv[2] @@ -63,8 +77,7 @@ p = pathlib.Path('CHANGELOG.md') s = p.read_text() start = s.index('## [Unreleased]\n') end = s.index('\n## [', start + 1) -section = s[start:end] -body = section[len('## [Unreleased]\n'):].strip() +body = s[start + len('## [Unreleased]\n'):end].strip() if not body: sys.exit('error: the Unreleased section of CHANGELOG.md is empty, nothing to release') s = s[:start] + f'## [Unreleased]\n\n## [{version}] - {day}\n\n' + body + '\n' + s[end:] @@ -78,34 +91,73 @@ print(f'CHANGELOG.md: Unreleased is now {version} ({day}), previous release v{pr PY echo - echo "prepared $version:" + echo "prepared $version on $branch:" git diff --stat | tail -1 grep -rnE '^\s*[0-9][^<]*' --include='*.md' . | grep -v target | grep -v "$version" \ | sed 's/^/ other dependency snippets, check by hand: /' || true echo - echo "review the diff and the lead paragraph of the new CHANGELOG section (the release notes), then: etc/release.sh release" + echo "review the diff, write the lead paragraph of the new CHANGELOG section (it becomes the release notes)," + echo "then: etc/release.sh propose [$(next_minor "$version")]" +} + +propose() { + local next="" dry_run="" + for arg in "$@"; do + case "$arg" in + --dry-run) dry_run=$arg ;; + *) next=$arg ;; + esac + done + local branch; branch=$(git branch --show-current) + [[ "$branch" == release/* ]] || fail "run this on the branch 'prepare' created (currently on $branch)" + local version=${branch#release/} + [ "$(version_of_pom)" = "$version" ] || fail "the POMs are at $(version_of_pom), the branch says $version" + grep -q "^## \[$version\] - " CHANGELOG.md || fail "CHANGELOG.md has no section for $version" + [ -n "$(git status --porcelain --untracked-files=no)" ] || fail "nothing to commit; was 'prepare' run?" + next=${next:-$(next_minor "$version")} + semver "$next" + + local notes; notes=$(release_notes "$version") + [ -n "$notes" ] || fail "the CHANGELOG section of $version is empty" + echo "$notes" | head -3 | grep -q '[a-z]' || echo "warning: the section of $version has no lead paragraph before its first heading" + + git commit -q -s -am "Release $version" + ./mvnw -q versions:set -DnewVersion="$next-SNAPSHOT" -DgenerateBackupPoms=false + git commit -q -s -am "Start $next" + echo "committed on $branch:" + git log --oneline -2 | sed 's/^/ /' + if [ "$dry_run" = "--dry-run" ]; then + echo "dry run: not pushing, not opening the pull request" + return + fi + git push -u origin "$branch" + local body + body=$(printf 'Release %s, then %s-SNAPSHOT.\n\n**Merge with a merge commit or a rebase, not a squash**: the commit "Release %s" has to reach `main` unchanged, `etc/release.sh release` tags it there.\n\nRelease notes, from CHANGELOG.md:\n\n%s' "$version" "$next" "$version" "$notes") + gh pr create --base main --head "$branch" --title "Release $version" --body "$body" + echo + echo "after the merge, on main: etc/release.sh release $version" } release() { - local yes=${1:-} - require_main - local version; version=$(version_of_pom) - [[ "$version" != *-SNAPSHOT ]] || fail "the POMs are at $version; run 'prepare' first" + local version=${1:-} + require_up_to_date_main + require_clean + if [ -z "$version" ]; then + # pipelines read to the end: an early exit would SIGPIPE git and, with pipefail, abort the script + version=$(git log --format=%s origin/main | awk '/^Release [0-9.]+$/ && !found { print substr($0, 9); found = 1 }') + [ -n "$version" ] || fail "no commit 'Release X.Y.Z' on main; name the version" + fi semver "$version" - grep -q "^## \[$version\] - " CHANGELOG.md || fail "CHANGELOG.md has no section for $version" git rev-parse -q --verify "refs/tags/v$version" >/dev/null && fail "the tag v$version exists already" - [ -n "$(git status --porcelain)" ] || fail "nothing to commit; was 'prepare' run?" - - echo "about to commit and push the release $version:" - git status --short | sed 's/^/ /' - echo " tag v$version (signed), pushed together with main" - if [ "$yes" != "--yes" ]; then - read -r -p "continue? [y/N] " answer - [ "$answer" = "y" ] || fail "aborted, nothing was committed" - fi - git commit -q -s -am "Release $version" - git tag -s "v$version" -m "$version" - git push origin main "v$version" + local commit; commit=$(git log --format='%H %s' origin/main | awk -v s="Release $version" '$0 ~ " "s"$" && !found { print $1; found = 1 }') + [ -n "$commit" ] || fail "no commit 'Release $version' on main. Was the pull request squashed? Then main has no commit with the version $version to tag; restore the two commits of the release branch with a new pull request (git cherry-pick) and merge it without squashing" + local pom_version; pom_version=$(git show "$commit:pom.xml" | awk '/[0-9.]+<\/version>/ && !found { sub(/.*/, ""); sub(/<\/version>.*/, ""); print; found = 1 }') + [ "$pom_version" = "$version" ] || fail "the commit $commit carries the version $pom_version in pom.xml, not $version" + git show "$commit:CHANGELOG.md" | grep -q "^## \[$version\] - " || fail "CHANGELOG.md at $commit has no section for $version" + + echo "tagging $(git log --oneline -1 "$commit") as v$version (signed) and pushing the tag" + git tag -s "v$version" -m "$version" "$commit" + git push origin "v$version" echo "pushed; the release workflow builds, tests, signs and publishes $version" if command -v gh >/dev/null; then sleep 20 @@ -114,22 +166,14 @@ release() { fi } -next() { - local version=$1 - semver "$version" - require_main - require_clean - local current; current=$(version_of_pom) - [[ "$current" != *-SNAPSHOT ]] || fail "the POMs are at $current already" - ./mvnw -q versions:set -DnewVersion="$version-SNAPSHOT" -DgenerateBackupPoms=false - git commit -q -s -am "Start $version" - git push origin main - echo "main is at $version-SNAPSHOT" +release_notes() { + awk -v v="$1" '/^## \[/ { in_section = ($0 ~ "^## \\[" v "\\]") ; next } in_section && !/^\[/ { print }' CHANGELOG.md \ + | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' } case "${1:-}" in prepare) [ $# -eq 2 ] || fail "usage: etc/release.sh prepare X.Y.Z"; prepare "$2" ;; + propose) shift; propose "$@" ;; release) release "${2:-}" ;; - next) [ $# -eq 2 ] || fail "usage: etc/release.sh next X.Y.Z"; next "$2" ;; - *) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;; + *) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;; esac From 745c256b3aa80d7d600b1ab3087f30e8de7a9b2a Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Tue, 6 Oct 2026 00:23:37 +0200 Subject: [PATCH 2/2] Count hand-run scripts and the ruleset export as documentation in the CI Signed-off-by: Thomas Darimont --- .github/scripts/changed-files.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/changed-files.sh b/.github/scripts/changed-files.sh index 564ae9b..5d7b034 100755 --- a/.github/scripts/changed-files.sh +++ b/.github/scripts/changed-files.sh @@ -11,7 +11,9 @@ set -euo pipefail is_doc() { case "$1" in *.md | LICENSE | NOTICE | .gitignore | .gitattributes | .lychee.toml | _typos.toml) return 0 ;; - .idea/* | .run/* | scratch/* | .github/dependabot.yml) return 0 ;; + .idea/* | .run/* | scratch/* | .github/dependabot.yml | .github/rulesets/*) return 0 ;; + # scripts people run by hand; etc/eclipse-formatter.prefs stays code, the build checks the formatting with it + etc/*.sh) return 0 ;; *) return 1 ;; esac }