This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 4eec06aca7a [improvement](release) Harden the release helper scripts: 
signature reminder, mail drafts, idempotent completion, tag and GitHub release 
(#66663)
4eec06aca7a is described below

commit 4eec06aca7a32e2a53a88e5ab1c9f58300701889
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Aug 14 16:54:41 2026 +0800

    [improvement](release) Harden the release helper scripts: signature 
reminder, mail drafts, idempotent completion, tag and GitHub release (#66663)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: #65488
    
    Problem Summary:
    
    Gaps in the release helper scripts, all found while preparing
    4.0.8-rc02.
    
    ---
    
    **1. `02-package-sign-upload.sh` signs the convenience binaries but
    never uploads them**
    
    The script writes `.asc` and `.sha512` next to each `BIN_FILES` entry
    and stops there, while `03-vote-mail.sh` already advertises those files
    under `BIN_DOWNLOAD_BASE`. Nothing told the RM to publish them, so a
    vote mail could go to `dev@` with dead `.asc` links.
    
    The reminder now prints on every successful exit, including the two
    paths that answer `n` at the SVN confirmations — the binaries are
    already signed by then, so the reminder is just as relevant there.
    
    ```
    [WARN] the binary .asc signatures are NOT uploaded by this script.
    Upload each binary together with its .asc and .sha512 to https://<base>/ :
      apache-doris-4.0.8-bin-x64.tar.gz  apache-doris-4.0.8-bin-x64.tar.gz.asc  
apache-doris-4.0.8-bin-x64.tar.gz.sha512
      ...
    03-vote-mail.sh advertises them under https://<base>/, so upload them 
BEFORE sending the vote email.
    ```
    
    **2. `03-vote-mail.sh` produced an incomplete draft**
    
    - The subject only reached the console and the `.eml` header, so the RM
    had to take the subject from one place and the body from another.
    `vote-email.txt` now starts with the `Subject:` line, and the whole mail
    can be copied from a single file.
    - The subject wording is now `[VOTE] Release for Apache Doris <tag>`,
    matching what is used on the list.
    - The body now ends with the RM's own `Here is my +1(binding)`, placed
    above the signature, so it does not have to be typed in by hand every
    release.
    
    **3. `04-release-complete.sh` could only ever run once**
    
    The first thing it did after checking the RC artifacts was:
    
    ```bash
    if svn info "$RELEASE_SVN_DIR" >/dev/null 2>&1; then
      die "release SVN folder already exists: $RELEASE_SVN_DIR (use --mail-only 
to regenerate the email)"
    fi
    ```
    
    So once the release was published, every re-run died — including a
    re-run right after the publish succeeded and the announce step failed,
    which is exactly when the RM wants to continue. `--mail-only` was the
    documented workaround, but it also skips the checks that would tell the
    RM whether the publish actually completed.
    
    It now discovers the dev and release SVN state first and converges from
    there:
    
    - all three release artifacts present: skip the publish steps, remove a
    dev RC folder that was left behind, draft the email;
    - nothing published: run the full flow, reusing a release directory that
    exists but is empty instead of trying to create it again;
    - some but not all release artifacts present: report exactly which files
    are missing and stop, rather than guessing. `svnmucc` commits all of its
    operations in one revision, so this state can only come from a manual
    change and deserves a human.
    
    The dev RC folder removal is also skipped when the folder is already
    gone, so the `svnmucc rm` cannot fail a re-run.
    
    **4. `04-release-complete.sh` now asks before every step**
    
    There used to be exactly one confirmation, right before the `svnmucc`
    commit. Each step now prints what it is about to do and waits:
    
    ```
    == step 3: Publish to the release SVN and remove the dev RC folder ==
         This is public and requires PMC permission.
         All of it lands in one SVN revision:
         mkdir https://dist.apache.org/repos/dist/release/doris/4.0/4.0.8
         mv    .../dev/doris/4.0.8-rc02/apache-doris-4.0.8-rc02-src.tar.gz
           ->  .../release/doris/4.0/4.0.8/apache-doris-4.0.8-src.tar.gz
         ...
    Proceed with step 3? [y/N]
    ```
    
    Declining any step stops the run without changing further state and says
    so, and because the script is idempotent the run can simply be started
    again later.
    
    **5. The announce draft carries its subject**
    
    `announce-email.txt` now starts with a `Subject:` line matching the
    header already written into `announce-email.eml`, the same treatment the
    vote email gets above.
    
    **6. `04-release-complete.sh` now finishes the release on GitHub too**
    
    Releasing 4.0.8 from the `4.0.8-rc02` tag still needed two manual steps
    after the SVN publish: pushing a `4.0.8` tag at the same commit, and
    creating the GitHub release by hand in the web UI. Step 04 does both
    now, and both are idempotent like the rest of the script.
    
    The tag step only runs when `TAG` carries an `rc` suffix. It verifies
    that an existing local or remote tag of that name sits on the RC commit,
    stops if it does not, and skips itself once the tag is on the remote.
    
    The release step never rewrites a published release. Its body follows
    the shape every previous Doris release uses — change log link, download
    page, release SVN source artifacts, and one `Binary(<arch>)` section per
    `BIN_FILES` entry, linked to the public mirror rather than the host the
    vote email advertises:
    
    ```
    [Change Log](https://github.com/apache/doris/issues/66642)
    
    - Official Downloads: https://doris.apache.org/download
    
    - Source:
        - 
[apache-doris-4.0.8-src.tar.gz](https://dist.apache.org/repos/dist/release/doris/4.0/4.0.8/apache-doris-4.0.8-src.tar.gz)
 ([asc](...))([sha512](...))
    
    - Binary(x64):
        - 
[apache-doris-4.0.8-bin-x64.tar.gz](https://download.velodb.io/apache-doris-4.0.8-bin-x64.tar.gz)
 ([asc](...))([sha512](...))
    ...
    ```
    
    Before creating it, the script prints which release GitHub currently
    marks Latest and asks whether this one should take that place, so a
    4.0.x maintenance release does not displace a newer 4.1.x by accident:
    
    ```
    The release currently marked Latest on apache/doris is 4.1.3.
    Mark 4.0.8 as the Latest release? [y/N]
    ```
    
    `gh` stays optional: a missing or unauthenticated `gh` warns and moves
    on, and `--skip-github-release` pushes the tag while leaving the release
    alone. `GITHUB_REPO` defaults to the `owner/repo` parsed out of the
    `GIT_REMOTE` URL, credentials stripped.
    
    **7. `git rev-parse` without `-q --verify`**
    
    Plain `git rev-parse 4.0.8^{commit}` echoes its argument back and exits
    non-zero when the tag does not exist, so `$(git rev-parse ... || true)`
    captured the string `4.0.8^{commit}` instead of an empty result. The tag
    step read that as a commit id and refused to tag a release that had
    never been tagged. Every call passes `-q --verify` now, and the fake
    `git` in the new test reproduces the real behaviour, so dropping the
    flags fails the suite.
    
    **8. A directory leak the existing test already caught**
    
    `tests/test-release-complete-checksum.sh` fails on current master:
    
    ```
    checksum temp directory was not removed after checksum verification 
failure: /var/folders/.../checksum-dir-32602-11908
    ```
    
    `checksum_dir` was `local` to `publish_to_release_svn`, while the `trap
    ... EXIT` referencing it is global. When `set -e` aborted the run the
    local was already out of scope, so the trap expanded it to an empty
    string and `rm -rf ""` left the directory behind. It is a script-level
    variable with a cleanup function now, and the test passes.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test
        - [x] Unit Test
    
    Three new cases and one updated case under `tools/release-tools/tests/`,
    all following the existing stub-`release.env` plus
    fake-binaries-on-`PATH` pattern already used by
    `test-release-complete-checksum.sh`:
    
    - `test-package-binary-upload-reminder.sh` (new) runs `02` end to end
    with a fake `git` and `gpg`, answers `n` at the first SVN confirmation,
    and asserts that each `.asc` was produced and that the reminder names
    it, names the download base, and states that the script does not upload.
    - `test-vote-mail-draft.sh` (new) runs `03` and asserts the subject line
    heads `vote-email.txt`, the same subject is a header in
    `vote-email.eml`, and the binding vote appears in both, above `Best
    Regards,`.
    - `test-release-complete-idempotent.sh` (new) drives `04` over an
    already-published release: with the dev RC folder already gone no SVN
    operation runs at all and the announce draft is still written with its
    `Subject:` line; with a stale dev RC folder the only `svnmucc` operation
    is the `rm` of that folder, no `mkdir`/`mv`/`put`; and a declined step
    runs nothing, names the step it stopped before, and does not fall
    through to the later steps.
    - `test-release-complete-github-release.sh` (new) covers the tag and
    GitHub release steps with a fake `git`/`gh`: a fresh run creates the tag
    from the RC commit, pushes it, and creates the release with the expected
    title and body; declining the Latest question passes `--latest=false`
    and accepting it passes `--latest`; a re-run with the tag pushed and the
    release published touches neither and still drafts the announce email; a
    `9.9.9` tag on a different commit stops the run; and
    `--skip-github-release` pushes the tag without creating the release.
    - `test-release-complete-checksum.sh` (updated) feeds one `y` per step.
    It covers the leak fix and passed unchanged in every other respect.
    
    Every test now stubs `gh` as well, so no case can reach the real GitHub
    API.
    
    `./tests/run.sh` is fully green on this branch (7 cases).
    `test-release-complete-checksum.sh` is red on master.
---
 tools/release-tools/02-package-sign-upload.sh      |  16 +
 tools/release-tools/03-vote-mail.sh                |  12 +-
 tools/release-tools/04-release-complete.sh         | 468 +++++++++++++++++----
 tools/release-tools/README.md                      |  85 +++-
 tools/release-tools/release.env                    |  12 +
 .../tests/test-package-binary-upload-reminder.sh   | 106 +++++
 .../tests/test-release-complete-checksum.sh        |  31 +-
 .../tests/test-release-complete-github-release.sh  | 227 ++++++++++
 .../tests/test-release-complete-idempotent.sh      | 181 ++++++++
 tools/release-tools/tests/test-vote-mail-draft.sh  |  88 ++++
 10 files changed, 1114 insertions(+), 112 deletions(-)

diff --git a/tools/release-tools/02-package-sign-upload.sh 
b/tools/release-tools/02-package-sign-upload.sh
index 8d6b20cc641..1b8c9f9075a 100755
--- a/tools/release-tools/02-package-sign-upload.sh
+++ b/tools/release-tools/02-package-sign-upload.sh
@@ -92,6 +92,22 @@ if [[ "$bin_count" -gt 0 ]]; then
   echo
 fi
 
+# The signatures above stay local. Remind the RM to publish them, on every
+# successful exit - including the paths below that stop before the SVN commit.
+bin_upload_reminder() {
+  [[ "${bin_count:-0}" -gt 0 ]] || return 0
+  local base="${BIN_DOWNLOAD_BASE%/}" b
+  echo
+  warn "the binary .asc signatures are NOT uploaded by this script."
+  echo "Upload each binary together with its .asc and .sha512 to ${base}/ :"
+  for b in "${BIN_FILES[@]}"; do
+    b="$(basename "$b")"
+    echo "  $b  $b.asc  $b.sha512"
+  done
+  echo "03-vote-mail.sh advertises them under ${base}/, so upload them BEFORE 
sending the vote email."
+}
+trap 'rc=$?; if [[ "$rc" -eq 0 ]]; then bin_upload_reminder; fi; exit "$rc"' 
EXIT
+
 # 5. upload to dev SVN (two confirmations; nothing public happens before them)
 echo "Target dev SVN folder: ${DEV_SVN_DIR}/"
 confirm "Checkout + add these 3 files for the above SVN URL?" || { warn 
"stopping before SVN."; exit 0; }
diff --git a/tools/release-tools/03-vote-mail.sh 
b/tools/release-tools/03-vote-mail.sh
index 025e154ea7a..06d4dc70430 100755
--- a/tools/release-tools/03-vote-mail.sh
+++ b/tools/release-tools/03-vote-mail.sh
@@ -41,7 +41,7 @@ fi
 FPR="$(gpg --list-keys --with-colons "$SIGNER" | awk -F: '/^fpr:/{print $10; 
exit}')"
 
 mkdir -p "$WORK_DIR"
-subject="[VOTE] Release Apache Doris ${TAG}"
+subject="[VOTE] Release for Apache Doris ${TAG}"
 body_file="$WORK_DIR/vote-email.txt"
 eml_file="$WORK_DIR/vote-email.eml"
 
@@ -88,11 +88,19 @@ The vote will be open for at least 72 hours.
 [ ] +0 No opinion
 [ ] -1 Do not release this package because ...
 
+Here is my +1(binding)
+
 Best Regards,
 ${SIGNER_NAME} (${APACHE_ID})
 EOF
 
-printf '%s\n' "$BODY" > "$body_file"
+# The .txt draft carries the subject line too, so the whole mail can be copied
+# from one file. The .eml keeps it as a real header below.
+{
+  echo "Subject: ${subject}"
+  echo
+  printf '%s\n' "$BODY"
+} > "$body_file"
 {
   echo "To: ${DEV_LIST}"
   echo "Subject: ${subject}"
diff --git a/tools/release-tools/04-release-complete.sh 
b/tools/release-tools/04-release-complete.sh
index 84691d55a4c..fc188310817 100755
--- a/tools/release-tools/04-release-complete.sh
+++ b/tools/release-tools/04-release-complete.sh
@@ -16,11 +16,16 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# Step 04 - publish the passed RC source artifact to the Apache release SVN
-# and generate the [ANNOUNCE] email draft.
+# Step 04 - publish the passed RC source artifact to the Apache release SVN,
+# tag the release on GitHub, and generate the [ANNOUNCE] email draft.
 #
-# The release SVN commit is public and requires PMC permission. This script
-# pauses for confirmation before changing SVN, and it never sends email.
+# The release SVN commit is public and requires PMC permission. Every step asks
+# for confirmation before it runs, and this script never sends email.
+#
+# The script is idempotent: it reads the current dev SVN, release SVN, tag and
+# GitHub release state first, skips whatever is already done, and can be re-run
+# safely after a successful publish or after stopping at any confirmation
+# prompt.
 set -euo pipefail
 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 # shellcheck source=release.env
@@ -31,22 +36,42 @@ warn() { echo "[WARN] $*"; }
 die()  { echo "[FAIL] $*" >&2; exit 1; }
 confirm() { local a; read -r -p "$1 [y/N] " a; [[ "$a" == y || "$a" == Y ]]; }
 
+# Keep the scratch directory in a global: an EXIT trap fires after the local
+# scope of the function that created it is gone, so a local would expand empty
+# and leak the directory whenever `set -e` aborts mid-run.
+CHECKSUM_DIR=""
+cleanup_checksum_dir() {
+  [[ -n "$CHECKSUM_DIR" ]] || return 0
+  rm -rf "$CHECKSUM_DIR"
+  CHECKSUM_DIR=""
+}
+trap cleanup_checksum_dir EXIT
+
 mail_only=0
+skip_github_release=0
 usage() {
   cat <<EOF
-Usage: $0 [--mail-only]
+Usage: $0 [--mail-only] [--skip-github-release]
 
 Publishes Apache Doris ${TAG} source artifacts from dev SVN to release SVN as
-Apache Doris ${VERSION}, then writes the [ANNOUNCE] email draft.
+Apache Doris ${VERSION}, pushes the RC-free ${VERSION} tag, publishes the 
GitHub
+release, then writes the [ANNOUNCE] email draft.
+
+Every step asks for confirmation first, and answering anything but y stops the
+run without changing more state. Re-running is safe: an already published
+release, an existing tag and an existing GitHub release are detected and left
+alone.
 
 Options:
-  --mail-only   Only write announce-email.txt and announce-email.eml.
+  --mail-only             Only write announce-email.txt and announce-email.eml.
+  --skip-github-release   Push the tag but leave the GitHub release alone.
 EOF
 }
 
 while [[ $# -gt 0 ]]; do
   case "$1" in
     --mail-only) mail_only=1; shift ;;
+    --skip-github-release) skip_github_release=1; shift ;;
     -h|--help) usage; exit 0 ;;
     *) die "unknown argument: $1" ;;
   esac
@@ -65,11 +90,312 @@ 
RELEASE_SVN_DIR="${RELEASE_SVN_DIR:-${RELEASE_SVN_BASE}/${RELEASE_SERIES}/${VERS
 RELEASE_SVN_PARENT_DIR="${RELEASE_SVN_PARENT_DIR:-${RELEASE_SVN_DIR%/*}}"
 DOWNLOAD_PAGE_URL="${DOWNLOAD_PAGE_URL:-https://doris.apache.org/download/}";
 ANNOUNCE_RELEASE_NOTES_URL="${ANNOUNCE_RELEASE_NOTES_URL:-}"
+# The GitHub release body has always linked the convenience binaries to their
+# public mirror, which is not the same host the vote email advertises.
+RELEASE_BIN_DOWNLOAD_BASE="${RELEASE_BIN_DOWNLOAD_BASE:-https://download.velodb.io}";
+# The RC-free tag that carries the GitHub release, e.g. 4.0.8-rc02 -> 4.0.8.
+RELEASE_TAG="${RELEASE_TAG:-${VERSION}}"
+GITHUB_RELEASE_TITLE="${GITHUB_RELEASE_TITLE:-Apache Doris ${VERSION} Release}"
+REPO_DIR="${REPO_DIR:-}"
+GIT_REMOTE="${GIT_REMOTE:-}"
+
+SRC_TAR="${DEV_SVN_DIR}/${PKG_BASE}.tar.gz"
+SRC_ASC="${SRC_TAR}.asc"
+SRC_SHA512="${SRC_TAR}.sha512"
+DST_TAR="${RELEASE_PKG_BASE}.tar.gz"
+DST_ASC="${DST_TAR}.asc"
+DST_SHA512="${DST_TAR}.sha512"
 
 require_tool() {
   command -v "$1" >/dev/null 2>&1 || die "missing tool: $1"
 }
 
+# Every step announces what it is about to do and waits for confirmation, so a
+# run can be stopped at any boundary without leaving half-finished state.
+step_no=0
+step() {
+  local title="$1" line
+  shift
+  step_no=$((step_no + 1))
+  echo
+  echo "== step ${step_no}: ${title} =="
+  for line in "$@"; do
+    echo "     ${line}"
+  done
+  confirm "Proceed with step ${step_no}?" || {
+    warn "stopped before step ${step_no}: ${title}"
+    warn "nothing further was changed; re-run this script to continue."
+    exit 0
+  }
+}
+
+svn_url_exists() { svn info "${svn_auth[@]}" "$1" >/dev/null 2>&1; }
+
+# --- discovered state ------------------------------------------------------
+DEV_DIR_EXISTS=0
+SRC_PRESENT=0             # of SRC_TAR, SRC_ASC, SRC_SHA512
+RELEASE_DIR_EXISTS=0
+RELEASE_PARENT_EXISTS=0
+DST_PRESENT=0             # of DST_TAR, DST_ASC, DST_SHA512
+DST_MISSING=()
+
+discover_svn_state() {
+  local url
+
+  DEV_DIR_EXISTS=0
+  SRC_PRESENT=0
+  RELEASE_DIR_EXISTS=0
+  RELEASE_PARENT_EXISTS=0
+  DST_PRESENT=0
+  DST_MISSING=()
+
+  svn_url_exists "$DEV_SVN_DIR" && DEV_DIR_EXISTS=1
+  for url in "$SRC_TAR" "$SRC_ASC" "$SRC_SHA512"; do
+    svn_url_exists "$url" && SRC_PRESENT=$((SRC_PRESENT + 1))
+  done
+
+  svn_url_exists "$RELEASE_SVN_PARENT_DIR" && RELEASE_PARENT_EXISTS=1
+  svn_url_exists "$RELEASE_SVN_DIR" && RELEASE_DIR_EXISTS=1
+  if [[ "$RELEASE_DIR_EXISTS" -eq 1 ]]; then
+    for url in "$DST_TAR" "$DST_ASC" "$DST_SHA512"; do
+      if svn_url_exists "${RELEASE_SVN_DIR}/${url}"; then
+        DST_PRESENT=$((DST_PRESENT + 1))
+      else
+        DST_MISSING+=("$url")
+      fi
+    done
+  else
+    DST_MISSING=("$DST_TAR" "$DST_ASC" "$DST_SHA512")
+  fi
+
+  echo
+  echo "--- current state ---"
+  echo "dev SVN     ${DEV_SVN_DIR}/"
+  echo "            folder: $([[ "$DEV_DIR_EXISTS" -eq 1 ]] && echo present || 
echo absent), RC artifacts: ${SRC_PRESENT}/3"
+  echo "release SVN ${RELEASE_SVN_DIR}/"
+  echo "            folder: $([[ "$RELEASE_DIR_EXISTS" -eq 1 ]] && echo 
present || echo absent), release artifacts: ${DST_PRESENT}/3"
+}
+
+verify_and_build_checksum() {
+  local src_tar_file
+
+  CHECKSUM_DIR="$(mktemp -d)"
+  src_tar_file="${PKG_BASE}.tar.gz"
+  FINAL_SHA512_FILE="${CHECKSUM_DIR}/${DST_SHA512}"
+
+  svn cat "${svn_auth[@]}" "$SRC_TAR" > "${CHECKSUM_DIR}/${src_tar_file}"
+  svn cat "${svn_auth[@]}" "$SRC_SHA512" > 
"${CHECKSUM_DIR}/${src_tar_file}.sha512"
+  svn cat "${svn_auth[@]}" "$SRC_ASC" > "${CHECKSUM_DIR}/${src_tar_file}.asc"
+  (
+    cd "$CHECKSUM_DIR"
+    sha512sum --check "${src_tar_file}.sha512"
+    gpg --verify "${src_tar_file}.asc" "$src_tar_file"
+    cp "$src_tar_file" "$DST_TAR"
+    sha512sum "$DST_TAR" > "$DST_SHA512"
+    sha512sum --check "$DST_SHA512"
+  )
+  ok "source RC checksum and signature verified: ${src_tar_file}"
+  ok "final sha512 ok: ${DST_SHA512}"
+}
+
+publish_to_release_svn() {
+  local -a svnmucc_ops op_lines
+
+  svnmucc_ops=()
+  op_lines=()
+  if [[ "$RELEASE_PARENT_EXISTS" -eq 0 ]]; then
+    op_lines+=("mkdir ${RELEASE_SVN_PARENT_DIR}")
+    svnmucc_ops+=(mkdir "$RELEASE_SVN_PARENT_DIR")
+  fi
+  if [[ "$RELEASE_DIR_EXISTS" -eq 0 ]]; then
+    op_lines+=("mkdir ${RELEASE_SVN_DIR}")
+    svnmucc_ops+=(mkdir "$RELEASE_SVN_DIR")
+  fi
+  op_lines+=("mv    ${SRC_TAR}")
+  op_lines+=("  ->  ${RELEASE_SVN_DIR}/${DST_TAR}")
+  svnmucc_ops+=(mv "$SRC_TAR" "${RELEASE_SVN_DIR}/${DST_TAR}")
+  op_lines+=("mv    ${SRC_ASC}")
+  op_lines+=("  ->  ${RELEASE_SVN_DIR}/${DST_ASC}")
+  svnmucc_ops+=(mv "$SRC_ASC" "${RELEASE_SVN_DIR}/${DST_ASC}")
+  op_lines+=("put   ${FINAL_SHA512_FILE}")
+  op_lines+=("  ->  ${RELEASE_SVN_DIR}/${DST_SHA512}")
+  svnmucc_ops+=(put "$FINAL_SHA512_FILE" "${RELEASE_SVN_DIR}/${DST_SHA512}")
+  op_lines+=("rm    ${DEV_SVN_DIR}")
+  svnmucc_ops+=(rm "$DEV_SVN_DIR")
+
+  step "Publish to the release SVN and remove the dev RC folder" \
+    "This is public and requires PMC permission." \
+    "All of it lands in one SVN revision:" \
+    "${op_lines[@]}"
+
+  if ! svnmucc "${svnmucc_auth[@]}" -m "Release Doris ${VERSION}" 
"${svnmucc_ops[@]}"; then
+    die "svnmucc release publish failed"
+  fi
+  cleanup_checksum_dir
+  ok "committed release artifacts: ${RELEASE_SVN_DIR}/"
+  ok "removed dev RC folder: ${DEV_SVN_DIR}/"
+}
+
+remove_dev_rc_folder() {
+  step "Remove the leftover dev RC folder" \
+    "The release artifacts are already published, so the RC folder is stale." \
+    "rm    ${DEV_SVN_DIR}"
+
+  if ! svnmucc "${svnmucc_auth[@]}" -m "Remove ${TAG} after releasing Doris 
${VERSION}" \
+      rm "$DEV_SVN_DIR"; then
+    die "svnmucc dev RC removal failed"
+  fi
+  ok "removed dev RC folder: ${DEV_SVN_DIR}/"
+}
+
+git_repo() { git -C "$REPO_DIR" "$@"; }
+
+# apache/doris out of any remote URL form, including one that carries a token.
+github_repo() {
+  if [[ -n "${GITHUB_REPO:-}" ]]; then
+    printf '%s\n' "$GITHUB_REPO"
+    return 0
+  fi
+  git_repo remote get-url "$GIT_REMOTE" 2>/dev/null |
+    sed -E 's#^.*github\.com[:/]##; s#\.git$##; s#/$##'
+}
+
+# Peeled commit of a tag on the remote, empty when the tag is not there.
+remote_tag_commit() {
+  local tag="$1" out
+  out="$(git_repo ls-remote --tags "$GIT_REMOTE" "refs/tags/${tag}" 
"refs/tags/${tag}^{}" 2>/dev/null || true)"
+  awk -v t="refs/tags/${tag}^{}" '$2==t{print $1; found=1} END{if(!found) exit 
1}' <<<"$out" 2>/dev/null ||
+    awk -v t="refs/tags/${tag}" '$2==t{print $1}' <<<"$out"
+}
+
+handle_release_tag() {
+  local rc_commit local_commit remote_commit
+
+  if [[ "$TAG" != *rc* ]]; then
+    ok "tag ${TAG} carries no rc suffix; no separate release tag is needed"
+    return 0
+  fi
+
+  [[ -n "$REPO_DIR" && -n "$GIT_REMOTE" ]] ||
+    die "REPO_DIR and GIT_REMOTE must be set in release.env to push the 
${RELEASE_TAG} tag"
+
+  # -q --verify matters here: plain rev-parse echoes the argument back and
+  # exits non-zero for a tag that does not exist, which would read as a commit.
+  rc_commit="$(git_repo rev-parse -q --verify "${TAG}^{commit}" 2>/dev/null || 
true)"
+  [[ -n "$rc_commit" ]] || die "RC tag ${TAG} not found in ${REPO_DIR}"
+
+  local_commit="$(git_repo rev-parse -q --verify "${RELEASE_TAG}^{commit}" 
2>/dev/null || true)"
+  remote_commit="$(remote_tag_commit "$RELEASE_TAG")"
+
+  if [[ -n "$local_commit" && "$local_commit" != "$rc_commit" ]]; then
+    die "local tag ${RELEASE_TAG} points at ${local_commit}, but ${TAG} is 
${rc_commit} - resolve by hand"
+  fi
+  if [[ -n "$remote_commit" && "$remote_commit" != "$rc_commit" ]]; then
+    die "${GIT_REMOTE} tag ${RELEASE_TAG} points at ${remote_commit}, but 
${TAG} is ${rc_commit} - resolve by hand"
+  fi
+  if [[ -n "$local_commit" && -n "$remote_commit" ]]; then
+    ok "release tag ${RELEASE_TAG} already on ${GIT_REMOTE} at 
${rc_commit:0:11}"
+    return 0
+  fi
+
+  step "Tag the release as ${RELEASE_TAG}" \
+    "${TAG} is an RC tag, so the release needs the RC-free tag as well." \
+    "commit: ${rc_commit}" \
+    "$([[ -n "$local_commit" ]] && echo "local tag ${RELEASE_TAG}: already 
created" || echo "git tag -a ${RELEASE_TAG} -m '${RELEASE_TAG} release' 
${rc_commit}")" \
+    "git push ${GIT_REMOTE} refs/tags/${RELEASE_TAG}   <- public"
+
+  if [[ -z "$local_commit" ]]; then
+    git_repo tag -a "$RELEASE_TAG" -m "${RELEASE_TAG} release" "$rc_commit"
+    ok "created local tag ${RELEASE_TAG}"
+  fi
+  git_repo push "$GIT_REMOTE" "refs/tags/${RELEASE_TAG}"
+  ok "pushed ${RELEASE_TAG} to ${GIT_REMOTE}"
+}
+
+github_release_body() {
+  local rn base name arch f
+  rn="${ANNOUNCE_RELEASE_NOTES_URL:-${RELEASE_NOTES_URL:-}}"
+
+  printf '[Change Log](%s)\n\n' "$rn"
+  printf -- '- Official Downloads: %s\n\n' "${DOWNLOAD_PAGE_URL%/}"
+  printf -- '- Source:\n'
+  printf '    - [%s.tar.gz](%s/%s.tar.gz) 
([asc](%s/%s.tar.gz.asc))([sha512](%s/%s.tar.gz.sha512))\n' \
+    "$RELEASE_PKG_BASE" \
+    "$RELEASE_SVN_DIR" "$RELEASE_PKG_BASE" \
+    "$RELEASE_SVN_DIR" "$RELEASE_PKG_BASE" \
+    "$RELEASE_SVN_DIR" "$RELEASE_PKG_BASE"
+
+  [[ "${#BIN_FILES[@]}" -gt 0 ]] || return 0
+  base="${RELEASE_BIN_DOWNLOAD_BASE%/}"
+  for f in "${BIN_FILES[@]}"; do
+    name="$(basename "$f")"
+    arch="${name#apache-doris-${VERSION}-bin-}"
+    arch="${arch%.tar.gz}"
+    printf '\n'
+    printf -- '- Binary(%s):\n' "$arch"
+    printf '    - [%s](%s/%s) ([asc](%s/%s.asc))([sha512](%s/%s.sha512))\n' \
+      "$name" "$base" "$name" "$base" "$name" "$base" "$name"
+  done
+}
+
+publish_github_release() {
+  local repo body_file current_latest latest_flag latest_label
+
+  if [[ "$skip_github_release" -eq 1 ]]; then
+    ok "--skip-github-release: leaving the GitHub release alone"
+    return 0
+  fi
+  if ! command -v gh >/dev/null 2>&1; then
+    warn "gh is not installed; skipping the GitHub release for ${RELEASE_TAG}"
+    return 0
+  fi
+  if ! gh auth status >/dev/null 2>&1; then
+    warn "gh is not authenticated; skipping the GitHub release for 
${RELEASE_TAG}"
+    return 0
+  fi
+
+  repo="$(github_repo)"
+  [[ -n "$repo" ]] || die "cannot determine the GitHub repository; set 
GITHUB_REPO in release.env"
+
+  if gh release view "$RELEASE_TAG" --repo "$repo" >/dev/null 2>&1; then
+    ok "GitHub release already published: $(gh release view "$RELEASE_TAG" 
--repo "$repo" --json url --jq .url)"
+    return 0
+  fi
+
+  mkdir -p "$WORK_DIR"
+  body_file="$WORK_DIR/github-release.md"
+  github_release_body > "$body_file"
+
+  current_latest="$(gh api "repos/${repo}/releases/latest" --jq .tag_name 
2>/dev/null || true)"
+
+  step "Publish the GitHub release for ${RELEASE_TAG}" \
+    "repo:  ${repo}" \
+    "title: ${GITHUB_RELEASE_TITLE}" \
+    "body:  ${body_file}" \
+    "This is public. The body is printed below; edit the file and re-run to 
change it." \
+    "$(sed 's/^/       /' "$body_file")"
+
+  echo
+  if [[ -n "$current_latest" ]]; then
+    echo "The release currently marked Latest on ${repo} is ${current_latest}."
+  fi
+  if confirm "Mark ${RELEASE_TAG} as the Latest release?"; then
+    latest_flag="--latest"
+    latest_label="Latest"
+  else
+    latest_flag="--latest=false"
+    latest_label="not Latest"
+  fi
+
+  gh release create "$RELEASE_TAG" \
+    --repo "$repo" \
+    --title "$GITHUB_RELEASE_TITLE" \
+    --notes-file "$body_file" \
+    --verify-tag \
+    "$latest_flag"
+  ok "published GitHub release ${RELEASE_TAG} (${latest_label})"
+}
+
 write_announce_email() {
   local rn subject body_file eml_file
 
@@ -106,7 +432,13 @@ On behalf of the Doris team,
 ${SIGNER_NAME}
 EOF
 
-  printf '%s\n' "$BODY" > "$body_file"
+  # The .txt draft carries the subject line too, so the whole mail can be
+  # copied from one file. The .eml keeps it as a real header below.
+  {
+    echo "Subject: ${subject}"
+    echo
+    printf '%s\n' "$BODY"
+  } > "$body_file"
   {
     echo "To: ${DEV_LIST}"
     echo "Subject: ${subject}"
@@ -125,106 +457,56 @@ EOF
   echo "(Not auto-sent by design - it's a public ASF list.)"
 }
 
-publish_to_release_svn() {
-  local src_tar src_asc src_sha512 dst_tar dst_asc dst_sha512 checksum_dir 
src_tar_file final_sha512_file release_parent_missing
-  local -a svnmucc_ops
-
+if [[ "$mail_only" -eq 0 ]]; then
   require_tool svn
   require_tool svnmucc
   require_tool gpg
   require_tool sha512sum
-
-  src_tar="${DEV_SVN_DIR}/${PKG_BASE}.tar.gz"
-  src_asc="${src_tar}.asc"
-  src_sha512="${src_tar}.sha512"
-  dst_tar="${RELEASE_PKG_BASE}.tar.gz"
-  dst_asc="${dst_tar}.asc"
-  dst_sha512="${dst_tar}.sha512"
+  require_tool git
 
   echo "== Apache Doris ${TAG} - complete release =="
   echo "Source dev SVN folder:     ${DEV_SVN_DIR}/"
   echo "Target release SVN folder: ${RELEASE_SVN_DIR}/"
-  echo
-  echo "Source files:"
-  echo "  ${PKG_BASE}.tar.gz"
-  echo "  ${PKG_BASE}.tar.gz.asc"
-  echo "  ${PKG_BASE}.tar.gz.sha512"
-  echo "Target files:"
-  echo "  ${dst_tar}"
-  echo "  ${dst_asc}"
-  echo "  ${dst_sha512}"
-  echo
   warn "Only PMC members can write to the release SVN directory."
 
-  svn info "${svn_auth[@]}" "$src_tar" >/dev/null || die "source artifact not 
found: $src_tar"
-  svn info "${svn_auth[@]}" "$src_asc" >/dev/null || die "source signature not 
found: $src_asc"
-  svn info "${svn_auth[@]}" "$src_sha512" >/dev/null || die "source checksum 
not found: $src_sha512"
-  if svn info "${svn_auth[@]}" "$RELEASE_SVN_DIR" >/dev/null 2>&1; then
-    die "release SVN folder already exists: $RELEASE_SVN_DIR (use --mail-only 
to regenerate the email)"
-  fi
-  release_parent_missing=0
-  if svn info "${svn_auth[@]}" "$RELEASE_SVN_PARENT_DIR" >/dev/null 2>&1; then
-    ok "release SVN parent exists: ${RELEASE_SVN_PARENT_DIR}"
+  step "Inspect the dev and release SVN state" \
+    "Read-only: svn info on the dev RC folder and the release folder." \
+    "Nothing is changed by this step; it decides what the later steps do."
+  discover_svn_state
+
+  if [[ "$DST_PRESENT" -eq 3 ]]; then
+    ok "release artifacts already published: ${RELEASE_SVN_DIR}/"
+    ok "nothing to publish; this run only cleans up and drafts the email"
+    if [[ "$DEV_DIR_EXISTS" -eq 1 ]]; then
+      remove_dev_rc_folder
+    else
+      ok "dev RC folder already removed: ${DEV_SVN_DIR}/"
+    fi
+  elif [[ "$DST_PRESENT" -eq 0 ]]; then
+    [[ "$SRC_PRESENT" -eq 3 ]] || die "cannot publish: ${SRC_PRESENT}/3 RC 
artifacts in ${DEV_SVN_DIR}/ and 0/3 published in ${RELEASE_SVN_DIR}/"
+    if [[ "$RELEASE_DIR_EXISTS" -eq 1 ]]; then
+      warn "release folder exists but is empty; it will be reused instead of 
created"
+    fi
+
+    step "Verify the RC artifacts and build the final checksum" \
+      "Downloads the RC tarball, signature and checksum to a temp directory," \
+      "checks the sha512 and the detached signature that the voters approved," 
\
+      "then writes ${DST_SHA512} for the RC-free tarball name." \
+      "Local only: no SVN state is changed by this step."
+    verify_and_build_checksum
+
+    publish_to_release_svn
   else
-    release_parent_missing=1
-    warn "release SVN parent will be created: ${RELEASE_SVN_PARENT_DIR}"
-  fi
-
-  checksum_dir="$(mktemp -d)"
-  trap 'rm -rf "$checksum_dir"' EXIT
-
-  src_tar_file="${PKG_BASE}.tar.gz"
-  final_sha512_file="${checksum_dir}/${dst_sha512}"
-  svn cat "${svn_auth[@]}" "$src_tar" > "${checksum_dir}/${src_tar_file}"
-  svn cat "${svn_auth[@]}" "$src_sha512" > 
"${checksum_dir}/${src_tar_file}.sha512"
-  svn cat "${svn_auth[@]}" "$src_asc" > "${checksum_dir}/${src_tar_file}.asc"
-  (
-    cd "$checksum_dir"
-    sha512sum --check "${src_tar_file}.sha512"
-    gpg --verify "${src_tar_file}.asc" "$src_tar_file"
-    cp "$src_tar_file" "$dst_tar"
-    sha512sum "$dst_tar" > "$dst_sha512"
-    sha512sum --check "$dst_sha512"
-  )
-  ok "source RC checksum and signature verified: ${src_tar_file}"
-  ok "final sha512 ok: ${dst_sha512}"
-
-  echo "--- svnmucc operations ---"
-  svnmucc_ops=()
-  if [[ "$release_parent_missing" -eq 1 ]]; then
-    echo "mkdir ${RELEASE_SVN_PARENT_DIR}"
-    svnmucc_ops+=(mkdir "$RELEASE_SVN_PARENT_DIR")
+    die "release folder is half-published (${DST_PRESENT}/3): missing 
${DST_MISSING[*]} in ${RELEASE_SVN_DIR}/ - fix it by hand, this script only 
publishes a complete set"
   fi
-  echo "mkdir ${RELEASE_SVN_DIR}"
-  svnmucc_ops+=(mkdir "$RELEASE_SVN_DIR")
-  echo "mv    ${src_tar}"
-  echo "  ->  ${RELEASE_SVN_DIR}/${dst_tar}"
-  svnmucc_ops+=(mv "$src_tar" "${RELEASE_SVN_DIR}/${dst_tar}")
-  echo "mv    ${src_asc}"
-  echo "  ->  ${RELEASE_SVN_DIR}/${dst_asc}"
-  svnmucc_ops+=(mv "$src_asc" "${RELEASE_SVN_DIR}/${dst_asc}")
-  echo "put   ${final_sha512_file}"
-  echo "  ->  ${RELEASE_SVN_DIR}/${dst_sha512}"
-  svnmucc_ops+=(put "$final_sha512_file" "${RELEASE_SVN_DIR}/${dst_sha512}")
-  echo "rm    ${DEV_SVN_DIR}"
-  svnmucc_ops+=(rm "$DEV_SVN_DIR")
-  echo
-  echo "Will commit the URL operations above in one SVN revision."
-  confirm "FINAL confirm - publish release SVN and remove dev RC now?" || { 
warn "stopping before SVN commit."; exit 0; }
 
-  if ! svnmucc "${svnmucc_auth[@]}" -m "Release Doris ${VERSION}" 
"${svnmucc_ops[@]}"; then
-    die "svnmucc release publish failed"
-  fi
-  rm -rf "$checksum_dir"
-  trap - EXIT
-  ok "committed release artifacts: ${RELEASE_SVN_DIR}/"
-  ok "removed dev RC folder: ${DEV_SVN_DIR}/"
-}
-
-if [[ "$mail_only" -eq 0 ]]; then
-  publish_to_release_svn
+  handle_release_tag
+  publish_github_release
 else
   ok "mail-only mode: skipping SVN publish"
 fi
 
+step "Write the [ANNOUNCE] email draft" \
+  "Writes announce-email.txt and announce-email.eml under ${WORK_DIR}." \
+  "Local only: the mail is never sent by this script."
 write_announce_email
diff --git a/tools/release-tools/README.md b/tools/release-tools/README.md
index d82509c54ff..c346dc4d558 100644
--- a/tools/release-tools/README.md
+++ b/tools/release-tools/README.md
@@ -60,7 +60,9 @@ Also make these boundaries clear:
   locally, but the scripts do not upload those binaries.
 - The scripts never send public emails. The RM must review and send the vote,
   result, and announce emails manually.
-- Step 04 writes to the Apache release SVN and requires PMC permission.
+- Step 04 writes to the Apache release SVN and requires PMC permission. It
+  confirms before every step and is safe to re-run: it reads the current SVN
+  state first and skips whatever is already done.
 
 ## Quick start for a new RC
 
@@ -166,6 +168,14 @@ Set these fields first:
 - `ANNOUNCE_RELEASE_NOTES_URL`: Link used in the announce email. If empty, step
   04 reuses `RELEASE_NOTES_URL` or prompts.
 - `BIN_FILES`: Optional absolute paths to prebuilt convenience binary tarballs.
+- `RELEASE_TAG`: RC-free tag step 04 pushes and publishes, `${VERSION}` by
+  default.
+- `GITHUB_REPO`: `owner/repo` for the GitHub release. Empty means parse it out
+  of the `GIT_REMOTE` URL.
+- `GITHUB_RELEASE_TITLE`: Release title, `Apache Doris <version> Release` by
+  default.
+- `RELEASE_BIN_DOWNLOAD_BASE`: Base URL the GitHub release body links the
+  binaries to. This is the public mirror, not `BIN_DOWNLOAD_BASE`.
 
 The default `REPO_DIR` is the enclosing Doris checkout because these scripts
 live under `tools/release-tools`. Override it only when you deliberately run 
the
@@ -221,7 +231,10 @@ tags point to the same commit. It then creates the source 
tarball with
 the three source files to the Apache dev SVN.
 
 If `BIN_FILES` is non-empty, this script also signs and checksums those binary
-tarballs in place. It does not upload binaries.
+tarballs in place. It does not upload binaries. When the run ends it reminds 
the
+RM to upload each binary together with its `.asc` and `.sha512` to
+`BIN_DOWNLOAD_BASE`, because step 03 advertises them under that base in the 
vote
+email. Do that before sending the vote email, or the links will be dead.
 
 This script pauses twice before touching the public dev SVN. Check the printed
 target URL before confirming.
@@ -230,8 +243,12 @@ target URL before confirming.
 
 Use this script to generate the `[VOTE]` email draft. It writes:
 
-- `vote-email.txt`
-- `vote-email.eml`
+- `vote-email.txt`, starting with a `Subject:` line so the subject and the body
+  can be copied from one file
+- `vote-email.eml`, carrying the same subject as a real mail header
+
+The subject is `[VOTE] Release for Apache Doris <version>-<rc>`, and the body
+ends with the RM's own `+1(binding)` above the signature.
 
 Review the draft and send it manually from the RM's `@apache.org` address to
 `[email protected]`.
@@ -241,17 +258,55 @@ Review the draft and send it manually from the RM's 
`@apache.org` address to
 Use this script only after the vote has passed and the `[RESULT]` email has
 been sent manually.
 
-It checks the passed RC source artifacts in the dev SVN, verifies the source
-tarball against the RC checksum and detached signature that voters approved,
-regenerates and verifies the final checksum sidecar with the RC-free source
-tarball name, then uses `svnmucc` to create the final release SVN directory,
-move the source tarball and detached signature there, upload the final 
checksum,
-and remove the dev RC folder in one SVN revision.
-
-It then writes:
-
-- `announce-email.txt`
-- `announce-email.eml`
+Every step announces what it is about to do and waits for a `y` before running.
+Answering anything else stops the run without changing further state, and the
+run can be continued later by starting the script again.
+
+The steps are:
+
+1. Inspect the dev and release SVN state. Read-only, and it decides what the
+   later steps do.
+2. Verify the RC artifacts and build the final checksum. It downloads the RC
+   tarball, signature and checksum, checks the sha512 and the detached
+   signature that voters approved, and regenerates the checksum sidecar under
+   the RC-free tarball name. Local only.
+3. Publish to the release SVN. One `svnmucc` revision creates the release
+   directory, moves the source tarball and detached signature there, uploads
+   the final checksum, and removes the dev RC folder.
+4. Tag the release. When `TAG` carries an `rc` suffix, the RC-free
+   `RELEASE_TAG` is created at the same commit and pushed to `GIT_REMOTE`.
+   Skipped when `TAG` has no `rc` suffix.
+5. Publish the GitHub release for that tag, with a body in the shape every
+   previous Doris release uses: the change log link, the download page, and
+   the source and binary artifacts. It prints which release GitHub currently
+   marks Latest and asks whether this one should take that place, so a 4.0.x
+   maintenance release does not displace a newer 4.1.x. Skipped with
+   `--skip-github-release`, and skipped with a warning when `gh` is missing or
+   not authenticated.
+6. Write the announce email draft.
+
+The script is idempotent, so it is safe to re-run after a successful publish or
+after stopping at any prompt:
+
+- All three release artifacts already published: the publish steps are skipped.
+  A dev RC folder left behind is removed, and the announce email is drafted.
+- Nothing published yet: the full flow runs. A release directory that exists
+  but is empty is reused instead of being created again.
+- Some but not all release artifacts present: the script refuses to guess and
+  reports exactly which files are missing. `svnmucc` commits all of its
+  operations in one revision, so this state only comes from manual changes.
+- Release tag already pushed at the RC commit: the tagging step reports it and
+  moves on. A tag of the same name on a different commit stops the run.
+- GitHub release already published: the step reports its URL and moves on. It
+  never rewrites a published release.
+
+It writes:
+
+- `announce-email.txt`, starting with a `Subject:` line so the subject and the
+  body can be copied from one file
+- `announce-email.eml`, carrying the same subject as a real mail header
+
+The subject is `[ANNOUNCE] Apache Doris <version> release`.
 
 Before sending the announce email, check that the release is visible on
 `downloads.apache.org`, update and verify the Doris download page, and wait for
diff --git a/tools/release-tools/release.env b/tools/release-tools/release.env
index f6f4c3d3e79..8b7be1bbf27 100644
--- a/tools/release-tools/release.env
+++ b/tools/release-tools/release.env
@@ -76,6 +76,18 @@ KEYS_URL="https://downloads.apache.org/doris/KEYS";
 #   export ASF_USERNAME=morningman
 #   export ASF_PASSWORD='...'          # Apache LDAP password
 
+# --- GitHub release (step 04) ---
+# When TAG carries an rc suffix, step 04 pushes RELEASE_TAG at the same commit
+# and publishes a GitHub release for it. It asks whether to mark that release
+# as Latest, so a maintenance release does not displace a newer minor version.
+# GITHUB_REPO defaults to the owner/repo parsed out of GIT_REMOTE's URL.
+RELEASE_TAG="${VERSION}"                # 4.0.8-rc02 -> 4.0.8
+GITHUB_REPO=""                          # e.g. apache/doris; empty -> derive 
from GIT_REMOTE
+GITHUB_RELEASE_TITLE="Apache Doris ${VERSION} Release"
+# The release body links the binaries to their public mirror, which is NOT the
+# same host BIN_DOWNLOAD_BASE names for the vote email.
+RELEASE_BIN_DOWNLOAD_BASE="https://download.velodb.io";
+
 # --- Vote email ---
 DEV_LIST="[email protected]"
 RELEASE_NOTES_URL=""                   # leave empty -> step 03 will prompt 
you for the issue link
diff --git a/tools/release-tools/tests/test-package-binary-upload-reminder.sh 
b/tools/release-tools/tests/test-package-binary-upload-reminder.sh
new file mode 100755
index 00000000000..d15d1316213
--- /dev/null
+++ b/tools/release-tools/tests/test-package-binary-upload-reminder.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+cp "${ROOT}/02-package-sign-upload.sh" "$tmp/"
+mkdir -p "$tmp/repo" "$tmp/bins"
+printf 'binary bytes\n' > "$tmp/bins/apache-doris-9.9.9-bin-x64.tar.gz"
+printf 'binary bytes\n' > "$tmp/bins/apache-doris-9.9.9-bin-arm64.tar.gz"
+
+cat > "$tmp/release.env" <<EOF
+ROOT="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+REPO_DIR="$tmp/repo"
+VERSION="9.9.9"
+RC="rc01"
+TAG="\${VERSION}-\${RC}"
+GIT_REMOTE="apache-test"
+PKG_BASE="apache-doris-\${TAG}-src"
+ARCHIVE_PREFIX="\${PKG_BASE}/"
+WORK_DIR="\${ROOT}/\${TAG}"
+BIN_FILES=(
+  "$tmp/bins/apache-doris-9.9.9-bin-x64.tar.gz"
+  "$tmp/bins/apache-doris-9.9.9-bin-arm64.tar.gz"
+)
+BIN_DOWNLOAD_BASE="https://binaries.example.test";
+SIGNING_KEY="DEADBEEF"
+DEV_SVN_BASE="https://dist.example.test/dev/doris";
+DEV_SVN_DIR="\${DEV_SVN_BASE}/\${TAG}"
+EOF
+
+cat > "$tmp/git" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+commit="1111111111111111111111111111111111111111"
+case "$1" in
+  rev-parse) printf '%s\n' "$commit" ;;
+  ls-remote) printf '%s\trefs/tags/9.9.9-rc01\n' "$commit" ;;
+  archive)   printf 'fake source tree\n' ;;
+  *) echo "unexpected git command: $1" >&2; exit 1 ;;
+esac
+EOF
+chmod +x "$tmp/git"
+
+cat > "$tmp/gpg" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+case "$1" in
+  --verify) exit 0 ;;
+  -u)
+    out=""
+    prev=""
+    for a in "$@"; do
+      [[ "$prev" == "--output" ]] && out="$a"
+      prev="$a"
+    done
+    [[ -n "$out" ]] || { echo "no --output in: $*" >&2; exit 1; }
+    printf 'fake signature\n' > "$out"
+    ;;
+  *) echo "unexpected gpg invocation: $*" >&2; exit 1 ;;
+esac
+EOF
+chmod +x "$tmp/gpg"
+
+# Answering "n" stops the script before the SVN commit. The binaries are 
already
+# signed at that point, so the reminder still has to reach the RM.
+out="$(printf 'n\n' | PATH="$tmp:$PATH" "$tmp/02-package-sign-upload.sh" 2>&1)"
+
+for name in apache-doris-9.9.9-bin-x64.tar.gz 
apache-doris-9.9.9-bin-arm64.tar.gz; do
+  [[ -f "$tmp/bins/$name.asc" ]] || { echo "missing signature: $name.asc" >&2; 
exit 1; }
+  if ! grep -qF "$name.asc" <<<"$out"; then
+    echo "02-package-sign-upload.sh did not remind the RM to upload $name.asc" 
>&2
+    printf '%s\n' "$out" >&2
+    exit 1
+  fi
+done
+
+if ! grep -qF "https://binaries.example.test/"; <<<"$out"; then
+  echo "the upload reminder must name the binary download base" >&2
+  printf '%s\n' "$out" >&2
+  exit 1
+fi
+
+if ! grep -qiF "NOT uploaded by this script" <<<"$out"; then
+  echo "the upload reminder must state that the script does not upload 
signatures" >&2
+  printf '%s\n' "$out" >&2
+  exit 1
+fi
diff --git a/tools/release-tools/tests/test-release-complete-checksum.sh 
b/tools/release-tools/tests/test-release-complete-checksum.sh
index 0bde8c9ed31..f3590e50345 100755
--- a/tools/release-tools/tests/test-release-complete-checksum.sh
+++ b/tools/release-tools/tests/test-release-complete-checksum.sh
@@ -40,8 +40,34 @@ 
ANNOUNCE_RELEASE_NOTES_URL="https://doris.example.test/release-notes";
 RELEASE_NOTES_URL=""
 DEV_LIST="[email protected]"
 SIGNER_NAME="Release Manager"
+REPO_DIR="${ROOT}"
+GIT_REMOTE="apache-test"
+BIN_FILES=()
 EOF
 
+# The 9.9.9 tag is already pushed here, so the tag step reports it and returns
+# without a prompt. test-release-complete-github-release.sh covers the tagging.
+cat > "$tmp/git" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+commit="2222222222222222222222222222222222222222"
+while [[ "${1:-}" == "-C" ]]; do shift 2; done
+case "${1:-}" in
+  rev-parse) printf '%s\n' "$commit" ;;
+  ls-remote) printf '%s\trefs/tags/9.9.9\n%s\trefs/tags/9.9.9^{}\n' "$commit" 
"$commit" ;;
+  *) echo "unexpected git command: $*" >&2; exit 1 ;;
+esac
+EOF
+chmod +x "$tmp/git"
+
+# Never let a test reach the real gh: report "not authenticated" so the
+# GitHub release step warns and skips.
+cat > "$tmp/gh" <<'EOF'
+#!/usr/bin/env bash
+exit 1
+EOF
+chmod +x "$tmp/gh"
+
 cat > "$tmp/svn" <<'EOF'
 #!/usr/bin/env bash
 set -euo pipefail
@@ -161,7 +187,8 @@ export FAKE_MKTEMP_ROOT="$tmp/mktemp-root"
 export FAKE_MKTEMP_LOG="$tmp/mktemp.log"
 mkdir -p "$FAKE_MKTEMP_ROOT"
 
-printf 'y\n' | bash "$tmp/04-release-complete.sh" >/dev/null
+# One y per step: inspect, verify, publish, announce.
+printf 'y\ny\ny\ny\n' | bash "$tmp/04-release-complete.sh" >/dev/null
 
 if grep -q 'mv 
https://dist.example.test/dev/doris/9.9.9-rc01/apache-doris-9.9.9-rc01-src.tar.gz.sha512'
 "$FAKE_SVNMUCC_LOG"; then
   echo "release completion must not move the RC checksum sidecar unchanged" >&2
@@ -187,7 +214,7 @@ if [[ -e "$success_checksum_dir" ]]; then
   exit 1
 fi
 
-if FAKE_BAD_SHA512=1 bash "$tmp/04-release-complete.sh" >/dev/null 2>&1; then
+if printf 'y\ny\ny\ny\n' | FAKE_BAD_SHA512=1 bash 
"$tmp/04-release-complete.sh" >/dev/null 2>&1; then
   echo "release completion unexpectedly succeeded with a bad RC checksum" >&2
   exit 1
 fi
diff --git a/tools/release-tools/tests/test-release-complete-github-release.sh 
b/tools/release-tools/tests/test-release-complete-github-release.sh
new file mode 100755
index 00000000000..f33ace3cd17
--- /dev/null
+++ b/tools/release-tools/tests/test-release-complete-github-release.sh
@@ -0,0 +1,227 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+cp "${ROOT}/04-release-complete.sh" "$tmp/"
+cat > "$tmp/release.env" <<'EOF'
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+VERSION="9.9.9"
+RC="rc01"
+TAG="${VERSION}-${RC}"
+PKG_BASE="apache-doris-${TAG}-src"
+RELEASE_PKG_BASE="apache-doris-${VERSION}-src"
+WORK_DIR="${ROOT}/${TAG}"
+REPO_DIR="${ROOT}"
+GIT_REMOTE="apache-test"
+GITHUB_REPO="apache/doris-test"
+DEV_SVN_BASE="https://dist.example.test/dev/doris";
+DEV_SVN_DIR="${DEV_SVN_BASE}/${TAG}"
+RELEASE_SVN_BASE="https://dist.example.test/release/doris";
+RELEASE_SERIES="${VERSION%.*}"
+RELEASE_BIN_DOWNLOAD_BASE="https://binaries.example.test";
+BIN_FILES=(
+  "/nonexistent/apache-doris-9.9.9-bin-x64.tar.gz"
+  "/nonexistent/apache-doris-9.9.9-bin-arm64.tar.gz"
+)
+DOWNLOAD_PAGE_URL="https://doris.example.test/download/";
+ANNOUNCE_RELEASE_NOTES_URL="https://doris.example.test/release-notes";
+RELEASE_NOTES_URL=""
+DEV_LIST="[email protected]"
+SIGNER_NAME="Release Manager"
+EOF
+
+# The release SVN publish is already done, so the run goes straight to the tag
+# and GitHub release steps.
+cat > "$tmp/svn" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+url="${@: -1}"
+[[ "$1" == "info" ]] || { echo "unexpected svn command: $*" >&2; exit 1; }
+case "$url" in
+  
https://dist.example.test/release/doris/9.9|https://dist.example.test/release/doris/9.9/9.9.9|https://dist.example.test/release/doris/9.9/9.9.9/*)
 ;;
+  *) exit 1 ;;
+esac
+EOF
+chmod +x "$tmp/svn"
+
+cat > "$tmp/svnmucc" <<'EOF'
+#!/usr/bin/env bash
+echo "svnmucc must not run when the release is already published" >&2
+exit 1
+EOF
+chmod +x "$tmp/svnmucc"
+
+cat > "$tmp/gpg" <<'EOF'
+#!/usr/bin/env bash
+exit 0
+EOF
+chmod +x "$tmp/gpg"
+
+# FAKE_TAG_PUSHED decides whether the RC-free tag is already on the remote,
+# FAKE_TAG_COMMIT lets a case point it at a different commit.
+cat > "$tmp/git" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+rc_commit="3333333333333333333333333333333333333333"
+tag_commit="${FAKE_TAG_COMMIT:-$rc_commit}"
+while [[ "${1:-}" == "-C" ]]; do shift 2; done
+case "${1:-}" in
+  rev-parse)
+    # Real git echoes the argument back and exits 1 for a missing ref unless
+    # -q --verify is passed. Reproduce that, so a caller that forgets the
+    # flags captures the argument instead of an empty string and the case
+    # below fails loudly.
+    quiet=0
+    for a in "$@"; do [[ "$a" == "--verify" ]] && quiet=1; done
+    ref="${@: -1}"
+    case "$ref" in
+      "9.9.9-rc01^{commit}") printf '%s\n' "$rc_commit" ;;
+      "9.9.9^{commit}")
+        if [[ "${FAKE_TAG_LOCAL:-0}" -eq 1 ]]; then
+          printf '%s\n' "$tag_commit"
+        else
+          [[ "$quiet" -eq 1 ]] || printf '%s\n' "$ref"
+          exit 1
+        fi
+        ;;
+      *) [[ "$quiet" -eq 1 ]] || printf '%s\n' "$ref"; exit 1 ;;
+    esac
+    ;;
+  ls-remote)
+    [[ "${FAKE_TAG_PUSHED:-0}" -eq 1 ]] || exit 0
+    printf '%s\trefs/tags/9.9.9\n%s\trefs/tags/9.9.9^{}\n' "$tag_commit" 
"$tag_commit"
+    ;;
+  tag|push) printf '%s\n' "$*" >> "$FAKE_GIT_LOG" ;;
+  *) echo "unexpected git command: $*" >&2; exit 1 ;;
+esac
+EOF
+chmod +x "$tmp/git"
+
+# FAKE_RELEASE_EXISTS decides whether the GitHub release is already published.
+cat > "$tmp/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+printf '%s\n' "$*" >> "$FAKE_GH_LOG"
+case "$1 ${2:-}" in
+  "auth status") exit 0 ;;
+  "release view")
+    [[ "${FAKE_RELEASE_EXISTS:-0}" -eq 1 ]] || exit 1
+    if [[ "$*" == *"--json url"* ]]; then
+      printf 'https://github.test/apache/doris-test/releases/tag/9.9.9\n'
+    fi
+    ;;
+  "api repos/apache/doris-test/releases/latest") printf '9.9.9-old\n' ;;
+  "release create")
+    shift 2
+    printf '%s\n' "$*" > "$FAKE_GH_CREATE"
+    while (($#)); do
+      if [[ "$1" == "--notes-file" ]]; then cp "$2" "$FAKE_GH_BODY"; fi
+      shift
+    done
+    ;;
+  *) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
+esac
+EOF
+chmod +x "$tmp/gh"
+
+export PATH="$tmp:$PATH"
+export FAKE_GIT_LOG="$tmp/git.log" FAKE_GH_LOG="$tmp/gh.log"
+export FAKE_GH_CREATE="$tmp/gh-create.log" FAKE_GH_BODY="$tmp/gh-body.md"
+
+run() { : > "$FAKE_GIT_LOG"; : > "$FAKE_GH_LOG"; rm -f "$FAKE_GH_CREATE" 
"$FAKE_GH_BODY"; }
+
+# --- fresh run: tag is created and pushed, release is published not-Latest ---
+run
+out="$(printf 'y\ny\ny\nn\ny\n' | bash "$tmp/04-release-complete.sh" 2>&1)"
+
+grep -qF "tag -a 9.9.9 -m 9.9.9 release 333333333" "$FAKE_GIT_LOG" || {
+  echo "the RC-free tag must be created from the RC commit" >&2; cat 
"$FAKE_GIT_LOG" >&2; exit 1; }
+grep -qF "push apache-test refs/tags/9.9.9" "$FAKE_GIT_LOG" || {
+  echo "the RC-free tag must be pushed" >&2; cat "$FAKE_GIT_LOG" >&2; exit 1; }
+[[ -f "$FAKE_GH_CREATE" ]] || { echo "the GitHub release must be created" >&2; 
exit 1; }
+grep -qF -- "--latest=false" "$FAKE_GH_CREATE" || {
+  echo "declining the Latest question must publish the release as not-Latest" 
>&2
+  cat "$FAKE_GH_CREATE" >&2; exit 1; }
+grep -qF -- "--title Apache Doris 9.9.9 Release" "$FAKE_GH_CREATE" || {
+  echo "unexpected release title" >&2; cat "$FAKE_GH_CREATE" >&2; exit 1; }
+grep -qF "The release currently marked Latest on apache/doris-test is 
9.9.9-old." <<<"$out" || {
+  echo "the Latest question must say which release is Latest today" >&2
+  printf '%s\n' "$out" >&2; exit 1; }
+
+# body follows the shape used by every previous Doris release
+grep -qxF '[Change Log](https://doris.example.test/release-notes)' 
"$FAKE_GH_BODY" || {
+  echo "the body must open with the change log link" >&2; cat "$FAKE_GH_BODY" 
>&2; exit 1; }
+grep -qxF -- '- Official Downloads: https://doris.example.test/download' 
"$FAKE_GH_BODY" || {
+  echo "the body must link the official download page" >&2; cat 
"$FAKE_GH_BODY" >&2; exit 1; }
+grep -qF 
'[apache-doris-9.9.9-src.tar.gz](https://dist.example.test/release/doris/9.9/9.9.9/apache-doris-9.9.9-src.tar.gz)'
 "$FAKE_GH_BODY" || {
+  echo "the body must link the release SVN source tarball" >&2; cat 
"$FAKE_GH_BODY" >&2; exit 1; }
+for arch in x64 arm64; do
+  grep -qxF -- "- Binary(${arch}):" "$FAKE_GH_BODY" || {
+    echo "the body must carry a Binary(${arch}) section" >&2; cat 
"$FAKE_GH_BODY" >&2; exit 1; }
+  grep -qF 
"(https://binaries.example.test/apache-doris-9.9.9-bin-${arch}.tar.gz)" 
"$FAKE_GH_BODY" || {
+    echo "the ${arch} binary must use the release download base" >&2; cat 
"$FAKE_GH_BODY" >&2; exit 1; }
+done
+
+# --- accepting the Latest question ------------------------------------------
+run
+printf 'y\ny\ny\ny\ny\n' | bash "$tmp/04-release-complete.sh" >/dev/null 2>&1
+grep -qF -- "--latest=false" "$FAKE_GH_CREATE" && {
+  echo "accepting the Latest question must publish the release as Latest" >&2
+  cat "$FAKE_GH_CREATE" >&2; exit 1; }
+grep -qF -- "--latest" "$FAKE_GH_CREATE" || {
+  echo "accepting the Latest question must pass --latest" >&2
+  cat "$FAKE_GH_CREATE" >&2; exit 1; }
+
+# --- re-run: tag already pushed and release already published ----------------
+run
+out="$(printf 'y\ny\n' | FAKE_TAG_LOCAL=1 FAKE_TAG_PUSHED=1 
FAKE_RELEASE_EXISTS=1 \
+  bash "$tmp/04-release-complete.sh" 2>&1)"
+
+if [[ -s "$FAKE_GIT_LOG" ]]; then
+  echo "a re-run must not create or push the tag again" >&2; cat 
"$FAKE_GIT_LOG" >&2; exit 1
+fi
+[[ -f "$FAKE_GH_CREATE" ]] && { echo "a re-run must not create the release 
again" >&2; exit 1; }
+grep -qF "release tag 9.9.9 already on apache-test" <<<"$out" || {
+  echo "a re-run must report the tag as already pushed" >&2; printf '%s\n' 
"$out" >&2; exit 1; }
+grep -qF "GitHub release already published" <<<"$out" || {
+  echo "a re-run must report the release as already published" >&2; printf 
'%s\n' "$out" >&2; exit 1; }
+[[ -f "$tmp/9.9.9-rc01/announce-email.txt" ]] || {
+  echo "a re-run must still write the announce email draft" >&2; exit 1; }
+
+# --- an existing tag on a different commit must stop the run ----------------
+run
+if printf 'y\ny\ny\ny\n' | FAKE_TAG_LOCAL=1 FAKE_TAG_PUSHED=1 \
+    FAKE_TAG_COMMIT=4444444444444444444444444444444444444444 \
+    bash "$tmp/04-release-complete.sh" >/dev/null 2>&1; then
+  echo "a 9.9.9 tag pointing at another commit must not be accepted" >&2
+  exit 1
+fi
+
+# --- --skip-github-release pushes the tag and leaves the release alone -------
+run
+printf 'y\ny\ny\n' | bash "$tmp/04-release-complete.sh" --skip-github-release 
>/dev/null 2>&1
+grep -qF "push apache-test refs/tags/9.9.9" "$FAKE_GIT_LOG" || {
+  echo "--skip-github-release must still push the tag" >&2; cat 
"$FAKE_GIT_LOG" >&2; exit 1; }
+[[ -f "$FAKE_GH_CREATE" ]] && { echo "--skip-github-release must not create 
the release" >&2; exit 1; }
+
+exit 0
diff --git a/tools/release-tools/tests/test-release-complete-idempotent.sh 
b/tools/release-tools/tests/test-release-complete-idempotent.sh
new file mode 100755
index 00000000000..78421d24ad0
--- /dev/null
+++ b/tools/release-tools/tests/test-release-complete-idempotent.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+cp "${ROOT}/04-release-complete.sh" "$tmp/"
+cat > "$tmp/release.env" <<'EOF'
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+VERSION="9.9.9"
+RC="rc01"
+TAG="${VERSION}-${RC}"
+PKG_BASE="apache-doris-${TAG}-src"
+RELEASE_PKG_BASE="apache-doris-${VERSION}-src"
+WORK_DIR="${ROOT}/${TAG}"
+DEV_SVN_BASE="https://dist.example.test/dev/doris";
+DEV_SVN_DIR="${DEV_SVN_BASE}/${TAG}"
+RELEASE_SVN_BASE="https://dist.example.test/release/doris";
+RELEASE_SERIES="${VERSION%.*}"
+DOWNLOAD_PAGE_URL="https://doris.example.test/download/";
+ANNOUNCE_RELEASE_NOTES_URL="https://doris.example.test/release-notes";
+RELEASE_NOTES_URL=""
+DEV_LIST="[email protected]"
+SIGNER_NAME="Release Manager"
+REPO_DIR="${ROOT}"
+GIT_REMOTE="apache-test"
+BIN_FILES=()
+EOF
+
+# This case is about the SVN state. The 9.9.9 tag is already pushed and gh
+# reports itself unauthenticated, so both of those steps report and return.
+cat > "$tmp/git" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+commit="2222222222222222222222222222222222222222"
+while [[ "${1:-}" == "-C" ]]; do shift 2; done
+case "${1:-}" in
+  rev-parse) printf '%s\n' "$commit" ;;
+  ls-remote) printf '%s\trefs/tags/9.9.9\n%s\trefs/tags/9.9.9^{}\n' "$commit" 
"$commit" ;;
+  *) echo "unexpected git command: $*" >&2; exit 1 ;;
+esac
+EOF
+chmod +x "$tmp/git"
+
+cat > "$tmp/gh" <<'EOF'
+#!/usr/bin/env bash
+exit 1
+EOF
+chmod +x "$tmp/gh"
+
+# The release is already published. FAKE_DEV_DIR_PRESENT decides whether the
+# stale RC folder is still sitting in the dev SVN.
+cat > "$tmp/svn" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+cmd="$1"
+shift
+url="${@: -1}"
+case "$cmd" in
+  info)
+    case "$url" in
+      https://dist.example.test/dev/doris/*)
+        [[ "${FAKE_DEV_DIR_PRESENT:-0}" -eq 1 ]] || exit 1
+        ;;
+      
https://dist.example.test/release/doris/9.9|https://dist.example.test/release/doris/9.9/9.9.9|https://dist.example.test/release/doris/9.9/9.9.9/*)
+        ;;
+      *)
+        exit 1
+        ;;
+    esac
+    ;;
+  *)
+    echo "unexpected svn command: $cmd $url" >&2
+    exit 1
+    ;;
+esac
+EOF
+chmod +x "$tmp/svn"
+
+cat > "$tmp/svnmucc" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+printf '%s\n' "$*" >> "$FAKE_SVNMUCC_LOG"
+EOF
+chmod +x "$tmp/svnmucc"
+
+cat > "$tmp/gpg" <<'EOF'
+#!/usr/bin/env bash
+echo "gpg must not run when nothing is published" >&2
+exit 1
+EOF
+chmod +x "$tmp/gpg"
+
+export PATH="$tmp:$PATH"
+export FAKE_SVNMUCC_LOG="$tmp/svnmucc.log"
+
+# --- re-run after a completed release, dev RC folder already gone -----------
+: > "$FAKE_SVNMUCC_LOG"
+out="$(printf 'y\ny\n' | bash "$tmp/04-release-complete.sh" 2>&1)"
+
+if [[ -s "$FAKE_SVNMUCC_LOG" ]]; then
+  echo "a re-run over a completed release must not touch SVN" >&2
+  cat "$FAKE_SVNMUCC_LOG" >&2
+  exit 1
+fi
+if ! grep -qF "release artifacts already published" <<<"$out"; then
+  echo "a re-run must report that the artifacts are already published" >&2
+  printf '%s\n' "$out" >&2
+  exit 1
+fi
+[[ -f "$tmp/9.9.9-rc01/announce-email.txt" ]] || {
+  echo "a re-run must still write the announce email draft" >&2
+  exit 1
+}
+if [[ "$(head -1 "$tmp/9.9.9-rc01/announce-email.txt")" != "Subject: 
[ANNOUNCE] Apache Doris 9.9.9 release" ]]; then
+  echo "announce-email.txt must start with the subject line" >&2
+  head -1 "$tmp/9.9.9-rc01/announce-email.txt" >&2
+  exit 1
+fi
+if ! grep -qxF "Subject: [ANNOUNCE] Apache Doris 9.9.9 release" 
"$tmp/9.9.9-rc01/announce-email.eml"; then
+  echo "announce-email.eml must carry the subject header" >&2
+  exit 1
+fi
+
+# --- re-run after a completed release, stale dev RC folder left behind ------
+: > "$FAKE_SVNMUCC_LOG"
+rm -rf "$tmp/9.9.9-rc01"
+out="$(printf 'y\ny\ny\n' | FAKE_DEV_DIR_PRESENT=1 bash 
"$tmp/04-release-complete.sh" 2>&1)"
+
+if ! grep -qF "rm https://dist.example.test/dev/doris/9.9.9-rc01"; 
"$FAKE_SVNMUCC_LOG"; then
+  echo "a stale dev RC folder must be removed on a re-run" >&2
+  cat "$FAKE_SVNMUCC_LOG" >&2
+  exit 1
+fi
+if grep -qE '(mkdir|mv|put) ' "$FAKE_SVNMUCC_LOG"; then
+  echo "a re-run must only remove the stale RC folder, not republish" >&2
+  cat "$FAKE_SVNMUCC_LOG" >&2
+  exit 1
+fi
+[[ -f "$tmp/9.9.9-rc01/announce-email.txt" ]] || {
+  echo "a re-run must still write the announce email draft" >&2
+  exit 1
+}
+
+# --- declining a step stops the run without changing anything ---------------
+: > "$FAKE_SVNMUCC_LOG"
+rm -rf "$tmp/9.9.9-rc01"
+out="$(printf 'y\nn\n' | FAKE_DEV_DIR_PRESENT=1 bash 
"$tmp/04-release-complete.sh" 2>&1)"
+
+if [[ -s "$FAKE_SVNMUCC_LOG" ]]; then
+  echo "declining a step must not run any SVN operation" >&2
+  cat "$FAKE_SVNMUCC_LOG" >&2
+  exit 1
+fi
+if ! grep -qF "stopped before step" <<<"$out"; then
+  echo "declining a step must say which step was stopped" >&2
+  printf '%s\n' "$out" >&2
+  exit 1
+fi
+if [[ -e "$tmp/9.9.9-rc01/announce-email.txt" ]]; then
+  echo "declining a step must not continue to the later steps" >&2
+  exit 1
+fi
diff --git a/tools/release-tools/tests/test-vote-mail-draft.sh 
b/tools/release-tools/tests/test-vote-mail-draft.sh
new file mode 100755
index 00000000000..d6e55a824a3
--- /dev/null
+++ b/tools/release-tools/tests/test-vote-mail-draft.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+cp "${ROOT}/03-vote-mail.sh" "$tmp/"
+cat > "$tmp/release.env" <<'EOF'
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+VERSION="9.9.9"
+RC="rc01"
+TAG="${VERSION}-${RC}"
+WORK_DIR="${ROOT}/${TAG}"
+BIN_FILES=("/nonexistent/apache-doris-${VERSION}-bin-x64.tar.gz")
+BIN_DOWNLOAD_BASE="https://binaries.example.test";
+APACHE_ID="rm"
+APACHE_EMAIL="[email protected]"
+SIGNER_NAME="Release Manager"
+SIGNING_KEY="DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF"
+DEV_SVN_BASE="https://dist.example.test/dev/doris";
+DEV_SVN_DIR="${DEV_SVN_BASE}/${TAG}"
+KEYS_URL="https://downloads.example.test/doris/KEYS";
+DEV_LIST="[email protected]"
+RELEASE_NOTES_URL="https://github.example.test/issues/1";
+VERIFY_GUIDE_URL="https://doris.example.test/release-verify";
+EOF
+
+cat > "$tmp/gpg" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+[[ "$1" == "--list-keys" ]]
+printf 'fpr:::::::::DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF:\n'
+EOF
+chmod +x "$tmp/gpg"
+
+PATH="$tmp:$PATH" "$tmp/03-vote-mail.sh" >/dev/null
+
+body="$tmp/9.9.9-rc01/vote-email.txt"
+eml="$tmp/9.9.9-rc01/vote-email.eml"
+subject="[VOTE] Release for Apache Doris 9.9.9-rc01"
+
+for f in "$body" "$eml"; do
+  [[ -f "$f" ]] || { echo "03-vote-mail.sh did not write $f" >&2; exit 1; }
+done
+
+# The subject must reach the RM from the draft itself, not only from the 
console.
+if [[ "$(head -1 "$body")" != "Subject: ${subject}" ]]; then
+  echo "vote-email.txt must start with the subject line: Subject: ${subject}" 
>&2
+  head -1 "$body" >&2
+  exit 1
+fi
+
+if ! grep -qxF "Subject: ${subject}" "$eml"; then
+  echo "vote-email.eml is missing the subject header: Subject: ${subject}" >&2
+  exit 1
+fi
+
+# The RM's own binding vote closes the body, ahead of the signature.
+for f in "$body" "$eml"; do
+  if ! grep -qxF "Here is my +1(binding)" "$f"; then
+    echo "$f is missing the binding vote line" >&2
+    exit 1
+  fi
+  vote_line="$(grep -nxF "Here is my +1(binding)" "$f" | head -1 | cut -d: 
-f1)"
+  regards_line="$(grep -nxF "Best Regards," "$f" | head -1 | cut -d: -f1)"
+  if [[ -z "$regards_line" || "$vote_line" -ge "$regards_line" ]]; then
+    echo "$f must place the binding vote before the signature" >&2
+    exit 1
+  fi
+done


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to