From 3449510e7005c299ec77633e8f921c14d94d1203 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Fri, 2 Oct 2026 20:06:34 -0500 Subject: [PATCH 01/12] Automate Java SDK releases --- .github/scripts/package-native-release.sh | 58 +++++ .github/scripts/release.sh | 254 ++++++++++++++++++++++ .github/scripts/test-release.sh | 61 ++++++ .github/workflows/README.md | 79 ++++--- .github/workflows/build-native-image.yml | 13 +- .github/workflows/prepare-release.yml | 185 ---------------- .github/workflows/publish-snapshot.yml | 1 + .github/workflows/release.yml | 201 +++++++++++++++++ CHANGELOG.md | 4 + gradle/publishing.gradle | 20 ++ gradle/versioning.gradle | 11 +- 11 files changed, 675 insertions(+), 212 deletions(-) create mode 100755 .github/scripts/package-native-release.sh create mode 100755 .github/scripts/release.sh create mode 100755 .github/scripts/test-release.sh delete mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/scripts/package-native-release.sh b/.github/scripts/package-native-release.sh new file mode 100755 index 0000000000..ab758c95f8 --- /dev/null +++ b/.github/scripts/package-native-release.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Packages each native test server binary into a reproducible release archive. +: "${RELEASE_VERSION:?RELEASE_VERSION is required.}" + +native_directory=${1:?Native artifact directory is required.} +asset_directory=${2:?Release asset directory is required.} +platforms=( + linux_amd64_musl + linux_amd64 + macOS_amd64 + macOS_arm64 + linux_arm64 + windows_amd64 +) + +native_directory=$(cd "$native_directory" && pwd) +mkdir -p "$asset_directory" +asset_directory=$(cd "$asset_directory" && pwd) +staging_directory=$(mktemp -d) +trap 'rm -rf "$staging_directory"' EXIT + +for platform in "${platforms[@]}"; do + source_directory="$native_directory/release-native-$platform" + mapfile -t binaries < <( + find "$source_directory" -type f -name 'temporal-test-server*' + ) + if [[ "${#binaries[@]}" -ne 1 ]]; then + echo "::error::Expected one native executable in $source_directory." + exit 1 + fi + + archive_root="temporal-test-server_${RELEASE_VERSION}_${platform}" + package_directory="$staging_directory/$archive_root" + mkdir "$package_directory" + if [[ "$platform" == windows_* ]]; then + executable="$package_directory/temporal-test-server.exe" + install -m 0755 "${binaries[0]}" "$executable" + touch -t 198001010000 "$package_directory" "$executable" + ( + cd "$staging_directory" + zip -X -qr "$asset_directory/$archive_root.zip" "$archive_root" + ) + else + executable="$package_directory/temporal-test-server" + install -m 0755 "${binaries[0]}" "$executable" + touch -t 198001010000 "$package_directory" "$executable" + COPYFILE_DISABLE=1 tar -cf - -C "$staging_directory" "$archive_root" \ + | gzip -n > "$asset_directory/$archive_root.tar.gz" + fi + rm -rf "$package_directory" +done + +( + cd "$asset_directory" + sha256sum ./*.tar.gz ./*.zip | sort -k2 > SHA256SUMS +) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh new file mode 100755 index 0000000000..713bb1fcec --- /dev/null +++ b/.github/scripts/release.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail +shopt -s nullglob + +# Reports a workflow-formatted error and exits. +fail() { + echo "::error::$*" >&2 + exit 1 +} + +# Fails when a required environment variable is empty. +require() { + [[ -n "${!1:-}" ]] || fail "$1 is required." +} + +# Writes release metadata to GitHub Actions or standard output. +write_output() { + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT" + else + printf '%s=%s\n' "$1" "$2" + fi +} + +# Lists the valid version headings at a given commit. +changelog_versions() { + git show "$1:CHANGELOG.md" \ + | sed -nE 's/^## \[([0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?)\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$/\1/p' \ + | sort +} + +# Extracts one version's changelog section at a given commit. +changelog_section() { + git show "$1:CHANGELOG.md" | awk -v heading="## [$2]" -v include="${3:-false}" ' + !seen && index($0, heading) == 1 { + seen = 1 + capture = 1 + if (include == "true") lines[++count] = $0 + next + } + capture && /^## / { capture = 0 } + capture { lines[++count] = $0 } + END { + first = 1 + while (first <= count && lines[first] == "") first++ + while (count >= first && lines[count] == "") count-- + for (line = first; line <= count; line++) print lines[line] + } + ' +} + +# Validates a changelog transition and identifies a release candidate. +candidate() { + require BASE_SHA + require EVENT_NAME + require HEAD_SHA + local notes=${1:?Release notes output path is required.} + local base head version tag existing + + if [[ "$BASE_SHA" =~ ^0+$ ]]; then + BASE_SHA=$(git rev-parse "$HEAD_SHA^") + fi + base=$(git rev-parse --verify "$BASE_SHA^{commit}") + head=$(git rev-parse --verify "$HEAD_SHA^{commit}") + git merge-base --is-ancestor "$base" "$head" \ + || fail "The base commit must be an ancestor of the release commit." + + mapfile -t base_versions < <(changelog_versions "$base") + mapfile -t head_versions < <(changelog_versions "$head") + for version in "${base_versions[@]}"; do + diff -q <(changelog_section "$base" "$version" true) \ + <(changelog_section "$head" "$version" true) >/dev/null \ + || fail "Published changelog section $version was changed." + done + + mapfile -t added < <(comm -13 \ + <(printf '%s\n' "${base_versions[@]}") \ + <(printf '%s\n' "${head_versions[@]}")) + if [[ "${#added[@]}" -eq 0 ]]; then + write_output release false + return + fi + [[ "${#added[@]}" -eq 1 ]] \ + || fail "A release commit must add exactly one versioned changelog section." + + version=${added[0]} + changelog_section "$head" "$version" > "$notes" + [[ -s "$notes" ]] || fail "Release notes for $version are empty." + git show "$head:CHANGELOG.md" | awk -v release="## [$version]" ' + /^## \[Unreleased\]$/ { unreleased = NR } + index($0, release) == 1 { candidate = NR } + END { exit !(unreleased && candidate && unreleased < candidate) } + ' || fail "[Unreleased] must remain above the new release section." + + tag="v$version" + if existing=$(git rev-parse --verify "refs/tags/$tag^{commit}" 2>/dev/null); then + [[ "$EVENT_NAME" == push && "$existing" == "$head" ]] \ + || fail "Release tag $tag already exists at $existing." + fi + write_output release true + write_output version "$version" + write_output tag "$tag" + write_output commit "$head" + write_output prerelease "$([[ "$version" == *-RC* ]] && echo true || echo false)" +} + +# Tests the project and verifies its primary local Maven publication. +build_maven() { + require RELEASE_COMMIT + require RELEASE_VERSION + local repository=${1:?Local Maven repository path is required.} + local pom="$repository/io/temporal/temporal-sdk/$RELEASE_VERSION/temporal-sdk-$RELEASE_VERSION.pom" + + ./gradlew --no-daemon \ + "-PreleaseVersion=$RELEASE_VERSION" \ + "-PreleaseCommit=$RELEASE_COMMIT" \ + "-Dmaven.repo.local=$repository" \ + build publishToMavenLocal + git diff --exit-code + [[ -f "$pom" ]] || fail "The temporal-sdk POM was not generated." + grep -Fq "$RELEASE_COMMIT" "$pom" \ + || fail "The generated POM does not identify the release commit." +} + +# Reports whether the exact release is already visible on Maven Central. +central_state() { + local pom="$RUNNER_TEMP/temporal-sdk-central.pom" + local url="https://repo1.maven.org/maven2/io/temporal/temporal-sdk/$RELEASE_VERSION/temporal-sdk-$RELEASE_VERSION.pom" + local status curl_status + set +e + status=$(curl --silent --show-error --output "$pom" --write-out '%{http_code}' "$url") + curl_status=$? + set -e + [[ "$curl_status" -eq 0 ]] || fail "Maven Central could not be read." + case "$status" in + 200) + grep -Fq "$RELEASE_COMMIT" "$pom" \ + || fail "Maven Central contains this version from another commit." + echo published + ;; + 404) echo absent ;; + *) fail "Maven Central returned HTTP $status." ;; + esac +} + +# Publishes the signed staging repository and waits for Maven Central. +publish_maven() { + require GRADLE_USER_HOME + require RELEASE_COMMIT + require RELEASE_VERSION + require RUN_ATTEMPT + require RUNNER_TEMP + local state variable signing_directory status attempt + + state=$(central_state) + [[ "$state" == absent ]] || return + [[ "$RUN_ATTEMPT" =~ ^[0-9]+$ ]] || fail "RUN_ATTEMPT must be numeric." + if ((RUN_ATTEMPT > 1)) && [[ "${MAVEN_RETRY_COMMIT:-}" != "$RELEASE_COMMIT" ]]; then + fail "Inspect Sonatype, then set MAVEN_RETRY_COMMIT to $RELEASE_COMMIT before rerunning." + fi + for variable in KEY KEY_ID KEY_PASSWORD RH_PASSWORD RH_USER; do + [[ -n "${!variable:-}" ]] || fail "Release secret $variable is not configured." + done + + umask 077 + signing_directory="$RUNNER_TEMP/release-gnupg" + signing_key="$signing_directory/secring.gpg" + properties="$GRADLE_USER_HOME/gradle.properties" + mkdir -p "$GRADLE_USER_HOME" "$signing_directory" + trap 'rm -f "$properties" "$signing_key"' EXIT + printf '%s' "$KEY" | base64 --decode > "$signing_key" + { + printf 'signing.keyId = %s\n' "$KEY_ID" + printf 'signing.password = %s\n' "$KEY_PASSWORD" + printf 'signing.secretKeyRingFile = %s\n' "$signing_key" + printf 'ossrhUsername = %s\n' "$RH_USER" + printf 'ossrhPassword = %s\n' "$RH_PASSWORD" + } > "$properties" + + set +e + ./gradlew --no-daemon \ + "-PreleaseVersion=$RELEASE_VERSION" \ + "-PreleaseCommit=$RELEASE_COMMIT" \ + publishToSonatype closeAndReleaseSonatypeStagingRepository + status=$? + set -e + [[ "$status" -eq 0 ]] \ + || echo "::warning::Gradle failed; checking Central before declaring an ambiguous publication." + + for attempt in {1..90}; do + state=$(central_state) + [[ "$state" == published ]] && return + [[ "$attempt" -eq 90 ]] || sleep 20 + done + fail "Maven publication is ambiguous. Inspect Sonatype before authorizing a retry." +} + +# Creates or validates the GitHub release, tag, notes, and assets. +publish_github() { + require GITHUB_REPOSITORY + require GITHUB_STEP_SUMMARY + require PRERELEASE + require RELEASE_COMMIT + require RELEASE_TAG + require RELEASE_VERSION + local notes=${1:?Release notes path is required.} + local assets=${2:?Release asset directory is required.} + local release tag + local release_assets=("$assets"/*) + [[ "${#release_assets[@]}" -gt 0 ]] || fail "No GitHub release assets were produced." + + if ! release=$(gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" \ + --json body,isDraft,isPrerelease,name,url 2>/dev/null); then + create=(release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --title "$RELEASE_TAG" --target "$RELEASE_COMMIT" --notes-file "$notes") + [[ "$PRERELEASE" == true ]] && create+=(--prerelease) + create+=("${release_assets[@]}") + gh "${create[@]}" + release=$(gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" \ + --json body,isDraft,isPrerelease,name,url) + fi + + jq -e --arg tag "$RELEASE_TAG" --argjson prerelease "$PRERELEASE" \ + --rawfile notes "$notes" \ + 'def normalized: gsub("\r"; "") | sub("\n+$"; ""); + .name == $tag and ((.body | normalized) == ($notes | normalized)) and + .isDraft == false and .isPrerelease == $prerelease' <<<"$release" >/dev/null + tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG") + jq -e --arg commit "$RELEASE_COMMIT" \ + '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null + + published_assets=$(mktemp -d) + trap 'rm -rf "$published_assets"' EXIT + gh release download --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" --dir "$published_assets" + diff -qr "$assets" "$published_assets" + { + echo "## Release published" + echo + echo "- GitHub: $(jq -r .url <<<"$release")" + echo "- Maven: https://central.sonatype.com/artifact/io.temporal/temporal-sdk/$RELEASE_VERSION" + echo "- Commit: \`$RELEASE_COMMIT\`" + } >> "$GITHUB_STEP_SUMMARY" +} + +# Dispatches the requested release operation. +command=${1:-} +shift || true +case "$command" in + candidate) candidate "$@" ;; + build-maven) build_maven "$@" ;; + publish-maven) publish_maven "$@" ;; + publish-github) publish_github "$@" ;; + *) fail "Unknown release command: $command" ;; +esac diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh new file mode 100755 index 0000000000..d818392deb --- /dev/null +++ b/.github/scripts/test-release.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercises changelog validation and deterministic packaging without external services. +script_directory=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +release_script="$script_directory/release.sh" +package_script="$script_directory/package-native-release.sh" +temporary_directory=$(mktemp -d) +trap 'rm -rf "$temporary_directory"' EXIT + +repository="$temporary_directory/repository" +mkdir "$repository" +git -C "$repository" init -q +git -C "$repository" config user.email test@example.com +git -C "$repository" config user.name "Release Test" +printf '# Changelog\n\n## [Unreleased]\n\n### Fixed\n- Fixed it.\n' \ + > "$repository/CHANGELOG.md" +git -C "$repository" add CHANGELOG.md +git -C "$repository" commit -qm base +base=$(git -C "$repository" rev-parse HEAD) +( + cd "$repository" + BASE_SHA="$base" HEAD_SHA="$base" EVENT_NAME=pull_request \ + GITHUB_OUTPUT="$temporary_directory/no-release-output" \ + "$release_script" candidate "$temporary_directory/no-release-notes" +) +grep -Fxq 'release=false' "$temporary_directory/no-release-output" + +printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-03\n\n### Fixed\n- Fixed it.\n' \ + > "$repository/CHANGELOG.md" +git -C "$repository" commit -qam release +head=$(git -C "$repository" rev-parse HEAD) +( + cd "$repository" + BASE_SHA="$base" HEAD_SHA="$head" EVENT_NAME=pull_request \ + GITHUB_OUTPUT="$temporary_directory/output" \ + "$release_script" candidate "$temporary_directory/notes" +) +grep -Fxq 'release=true' "$temporary_directory/output" +grep -Fxq 'version=1.41.0' "$temporary_directory/output" +grep -Fxq '### Fixed' "$temporary_directory/notes" +printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-04\n\n### Fixed\n- Fixed it.\n' \ + > "$repository/CHANGELOG.md" +git -C "$repository" commit -qam rewrite +rewrite=$(git -C "$repository" rev-parse HEAD) +if (cd "$repository" && BASE_SHA="$head" HEAD_SHA="$rewrite" EVENT_NAME=push \ + "$release_script" candidate "$temporary_directory/rewrite-notes" 2>/dev/null); then + echo "Published changelog edits must be rejected." >&2 + exit 1 +fi + +native="$temporary_directory/native" +for platform in linux_amd64_musl linux_amd64 macOS_amd64 macOS_arm64 linux_arm64 windows_amd64; do + mkdir -p "$native/release-native-$platform" + printf 'binary for %s' "$platform" > "$native/release-native-$platform/temporal-test-server" +done +RELEASE_VERSION=1.41.0 "$package_script" "$native" "$temporary_directory/first" +RELEASE_VERSION=1.41.0 "$package_script" "$native" "$temporary_directory/second" +diff -qr "$temporary_directory/first" "$temporary_directory/second" +[[ $(find "$temporary_directory/first" -type f | wc -l) -eq 7 ]] +(cd "$temporary_directory/first" && sha256sum -c SHA256SUMS) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 885fe8a227..9ec613500d 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,10 +1,44 @@ -# sdk-java Github Workflows +# sdk-java GitHub workflows -## Prepare Release (prepare-release.yml) +## Releases -This is a [manually triggered](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) workflow that uses the gradle build files already present in the sdk-java repository to prepare release artifacts for publication. This workflow takes a tag string and a git ref to use in peparing a release. There is an expectation that if preparing a given release (e.g. v1.2.3) then there exists a file in the repository, releases/ (i.e. releases/v1.2.3) containing release notes. This file must be present on the ref passed to the workflow invocation. +[`release.yml`](release.yml) publishes a release from an exact commit after a +release pull request is merged. Normal releases require two human decisions: -This workflow requires five secrets: +1. Review and merge a pull request that promotes the desired entries from the + `CHANGELOG.md` `[Unreleased]` section into one new + `## [X.Y.Z] - YYYY-MM-DD` section. +2. Approve the `release-publication` GitHub environment after the workflow has + tested the Maven publications and built the native test server executables. + Approval must be given within 30 days or GitHub automatically fails the + waiting job. + +The protected `publish` job depends on both validation paths, so GitHub does +not request approval until they succeed. GitHub marks the job as **Waiting** +and sends the configured required reviewers a deployment review notification; +open the workflow run and select **Review deployments** to approve or reject +it. Notification delivery outside GitHub depends on each reviewer's settings +or an optional Slack or Microsoft Teams deployment integration. + +The workflow then releases the signed Java artifacts through Sonatype, verifies +the `temporal-sdk` POM and its exact commit on Maven Central, and publishes the +GitHub release and tag. Release candidates are always bound to the full merge +commit SHA. RC versions use headings such as +`## [1.41.0-RC1] - 2026-10-03` and are published as GitHub prereleases. + +An ordinary pull request that only adds entries beneath `[Unreleased]` runs the +candidate check but does not start a release. + +Maintainers can check the release scripts locally with +`.github/scripts/test-release.sh`. + +### One-time repository setup + +Configure the existing `release-publication` environment with required +reviewers. GitHub permits up to six users or teams with repository read access, +and one listed reviewer must approve. Preventing self-review is recommended. +Keep its deployment branch policy restricted to `main` and make these secrets +available to the workflow, preferably as environment secrets: - `JAR_SIGNING_KEY` - `JAR_SIGNING_KEY_ID` @@ -12,27 +46,22 @@ This workflow requires five secrets: - `RH_PASSWORD` - `RH_USER` - The results of running this workflow are - - - A *DRAFT* Github release will be created - - Signed jars *STAGED* to the Sonatype Nexus artifact repository - - To complete the release, the releaser should - - - Validate and publish the Github release - - Approve and publish the jars via the Sonatype UI - -### Testing +The signing key is the base64-encoded secret key ring used by Gradle signing. +The RH credentials must be authorized to publish `io.temporal` through the +Sonatype staging API. -This workflow does not publish release artifacts in a way that is externally -visible and thus it is safe to execute at any time as long as the resulting -draft release and unpublished jars are cleaned up. +### Recovery -Workflows can also be invoked from the `gh` cli. To invoke this workflow and watch its progress +Jobs before Maven publication are safe to rerun. A rerun also continues after +Maven publication when the `temporal-sdk` POM is on Maven Central and its +`scm.tag` matches the release commit. -```.sh -$ gh workflow run --repo temporalio/sdk-java --field tag=v1.2.3 prepare-release.yml -$ gh run list --workflow prepare-release.yml --repo temporalio/sdk-java -$ # Note ID of your workflow run in the output of the command above -$ gh run watch --repo temporalio/sdk-java -``` +If a Sonatype request fails and the release does not become visible on Central, +the workflow stops with an ambiguous-publication error. Inspect Sonatype before +rerunning. Do not authorize another staging generation until the earlier one is +known to be inactive. After that inspection, set the `release-publication` +environment variable `MAVEN_RETRY_COMMIT` to the exact 40-character release +commit and rerun the workflow. Clear the variable after the release; its value +is bound to that commit and cannot authorize another candidate. A published +version or GitHub tag that points to another commit is a permanent error and +must not be replaced. diff --git a/.github/workflows/build-native-image.yml b/.github/workflows/build-native-image.yml index 33515cde6c..0c5e120065 100644 --- a/.github/workflows/build-native-image.yml +++ b/.github/workflows/build-native-image.yml @@ -17,6 +17,11 @@ on: description: "Upload the native test server executable as an artifact" required: false default: false + artifact_prefix: + type: string + description: "Optional prefix for uploaded artifact names" + required: false + default: "" workflow_call: inputs: ref: @@ -29,6 +34,11 @@ on: description: "Upload the native test server executable as an artifact" required: false default: false + artifact_prefix: + type: string + description: "Optional prefix for uploaded artifact names" + required: false + default: "" env: INPUT_REF: ${{ inputs.ref }} @@ -111,8 +121,9 @@ jobs: if: ${{ inputs.upload_artifact }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: ${{ matrix.musl && format('{0}_{1}_musl', matrix.os_family, matrix.arch) || format('{0}_{1}', matrix.os_family, matrix.arch)}} + name: ${{ format('{0}{1}', inputs.artifact_prefix, matrix.musl && format('{0}_{1}_musl', matrix.os_family, matrix.arch) || format('{0}_{1}', matrix.os_family, matrix.arch)) }} path: | temporal-test-server/build/native/nativeCompile/temporal-test-server* if-no-files-found: error + overwrite: true retention-days: 1 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml deleted file mode 100644 index b43ce62c88..0000000000 --- a/.github/workflows/prepare-release.yml +++ /dev/null @@ -1,185 +0,0 @@ -name: Prepare release -defaults: - run: - shell: bash -euo pipefail -O nullglob {0} -on: - workflow_dispatch: - inputs: - tag: - type: string - description: "Release version tag (e.g. v1.2.3)" - required: true - ref: - type: string - description: "Git ref from which to release" - required: true - default: "main" - do_build_native_images: - type: boolean - description: "Native Test Server" - required: true - default: "true" - do_publish_jars: - type: boolean - description: "Publish Java Artifacts" - required: true - default: "true" - -permissions: - contents: read - -env: - INPUT_REF: ${{ github.event.inputs.ref }} - INPUT_TAG: ${{ github.event.inputs.tag }} - -jobs: - create_draft_release: - name: Create Github draft release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Audit gh version - run: gh --version - - - name: Check for existing release - id: check_release - run: | - echo "::echo::on" - gh release view --repo "$GITHUB_REPOSITORY" "$INPUT_TAG" \ - && echo "::set-output name=already_exists::true" \ - || echo "::set-output name=already_exists::false" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Checkout repo - if: steps.check_release.outputs.already_exists == 'false' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ env.INPUT_REF }} - - - name: Create release - if: steps.check_release.outputs.already_exists == 'false' - run: > - gh release create - "$INPUT_REF" - --draft - --repo "$GITHUB_REPOSITORY" - --title "$INPUT_TAG" - --target "$INPUT_REF" - --notes-file releases/"$INPUT_TAG" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish_java_artifacts: - name: Publish Java Artifacts - if: github.event.inputs.do_publish_jars == 'true' - runs-on: ubuntu-latest - needs: create_draft_release - steps: - - name: Checkout repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ env.INPUT_REF }} - - # Our custom gradle version sniffing builds the maven release artifact - # names out of the git tag ... but the repo isn't tagged (yet) so add a - # tag to the _local_ clone just to get the right jar names. This tag - # does not get pushed back to the origin. Once the artifacts have been - # inspected and verified, the manual act of publishing the draft GH - # release creates the tag. - - name: Temporary tag - run: git tag "$INPUT_TAG" - - - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "23" - distribution: "temurin" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - - - name: Set up signing key - run: mkdir -p "$HOME/.gnupg" && echo -n "$KEY" | base64 -d > "$HOME/.gnupg/secring.gpg" - env: - KEY: ${{ secrets.JAR_SIGNING_KEY }} - - # Prefer env variables here rather than inline ${{ secrets.FOO }} to - # decrease the likelihood that secrets end up printed to stdout. - - name: Set up secret gradle properties - run: | - mkdir -p "$HOME/.gradle" - envsubst >"$HOME/.gradle/gradle.properties" < temporal-test-server_1.2.3_linux_amd64 - # the name of the directory created becomes the basename of the archive (*.tar.gz or *.zip) and - # the root directory of the contents of the archive. - - name: Rename dirs - run: | - version="$(sed 's/^v//'<<<"$INPUT_TAG")" - for dir in *; do mv "$dir" "temporal-test-server_${version}_${dir}"; done - - - name: Tar (linux, macOS) - run: for dir in *{linux,macOS}*; do tar cvzf "${dir}.tar.gz" "$dir"; done - - - name: Zip (windows) - run: for dir in *windows*; do zip -r "${dir}.zip" "$dir"; done - - - name: Upload release archives - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: release-archives - path: | - *.zip - *.tar.gz - if-no-files-found: error - retention-days: 1 - - - name: Upload - run: | - until gh release upload --clobber --repo $GITHUB_REPOSITORY "$INPUT_TAG" *.zip *.tar.gz; do - echo "Failed to upload release artifacts. Will retry in 20s" - sleep 20 - done - timeout-minutes: 10 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 8628e4b274..ca8b4d3053 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -15,6 +15,7 @@ on: - 'main' paths-ignore: - 'releases/**' + - 'CHANGELOG.md' - 'docker/buildkite/**' - '.buildkite/**' - '.github/**' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..dfa03fafae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,201 @@ +name: Release + +defaults: + run: + shell: bash -euo pipefail {0} + +on: + pull_request: + branches: [main, "releases/**", "v*.*.x", "*.*.x", "release_*_*_x"] + paths: [CHANGELOG.md] + push: + branches: [main, "releases/**", "v*.*.x", "*.*.x", "release_*_*_x"] + paths: [CHANGELOG.md] + +permissions: + contents: read + +jobs: + candidate: + name: Resolve release candidate + runs-on: ubuntu-latest + outputs: + commit: ${{ steps.metadata.outputs.commit }} + prerelease: ${{ steps.metadata.outputs.prerelease }} + release: ${{ steps.metadata.outputs.release }} + tag: ${{ steps.metadata.outputs.tag }} + version: ${{ steps.metadata.outputs.version }} + steps: + - name: Checkout candidate + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Validate changelog transition + id: metadata + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + EVENT_NAME: ${{ github.event_name }} + HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: .github/scripts/release.sh candidate "$RUNNER_TEMP/release-notes.md" + + - name: Upload exact release notes + if: github.event_name == 'push' && steps.metadata.outputs.release == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: release-notes-${{ steps.metadata.outputs.commit }} + path: ${{ runner.temp }}/release-notes.md + if-no-files-found: error + overwrite: true + retention-days: 90 + + - name: Summarize candidate + if: steps.metadata.outputs.release == 'true' + env: + RELEASE_COMMIT: ${{ steps.metadata.outputs.commit }} + RELEASE_TAG: ${{ steps.metadata.outputs.tag }} + run: | + { + echo "## Release candidate" + echo + echo "- Tag: \`$RELEASE_TAG\`" + echo "- Commit: \`$RELEASE_COMMIT\`" + } >> "$GITHUB_STEP_SUMMARY" + + build_maven: + name: Test and inspect Maven publications + if: github.event_name == 'push' && needs.candidate.outputs.release == 'true' + needs: candidate + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout exact release commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ needs.candidate.outputs.commit }} + submodules: recursive + + - name: Set up Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: "23" + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + + - name: Test and inspect Maven publications + env: + RELEASE_COMMIT: ${{ needs.candidate.outputs.commit }} + RELEASE_VERSION: ${{ needs.candidate.outputs.version }} + run: >- + .github/scripts/release.sh build-maven + "$RUNNER_TEMP/maven-repository" + + build_native: + name: Build native test server + if: github.event_name == 'push' && needs.candidate.outputs.release == 'true' + needs: candidate + uses: ./.github/workflows/build-native-image.yml + with: + artifact_prefix: release-native- + ref: ${{ needs.candidate.outputs.commit }} + upload_artifact: true + + package_native: + name: Package native release assets + if: github.event_name == 'push' && needs.candidate.outputs.release == 'true' + needs: [candidate, build_native] + runs-on: ubuntu-latest + permissions: + actions: read + steps: + - name: Checkout exact release commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ needs.candidate.outputs.commit }} + + - name: Download native executables + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: release-native-* + path: native + + - name: Package native executables + env: + RELEASE_VERSION: ${{ needs.candidate.outputs.version }} + run: .github/scripts/package-native-release.sh native release-assets + + - name: Preserve exact release assets + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: release-assets-${{ needs.candidate.outputs.commit }} + path: release-assets + if-no-files-found: error + overwrite: true + retention-days: 90 + + publish: + name: Publish ${{ needs.candidate.outputs.tag }} + if: github.event_name == 'push' && needs.candidate.outputs.release == 'true' + needs: [candidate, build_maven, package_native] + runs-on: ubuntu-latest + timeout-minutes: 150 + concurrency: + group: sdk-java-release-publication + cancel-in-progress: false + environment: + name: release-publication + permissions: + actions: read + contents: write + env: + GRADLE_USER_HOME: ${{ runner.temp }}/release-gradle-home + RELEASE_COMMIT: ${{ needs.candidate.outputs.commit }} + RELEASE_TAG: ${{ needs.candidate.outputs.tag }} + RELEASE_VERSION: ${{ needs.candidate.outputs.version }} + steps: + - name: Checkout exact release commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ needs.candidate.outputs.commit }} + submodules: recursive + + - name: Download release inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: ${{ format('*-{0}', needs.candidate.outputs.commit) }} + path: release-inputs + + - name: Set up Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: "23" + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + + - name: Publish and verify Maven artifacts + env: + KEY: ${{ secrets.JAR_SIGNING_KEY }} + KEY_ID: ${{ secrets.JAR_SIGNING_KEY_ID }} + KEY_PASSWORD: ${{ secrets.JAR_SIGNING_KEY_PASSWORD }} + MAVEN_RETRY_COMMIT: ${{ vars.MAVEN_RETRY_COMMIT }} + RH_PASSWORD: ${{ secrets.RH_PASSWORD }} + RH_USER: ${{ secrets.RH_USER }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: .github/scripts/release.sh publish-maven + + - name: Publish and verify GitHub release + env: + GH_TOKEN: ${{ github.token }} + PRERELEASE: ${{ needs.candidate.outputs.prerelease }} + run: >- + .github/scripts/release.sh publish-github + "release-inputs/release-notes-$RELEASE_COMMIT/release-notes.md" + "release-inputs/release-assets-$RELEASE_COMMIT" diff --git a/CHANGELOG.md b/CHANGELOG.md index af4bac6c19..266822abbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ appropriate heading (create the heading if it does not yet exist). Within each heading content can be free-form. Feel free to include examples, links to docs, or any other relevant information. +A release PR moves the entries being released into exactly one new heading in +the form `## [X.Y.Z] - YYYY-MM-DD` (or `X.Y.Z-RCN` for a release candidate) and +leaves an `## [Unreleased]` section above it. Versioned sections are immutable. + ### :boom: Breaking Changes — removed or backwards-incompatible features ### Added — new features ### Changed — changes in existing functionality diff --git a/gradle/publishing.gradle b/gradle/publishing.gradle index fdde671b93..7de6d16287 100644 --- a/gradle/publishing.gradle +++ b/gradle/publishing.gradle @@ -1,4 +1,21 @@ +import java.time.Duration + +def releaseCommit = rootProject.findProperty('releaseCommit')?.toString()?.toLowerCase() +if (releaseCommit != null && !(releaseCommit ==~ /^[0-9a-f]{40}$/)) { + throw new GradleException( + "Invalid releaseCommit '${releaseCommit}'. Expected a full 40-character commit SHA.") +} + nexusPublishing { + if (releaseCommit != null) { + repositoryDescription = "sdk-java:${releaseCommit}" + } + transitionCheckOptions { + // Central transitions can take several minutes. + maxRetries = 180 + delayBetween = Duration.ofSeconds(10) + } + // to release to sonatype use ./gradlew publishToSonatype repositories { sonatype { @@ -53,6 +70,9 @@ subprojects { connection = 'scm:git@github.com:temporalio/sdk-java.git' developerConnection = 'scm:git@github.com:temporalio/sdk-java.git' url = 'https://github.com/temporalio/sdk-java.git' + if (releaseCommit != null) { + tag = releaseCommit + } } licenses { diff --git a/gradle/versioning.gradle b/gradle/versioning.gradle index 7cdddffae3..e4017d5234 100644 --- a/gradle/versioning.gradle +++ b/gradle/versioning.gradle @@ -21,6 +21,15 @@ ext.getTag = { -> // 0.20.2-RC1-g000a42a -> 0.20.2-SNAPSHOT // 0.20.2-RC1-somepostfix-g000a42a -> 0.20.2-SNAPSHOT ext.getVersionName = { -> + if (rootProject.hasProperty('releaseVersion')) { + String releaseVersion = rootProject.property('releaseVersion').toString() + if (!(releaseVersion ==~ /^\d+[.]\d+[.]\d+(?:-RC\d+)?$/)) { + throw new GradleException( + "Invalid releaseVersion '${releaseVersion}'. Expected X.Y.Z or X.Y.Z-RCN.") + } + return releaseVersion + } + String tag = getTag() // The last element of describe should start with g according to git describe format @@ -57,4 +66,4 @@ version = getVersionName() subprojects { group = 'io.temporal' version = getVersionName() -} \ No newline at end of file +} From 74cb003d4102398bf8166ea6e9339fb4e5820d67 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Mon, 5 Oct 2026 16:16:48 -0500 Subject: [PATCH 02/12] Support draft GitHub releases --- .github/scripts/release.sh | 34 +++++++++++++++++++++++++--------- .github/workflows/README.md | 6 ++++++ .github/workflows/release.yml | 1 + 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 713bb1fcec..f198d6db9f 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -205,36 +205,52 @@ publish_github() { require RELEASE_VERSION local notes=${1:?Release notes path is required.} local assets=${2:?Release asset directory is required.} - local release tag + local draft_release=${DRAFT_RELEASE:-0} + local is_draft=false + local release tag summary_title local release_assets=("$assets"/*) + case "$draft_release" in + 0) ;; + 1) is_draft=true ;; + *) fail "DRAFT_RELEASE must be 0, 1, or unset." ;; + esac [[ "${#release_assets[@]}" -gt 0 ]] || fail "No GitHub release assets were produced." if ! release=$(gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" \ - --json body,isDraft,isPrerelease,name,url 2>/dev/null); then + --json body,isDraft,isPrerelease,name,targetCommitish,url 2>/dev/null); then create=(release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ --title "$RELEASE_TAG" --target "$RELEASE_COMMIT" --notes-file "$notes") + [[ "$is_draft" == true ]] && create+=(--draft) [[ "$PRERELEASE" == true ]] && create+=(--prerelease) create+=("${release_assets[@]}") gh "${create[@]}" release=$(gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" \ - --json body,isDraft,isPrerelease,name,url) + --json body,isDraft,isPrerelease,name,targetCommitish,url) fi - jq -e --arg tag "$RELEASE_TAG" --argjson prerelease "$PRERELEASE" \ + jq -e --arg tag "$RELEASE_TAG" --argjson draft "$is_draft" \ + --argjson prerelease "$PRERELEASE" \ --rawfile notes "$notes" \ 'def normalized: gsub("\r"; "") | sub("\n+$"; ""); .name == $tag and ((.body | normalized) == ($notes | normalized)) and - .isDraft == false and .isPrerelease == $prerelease' <<<"$release" >/dev/null - tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG") - jq -e --arg commit "$RELEASE_COMMIT" \ - '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null + .isDraft == $draft and .isPrerelease == $prerelease' <<<"$release" >/dev/null + if tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" 2>/dev/null); then + jq -e --arg commit "$RELEASE_COMMIT" \ + '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null + else + [[ "$is_draft" == true ]] || fail "Published release tag $RELEASE_TAG is missing." + jq -e --arg commit "$RELEASE_COMMIT" \ + '.targetCommitish == $commit' <<<"$release" >/dev/null + fi published_assets=$(mktemp -d) trap 'rm -rf "$published_assets"' EXIT gh release download --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" --dir "$published_assets" diff -qr "$assets" "$published_assets" + summary_title="Release published" + [[ "$is_draft" == true ]] && summary_title="Release drafted" { - echo "## Release published" + echo "## $summary_title" echo echo "- GitHub: $(jq -r .url <<<"$release")" echo "- Maven: https://central.sonatype.com/artifact/io.temporal/temporal-sdk/$RELEASE_VERSION" diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 9ec613500d..29a220eed6 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -26,6 +26,12 @@ GitHub release and tag. Release candidates are always bound to the full merge commit SHA. RC versions use headings such as `## [1.41.0-RC1] - 2026-10-03` and are published as GitHub prereleases. +Set the `release-publication` environment variable `DRAFT_RELEASE` to `1` to +leave the GitHub release as a draft for final inspection. The Maven artifacts +are still published to Maven Central and cannot be recalled. Clear the variable +or set it to `0` for the normal public GitHub release; other values fail the +publication job. + An ordinary pull request that only adds entries beneath `[Unreleased]` runs the candidate check but does not start a release. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfa03fafae..b2d3bb5876 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -193,6 +193,7 @@ jobs: - name: Publish and verify GitHub release env: + DRAFT_RELEASE: ${{ vars.DRAFT_RELEASE }} GH_TOKEN: ${{ github.token }} PRERELEASE: ${{ needs.candidate.outputs.prerelease }} run: >- From 164ae05e0d18c75134a64efc5b1aa8837bbb2e4b Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Mon, 5 Oct 2026 16:31:20 -0500 Subject: [PATCH 03/12] Allow manual release dispatches --- .github/scripts/release.sh | 43 ++++++++++++++++++++++++++++++--- .github/scripts/test-release.sh | 23 ++++++++++++++++++ .github/workflows/README.md | 10 ++++++++ .github/workflows/release.yml | 37 ++++++++++++++++++++++++---- 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index f198d6db9f..6c8ece747b 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -51,11 +51,36 @@ changelog_section() { # Validates a changelog transition and identifies a release candidate. candidate() { - require BASE_SHA require EVENT_NAME require HEAD_SHA local notes=${1:?Release notes output path is required.} - local base head version tag existing + local base dispatch_head draft_release existing release_commit tag version + + if [[ "$EVENT_NAME" == workflow_dispatch ]]; then + require DEFAULT_BRANCH + require MANUAL_DRAFT_RELEASE + require MANUAL_REF + require MANUAL_VERSION + [[ "$MANUAL_REF" == "$DEFAULT_BRANCH" ]] \ + || fail "Manual releases must run from $DEFAULT_BRANCH." + [[ "$MANUAL_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ ]] \ + || fail "Manual release version must look like 1.2.3 or 1.2.3-RC1." + case "$MANUAL_DRAFT_RELEASE" in + true) draft_release=1 ;; + false) draft_release=0 ;; + *) fail "Manual draft selection must be true or false." ;; + esac + + dispatch_head=$(git rev-parse --verify "$HEAD_SHA^{commit}") + release_commit=$(git log -1 --first-parent --format=%H --fixed-strings \ + -S"## [$MANUAL_VERSION]" "$dispatch_head" -- CHANGELOG.md) + [[ -n "$release_commit" ]] \ + || fail "Version $MANUAL_VERSION was not introduced on $DEFAULT_BRANCH." + BASE_SHA=$(git rev-parse --verify "$release_commit^1") + HEAD_SHA=$release_commit + else + require BASE_SHA + fi if [[ "$BASE_SHA" =~ ^0+$ ]]; then BASE_SHA=$(git rev-parse "$HEAD_SHA^") @@ -84,6 +109,13 @@ candidate() { || fail "A release commit must add exactly one versioned changelog section." version=${added[0]} + if [[ "$EVENT_NAME" == workflow_dispatch ]]; then + [[ "$version" == "$MANUAL_VERSION" ]] \ + || fail "Commit $head does not introduce version $MANUAL_VERSION." + diff -q <(changelog_section "$head" "$version" true) \ + <(changelog_section "$dispatch_head" "$version" true) >/dev/null \ + || fail "Release notes for $version changed after commit $head." + fi changelog_section "$head" "$version" > "$notes" [[ -s "$notes" ]] || fail "Release notes for $version are empty." git show "$head:CHANGELOG.md" | awk -v release="## [$version]" ' @@ -94,10 +126,13 @@ candidate() { tag="v$version" if existing=$(git rev-parse --verify "refs/tags/$tag^{commit}" 2>/dev/null); then - [[ "$EVENT_NAME" == push && "$existing" == "$head" ]] \ + [[ ("$EVENT_NAME" == push || "$EVENT_NAME" == workflow_dispatch) && \ + "$existing" == "$head" ]] \ || fail "Release tag $tag already exists at $existing." fi write_output release true + [[ "$EVENT_NAME" == workflow_dispatch ]] \ + && write_output draft_release "$draft_release" write_output version "$version" write_output tag "$tag" write_output commit "$head" @@ -205,7 +240,7 @@ publish_github() { require RELEASE_VERSION local notes=${1:?Release notes path is required.} local assets=${2:?Release asset directory is required.} - local draft_release=${DRAFT_RELEASE:-0} + local draft_release=${MANUAL_DRAFT_RELEASE:-${DRAFT_RELEASE:-0}} local is_draft=false local release tag summary_title local release_assets=("$assets"/*) diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh index d818392deb..ccfbf25776 100755 --- a/.github/scripts/test-release.sh +++ b/.github/scripts/test-release.sh @@ -39,6 +39,29 @@ head=$(git -C "$repository" rev-parse HEAD) grep -Fxq 'release=true' "$temporary_directory/output" grep -Fxq 'version=1.41.0' "$temporary_directory/output" grep -Fxq '### Fixed' "$temporary_directory/notes" +printf 'Later change.\n' > "$repository/README.md" +git -C "$repository" add README.md +git -C "$repository" commit -qm later +later=$(git -C "$repository" rev-parse HEAD) +( + cd "$repository" + DEFAULT_BRANCH=main EVENT_NAME=workflow_dispatch HEAD_SHA="$later" \ + MANUAL_DRAFT_RELEASE=true MANUAL_REF=main MANUAL_VERSION=1.41.0 \ + GITHUB_OUTPUT="$temporary_directory/manual-output" \ + "$release_script" candidate "$temporary_directory/manual-notes" +) +grep -Fxq 'release=true' "$temporary_directory/manual-output" +grep -Fxq "commit=$head" "$temporary_directory/manual-output" +grep -Fxq 'draft_release=1' "$temporary_directory/manual-output" +diff -q "$temporary_directory/notes" "$temporary_directory/manual-notes" +( + cd "$repository" + DEFAULT_BRANCH=main EVENT_NAME=workflow_dispatch HEAD_SHA="$later" \ + MANUAL_DRAFT_RELEASE=false MANUAL_REF=main MANUAL_VERSION=1.41.0 \ + GITHUB_OUTPUT="$temporary_directory/manual-public-output" \ + "$release_script" candidate "$temporary_directory/manual-public-notes" +) +grep -Fxq 'draft_release=0' "$temporary_directory/manual-public-output" printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-04\n\n### Fixed\n- Fixed it.\n' \ > "$repository/CHANGELOG.md" git -C "$repository" commit -qam rewrite diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 29a220eed6..2408a9c1e1 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -35,6 +35,16 @@ publication job. An ordinary pull request that only adds entries beneath `[Unreleased]` runs the candidate check but does not start a release. +To run an existing release candidate manually, open the **Release** workflow, +select **Run workflow** on `main`, enter the version without its leading `v`, +and choose whether to hold the GitHub Release as a draft. The version must +already have a versioned `CHANGELOG.md` section. The workflow finds the exact +first-parent commit that introduced that section and runs it through the same +validation, build, approval, and publication jobs as an automatic release. +The manual draft selection overrides `DRAFT_RELEASE` for that run. It affects +only the GitHub Release; Maven artifacts are published after approval either +way. + Maintainers can check the release scripts locally with `.github/scripts/test-release.sh`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2d3bb5876..6cede2bf52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,17 @@ on: push: branches: [main, "releases/**", "v*.*.x", "*.*.x", "release_*_*_x"] paths: [CHANGELOG.md] + workflow_dispatch: + inputs: + version: + description: Version already present in CHANGELOG.md, without a leading v + required: true + type: string + draft_release: + description: Hold the GitHub Release as a draft after publishing Maven artifacts + required: true + default: true + type: boolean permissions: contents: read @@ -21,6 +32,7 @@ jobs: runs-on: ubuntu-latest outputs: commit: ${{ steps.metadata.outputs.commit }} + draft_release: ${{ steps.metadata.outputs.draft_release }} prerelease: ${{ steps.metadata.outputs.prerelease }} release: ${{ steps.metadata.outputs.release }} tag: ${{ steps.metadata.outputs.tag }} @@ -36,12 +48,18 @@ jobs: id: metadata env: BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_NAME: ${{ github.event_name }} HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + MANUAL_DRAFT_RELEASE: ${{ inputs.draft_release }} + MANUAL_REF: ${{ github.ref_name }} + MANUAL_VERSION: ${{ inputs.version }} run: .github/scripts/release.sh candidate "$RUNNER_TEMP/release-notes.md" - name: Upload exact release notes - if: github.event_name == 'push' && steps.metadata.outputs.release == 'true' + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + steps.metadata.outputs.release == 'true' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: release-notes-${{ steps.metadata.outputs.commit }} @@ -65,7 +83,9 @@ jobs: build_maven: name: Test and inspect Maven publications - if: github.event_name == 'push' && needs.candidate.outputs.release == 'true' + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + needs.candidate.outputs.release == 'true' needs: candidate runs-on: ubuntu-latest timeout-minutes: 90 @@ -96,7 +116,9 @@ jobs: build_native: name: Build native test server - if: github.event_name == 'push' && needs.candidate.outputs.release == 'true' + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + needs.candidate.outputs.release == 'true' needs: candidate uses: ./.github/workflows/build-native-image.yml with: @@ -106,7 +128,9 @@ jobs: package_native: name: Package native release assets - if: github.event_name == 'push' && needs.candidate.outputs.release == 'true' + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + needs.candidate.outputs.release == 'true' needs: [candidate, build_native] runs-on: ubuntu-latest permissions: @@ -140,7 +164,9 @@ jobs: publish: name: Publish ${{ needs.candidate.outputs.tag }} - if: github.event_name == 'push' && needs.candidate.outputs.release == 'true' + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + needs.candidate.outputs.release == 'true' needs: [candidate, build_maven, package_native] runs-on: ubuntu-latest timeout-minutes: 150 @@ -195,6 +221,7 @@ jobs: env: DRAFT_RELEASE: ${{ vars.DRAFT_RELEASE }} GH_TOKEN: ${{ github.token }} + MANUAL_DRAFT_RELEASE: ${{ needs.candidate.outputs.draft_release }} PRERELEASE: ${{ needs.candidate.outputs.prerelease }} run: >- .github/scripts/release.sh publish-github From 3fd6c7d3a73ffa7a917f17cd245160308cf10fb1 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 8 Oct 2026 11:12:53 -0500 Subject: [PATCH 04/12] Fix release workflow runner context --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cede2bf52..97b83a1b07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,7 +179,6 @@ jobs: actions: read contents: write env: - GRADLE_USER_HOME: ${{ runner.temp }}/release-gradle-home RELEASE_COMMIT: ${{ needs.candidate.outputs.commit }} RELEASE_TAG: ${{ needs.candidate.outputs.tag }} RELEASE_VERSION: ${{ needs.candidate.outputs.version }} @@ -208,6 +207,7 @@ jobs: - name: Publish and verify Maven artifacts env: + GRADLE_USER_HOME: ${{ runner.temp }}/release-gradle-home KEY: ${{ secrets.JAR_SIGNING_KEY }} KEY_ID: ${{ secrets.JAR_SIGNING_KEY_ID }} KEY_PASSWORD: ${{ secrets.JAR_SIGNING_KEY_PASSWORD }} From e13bc3d663af55705ee15697c894e52d556b6292 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 8 Oct 2026 11:43:54 -0500 Subject: [PATCH 05/12] Publish GitHub releases before Maven --- .github/scripts/release.sh | 37 +++++++++++++++++++++++------------ .github/workflows/README.md | 29 +++++++++++++++------------ .github/workflows/release.yml | 26 +++++++++++++----------- 3 files changed, 55 insertions(+), 37 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 6c8ece747b..75ad9fb132 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -237,16 +237,15 @@ publish_github() { require PRERELEASE require RELEASE_COMMIT require RELEASE_TAG - require RELEASE_VERSION local notes=${1:?Release notes path is required.} local assets=${2:?Release asset directory is required.} - local draft_release=${MANUAL_DRAFT_RELEASE:-${DRAFT_RELEASE:-0}} - local is_draft=false - local release tag summary_title + local draft_release=${MANUAL_DRAFT_RELEASE:-${DRAFT_RELEASE:-1}} + local requested_draft=false + local actual_draft published_assets release summary_title tag local release_assets=("$assets"/*) case "$draft_release" in 0) ;; - 1) is_draft=true ;; + 1) requested_draft=true ;; *) fail "DRAFT_RELEASE must be 0, 1, or unset." ;; esac [[ "${#release_assets[@]}" -gt 0 ]] || fail "No GitHub release assets were produced." @@ -255,7 +254,7 @@ publish_github() { --json body,isDraft,isPrerelease,name,targetCommitish,url 2>/dev/null); then create=(release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ --title "$RELEASE_TAG" --target "$RELEASE_COMMIT" --notes-file "$notes") - [[ "$is_draft" == true ]] && create+=(--draft) + [[ "$requested_draft" == true ]] && create+=(--draft) [[ "$PRERELEASE" == true ]] && create+=(--prerelease) create+=("${release_assets[@]}") gh "${create[@]}" @@ -263,32 +262,44 @@ publish_github() { --json body,isDraft,isPrerelease,name,targetCommitish,url) fi - jq -e --arg tag "$RELEASE_TAG" --argjson draft "$is_draft" \ - --argjson prerelease "$PRERELEASE" \ + jq -e --arg tag "$RELEASE_TAG" --argjson prerelease "$PRERELEASE" \ --rawfile notes "$notes" \ 'def normalized: gsub("\r"; "") | sub("\n+$"; ""); .name == $tag and ((.body | normalized) == ($notes | normalized)) and - .isDraft == $draft and .isPrerelease == $prerelease' <<<"$release" >/dev/null + .isPrerelease == $prerelease' <<<"$release" >/dev/null + actual_draft=$(jq -r .isDraft <<<"$release") if tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" 2>/dev/null); then jq -e --arg commit "$RELEASE_COMMIT" \ '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null else - [[ "$is_draft" == true ]] || fail "Published release tag $RELEASE_TAG is missing." + [[ "$actual_draft" == true ]] || fail "Published release tag $RELEASE_TAG is missing." jq -e --arg commit "$RELEASE_COMMIT" \ '.targetCommitish == $commit' <<<"$release" >/dev/null fi published_assets=$(mktemp -d) - trap 'rm -rf "$published_assets"' EXIT + trap "rm -rf -- '$published_assets'" EXIT gh release download --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" --dir "$published_assets" diff -qr "$assets" "$published_assets" + + if [[ "$requested_draft" == false && "$actual_draft" == true ]]; then + gh release edit --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" --draft=false + release=$(gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" \ + --json body,isDraft,isPrerelease,name,targetCommitish,url) + actual_draft=$(jq -r .isDraft <<<"$release") + [[ "$actual_draft" == false ]] || fail "GitHub release $RELEASE_TAG remains a draft." + tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG") + jq -e --arg commit "$RELEASE_COMMIT" \ + '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null + fi + + write_output draft "$actual_draft" summary_title="Release published" - [[ "$is_draft" == true ]] && summary_title="Release drafted" + [[ "$actual_draft" == true ]] && summary_title="Release drafted" { echo "## $summary_title" echo echo "- GitHub: $(jq -r .url <<<"$release")" - echo "- Maven: https://central.sonatype.com/artifact/io.temporal/temporal-sdk/$RELEASE_VERSION" echo "- Commit: \`$RELEASE_COMMIT\`" } >> "$GITHUB_STEP_SUMMARY" } diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 2408a9c1e1..aa666aca8d 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -20,17 +20,20 @@ open the workflow run and select **Review deployments** to approve or reject it. Notification delivery outside GitHub depends on each reviewer's settings or an optional Slack or Microsoft Teams deployment integration. -The workflow then releases the signed Java artifacts through Sonatype, verifies -the `temporal-sdk` POM and its exact commit on Maven Central, and publishes the -GitHub release and tag. Release candidates are always bound to the full merge -commit SHA. RC versions use headings such as +The workflow creates and verifies the GitHub release before publishing any +Maven artifacts. Draft GitHub releases are the default and leave Maven +unpublished. Release candidates are always bound to the full merge commit SHA. +RC versions use headings such as `## [1.41.0-RC1] - 2026-10-03` and are published as GitHub prereleases. -Set the `release-publication` environment variable `DRAFT_RELEASE` to `1` to -leave the GitHub release as a draft for final inspection. The Maven artifacts -are still published to Maven Central and cannot be recalled. Clear the variable -or set it to `0` for the normal public GitHub release; other values fail the -publication job. +Leave the `release-publication` environment variable `DRAFT_RELEASE` unset, or +set it to `1`, to keep the GitHub release as a draft for final inspection. Set +it to `0` to publish the GitHub release and then Maven in the same run. Other +values fail the publication job. To finish a draft later, manually run the +**Release** workflow for the same version with the draft option cleared. The +workflow validates and publishes the existing GitHub draft before publishing +Maven. Publishing the draft in the GitHub UI first is also safe; rerun the +workflow afterward to publish Maven. An ordinary pull request that only adds entries beneath `[Unreleased]` runs the candidate check but does not start a release. @@ -41,9 +44,8 @@ and choose whether to hold the GitHub Release as a draft. The version must already have a versioned `CHANGELOG.md` section. The workflow finds the exact first-parent commit that introduced that section and runs it through the same validation, build, approval, and publication jobs as an automatic release. -The manual draft selection overrides `DRAFT_RELEASE` for that run. It affects -only the GitHub Release; Maven artifacts are published after approval either -way. +The manual draft selection overrides `DRAFT_RELEASE` for that run. Maven is +published only after the GitHub release is public. Maintainers can check the release scripts locally with `.github/scripts/test-release.sh`. @@ -68,7 +70,8 @@ Sonatype staging API. ### Recovery -Jobs before Maven publication are safe to rerun. A rerun also continues after +Jobs before GitHub publication are safe to rerun. A draft GitHub release keeps +Maven unpublished. Once the GitHub release is public, a rerun continues after Maven publication when the `temporal-sdk` POM is on Maven Central and its `scm.tag` matches the release commit. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97b83a1b07..2462f8a4b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -196,16 +196,31 @@ jobs: pattern: ${{ format('*-{0}', needs.candidate.outputs.commit) }} path: release-inputs + - name: Publish or draft GitHub release + id: github_release + env: + DRAFT_RELEASE: ${{ vars.DRAFT_RELEASE }} + GH_TOKEN: ${{ github.token }} + MANUAL_DRAFT_RELEASE: ${{ needs.candidate.outputs.draft_release }} + PRERELEASE: ${{ needs.candidate.outputs.prerelease }} + run: >- + .github/scripts/release.sh publish-github + "release-inputs/release-notes-$RELEASE_COMMIT/release-notes.md" + "release-inputs/release-assets-$RELEASE_COMMIT" + - name: Set up Java + if: steps.github_release.outputs.draft == 'false' uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: java-version: "23" distribution: temurin - name: Set up Gradle + if: steps.github_release.outputs.draft == 'false' uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - name: Publish and verify Maven artifacts + if: steps.github_release.outputs.draft == 'false' env: GRADLE_USER_HOME: ${{ runner.temp }}/release-gradle-home KEY: ${{ secrets.JAR_SIGNING_KEY }} @@ -216,14 +231,3 @@ jobs: RH_USER: ${{ secrets.RH_USER }} RUN_ATTEMPT: ${{ github.run_attempt }} run: .github/scripts/release.sh publish-maven - - - name: Publish and verify GitHub release - env: - DRAFT_RELEASE: ${{ vars.DRAFT_RELEASE }} - GH_TOKEN: ${{ github.token }} - MANUAL_DRAFT_RELEASE: ${{ needs.candidate.outputs.draft_release }} - PRERELEASE: ${{ needs.candidate.outputs.prerelease }} - run: >- - .github/scripts/release.sh publish-github - "release-inputs/release-notes-$RELEASE_COMMIT/release-notes.md" - "release-inputs/release-assets-$RELEASE_COMMIT" From 4f4d1d42acc466639c559f8b2ce94cf49fc24f13 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 8 Oct 2026 19:06:46 -0500 Subject: [PATCH 06/12] Support releases from backport branches --- .github/scripts/release.sh | 14 ++++++++------ .github/scripts/test-release.sh | 25 ++++++++++++++++++++++--- .github/workflows/README.md | 22 +++++++++++++--------- .github/workflows/release.yml | 3 +-- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 75ad9fb132..92e0c6b046 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -57,12 +57,9 @@ candidate() { local base dispatch_head draft_release existing release_commit tag version if [[ "$EVENT_NAME" == workflow_dispatch ]]; then - require DEFAULT_BRANCH require MANUAL_DRAFT_RELEASE require MANUAL_REF require MANUAL_VERSION - [[ "$MANUAL_REF" == "$DEFAULT_BRANCH" ]] \ - || fail "Manual releases must run from $DEFAULT_BRANCH." [[ "$MANUAL_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ ]] \ || fail "Manual release version must look like 1.2.3 or 1.2.3-RC1." case "$MANUAL_DRAFT_RELEASE" in @@ -75,7 +72,7 @@ candidate() { release_commit=$(git log -1 --first-parent --format=%H --fixed-strings \ -S"## [$MANUAL_VERSION]" "$dispatch_head" -- CHANGELOG.md) [[ -n "$release_commit" ]] \ - || fail "Version $MANUAL_VERSION was not introduced on $DEFAULT_BRANCH." + || fail "Version $MANUAL_VERSION was not introduced on $MANUAL_REF." BASE_SHA=$(git rev-parse --verify "$release_commit^1") HEAD_SHA=$release_commit else @@ -87,8 +84,13 @@ candidate() { fi base=$(git rev-parse --verify "$BASE_SHA^{commit}") head=$(git rev-parse --verify "$HEAD_SHA^{commit}") - git merge-base --is-ancestor "$base" "$head" \ - || fail "The base commit must be an ancestor of the release commit." + if [[ "$EVENT_NAME" == pull_request ]]; then + base=$(git merge-base "$base" "$head") \ + || fail "The pull request branches must have a common ancestor." + else + git merge-base --is-ancestor "$base" "$head" \ + || fail "The base commit must be an ancestor of the release commit." + fi mapfile -t base_versions < <(changelog_versions "$base") mapfile -t head_versions < <(changelog_versions "$head") diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh index ccfbf25776..7e5ea59064 100755 --- a/.github/scripts/test-release.sh +++ b/.github/scripts/test-release.sh @@ -26,6 +26,24 @@ base=$(git -C "$repository" rev-parse HEAD) ) grep -Fxq 'release=false' "$temporary_directory/no-release-output" +git -C "$repository" switch -qc feature +printf '# Changelog\n\n## [Unreleased]\n\n### Fixed\n- Fixed it.\n- Fixed on the feature branch.\n' \ + > "$repository/CHANGELOG.md" +git -C "$repository" commit -qam feature +feature=$(git -C "$repository" rev-parse HEAD) +git -C "$repository" switch -q - +printf 'The target branch advanced.\n' > "$repository/README.md" +git -C "$repository" add README.md +git -C "$repository" commit -qm advance +target=$(git -C "$repository" rev-parse HEAD) +( + cd "$repository" + BASE_SHA="$target" HEAD_SHA="$feature" EVENT_NAME=pull_request \ + GITHUB_OUTPUT="$temporary_directory/divergent-output" \ + "$release_script" candidate "$temporary_directory/divergent-notes" +) +grep -Fxq 'release=false' "$temporary_directory/divergent-output" + printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-03\n\n### Fixed\n- Fixed it.\n' \ > "$repository/CHANGELOG.md" git -C "$repository" commit -qam release @@ -43,10 +61,11 @@ printf 'Later change.\n' > "$repository/README.md" git -C "$repository" add README.md git -C "$repository" commit -qm later later=$(git -C "$repository" rev-parse HEAD) +git -C "$repository" branch releases/1.41.x "$later" ( cd "$repository" - DEFAULT_BRANCH=main EVENT_NAME=workflow_dispatch HEAD_SHA="$later" \ - MANUAL_DRAFT_RELEASE=true MANUAL_REF=main MANUAL_VERSION=1.41.0 \ + EVENT_NAME=workflow_dispatch HEAD_SHA="$later" \ + MANUAL_DRAFT_RELEASE=true MANUAL_REF=releases/1.41.x MANUAL_VERSION=1.41.0 \ GITHUB_OUTPUT="$temporary_directory/manual-output" \ "$release_script" candidate "$temporary_directory/manual-notes" ) @@ -56,7 +75,7 @@ grep -Fxq 'draft_release=1' "$temporary_directory/manual-output" diff -q "$temporary_directory/notes" "$temporary_directory/manual-notes" ( cd "$repository" - DEFAULT_BRANCH=main EVENT_NAME=workflow_dispatch HEAD_SHA="$later" \ + EVENT_NAME=workflow_dispatch HEAD_SHA="$later" \ MANUAL_DRAFT_RELEASE=false MANUAL_REF=main MANUAL_VERSION=1.41.0 \ GITHUB_OUTPUT="$temporary_directory/manual-public-output" \ "$release_script" candidate "$temporary_directory/manual-public-notes" diff --git a/.github/workflows/README.md b/.github/workflows/README.md index aa666aca8d..3964153da3 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -39,13 +39,14 @@ An ordinary pull request that only adds entries beneath `[Unreleased]` runs the candidate check but does not start a release. To run an existing release candidate manually, open the **Release** workflow, -select **Run workflow** on `main`, enter the version without its leading `v`, -and choose whether to hold the GitHub Release as a draft. The version must -already have a versioned `CHANGELOG.md` section. The workflow finds the exact -first-parent commit that introduced that section and runs it through the same -validation, build, approval, and publication jobs as an automatic release. -The manual draft selection overrides `DRAFT_RELEASE` for that run. Maven is -published only after the GitHub release is public. +select **Run workflow** on `main` or a supported backport branch, enter the +version without its leading `v`, and choose whether to hold the GitHub Release +as a draft. The version must already have a versioned `CHANGELOG.md` section. +The workflow finds the exact first-parent commit on the selected branch that +introduced that section and runs it through the same validation, build, +approval, and publication jobs as an automatic release. The manual draft +selection overrides `DRAFT_RELEASE` for that run. Maven is published only after +the GitHub release is public. Maintainers can check the release scripts locally with `.github/scripts/test-release.sh`. @@ -55,8 +56,11 @@ Maintainers can check the release scripts locally with Configure the existing `release-publication` environment with required reviewers. GitHub permits up to six users or teams with repository read access, and one listed reviewer must approve. Preventing self-review is recommended. -Keep its deployment branch policy restricted to `main` and make these secrets -available to the workflow, preferably as environment secrets: +Restrict its deployment branch policy to `main` and the supported backport +patterns `releases/*`, `v*.*.x`, `*.*.x`, and `release_*_*_x`. Add an explicit +pattern for each additional slash-separated level used below `releases/`. +Make these secrets available to the workflow, preferably as environment +secrets: - `JAR_SIGNING_KEY` - `JAR_SIGNING_KEY_ID` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2462f8a4b7..ceab830765 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ on: required: true type: string draft_release: - description: Hold the GitHub Release as a draft after publishing Maven artifacts + description: Hold the GitHub Release as a draft and leave Maven unpublished required: true default: true type: boolean @@ -48,7 +48,6 @@ jobs: id: metadata env: BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_NAME: ${{ github.event_name }} HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} MANUAL_DRAFT_RELEASE: ${{ inputs.draft_release }} From 0a254b40802bc239e09eb83cf0618c1e39005415 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 8 Oct 2026 19:47:40 -0500 Subject: [PATCH 07/12] Harden release publication recovery --- .github/scripts/release.sh | 27 +++++++++++++++++++-------- .github/scripts/test-release.sh | 7 +++++++ .github/workflows/README.md | 6 ++++++ .github/workflows/release.yml | 3 ++- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 92e0c6b046..74df24f79f 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -128,6 +128,8 @@ candidate() { tag="v$version" if existing=$(git rev-parse --verify "refs/tags/$tag^{commit}" 2>/dev/null); then + [[ $(git cat-file -t "refs/tags/$tag") == commit ]] \ + || fail "Release tag $tag must be a lightweight tag." [[ ("$EVENT_NAME" == push || "$EVENT_NAME" == workflow_dispatch) && \ "$existing" == "$head" ]] \ || fail "Release tag $tag already exists at $existing." @@ -183,18 +185,22 @@ central_state() { # Publishes the signed staging repository and waits for Maven Central. publish_maven() { require GRADLE_USER_HOME + require MAVEN_RETRY_REQUIRED require RELEASE_COMMIT require RELEASE_VERSION - require RUN_ATTEMPT require RUNNER_TEMP local state variable signing_directory status attempt state=$(central_state) [[ "$state" == absent ]] || return - [[ "$RUN_ATTEMPT" =~ ^[0-9]+$ ]] || fail "RUN_ATTEMPT must be numeric." - if ((RUN_ATTEMPT > 1)) && [[ "${MAVEN_RETRY_COMMIT:-}" != "$RELEASE_COMMIT" ]]; then - fail "Inspect Sonatype, then set MAVEN_RETRY_COMMIT to $RELEASE_COMMIT before rerunning." - fi + case "$MAVEN_RETRY_REQUIRED" in + false) ;; + true) + [[ "${MAVEN_RETRY_COMMIT:-}" == "$RELEASE_COMMIT" ]] \ + || fail "Inspect Sonatype, then set MAVEN_RETRY_COMMIT to $RELEASE_COMMIT before rerunning." + ;; + *) fail "MAVEN_RETRY_REQUIRED must be true or false." ;; + esac for variable in KEY KEY_ID KEY_PASSWORD RH_PASSWORD RH_USER; do [[ -n "${!variable:-}" ]] || fail "Release secret $variable is not configured." done @@ -243,7 +249,7 @@ publish_github() { local assets=${2:?Release asset directory is required.} local draft_release=${MANUAL_DRAFT_RELEASE:-${DRAFT_RELEASE:-1}} local requested_draft=false - local actual_draft published_assets release summary_title tag + local actual_draft maven_retry_required=false published_assets release summary_title tag local release_assets=("$assets"/*) case "$draft_release" in 0) ;; @@ -262,6 +268,8 @@ publish_github() { gh "${create[@]}" release=$(gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" \ --json body,isDraft,isPrerelease,name,targetCommitish,url) + elif [[ $(jq -r .isDraft <<<"$release") == false ]]; then + maven_retry_required=true fi jq -e --arg tag "$RELEASE_TAG" --argjson prerelease "$PRERELEASE" \ @@ -272,7 +280,8 @@ publish_github() { actual_draft=$(jq -r .isDraft <<<"$release") if tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" 2>/dev/null); then jq -e --arg commit "$RELEASE_COMMIT" \ - '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null + '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null \ + || fail "Release tag $RELEASE_TAG must be lightweight and point to $RELEASE_COMMIT." else [[ "$actual_draft" == true ]] || fail "Published release tag $RELEASE_TAG is missing." jq -e --arg commit "$RELEASE_COMMIT" \ @@ -292,10 +301,12 @@ publish_github() { [[ "$actual_draft" == false ]] || fail "GitHub release $RELEASE_TAG remains a draft." tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG") jq -e --arg commit "$RELEASE_COMMIT" \ - '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null + '.object.type == "commit" and .object.sha == $commit' <<<"$tag" >/dev/null \ + || fail "Release tag $RELEASE_TAG must be lightweight and point to $RELEASE_COMMIT." fi write_output draft "$actual_draft" + write_output maven_retry_required "$maven_retry_required" summary_title="Release published" [[ "$actual_draft" == true ]] && summary_title="Release drafted" { diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh index 7e5ea59064..a794762d6d 100755 --- a/.github/scripts/test-release.sh +++ b/.github/scripts/test-release.sh @@ -57,6 +57,13 @@ head=$(git -C "$repository" rev-parse HEAD) grep -Fxq 'release=true' "$temporary_directory/output" grep -Fxq 'version=1.41.0' "$temporary_directory/output" grep -Fxq '### Fixed' "$temporary_directory/notes" +git -C "$repository" tag -a v1.41.0 -m "Annotated release tag" "$head" +if (cd "$repository" && BASE_SHA="$base" HEAD_SHA="$head" EVENT_NAME=push \ + "$release_script" candidate "$temporary_directory/annotated-notes" 2>/dev/null); then + echo "Annotated release tags must be rejected." >&2 + exit 1 +fi +git -C "$repository" tag -d v1.41.0 >/dev/null printf 'Later change.\n' > "$repository/README.md" git -C "$repository" add README.md git -C "$repository" commit -qm later diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 3964153da3..17d16d7ec9 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -50,6 +50,8 @@ the GitHub release is public. Maintainers can check the release scripts locally with `.github/scripts/test-release.sh`. +The local check requires Bash 4 or newer and GNU `sha256sum`; on macOS these +are available from the Homebrew `bash` and `coreutils` packages. ### One-time repository setup @@ -88,3 +90,7 @@ commit and rerun the workflow. Clear the variable after the release; its value is bound to that commit and cannot authorize another candidate. A published version or GitHub tag that points to another commit is a permanent error and must not be replaced. + +A run that finds an already-public GitHub release but no matching Central POM +uses the same recovery gate, even when it is a fresh manual run. Promote drafts +through the workflow to keep the first Maven publication attempt unambiguous. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ceab830765..f36961fd81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,6 +134,7 @@ jobs: runs-on: ubuntu-latest permissions: actions: read + contents: read steps: - name: Checkout exact release commit uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -225,8 +226,8 @@ jobs: KEY: ${{ secrets.JAR_SIGNING_KEY }} KEY_ID: ${{ secrets.JAR_SIGNING_KEY_ID }} KEY_PASSWORD: ${{ secrets.JAR_SIGNING_KEY_PASSWORD }} + MAVEN_RETRY_REQUIRED: ${{ steps.github_release.outputs.maven_retry_required }} MAVEN_RETRY_COMMIT: ${{ vars.MAVEN_RETRY_COMMIT }} RH_PASSWORD: ${{ secrets.RH_PASSWORD }} RH_USER: ${{ secrets.RH_USER }} - RUN_ATTEMPT: ${{ github.run_attempt }} run: .github/scripts/release.sh publish-maven From 713735871dba097c780ee0c64102f37fea2c61a5 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 8 Oct 2026 21:03:33 -0500 Subject: [PATCH 08/12] Make release commit resolution consistent --- .github/scripts/release.sh | 62 ++++++++++++++++++++++++--------- .github/scripts/test-release.sh | 16 +++++++++ .github/workflows/release.yml | 1 + 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 74df24f79f..b9165749c5 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -49,12 +49,41 @@ changelog_section() { ' } +# Validates a changelog transition and prints its newly added version, if any. +changelog_transition() { + local base=$1 + local head=$2 + local duplicate version + local -a added base_versions head_versions + + mapfile -t base_versions < <(changelog_versions "$base") + mapfile -t head_versions < <(changelog_versions "$head") + duplicate=$(printf '%s\n' "${base_versions[@]}" | uniq -d | sed -n '1p') + [[ -z "$duplicate" ]] \ + || fail "Changelog version $duplicate appears more than once at $base." + duplicate=$(printf '%s\n' "${head_versions[@]}" | uniq -d | sed -n '1p') + [[ -z "$duplicate" ]] \ + || fail "Changelog version $duplicate appears more than once at $head." + for version in "${base_versions[@]}"; do + diff -q <(changelog_section "$base" "$version" true) \ + <(changelog_section "$head" "$version" true) >/dev/null \ + || fail "Published changelog section $version was changed." + done + + mapfile -t added < <(comm -13 \ + <(printf '%s\n' "${base_versions[@]}") \ + <(printf '%s\n' "${head_versions[@]}")) + [[ "${#added[@]}" -le 1 ]] \ + || fail "A release commit must add exactly one versioned changelog section." + printf '%s' "${added[0]:-}" +} + # Validates a changelog transition and identifies a release candidate. candidate() { require EVENT_NAME require HEAD_SHA local notes=${1:?Release notes output path is required.} - local base dispatch_head draft_release existing release_commit tag version + local base dispatch_head draft_release existing release_base release_commit tag version if [[ "$EVENT_NAME" == workflow_dispatch ]]; then require MANUAL_DRAFT_RELEASE @@ -92,31 +121,32 @@ candidate() { || fail "The base commit must be an ancestor of the release commit." fi - mapfile -t base_versions < <(changelog_versions "$base") - mapfile -t head_versions < <(changelog_versions "$head") - for version in "${base_versions[@]}"; do - diff -q <(changelog_section "$base" "$version" true) \ - <(changelog_section "$head" "$version" true) >/dev/null \ - || fail "Published changelog section $version was changed." - done - - mapfile -t added < <(comm -13 \ - <(printf '%s\n' "${base_versions[@]}") \ - <(printf '%s\n' "${head_versions[@]}")) - if [[ "${#added[@]}" -eq 0 ]]; then + version=$(changelog_transition "$base" "$head") + if [[ -z "$version" ]]; then write_output release false return fi - [[ "${#added[@]}" -eq 1 ]] \ - || fail "A release commit must add exactly one versioned changelog section." - version=${added[0]} if [[ "$EVENT_NAME" == workflow_dispatch ]]; then [[ "$version" == "$MANUAL_VERSION" ]] \ || fail "Commit $head does not introduce version $MANUAL_VERSION." diff -q <(changelog_section "$head" "$version" true) \ <(changelog_section "$dispatch_head" "$version" true) >/dev/null \ || fail "Release notes for $version changed after commit $head." + elif [[ "$EVENT_NAME" == push ]]; then + release_commit=$(git log -1 --first-parent --format=%H --fixed-strings \ + -S"## [$version]" "$head" -- CHANGELOG.md) + [[ -n "$release_commit" ]] \ + || fail "The commit that introduced version $version was not found." + git merge-base --is-ancestor "$base" "$release_commit" \ + || fail "Version $version was introduced before this push." + release_base=$(git rev-parse --verify "$release_commit^1") + [[ $(changelog_transition "$release_base" "$release_commit") == "$version" ]] \ + || fail "Commit $release_commit does not introduce version $version." + diff -q <(changelog_section "$release_commit" "$version" true) \ + <(changelog_section "$head" "$version" true) >/dev/null \ + || fail "Release notes for $version changed after commit $release_commit." + head=$release_commit fi changelog_section "$head" "$version" > "$notes" [[ -s "$notes" ]] || fail "Release notes for $version are empty." diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh index a794762d6d..845323c560 100755 --- a/.github/scripts/test-release.sh +++ b/.github/scripts/test-release.sh @@ -68,6 +68,14 @@ printf 'Later change.\n' > "$repository/README.md" git -C "$repository" add README.md git -C "$repository" commit -qm later later=$(git -C "$repository" rev-parse HEAD) +( + cd "$repository" + BASE_SHA="$base" HEAD_SHA="$later" EVENT_NAME=push \ + GITHUB_OUTPUT="$temporary_directory/push-output" \ + "$release_script" candidate "$temporary_directory/push-notes" +) +grep -Fxq "commit=$head" "$temporary_directory/push-output" +diff -q "$temporary_directory/notes" "$temporary_directory/push-notes" git -C "$repository" branch releases/1.41.x "$later" ( cd "$repository" @@ -88,6 +96,14 @@ diff -q "$temporary_directory/notes" "$temporary_directory/manual-notes" "$release_script" candidate "$temporary_directory/manual-public-notes" ) grep -Fxq 'draft_release=0' "$temporary_directory/manual-public-output" +printf '\n## [1.41.0] - 2026-10-04\n\nDuplicate.\n' >> "$repository/CHANGELOG.md" +git -C "$repository" commit -qam duplicate +duplicate=$(git -C "$repository" rev-parse HEAD) +if (cd "$repository" && BASE_SHA="$later" HEAD_SHA="$duplicate" EVENT_NAME=push \ + "$release_script" candidate "$temporary_directory/duplicate-notes" 2>/dev/null); then + echo "Duplicate release headings must be rejected." >&2 + exit 1 +fi printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-04\n\n### Fixed\n- Fixed it.\n' \ > "$repository/CHANGELOG.md" git -C "$repository" commit -qam rewrite diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f36961fd81..1661e4c24b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,6 +187,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + persist-credentials: false ref: ${{ needs.candidate.outputs.commit }} submodules: recursive From 20431250260e5d45ba6e3af774ea297c2c7ba1bc Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 8 Oct 2026 21:09:44 -0500 Subject: [PATCH 09/12] Validate release headings consistently --- .github/scripts/release.sh | 19 +++++++++++++++---- .github/scripts/test-release.sh | 20 ++++++++++++-------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index b9165749c5..6ae993a6bb 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -29,10 +29,19 @@ changelog_versions() { | sort } +# Lists every semantic-version token used in a level-two heading. +changelog_version_tokens() { + git show "$1:CHANGELOG.md" \ + | sed -nE 's/^## \[([0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?)\].*$/\1/p' \ + | sort +} + # Extracts one version's changelog section at a given commit. changelog_section() { - git show "$1:CHANGELOG.md" | awk -v heading="## [$2]" -v include="${3:-false}" ' + git show "$1:CHANGELOG.md" | awk -v heading="## [$2] - " -v include="${3:-false}" ' !seen && index($0, heading) == 1 { + date = substr($0, length(heading) + 1) + if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) next seen = 1 capture = 1 if (include == "true") lines[++count] = $0 @@ -54,14 +63,16 @@ changelog_transition() { local base=$1 local head=$2 local duplicate version - local -a added base_versions head_versions + local -a added base_tokens base_versions head_tokens head_versions mapfile -t base_versions < <(changelog_versions "$base") mapfile -t head_versions < <(changelog_versions "$head") - duplicate=$(printf '%s\n' "${base_versions[@]}" | uniq -d | sed -n '1p') + mapfile -t base_tokens < <(changelog_version_tokens "$base") + mapfile -t head_tokens < <(changelog_version_tokens "$head") + duplicate=$(printf '%s\n' "${base_tokens[@]}" | uniq -d | sed -n '1p') [[ -z "$duplicate" ]] \ || fail "Changelog version $duplicate appears more than once at $base." - duplicate=$(printf '%s\n' "${head_versions[@]}" | uniq -d | sed -n '1p') + duplicate=$(printf '%s\n' "${head_tokens[@]}" | uniq -d | sed -n '1p') [[ -z "$duplicate" ]] \ || fail "Changelog version $duplicate appears more than once at $head." for version in "${base_versions[@]}"; do diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh index 845323c560..1b776e8974 100755 --- a/.github/scripts/test-release.sh +++ b/.github/scripts/test-release.sh @@ -44,6 +44,18 @@ target=$(git -C "$repository" rev-parse HEAD) ) grep -Fxq 'release=false' "$temporary_directory/divergent-output" +git -C "$repository" switch -qc malformed +printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - TBD\n\nWrong notes.\n\n## [1.41.0] - 2026-10-03\n\nCorrect notes.\n' \ + > "$repository/CHANGELOG.md" +git -C "$repository" commit -qam malformed +malformed=$(git -C "$repository" rev-parse HEAD) +if (cd "$repository" && BASE_SHA="$target" HEAD_SHA="$malformed" EVENT_NAME=pull_request \ + "$release_script" candidate "$temporary_directory/malformed-notes" 2>/dev/null); then + echo "Malformed duplicate release headings must be rejected." >&2 + exit 1 +fi +git -C "$repository" switch -q - + printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-03\n\n### Fixed\n- Fixed it.\n' \ > "$repository/CHANGELOG.md" git -C "$repository" commit -qam release @@ -96,14 +108,6 @@ diff -q "$temporary_directory/notes" "$temporary_directory/manual-notes" "$release_script" candidate "$temporary_directory/manual-public-notes" ) grep -Fxq 'draft_release=0' "$temporary_directory/manual-public-output" -printf '\n## [1.41.0] - 2026-10-04\n\nDuplicate.\n' >> "$repository/CHANGELOG.md" -git -C "$repository" commit -qam duplicate -duplicate=$(git -C "$repository" rev-parse HEAD) -if (cd "$repository" && BASE_SHA="$later" HEAD_SHA="$duplicate" EVENT_NAME=push \ - "$release_script" candidate "$temporary_directory/duplicate-notes" 2>/dev/null); then - echo "Duplicate release headings must be rejected." >&2 - exit 1 -fi printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-04\n\n### Fixed\n- Fixed it.\n' \ > "$repository/CHANGELOG.md" git -C "$repository" commit -qam rewrite From ea1b96949367e9f83195be3baa88d03c34ae5783 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 8 Oct 2026 21:23:56 -0500 Subject: [PATCH 10/12] Reject malformed release headings --- .github/scripts/release.sh | 45 +++++++++++++++++++++++---------- .github/scripts/test-release.sh | 16 ++++++++++-- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 6ae993a6bb..698643ec3e 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -58,23 +58,37 @@ changelog_section() { ' } +# Prints one version's exact dated heading at a given commit. +changelog_heading() { + changelog_section "$1" "$2" true | sed -n '1p' +} + +# Rejects malformed or repeated semantic-version headings at a commit. +validate_version_headings() { + local commit=$1 + local duplicate + local -a tokens versions + + mapfile -t tokens < <(changelog_version_tokens "$commit") + mapfile -t versions < <(changelog_versions "$commit") + diff -q <(printf '%s\n' "${tokens[@]}") \ + <(printf '%s\n' "${versions[@]}") >/dev/null \ + || fail "Version headings at $commit must use ## [X.Y.Z] - YYYY-MM-DD." + duplicate=$(printf '%s\n' "${tokens[@]}" | uniq -d | sed -n '1p') + [[ -z "$duplicate" ]] \ + || fail "Changelog version $duplicate appears more than once at $commit." +} + # Validates a changelog transition and prints its newly added version, if any. changelog_transition() { local base=$1 local head=$2 - local duplicate version - local -a added base_tokens base_versions head_tokens head_versions + local version + local -a added base_versions head_versions + validate_version_headings "$head" mapfile -t base_versions < <(changelog_versions "$base") mapfile -t head_versions < <(changelog_versions "$head") - mapfile -t base_tokens < <(changelog_version_tokens "$base") - mapfile -t head_tokens < <(changelog_version_tokens "$head") - duplicate=$(printf '%s\n' "${base_tokens[@]}" | uniq -d | sed -n '1p') - [[ -z "$duplicate" ]] \ - || fail "Changelog version $duplicate appears more than once at $base." - duplicate=$(printf '%s\n' "${head_tokens[@]}" | uniq -d | sed -n '1p') - [[ -z "$duplicate" ]] \ - || fail "Changelog version $duplicate appears more than once at $head." for version in "${base_versions[@]}"; do diff -q <(changelog_section "$base" "$version" true) \ <(changelog_section "$head" "$version" true) >/dev/null \ @@ -94,7 +108,7 @@ candidate() { require EVENT_NAME require HEAD_SHA local notes=${1:?Release notes output path is required.} - local base dispatch_head draft_release existing release_base release_commit tag version + local base dispatch_head draft_release existing heading release_base release_commit tag version if [[ "$EVENT_NAME" == workflow_dispatch ]]; then require MANUAL_DRAFT_RELEASE @@ -109,8 +123,12 @@ candidate() { esac dispatch_head=$(git rev-parse --verify "$HEAD_SHA^{commit}") + validate_version_headings "$dispatch_head" + heading=$(changelog_heading "$dispatch_head" "$MANUAL_VERSION") + [[ -n "$heading" ]] \ + || fail "Version $MANUAL_VERSION was not found on $MANUAL_REF." release_commit=$(git log -1 --first-parent --format=%H --fixed-strings \ - -S"## [$MANUAL_VERSION]" "$dispatch_head" -- CHANGELOG.md) + -S"$heading" "$dispatch_head" -- CHANGELOG.md) [[ -n "$release_commit" ]] \ || fail "Version $MANUAL_VERSION was not introduced on $MANUAL_REF." BASE_SHA=$(git rev-parse --verify "$release_commit^1") @@ -145,8 +163,9 @@ candidate() { <(changelog_section "$dispatch_head" "$version" true) >/dev/null \ || fail "Release notes for $version changed after commit $head." elif [[ "$EVENT_NAME" == push ]]; then + heading=$(changelog_heading "$head" "$version") release_commit=$(git log -1 --first-parent --format=%H --fixed-strings \ - -S"## [$version]" "$head" -- CHANGELOG.md) + -S"$heading" "$head" -- CHANGELOG.md) [[ -n "$release_commit" ]] \ || fail "The commit that introduced version $version was not found." git merge-base --is-ancestor "$base" "$release_commit" \ diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh index 1b776e8974..953052a464 100755 --- a/.github/scripts/test-release.sh +++ b/.github/scripts/test-release.sh @@ -45,15 +45,27 @@ target=$(git -C "$repository" rev-parse HEAD) grep -Fxq 'release=false' "$temporary_directory/divergent-output" git -C "$repository" switch -qc malformed -printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - TBD\n\nWrong notes.\n\n## [1.41.0] - 2026-10-03\n\nCorrect notes.\n' \ +printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - TBD\n\nWrong notes.\n' \ > "$repository/CHANGELOG.md" git -C "$repository" commit -qam malformed malformed=$(git -C "$repository" rev-parse HEAD) if (cd "$repository" && BASE_SHA="$target" HEAD_SHA="$malformed" EVENT_NAME=pull_request \ "$release_script" candidate "$temporary_directory/malformed-notes" 2>/dev/null); then - echo "Malformed duplicate release headings must be rejected." >&2 + echo "Malformed release headings must be rejected." >&2 exit 1 fi +printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-03\n\nCorrect notes.\n' \ + > "$repository/CHANGELOG.md" +git -C "$repository" commit -qam corrected +corrected=$(git -C "$repository" rev-parse HEAD) +( + cd "$repository" + BASE_SHA="$malformed" HEAD_SHA="$corrected" EVENT_NAME=push \ + GITHUB_OUTPUT="$temporary_directory/corrected-output" \ + "$release_script" candidate "$temporary_directory/corrected-notes" +) +grep -Fxq "commit=$corrected" "$temporary_directory/corrected-output" +grep -Fxq 'Correct notes.' "$temporary_directory/corrected-notes" git -C "$repository" switch -q - printf '# Changelog\n\n## [Unreleased]\n\n## [1.41.0] - 2026-10-03\n\n### Fixed\n- Fixed it.\n' \ From b0151ab857a0a5040a56212fa09b97927b08721b Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 8 Oct 2026 21:33:47 -0500 Subject: [PATCH 11/12] Anchor release heading lookup --- .github/scripts/release.sh | 16 ++++++++++++---- .github/scripts/test-release.sh | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 698643ec3e..115e1c2e9b 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -63,6 +63,16 @@ changelog_heading() { changelog_section "$1" "$2" true | sed -n '1p' } +# Finds the first-parent commit that added an exact heading line. +changelog_heading_commit() { + local head=$1 + local heading=$2 + local pattern + + pattern=$(printf '%s\n' "$heading" | sed 's/[][\\.^$*+?(){}|]/\\&/g') + git log -1 --first-parent --format=%H -G"^$pattern$" "$head" -- CHANGELOG.md +} + # Rejects malformed or repeated semantic-version headings at a commit. validate_version_headings() { local commit=$1 @@ -127,8 +137,7 @@ candidate() { heading=$(changelog_heading "$dispatch_head" "$MANUAL_VERSION") [[ -n "$heading" ]] \ || fail "Version $MANUAL_VERSION was not found on $MANUAL_REF." - release_commit=$(git log -1 --first-parent --format=%H --fixed-strings \ - -S"$heading" "$dispatch_head" -- CHANGELOG.md) + release_commit=$(changelog_heading_commit "$dispatch_head" "$heading") [[ -n "$release_commit" ]] \ || fail "Version $MANUAL_VERSION was not introduced on $MANUAL_REF." BASE_SHA=$(git rev-parse --verify "$release_commit^1") @@ -164,8 +173,7 @@ candidate() { || fail "Release notes for $version changed after commit $head." elif [[ "$EVENT_NAME" == push ]]; then heading=$(changelog_heading "$head" "$version") - release_commit=$(git log -1 --first-parent --format=%H --fixed-strings \ - -S"$heading" "$head" -- CHANGELOG.md) + release_commit=$(changelog_heading_commit "$head" "$heading") [[ -n "$release_commit" ]] \ || fail "The commit that introduced version $version was not found." git merge-base --is-ancestor "$base" "$release_commit" \ diff --git a/.github/scripts/test-release.sh b/.github/scripts/test-release.sh index 953052a464..a160c8bb92 100755 --- a/.github/scripts/test-release.sh +++ b/.github/scripts/test-release.sh @@ -88,6 +88,8 @@ if (cd "$repository" && BASE_SHA="$base" HEAD_SHA="$head" EVENT_NAME=push \ exit 1 fi git -C "$repository" tag -d v1.41.0 >/dev/null +printf '# Changelog\n\n## [Unreleased]\n\nReference: `## [1.41.0] - 2026-10-03`\n\n## [1.41.0] - 2026-10-03\n\n### Fixed\n- Fixed it.\n' \ + > "$repository/CHANGELOG.md" printf 'Later change.\n' > "$repository/README.md" git -C "$repository" add README.md git -C "$repository" commit -qm later From 3b47452876c4c64723ae49699271eb0ea41f79a4 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Fri, 9 Oct 2026 16:58:56 -0500 Subject: [PATCH 12/12] Keep changelog policy additions under Unreleased --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 266822abbf..af4bac6c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,6 @@ appropriate heading (create the heading if it does not yet exist). Within each heading content can be free-form. Feel free to include examples, links to docs, or any other relevant information. -A release PR moves the entries being released into exactly one new heading in -the form `## [X.Y.Z] - YYYY-MM-DD` (or `X.Y.Z-RCN` for a release candidate) and -leaves an `## [Unreleased]` section above it. Versioned sections are immutable. - ### :boom: Breaking Changes — removed or backwards-incompatible features ### Added — new features ### Changed — changes in existing functionality