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..115e1c2e9b
--- /dev/null
+++ b/.github/scripts/release.sh
@@ -0,0 +1,397 @@
+#!/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
+}
+
+# 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}" '
+ !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
+ 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]
+ }
+ '
+}
+
+# Prints one version's exact dated heading at a given commit.
+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
+ 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 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")
+ 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 heading release_base release_commit tag version
+
+ if [[ "$EVENT_NAME" == workflow_dispatch ]]; then
+ require MANUAL_DRAFT_RELEASE
+ require MANUAL_REF
+ require MANUAL_VERSION
+ [[ "$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}")
+ 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=$(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")
+ HEAD_SHA=$release_commit
+ else
+ require BASE_SHA
+ fi
+
+ 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}")
+ 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
+
+ version=$(changelog_transition "$base" "$head")
+ if [[ -z "$version" ]]; then
+ write_output release false
+ return
+ fi
+
+ 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
+ heading=$(changelog_heading "$head" "$version")
+ 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" \
+ || 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."
+ 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
+ [[ $(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."
+ 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"
+ 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 MAVEN_RETRY_REQUIRED
+ require RELEASE_COMMIT
+ require RELEASE_VERSION
+ require RUNNER_TEMP
+ local state variable signing_directory status attempt
+
+ state=$(central_state)
+ [[ "$state" == absent ]] || return
+ 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
+
+ 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
+ local notes=${1:?Release notes path is required.}
+ local assets=${2:?Release asset directory is required.}
+ local draft_release=${MANUAL_DRAFT_RELEASE:-${DRAFT_RELEASE:-1}}
+ local requested_draft=false
+ local actual_draft maven_retry_required=false published_assets release summary_title tag
+ local release_assets=("$assets"/*)
+ case "$draft_release" in
+ 0) ;;
+ 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."
+
+ if ! release=$(gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" \
+ --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")
+ [[ "$requested_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,targetCommitish,url)
+ elif [[ $(jq -r .isDraft <<<"$release") == false ]]; then
+ maven_retry_required=true
+ 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
+ .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 \
+ || 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" \
+ '.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"
+
+ 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 \
+ || 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"
+ {
+ echo "## $summary_title"
+ echo
+ echo "- GitHub: $(jq -r .url <<<"$release")"
+ 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..a160c8bb92
--- /dev/null
+++ b/.github/scripts/test-release.sh
@@ -0,0 +1,144 @@
+#!/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"
+
+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"
+
+git -C "$repository" switch -qc malformed
+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 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' \
+ > "$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"
+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 '# 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
+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"
+ 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"
+)
+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"
+ 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
+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..17d16d7ec9 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -1,10 +1,68 @@
-# 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 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.
+
+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.
+
+To run an existing release candidate manually, open the **Release** workflow,
+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`.
+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
+
+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.
+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`
@@ -12,27 +70,27 @@ 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
+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.
-### Testing
+### Recovery
-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.
+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.
-Workflows can also be invoked from the `gh` cli. To invoke this workflow and watch its progress
+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.
-```.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
-```
+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/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..1661e4c24b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,234 @@
+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]
+ 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 and leave Maven unpublished
+ required: true
+ default: true
+ type: boolean
+
+permissions:
+ contents: read
+
+jobs:
+ candidate:
+ name: Resolve release candidate
+ 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 }}
+ 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 }}
+ 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' || github.event_name == 'workflow_dispatch') &&
+ 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' || github.event_name == 'workflow_dispatch') &&
+ 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' || github.event_name == 'workflow_dispatch') &&
+ 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' || github.event_name == 'workflow_dispatch') &&
+ needs.candidate.outputs.release == 'true'
+ needs: [candidate, build_native]
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: 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' || github.event_name == 'workflow_dispatch') &&
+ 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:
+ 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
+ persist-credentials: false
+ 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: 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 }}
+ 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: .github/scripts/release.sh publish-maven
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
+}