feat: publish a GitHub release in release step 4 and make it idempotent

04-release-complete.sh now turns the released Git tag into a GitHub release
with `gh release create --verify-tag`, so it publishes the existing tag and
never creates one. The generated notes link the release-note issue, the
operator image (apache/doris:operator-<version>, with its docker pull command
and Docker Hub page), and the formal source artifacts.

The full run is now idempotent, so an interrupted release can be repeated:
release files already present in release SVN are detected by name and
packaging, signing, and the SVN commit are skipped; a release directory
holding only some of the three files is reported as incomplete and stops the
run for manual repair; a GitHub release whose notes already match is left
untouched, and differing notes are replaced only after a confirmation. The
new --github-only flag publishes or refreshes only the GitHub release.

Vote and announcement drafts now start with a Subject line, so the mail title
no longer has to be retyped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/tools/release-tools/03-vote-mail.sh b/tools/release-tools/03-vote-mail.sh
index 82973f1..4bbf35b 100755
--- a/tools/release-tools/03-vote-mail.sh
+++ b/tools/release-tools/03-vote-mail.sh
@@ -64,7 +64,10 @@
 EOF
 )"
 
-printf '%s\n' "$BODY" > "$body_file"
+{
+  printf 'Subject: %s\n' "$subject"
+  printf '\n%s\n' "$BODY"
+} > "$body_file"
 {
   printf 'To: %s\n' "$VOTE_TO"
   printf 'Subject: %s\n' "$subject"
diff --git a/tools/release-tools/04-release-complete.sh b/tools/release-tools/04-release-complete.sh
index 92c8c8c..82d5b02 100755
--- a/tools/release-tools/04-release-complete.sh
+++ b/tools/release-tools/04-release-complete.sh
@@ -24,20 +24,32 @@
 source "${HERE}/lib/release-common.sh"
 
 usage() {
-  printf 'Usage: %s [--mail-only]\n' "$0"
-  printf '  --mail-only  regenerate announcement drafts without packaging or SVN\n'
+  printf 'Usage: %s [--mail-only | --github-only]\n' "$0"
+  printf '  --mail-only    regenerate announcement drafts without packaging or SVN\n'
+  printf '  --github-only  publish or refresh the GitHub release only\n'
+  printf '\n'
+  printf 'A full run is idempotent: it detects the release files already present\n'
+  printf 'in release SVN and a GitHub release that already carries these notes,\n'
+  printf 'and skips those steps instead of failing.\n'
 }
 
 mail_only=0
+github_only=0
 while [[ "$#" -gt 0 ]]; do
   case "$1" in
     --mail-only) mail_only=1 ;;
+    --github-only) github_only=1 ;;
     -h|--help) usage; exit 0 ;;
     *) usage >&2; die "unknown argument: $1" ;;
   esac
   shift
 done
 
+if [[ "$mail_only" -eq 1 && "$github_only" -eq 1 ]]; then
+  usage >&2
+  die "--mail-only and --github-only are mutually exclusive"
+fi
+
 write_announce_email() {
   local subject body_file eml_file body
   mkdir -p "$WORK_DIR"
@@ -69,7 +81,10 @@
 EOF
   )"
 
-  printf '%s\n' "$body" > "$body_file"
+  {
+    printf 'Subject: %s\n' "$subject"
+    printf '\n%s\n' "$body"
+  } > "$body_file"
   {
     printf 'To: %s\n' "$ANNOUNCE_TO"
     printf 'Subject: %s\n' "$subject"
@@ -79,35 +94,156 @@
 
   ok "announcement draft: ${body_file}"
   ok "mail draft: ${eml_file}"
+  printf 'Subject: %s\n' "$subject"
   printf '%s\n' '----------------------------------------------------------------'
   printf '%s\n' "$body"
   printf '%s\n' '----------------------------------------------------------------'
   printf 'Review and send the message manually to %s. No email was sent.\n' "$ANNOUNCE_TO"
 }
 
+# Writes the GitHub release notes and prints only the file path.
+write_github_release_notes() {
+  local notes_file="${WORK_DIR}/github-release-notes.md"
+  mkdir -p "$WORK_DIR"
+
+  cat > "$notes_file" <<EOF
+Apache Doris Operator ${VERSION} is released.
+
+## Release note
+
+${RELEASE_NOTES_URL}
+
+## Docker image
+
+\`${DOCKER_IMAGE}\`
+
+\`\`\`shell
+docker pull ${DOCKER_IMAGE}
+\`\`\`
+
+${DOCKER_IMAGE_URL}
+
+## Source download
+
+The official Apache source release of ${VERSION}:
+
+- ${RELEASE_SVN_DIR}/${PKG_BASE}.tar.gz
+- ${RELEASE_SVN_DIR}/${PKG_BASE}.tar.gz.asc
+- ${RELEASE_SVN_DIR}/${PKG_BASE}.tar.gz.sha512
+
+KEYS: ${KEYS_URL}
+
+How to verify: ${VERIFY_GUIDE_URL}
+EOF
+
+  printf '%s\n' "$notes_file"
+}
+
+github_release_exists() {
+  gh release view "$TAG" --repo "$GITHUB_REPO" >/dev/null 2>&1
+}
+
+github_release_body() {
+  gh release view "$TAG" --repo "$GITHUB_REPO" --json body --jq '.body' 2>/dev/null |
+    tr -d '\r'
+}
+
+# Turns the existing Git tag into a GitHub release. Never creates a tag, and
+# never rewrites notes that already match the generated ones.
+publish_github_release() {
+  local notes_file title current desired
+
+  gh auth status >/dev/null 2>&1 ||
+    die "gh is not authenticated; run 'gh auth login' or export GH_TOKEN"
+
+  notes_file="$(write_github_release_notes)"
+  title="Apache Doris Operator ${VERSION}"
+  ok "GitHub release notes: ${notes_file}"
+  printf '%s\n' '----------------------------------------------------------------'
+  cat "$notes_file"
+  printf '%s\n' '----------------------------------------------------------------'
+
+  if github_release_exists; then
+    current="$(github_release_body)"
+    desired="$(cat "$notes_file")"
+    if [[ "$current" == "$desired" ]]; then
+      ok "GitHub release ${TAG} is already published: ${GITHUB_TAG_URL}"
+      return 0
+    fi
+
+    warn "GitHub release ${TAG} exists with different notes"
+    if ! confirm "Replace the notes of GitHub release ${TAG}?"; then
+      warn "left the existing GitHub release untouched: ${GITHUB_TAG_URL}"
+      return 0
+    fi
+    gh release edit "$TAG" --repo "$GITHUB_REPO" \
+      --title "$title" --notes-file "$notes_file"
+    ok "updated GitHub release: ${GITHUB_TAG_URL}"
+    return 0
+  fi
+
+  printf 'GitHub release target: %s (from existing tag %s)\n' "$GITHUB_REPO" "$TAG"
+  if ! confirm "Create the GitHub release for tag ${TAG}?"; then
+    warn "stopped before creating the GitHub release"
+    return 0
+  fi
+  gh release create "$TAG" --repo "$GITHUB_REPO" --verify-tag \
+    --title "$title" --notes-file "$notes_file"
+  ok "created GitHub release: ${GITHUB_TAG_URL}"
+}
+
 if [[ "$mail_only" -eq 1 ]]; then
   validate_release_config mail
-  ok "mail-only mode: skipping tag, package, signing, and SVN operations"
+  ok "mail-only mode: skipping tag, package, signing, SVN, and GitHub operations"
   write_announce_email
   exit 0
 fi
 
 validate_release_config
-require_tools git gpg svn sha512sum gzip || die "install the missing release prerequisites"
+
+if [[ "$github_only" -eq 1 ]]; then
+  require_tools git gh || die "install the missing release prerequisites"
+  ok "github-only mode: skipping package, signing, SVN, and mail operations"
+  verify_tag_consistency
+  publish_github_release
+  exit 0
+fi
+
+require_tools git gpg svn sha512sum gzip gh || die "install the missing release prerequisites"
 export GPG_TTY="$(tty 2>/dev/null || true)"
 
-SIGNER="$(resolve_signing_key)"
-ok "signer: ${SIGNER}"
 verify_tag_consistency
-prepare_source_artifacts "$SIGNER"
 
-stage_and_commit_version_dir \
-  "$RELEASE_SVN_BASE" \
-  "$RELEASE_SVN_DIR" \
-  "release-svn" \
-  "Release Apache Doris Operator ${VERSION}" \
-  "${SOURCE_ARTIFACTS[@]}"
+release_published=0
+release_state="$(
+  svn_version_dir_state "$RELEASE_SVN_DIR" \
+    "${PKG_BASE}.tar.gz" "${PKG_BASE}.tar.gz.asc" "${PKG_BASE}.tar.gz.sha512"
+)"
 
-if [[ "$SVN_COMMITTED" -eq 1 ]]; then
+case "$release_state" in
+  complete)
+    ok "release files already published, skipping packaging and upload: ${RELEASE_SVN_DIR}/"
+    release_published=1
+    ;;
+  partial)
+    die "incomplete release directory: ${RELEASE_SVN_DIR}/; inspect and repair it manually"
+    ;;
+  *)
+    SIGNER="$(resolve_signing_key)"
+    ok "signer: ${SIGNER}"
+    prepare_source_artifacts "$SIGNER"
+
+    stage_and_commit_version_dir \
+      "$RELEASE_SVN_BASE" \
+      "$RELEASE_SVN_DIR" \
+      "release-svn" \
+      "Release Apache Doris Operator ${VERSION}" \
+      "${SOURCE_ARTIFACTS[@]}"
+    release_published="$SVN_COMMITTED"
+    ;;
+esac
+
+if [[ "$release_published" -eq 1 ]]; then
+  publish_github_release
   write_announce_email
 fi
diff --git a/tools/release-tools/README.md b/tools/release-tools/README.md
index 9b64a66..9d6982e 100644
--- a/tools/release-tools/README.md
+++ b/tools/release-tools/README.md
@@ -20,16 +20,16 @@
 # Doris Operator release tools
 
 These scripts package, sign, and publish Apache Doris Operator source releases.
-They publish source artifacts only and generate vote and announcement email
-drafts. They do not create Git tags or send email.
-
-The checked-in defaults target version and Git tag `26.0.0`.
+They publish source artifacts only, turn the released Git tag into a GitHub
+release, and generate vote and announcement email drafts. They do not create
+Git tags or send email.
 
 ## Prerequisites
 
-Install `git`, `gpg`, `svn`, `svnmucc`, `sha512sum`, `curl`, and `gzip`. The
-selected Git tag must already exist locally and on the remote configured by
-`GIT_REMOTE`.
+Install `git`, `gpg`, `svn`, `svnmucc`, `sha512sum`, `curl`, `gzip`, and `gh`.
+The selected Git tag must already exist locally and on the remote configured by
+`GIT_REMOTE`. `gh` must be authenticated for `GITHUB_REPO` (`gh auth login`, or
+export `GH_TOKEN`); it is used only by step 4.
 
 Edit `release.env` before each release. In particular, verify:
 
@@ -37,6 +37,8 @@
 - `APACHE_ID`, `APACHE_EMAIL`, and `SIGNER_NAME`.
 - `SIGNING_KEY`: required full fingerprint of a locally available secret key.
 - release notes, verification, download, and mailing-list URLs.
+- `GITHUB_REPO`, plus `DOCKER_IMAGE` and `DOCKER_IMAGE_URL` for the operator
+  image published with this release, for example `apache/doris:operator-26.0.1`.
 - `WORK_DIR`, which stores generated artifacts, SVN working copies, and drafts.
 
 `TAG` must be the final version with no RC suffix. Source artifacts and SVN
@@ -112,6 +114,9 @@
 This writes `vote-email.txt` and `vote-email.eml` under `WORK_DIR`.
 It prints the subject and body, then leaves sending to the release manager.
 
+Both vote and announcement drafts start with a `Subject:` line followed by a
+blank line, so the mail title never has to be retyped.
+
 ### 4. Complete a passed release
 
 ```bash
@@ -129,8 +134,33 @@
 
 It does not inspect, compare, promote, move, or delete anything under dev SVN.
 It refuses to overwrite an existing release directory and requires two
-confirmations. Only after a successful commit does it create
-`announce-email.txt` and `announce-email.eml`.
+confirmations.
+
+Once the release files are in release SVN, the script turns the existing Git
+tag into a GitHub release on `GITHUB_REPO`. The notes are written to
+`github-release-notes.md` under `WORK_DIR`, printed for review, and published
+with `gh` after one more confirmation. They link the release-note issue
+(`RELEASE_NOTES_URL`), the operator image (`DOCKER_IMAGE`, for example
+`apache/doris:operator-26.0.1`, with its `docker pull` command and
+`DOCKER_IMAGE_URL`), and the formal source artifacts. `gh release create` runs
+with `--verify-tag`, so it publishes the existing tag and never creates one.
+
+Finally the script writes `announce-email.txt` and `announce-email.eml`.
+
+The whole run is idempotent, so an interrupted release can simply be repeated:
+
+- release files already present in release SVN are detected by name, and
+  packaging, signing, and the SVN commit are skipped;
+- a release directory holding only some of the three files is reported as
+  incomplete and stops the run for manual repair;
+- an existing GitHub release whose notes already match is left untouched, and
+  differing notes are replaced only after an explicit confirmation.
+
+To publish or refresh only the GitHub release:
+
+```bash
+./04-release-complete.sh --github-only
+```
 
 To regenerate only the announcement drafts:
 
@@ -138,16 +168,19 @@
 ./04-release-complete.sh --mail-only
 ```
 
-`--mail-only` skips Git, packaging, GPG, checksums, and SVN.
+`--github-only` skips packaging, GPG, checksums, SVN, and the mail drafts.
+`--mail-only` skips Git, packaging, GPG, checksums, SVN, and GitHub.
 
 ## Safety boundaries
 
-- No script creates, updates, or pushes a Git tag.
+- No script creates, updates, or pushes a Git tag; the GitHub release is
+  published from the existing tag with `gh release create --verify-tag`.
 - Local and remote tags are compared by peeled commit ID.
 - Generated signatures and checksums are verified immediately.
 - Dev and release uploads stop before checkout if the version directory exists.
 - SVN target URLs and staged files are shown before both confirmations.
 - SVN uploads contain only the source archive, signature, and checksum.
+- Existing GitHub release notes are replaced only after a confirmation.
 - Public emails are drafts only.
 - No email was sent by any script; the release manager sends drafts manually.
 - Formal release packaging is independent from dev SVN.
diff --git a/tools/release-tools/lib/release-common.sh b/tools/release-tools/lib/release-common.sh
index 6d7ffab..eabb0af 100644
--- a/tools/release-tools/lib/release-common.sh
+++ b/tools/release-tools/lib/release-common.sh
@@ -76,7 +76,7 @@
   )
 
   if [[ "$mode" != "mail" ]]; then
-    required+=(REPO_DIR GIT_REMOTE)
+    required+=(REPO_DIR GIT_REMOTE GITHUB_REPO DOCKER_IMAGE DOCKER_IMAGE_URL)
   fi
 
   for name in "${required[@]}"; do
@@ -96,6 +96,10 @@
 
   if [[ "$mode" != "mail" ]]; then
     [[ "$REPO_DIR" == /* ]] || die "release.env: REPO_DIR must be an absolute path"
+    [[ "$GITHUB_REPO" == */* && "$GITHUB_REPO" != */*/* ]] ||
+      die "release.env: GITHUB_REPO must be <owner>/<repo>"
+    [[ "$DOCKER_IMAGE" == *:*"${VERSION}" ]] ||
+      die "release.env: DOCKER_IMAGE must be a <image>:<tag> ending with ${VERSION}"
   fi
 }
 
@@ -233,6 +237,33 @@
   svn info "${SVN_AUTH_ARGS[@]}" "$url" >/dev/null 2>&1
 }
 
+# Prints the publication state of an SVN version directory, so a workflow can
+# tell an interrupted upload from a finished one and re-run safely:
+#   missing   the directory does not exist yet
+#   complete  the directory exists and holds every expected file name
+#   partial   the directory exists but at least one expected file is absent
+svn_version_dir_state() {
+  local url="$1"
+  shift
+  local listing name
+  [[ "$#" -gt 0 ]] || die "no file names supplied for SVN inspection"
+
+  build_svn_auth_args
+  if ! svn_url_exists "$url"; then
+    printf 'missing\n'
+    return 0
+  fi
+
+  listing="$(svn ls "${SVN_AUTH_ARGS[@]}" "$url" 2>/dev/null || true)"
+  for name in "$@"; do
+    if ! printf '%s\n' "$listing" | grep -Fxq "$name"; then
+      printf 'partial\n'
+      return 0
+    fi
+  done
+  printf 'complete\n'
+}
+
 stage_and_commit_version_dir() {
   local svn_base="$1" svn_dir="$2" stage_name="$3" commit_message="$4"
   shift 4
diff --git a/tools/release-tools/release.env b/tools/release-tools/release.env
index f5c4b53..e466a49 100644
--- a/tools/release-tools/release.env
+++ b/tools/release-tools/release.env
@@ -21,7 +21,7 @@
 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 REPO_DIR="${ROOT}/../.."
 
-VERSION="26.0.0"
+VERSION="26.0.1"
 TAG="${VERSION}"
 GIT_REMOTE="upstream-apache"
 
@@ -43,11 +43,16 @@
 # Required: full fingerprint of the secret key used to sign release artifacts.
 SIGNING_KEY=""
 
-GITHUB_TAG_URL="https://github.com/apache/doris-operator/releases/tag/${TAG}"
-RELEASE_NOTES_URL="https://github.com/apache/doris-operator/issues/506"
+GITHUB_REPO="apache/doris-operator"
+GITHUB_TAG_URL="https://github.com/${GITHUB_REPO}/releases/tag/${TAG}"
+RELEASE_NOTES_URL="https://github.com/apache/doris-operator/issues/512"
 VERIFY_GUIDE_URL="https://doris.apache.org/community/release-and-verify/release-verify"
 DOWNLOAD_PAGE_URL="${GITHUB_TAG_URL}"
 
+# Operator container image published for this release.
+DOCKER_IMAGE="apache/doris:operator-${VERSION}"
+DOCKER_IMAGE_URL="https://hub.docker.com/r/apache/doris/tags?name=operator-${VERSION}"
+
 VOTE_TO="dev@doris.apache.org"
 ANNOUNCE_TO="announce@apache.org"
 
diff --git a/tools/release-tools/tests/test-config.sh b/tools/release-tools/tests/test-config.sh
index cf00b17..a2aa5b2 100755
--- a/tools/release-tools/tests/test-config.sh
+++ b/tools/release-tools/tests/test-config.sh
@@ -24,16 +24,20 @@
 # shellcheck source=../lib/release-common.sh
 source "${TOOLS_ROOT}/lib/release-common.sh"
 
-assert_eq "26.0.0" "$VERSION"
+[[ -n "$VERSION" ]] || fail "release.env does not set VERSION"
+[[ -n "$GIT_REMOTE" ]] || fail "release.env does not set GIT_REMOTE"
 assert_eq "$VERSION" "$TAG"
-assert_eq "upstream-apache" "$GIT_REMOTE"
-assert_eq "apache-doris-operator-26.0.0-src" "$PKG_BASE"
+assert_eq "apache-doris-operator-${VERSION}-src" "$PKG_BASE"
 assert_eq "${PKG_BASE}/" "$ARCHIVE_PREFIX"
-assert_eq "https://dist.apache.org/repos/dist/dev/doris/doris-operator/26.0.0" "$DEV_SVN_DIR"
-assert_eq "https://dist.apache.org/repos/dist/release/doris/doris-operator/26.0.0" "$RELEASE_SVN_DIR"
+assert_eq "https://dist.apache.org/repos/dist/dev/doris/doris-operator/${VERSION}" "$DEV_SVN_DIR"
+assert_eq "https://dist.apache.org/repos/dist/release/doris/doris-operator/${VERSION}" "$RELEASE_SVN_DIR"
 assert_eq "https://downloads.apache.org/doris/KEYS" "$KEYS_URL"
 assert_eq "https://dist.apache.org/repos/dist/dev/doris" "$DEV_KEYS_SVN_BASE"
 assert_eq "https://dist.apache.org/repos/dist/release/doris" "$RELEASE_KEYS_SVN_BASE"
+assert_eq "apache/doris-operator" "$GITHUB_REPO"
+assert_eq "https://github.com/${GITHUB_REPO}/releases/tag/${TAG}" "$GITHUB_TAG_URL"
+assert_eq "apache/doris:operator-${VERSION}" "$DOCKER_IMAGE"
+assert_eq "https://hub.docker.com/r/apache/doris/tags?name=operator-${VERSION}" "$DOCKER_IMAGE_URL"
 
 validate_release_config
 
@@ -44,6 +48,16 @@
 fi
 assert_file_contains "$error_file" "TAG must equal VERSION"
 
+if (GITHUB_REPO="doris-operator"; validate_release_config) 2>"$error_file"; then
+  fail "configuration validation accepted a GITHUB_REPO without an owner"
+fi
+assert_file_contains "$error_file" "GITHUB_REPO must be <owner>/<repo>"
+
+if (DOCKER_IMAGE="apache/doris:operator-0.0.0"; validate_release_config) 2>"$error_file"; then
+  fail "configuration validation accepted a DOCKER_IMAGE from another version"
+fi
+assert_file_contains "$error_file" "DOCKER_IMAGE must be"
+
 if grep -Eq '^[[:space:]]*ASF_(USERNAME|PASSWORD)=' "${TOOLS_ROOT}/release.env"; then
   fail "release.env stores SVN credentials"
 fi
diff --git a/tools/release-tools/tests/test-mail.sh b/tools/release-tools/tests/test-mail.sh
index 366828e..c58799a 100755
--- a/tools/release-tools/tests/test-mail.sh
+++ b/tools/release-tools/tests/test-mail.sh
@@ -45,10 +45,13 @@
 APACHE_EMAIL="release-manager@apache.org"
 SIGNER_NAME="Release Manager"
 SIGNING_KEY="0123456789ABCDEF0123456789ABCDEF01234567"
+GITHUB_REPO="apache-test/doris-operator"
 GITHUB_TAG_URL="https://github.example.test/apache/doris-operator/releases/tag/\${TAG}"
 RELEASE_NOTES_URL="https://github.example.test/apache/doris-operator/releases/notes/\${TAG}"
 VERIFY_GUIDE_URL="https://doris.example.test/release-verify"
 DOWNLOAD_PAGE_URL="https://github.example.test/apache/doris-operator/releases/tag/\${TAG}"
+DOCKER_IMAGE="apache/doris:operator-\${VERSION}"
+DOCKER_IMAGE_URL="https://hub.example.test/r/apache/doris/tags?name=operator-\${VERSION}"
 VOTE_TO="dev@example.test"
 ANNOUNCE_TO="announce@example.test"
 WORK_DIR="${tmp}/work"
@@ -74,6 +77,7 @@
 assert_exists "$vote_body"
 assert_exists "$vote_eml"
 assert_file_contains "$vote_eml" "Subject: [VOTE] Release Apache Doris Operator 9.9.9"
+assert_eq "Subject: [VOTE] Release Apache Doris Operator 9.9.9" "$(head -n 1 "$vote_body")"
 assert_file_contains "$vote_body" "https://github.example.test/apache/doris-operator/releases/tag/9.9.9"
 assert_file_contains "$vote_body" "https://dist.example.test/dev/doris/doris-operator/9.9.9/"
 assert_file_contains "$vote_body" "0123456789ABCDEF0123456789ABCDEF01234567"
@@ -91,14 +95,22 @@
 assert_exists "$announce_eml"
 assert_file_contains "$announce_eml" "To: announce@example.test"
 assert_file_contains "$announce_eml" "Subject: [ANNOUNCE] Apache Doris Operator 9.9.9 release"
+assert_eq "Subject: [ANNOUNCE] Apache Doris Operator 9.9.9 release" "$(head -n 1 "$announce_body")"
+assert_file_contains "${tmp}/announce-output" "Subject: [ANNOUNCE] Apache Doris Operator 9.9.9 release"
 assert_file_contains "$announce_body" "automates the deployment and management"
 assert_file_contains "$announce_body" "https://dist.example.test/release/doris/doris-operator/9.9.9/apache-doris-operator-9.9.9-src.tar.gz"
 assert_file_contains "$announce_body" "Thank you to everyone"
-assert_file_contains "${tmp}/announce-output" "mail-only mode: skipping tag, package, signing, and SVN operations"
+assert_file_contains "${tmp}/announce-output" "mail-only mode: skipping tag, package, signing, SVN, and GitHub operations"
+assert_not_exists "${tmp}/work/github-release-notes.md"
 [[ ! -s "$COMMAND_LOG" ]] || fail "--mail-only invoked an external release command"
 
 if PATH="${tmp}/fake-bin:${PATH}" "${tool_copy}/04-release-complete.sh" --unknown >/dev/null 2>&1; then
   fail "04-release-complete.sh accepted an unknown argument"
 fi
 
+if PATH="${tmp}/fake-bin:${PATH}" "${tool_copy}/04-release-complete.sh" \
+  --mail-only --github-only >/dev/null 2>&1; then
+  fail "04-release-complete.sh accepted --mail-only together with --github-only"
+fi
+
 pass
diff --git a/tools/release-tools/tests/test-syntax.sh b/tools/release-tools/tests/test-syntax.sh
index 34de8c1..d1ef9ac 100755
--- a/tools/release-tools/tests/test-syntax.sh
+++ b/tools/release-tools/tests/test-syntax.sh
@@ -37,6 +37,11 @@
   './03-vote-mail.sh' \
   './04-release-complete.sh' \
   '--mail-only' \
+  '--github-only' \
+  '--verify-tag' \
+  'The whole run is idempotent' \
+  'apache/doris:operator-' \
+  'Subject:' \
   'refuses to overwrite' \
   'does not inspect, compare, promote, move, or delete anything under dev SVN' \
   'prints the subject and body' \
diff --git a/tools/release-tools/tests/test-workflows.sh b/tools/release-tools/tests/test-workflows.sh
index 34318f5..c950860 100755
--- a/tools/release-tools/tests/test-workflows.sh
+++ b/tools/release-tools/tests/test-workflows.sh
@@ -58,10 +58,13 @@
 APACHE_EMAIL="release-manager@apache.org"
 SIGNER_NAME="Release Manager"
 SIGNING_KEY="0123456789ABCDEF0123456789ABCDEF01234567"
+GITHUB_REPO="apache-test/doris-operator"
 GITHUB_TAG_URL="https://github.example.test/apache/doris-operator/releases/tag/\${TAG}"
-RELEASE_NOTES_URL="https://github.example.test/apache/doris-operator/releases/notes/\${TAG}"
+RELEASE_NOTES_URL="https://github.example.test/apache/doris-operator/issues/512"
 VERIFY_GUIDE_URL="https://doris.example.test/release-verify"
 DOWNLOAD_PAGE_URL="https://github.example.test/apache/doris-operator/releases/tag/\${TAG}"
+DOCKER_IMAGE="apache/doris:operator-\${VERSION}"
+DOCKER_IMAGE_URL="https://hub.example.test/r/apache/doris/tags?name=operator-\${VERSION}"
 VOTE_TO="dev@example.test"
 ANNOUNCE_TO="announce@example.test"
 WORK_DIR="${tmp}/work"
@@ -69,8 +72,12 @@
 
 export FAKE_GPG_LOG="${tmp}/gpg.log"
 export FAKE_SVN_LOG="${tmp}/svn.log"
+export FAKE_GH_LOG="${tmp}/gh.log"
+export FAKE_GH_RELEASE="${tmp}/gh-release-body"
 : > "$FAKE_GPG_LOG"
 : > "$FAKE_SVN_LOG"
+: > "$FAKE_GH_LOG"
+rm -f "$FAKE_GH_RELEASE"
 
 cat > "${fake_bin}/gpg" <<'EOF'
 #!/usr/bin/env bash
@@ -112,12 +119,48 @@
     destination="${@: -1}"
     mkdir -p "$destination"
     ;;
+  ls)
+    [[ -n "${FAKE_SVN_LS_FILE:-}" && -f "${FAKE_SVN_LS_FILE:-}" ]] || exit 1
+    cat "$FAKE_SVN_LS_FILE"
+    ;;
   add|status|commit) exit 0 ;;
   *) exit 1 ;;
 esac
 EOF
 chmod +x "${fake_bin}/svn"
 
+cat > "${fake_bin}/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+printf '%s\n' "$*" >> "$FAKE_GH_LOG"
+all_args=" $* "
+[[ "${1:-}" != "auth" ]] || exit 0
+[[ "${1:-}" == "release" ]] || exit 1
+action="${2:-}"
+notes=""
+while [[ "$#" -gt 0 ]]; do
+  [[ "$1" != "--notes-file" ]] || notes="$2"
+  shift
+done
+case "$action" in
+  view)
+    [[ -f "$FAKE_GH_RELEASE" ]] || exit 1
+    [[ "$all_args" != *" --json "* ]] || cat "$FAKE_GH_RELEASE"
+    ;;
+  create)
+    [[ "$all_args" == *" --verify-tag "* ]] || exit 1
+    [[ -n "$notes" ]] || exit 1
+    cp "$notes" "$FAKE_GH_RELEASE"
+    ;;
+  edit)
+    [[ -n "$notes" ]] || exit 1
+    cp "$notes" "$FAKE_GH_RELEASE"
+    ;;
+  *) exit 1 ;;
+esac
+EOF
+chmod +x "${fake_bin}/gh"
+
 test_path="${fake_bin}:${PATH}"
 unset FAKE_EXISTING_URL || true
 if ! printf 'y\ny\n' | PATH="$test_path" "${tool_copy}/02-package-sign-upload.sh" > "${tmp}/dev-output" 2>&1; then
@@ -139,7 +182,7 @@
 
 : > "$FAKE_SVN_LOG"
 : > "$FAKE_GPG_LOG"
-if ! printf 'y\ny\n' | PATH="$test_path" "${tool_copy}/04-release-complete.sh" > "${tmp}/release-output" 2>&1; then
+if ! printf 'y\ny\ny\n' | PATH="$test_path" "${tool_copy}/04-release-complete.sh" > "${tmp}/release-output" 2>&1; then
   cat "${tmp}/release-output" >&2
   fail "formal release workflow failed"
 fi
@@ -152,6 +195,62 @@
 assert_exists "${tmp}/work/announce-email.txt"
 assert_exists "${tmp}/work/announce-email.eml"
 
+notes_file="${tmp}/work/github-release-notes.md"
+assert_exists "$notes_file"
+assert_file_contains "$notes_file" "https://github.example.test/apache/doris-operator/issues/512"
+assert_file_contains "$notes_file" "docker pull apache/doris:operator-9.9.9"
+assert_file_contains "$notes_file" "https://hub.example.test/r/apache/doris/tags?name=operator-9.9.9"
+assert_file_contains "$notes_file" "https://dist.example.test/release/doris/doris-operator/9.9.9/apache-doris-operator-9.9.9-src.tar.gz"
+assert_file_contains "$FAKE_GH_LOG" "release create 9.9.9 --repo apache-test/doris-operator --verify-tag"
+assert_exists "$FAKE_GH_RELEASE"
+
+# A repeated run of a finished release must not repackage, re-upload, or
+# rewrite the GitHub release.
+: > "$FAKE_SVN_LOG"
+: > "$FAKE_GPG_LOG"
+: > "$FAKE_GH_LOG"
+rm -f "${tmp}/work/announce-email.txt" "${tmp}/work/announce-email.eml"
+release_listing="${tmp}/release-listing"
+printf '%s\n' \
+  'apache-doris-operator-9.9.9-src.tar.gz' \
+  'apache-doris-operator-9.9.9-src.tar.gz.asc' \
+  'apache-doris-operator-9.9.9-src.tar.gz.sha512' > "$release_listing"
+export FAKE_SVN_LS_FILE="$release_listing"
+export FAKE_EXISTING_URL="https://dist.example.test/release/doris/doris-operator/9.9.9"
+if ! printf 'y\ny\ny\n' | PATH="$test_path" "${tool_copy}/04-release-complete.sh" > "${tmp}/rerun-output" 2>&1; then
+  cat "${tmp}/rerun-output" >&2
+  fail "repeated formal release run was not idempotent"
+fi
+assert_file_contains "${tmp}/rerun-output" "release files already published"
+assert_file_contains "${tmp}/rerun-output" "is already published: https://github.example.test/apache/doris-operator/releases/tag/9.9.9"
+assert_file_not_contains "$FAKE_SVN_LOG" "checkout"
+assert_file_not_contains "$FAKE_SVN_LOG" "commit"
+assert_file_not_contains "$FAKE_GH_LOG" "release create"
+assert_file_not_contains "$FAKE_GH_LOG" "release edit"
+[[ ! -s "$FAKE_GPG_LOG" ]] || fail "repeated formal release run signed the artifacts again"
+assert_exists "${tmp}/work/announce-email.txt"
+assert_exists "${tmp}/work/announce-email.eml"
+
+: > "$FAKE_SVN_LOG"
+: > "$FAKE_GH_LOG"
+if ! printf 'y\n' | PATH="$test_path" "${tool_copy}/04-release-complete.sh" --github-only > "${tmp}/github-only-output" 2>&1; then
+  cat "${tmp}/github-only-output" >&2
+  fail "github-only mode failed"
+fi
+assert_file_contains "${tmp}/github-only-output" "github-only mode"
+assert_file_contains "$FAKE_GH_LOG" "release view 9.9.9"
+[[ ! -s "$FAKE_SVN_LOG" ]] || fail "github-only mode invoked svn"
+
+: > "$FAKE_SVN_LOG"
+printf '%s\n' 'apache-doris-operator-9.9.9-src.tar.gz' > "$release_listing"
+if printf 'y\ny\ny\n' | PATH="$test_path" "${tool_copy}/04-release-complete.sh" > "${tmp}/partial-output" 2>&1; then
+  fail "formal release workflow accepted an incomplete release directory"
+fi
+assert_file_contains "${tmp}/partial-output" "incomplete release directory"
+assert_file_not_contains "$FAKE_SVN_LOG" "checkout"
+assert_file_not_contains "$FAKE_SVN_LOG" "commit"
+unset FAKE_SVN_LS_FILE
+
 : > "$FAKE_SVN_LOG"
 export FAKE_EXISTING_URL="https://dist.example.test/dev/doris/doris-operator/9.9.9"
 if printf 'y\ny\n' | PATH="$test_path" "${tool_copy}/02-package-sign-upload.sh" >/dev/null 2>&1; then