Update release process (#25002)

* pypi_deploy.sh: dry run by default (`twine check`), `--publish` to
upload. `--wheel-dir` publishes the wheels of a previous dry run without
downloading them again. Missing packages need confirmation.
* Add a release tracker template
* release-management.md: document the missing tracker steps
* RELEASING.md: fix the iree-turbine release docs link.

Co-Authored-By: Claude

---------

Signed-off-by: Tobias Fuchs <fuchs@roofline.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/RELEASING.md b/RELEASING.md
index 3a4d98c..14c75e3 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -103,4 +103,4 @@
 * Releasing IREE core packages:
   https://iree.dev/developers/general/release-management/
 * Releasing iree-turbine packages:
-  https://github.com/iree-org/iree-turbine/blob/main/docs/releasing.md
+  https://github.com/iree-org/iree-turbine/blob/main/docs/infra/releasing.md
diff --git a/build_tools/python_deploy/pypi_deploy.sh b/build_tools/python_deploy/pypi_deploy.sh
index 9c889f2..9af35a9 100755
--- a/build_tools/python_deploy/pypi_deploy.sh
+++ b/build_tools/python_deploy/pypi_deploy.sh
@@ -22,15 +22,97 @@
 #   python -m venv .venv
 #   source .venv/bin/activate
 #   python -m pip install -r ./pypi_deploy_requirements.txt
-#   ./pypi_deploy.sh iree-3.4.0rc20250430
+#   ./pypi_deploy.sh iree-3.4.0rc20250430            # dry run
+#   ./pypi_deploy.sh --publish iree-3.4.0rc20250430  # upload to PyPI
+#
+# By default the script runs as a dry run: wheels are downloaded, promoted and
+# validated with `twine check`, but nothing is uploaded. Pass --publish to
+# upload to PyPI.
+#
+# To publish the wheels from a previous dry run without downloading them again,
+# pass the directory that the dry run printed:
+#   ./pypi_deploy.sh --publish --wheel-dir /tmp/iree_pypi_wheels.XXXXX
+#
+# Each package is uploaded separately. If an upload fails (e.g. because your
+# credentials lack permission for that PyPI project), the remaining packages
+# are still uploaded and the failures are listed in a summary at the end.
 
 set -euo pipefail
 
-RELEASE="$1"
+function print_usage() {
+  echo "Usage: $0 [--publish] <release tag, e.g. iree-3.4.0rc20250430>"
+  echo "       $0 [--publish] --wheel-dir <directory from a previous run>"
+}
+
+PUBLISH=0
+RELEASE=""
+WHEEL_DIR=""
+while (( $# > 0 )); do
+  case "$1" in
+    --publish)
+      PUBLISH=1
+      ;;
+    --wheel-dir)
+      if (( $# < 2 )); then
+        echo "--wheel-dir requires a directory."
+        print_usage
+        exit 1
+      fi
+      WHEEL_DIR="$2"
+      shift
+      ;;
+    --wheel-dir=*)
+      WHEEL_DIR="${1#--wheel-dir=}"
+      ;;
+    -h|--help)
+      print_usage
+      exit 0
+      ;;
+    -*)
+      echo "Unknown option: $1"
+      print_usage
+      exit 1
+      ;;
+    *)
+      if [[ -n "${RELEASE}" ]]; then
+        echo "Only one release may be given."
+        print_usage
+        exit 1
+      fi
+      RELEASE="$1"
+      ;;
+  esac
+  shift
+done
+if [[ -n "${WHEEL_DIR}" && -n "${RELEASE}" ]]; then
+  echo "Pass either a release tag or --wheel-dir, not both."
+  print_usage
+  exit 1
+fi
+if [[ -z "${WHEEL_DIR}" && -z "${RELEASE}" ]]; then
+  print_usage
+  exit 1
+fi
+if [[ -n "${WHEEL_DIR}" && ! -d "${WHEEL_DIR}" ]]; then
+  echo "Wheel directory '${WHEEL_DIR}' does not exist."
+  exit 1
+fi
+
+# Packages that every release is expected to contain, as named in wheel files.
+EXPECTED_PACKAGES=(
+  iree_base_compiler
+  iree_base_runtime
+  iree_tools_tf
+  iree_tools_tflite
+)
 
 SCRIPT_DIR="$(dirname -- "$( readlink -f -- "$0"; )")";
 REQUIREMENTS_FILE="${SCRIPT_DIR}/pypi_deploy_requirements.txt"
-TMPDIR="$(mktemp --directory --tmpdir iree_pypi_wheels.XXXXX)"
+if [[ -n "${WHEEL_DIR}" ]]; then
+  TMPDIR="$(readlink -f -- "${WHEEL_DIR}")"
+else
+  TMPDIR="$(mktemp --directory --tmpdir iree_pypi_wheels.XXXXX)"
+fi
 
 function check_command_exists() {
   if ! command -v "$1" > /dev/null; then
@@ -76,6 +158,41 @@
   ls
 }
 
+function confirm_or_exit() {
+  local reply=""
+  if ! read -r -p "$1 [y/N] " reply < /dev/tty; then
+    echo ""
+    echo "Could not read confirmation, aborting."
+    exit 1
+  fi
+  if [[ ! "${reply}" =~ ^[Yy]([Ee][Ss])?$ ]]; then
+    echo "Aborting."
+    exit 1
+  fi
+}
+
+function check_expected_packages() {
+  echo ""
+  echo "Checking for expected packages..."
+  local missing=()
+  local package
+  for package in "${EXPECTED_PACKAGES[@]}"; do
+    local wheels=( "${package}"-*.whl )
+    if [[ -e "${wheels[0]}" ]]; then
+      echo "  ${package}: ${#wheels[@]} wheel(s)"
+    else
+      echo "  ${package}: MISSING"
+      missing+=( "${package}" )
+    fi
+  done
+
+  if (( ${#missing[@]} > 0 )); then
+    echo ""
+    echo "'${RELEASE:-${TMPDIR}}' has no wheels for: ${missing[*]}"
+    confirm_or_exit "Continue without them?"
+  fi
+}
+
 function edit_release_versions() {
   echo ""
   echo "Editing release versions..."
@@ -89,27 +206,99 @@
 }
 
 function upload_wheels() {
+  # In a dry run, `twine check` stands in for `twine upload` so the wheels are
+  # validated.
+  local twine_args=( upload --verbose )
+  local action="Uploading"
+  local done_label="uploaded"
   echo ""
-  echo "Uploading wheels..."
-  twine upload --verbose *
+  if (( PUBLISH )); then
+    echo "Uploading wheels..."
+  else
+    twine_args=( check --strict )
+    action="Checking"
+    done_label="ok"
+    echo "Dry run: checking wheels instead of uploading (pass --publish to upload)..."
+  fi
+
+  # Group and upload wheels by package.
+  local packages=()
+  local file
+  for file in *.whl; do
+    packages+=( "${file%%-*}" )
+  done
+  mapfile -t packages < <(printf '%s\n' "${packages[@]}" | sort -u)
+
+  local succeeded=()
+  local failed=()
+  local package
+  for package in "${packages[@]}"; do
+    echo ""
+    echo "${action} ${package}..."
+    if twine "${twine_args[@]}" "${package}"-*.whl; then
+      succeeded+=( "${package}" )
+    else
+      echo "${action} ${package} failed, continuing with remaining packages."
+      failed+=( "${package}" )
+    fi
+  done
+
+  echo ""
+  if (( PUBLISH )); then
+    echo "Upload summary:"
+  else
+    echo "Dry run. Wheels that would be uploaded:"
+    ls -1
+    echo ""
+    echo "Check summary:"
+  fi
+  for package in "${succeeded[@]}"; do
+    echo "  ${done_label}: ${package}"
+  done
+  for package in "${failed[@]}"; do
+    echo "  FAILED: ${package}"
+  done
+  if (( PUBLISH && ${#failed[@]} > 0 )); then
+    echo ""
+    echo "Some packages were not uploaded. Their wheels are kept in ${TMPDIR}."
+    echo "  twine upload ${TMPDIR}/<package>-*.whl"
+  fi
+  if (( ! PUBLISH )); then
+    echo ""
+    echo "To upload these wheels without downloading them again, run:"
+    echo "  $0 --publish --wheel-dir ${TMPDIR}"
+  fi
 }
 
 
 function main() {
+  local source="${RELEASE:-${TMPDIR}}"
+  if (( PUBLISH )); then
+    echo "Publishing '${source}' to PyPI."
+  else
+    echo "Dry run for '${source}', nothing will be uploaded (pass --publish to upload)."
+  fi
   echo "Changing into ${TMPDIR}"
   cd "${TMPDIR}"
 
   set +e
   check_requirements
 
-  if ! check_command_exists gh; then
+  if [[ -z "${WHEEL_DIR}" ]] && ! check_command_exists gh; then
     echo "The GitHub CLI 'gh' is required. See https://github.com/cli/cli#installation."
     echo " Googlers, the PPA should already be on your linux machine."
     exit 1
   fi
   set -e
 
-  download_wheels
+  if [[ -z "${WHEEL_DIR}" ]]; then
+    download_wheels
+  else
+    echo ""
+    echo "Using wheels from '${TMPDIR}' instead of downloading:"
+    ls
+  fi
+  check_expected_packages
   edit_release_versions
   upload_wheels
 }
diff --git a/docs/website/docs/developers/general/release-management.md b/docs/website/docs/developers/general/release-management.md
index 0bf7482..b4b8029 100644
--- a/docs/website/docs/developers/general/release-management.md
+++ b/docs/website/docs/developers/general/release-management.md
@@ -203,10 +203,24 @@
 
 ## :octicons-rocket-16: Running a release
 
-A pinned issue tracking the next release should be filed like
-<https://github.com/iree-org/iree/issues/18380>. Developers authoring patches
-that include major or breaking changes should coordinate merge timing and
-contribute release notes on those issues.
+Developers authoring patches that include major or breaking changes should
+coordinate merge timing and contribute release notes on the pinned issue that
+tracks the next release. A pinned issue tracking the release should be filed,
+based on
+[release-tracker-template.md](https://github.com/iree-org/iree/blob/main/docs/website/docs/developers/general/release-tracker-template.md):
+copy it, replace the placeholders, and file it with the GitHub CLI:
+
+```bash
+cp docs/website/docs/developers/general/release-tracker-template.md /tmp/release-tracker.md
+# Fill in the version, release date and previous release in /tmp/release-tracker.md.
+gh issue create --repo iree-org/iree \
+  --title "Release Tracker vX.Y.Z - (YYYY-MM-DD)" \
+  --body-file /tmp/release-tracker.md
+gh issue pin <issue number> --repo iree-org/iree
+```
+
+Until the release, watch for major or breaking changes and decide whether to
+batch them with this release or defer them until the next one.
 
 ### :material-check-all: Picking a candidate to promote
 
@@ -223,44 +237,113 @@
 the proposal and solicit feedback. People may point out known regressions or
 request that some feature make the cut.
 
+### :octicons-note-16: Compiling release notes
+
+Release notes are collected on the tracking issue, where either contributors add
+announcements and notable changes as they land, or they are collected on release
+day.
+
+Generate the "New contributors" list and the full changelog for the range from
+the previous stable release to the candidate, e.g. with the "Generate release
+notes" button when drafting a GitHub release, or:
+
+```bash
+gh api repos/iree-org/iree/releases/generate-notes \
+  -f tag_name=v3.12.0 \
+  -f target_commitish=iree-3.12.0rc20260917 \
+  -f previous_tag_name=v3.11.0 --jq .body
+```
+
+The "Release notes" section of the
+[release tracker template](https://github.com/iree-org/iree/blob/main/docs/website/docs/developers/general/release-tracker-template.md)
+has the expected structure.
+
+The header of the release notes lists the VMFB bytecode version and the HAL
+module version, and whether they changed since the previous release:
+
+* VMFB bytecode version: `IREE_VM_BYTECODE_VERSION_MAJOR` and
+  `IREE_VM_BYTECODE_VERSION_MINOR`.
+* HAL module version: `IREE_HAL_MODULE_VERSION_LATEST`.
+
 ### :octicons-package-dependents-16: Promoting a candidate to stable
 
 1. (Authorized users only) Push to PyPI using
-    [pypi_deploy.sh](https://github.com/iree-org/iree/blob/main//build_tools/python_deploy/pypi_deploy.sh)
+    [pypi_deploy.sh](https://github.com/iree-org/iree/blob/main//build_tools/python_deploy/pypi_deploy.sh).
+    The script is a dry run by default. Check its output, then pass
+    `--publish` to upload. Keep the whl folder for upload to GitHub.
 
 2. Create a new release on GitHub:
 
-    * Set the tag to be created and select a target commit. For example, if the
-        candidate release was tagged `iree-3.1.0rc20241119` at commit `3ed07da`,
-        set the new release tag `v3.1.0` and use the same commit.
+    * Create a new GitHub draft release (via the WebUI or CLI). Set the tag to
+        be created and select a target commit. For example, if the candidate
+        release was tagged `iree-3.1.0rc20241119` at commit `3ed07da`, set the
+        new release tag `v3.1.0` and use the same commit. GitHub creates the
+        tag when the release is published, so nothing needs to be pushed
+        beforehand.
 
         ![rename_tag](./release-tag.png)
 
-        If the commit does not appear in the list, create and push a tag
-        manually:
+        The target picker only lists recent commits. If the candidate's commit
+        does not appear there, create and push the tag yourself, then select it
+        as an existing tag:
 
         ```bash
-        git checkout iree-3.1.0rc20250107
-        git tag -a v3.1.0 -m "Version 3.1.0 release."
-        git push upstream v3.1.0
+        git tag -a v3.1.0 iree-3.1.0rc20241119 -m "Version 3.1.0 release."
+        git rev-parse 'v3.1.0^{commit}'  # Check that this is the candidate's commit.
+        git push upstream refs/tags/v3.1.0  # Pushes only the tag.
+        ```
+
+        The release can also be created from the command line. `gh` creates
+        the tag on publish, the same way the web UI does. The command also sets
+        the title and release notes from the next two steps. The assets are
+        uploaded to the draft later. You can extract the release notes
+        `notes.md` from the release tracking issue.
+
+        ```bash
+        gh issue view <issue number> --repo iree-org/iree --json body --jq .body \
+          | tr -d '\r' \
+          | awk 'started { print; next } prev == "---" && $0 == "---" { started = 1 } { prev = $0 }' \
+          > notes.md
+        gh release create v3.1.0 --repo iree-org/iree \
+          --target "$(git rev-parse 'iree-3.1.0rc20241119^{commit}')" \
+          --draft --title "Release v3.1.0" --notes-file notes.md
         ```
 
     * Set the title to `Release vX.Y.Z`.
 
     * Paste the release notes from the release tracking issue.
 
-    * Upload the `.whl` files produced by the `pypy_deploy.sh` script (look for
-        them in your `/tmp/` directory). These have the stable release versions
-        in them.
+    * Upload the `iree-dist-*.tar.xz` files of the release candidate and
+        `.whl` files of `pypi_deploy.sh` to the release draft.
 
-    * Download the `iree-dist-.*.tar.xz` files from the candidate release and
-        upload them to the new stable release.
+        Download the `iree-dist-*.tar.xz` files from the candidate release.
 
-    * Uncheck the option for "pre-release", and check the option for "latest".
+        ```bash
+        WHEEL_DIR=/tmp/iree_pypi_wheels.XXXXX
+        gh release download iree-3.1.0rc20241119 --repo iree-org/iree \
+          --pattern 'iree-dist-*' --dir "${WHEEL_DIR}"
+        gh release upload v3.1.0 --repo iree-org/iree \
+          "${WHEEL_DIR}"/*.whl "${WHEEL_DIR}"/iree-dist-*.tar.xz
+        ```
+
+    * Publish the release. Uncheck the option for "pre-release", and check the
+        option for "latest" and hit publish.
 
         ![promote_release](./release-latest.png)
 
-3. Complete any remaining checkbox items on the release tracking issue then
+        Or via the CLI:
+        `gh release edit v3.1.0 --repo iree-org/iree --draft=false --latest`.
+
+3. Release the iree-turbine packages, following
+   [iree-turbine's release docs](https://github.com/iree-org/iree-turbine/blob/main/docs/infra/releasing.md).
+
+4. Increment the versions in source code to the next minor release, so that
+   nightly releases sort after the new stable release (see
+   [Versioning scheme](./versioning-scheme.md)): set `package-version` to
+   `X.{Y+1}.0.dev` in both `compiler/version.json` and `runtime/version.json`,
+   e.g. <https://github.com/iree-org/iree/pull/23866>.
+
+5. Complete any remaining checkbox items on the release tracking issue then
    close it and open a new one for the next release.
 
 ## :octicons-stack-16: Creating a patch release
@@ -273,8 +356,8 @@
     <!-- TODO(scotttodd): Does this need a branch, or would just a tag work? -->
 
     ```shell
-    git checkout v3.0.0
-    git checkout -b v3.0.1
+    git checkout v3.12.0
+    git checkout -b v3.12.1
     ```
 
 2. Apply and commit the patches.
diff --git a/docs/website/docs/developers/general/release-tracker-template.md b/docs/website/docs/developers/general/release-tracker-template.md
new file mode 100644
index 0000000..5cbc38f
--- /dev/null
+++ b/docs/website/docs/developers/general/release-tracker-template.md
@@ -0,0 +1,66 @@
+<!-- markdownlint-disable-file line-length -->
+
+## Overview
+
+> [!IMPORTANT]
+> The X.YY.Z release is scheduled for YYYY-MM-DD.
+
+Previous stable release: [vA.BB.C](https://github.com/iree-org/iree/releases/tag/vA.BB.C).
+The process follows <https://iree.dev/developers/general/release-management/>.
+
+## Release checklist
+
+- [ ] Watch for major/breaking changes and decide to either batch them with this release or defer them until the next release
+- [ ] Choose release candidates from nightly releases to promote. The candidates should contain no major regressions and should include all packages, even those marked `experimental` in [`.github/workflows/build_package.yml`](https://github.com/iree-org/iree/blob/main/.github/workflows/build_package.yml) like macOS and Windows packages.
+    - Chosen: [`iree-X.YY.ZrcYYYYMMDD`](https://github.com/iree-org/iree/releases/tag/iree-X.YY.ZrcYYYYMMDD) at <https://github.com/iree-org/iree/commit/COMMIT> (YYYY-MM-DD). It has packages for all platforms, including macOS and Windows, and no major regressions vs vA.BB.C.
+- [ ] Compile release notes
+    - Changes since the previous release: <https://github.com/iree-org/iree/compare/vA.BB.C...iree-X.YY.ZrcYYYYMMDD>
+- [ ] Push IREE packages to PyPI
+- [ ] Create a new release on GitHub
+- [ ] Push iree-turbine packages to PyPI and create a new release on GitHub
+- [ ] Increment version.json files
+    - `X.YY.Z.dev` -> `X.(YY+1).0.dev`
+- [ ] Close this issue and open a new one for the next release
+
+## Testing instructions
+
+(Update this once release candidates are selected. Versions should match within each repository.)
+
+```shell
+pip install \
+  --find-links https://iree.dev/pip-release-links.html \
+  iree-base-compiler==X.YY.ZrcYYYYMMDD \
+  iree-base-runtime==X.YY.ZrcYYYYMMDD \
+  iree-tools-tf==YYYYMMDD.NNNN \
+  iree-tools-tflite==YYYYMMDD.NNNN
+```
+
+## Release notes
+
+---
+---
+
+# IREE vX.YY.Z Release Notes
+
+**Release Candidate:** `iree-X.YY.ZrcYYYYMMDD`
+**PRs:** k since vA.BB.C
+**VMFB Bytecode Version:** N.M ([un]changed from vA.BB.C)
+**HAL Module Version:** O.P ([un]changed from vA.BB.C)
+
+## Breaking changes
+
+* **ITEM** DESCRIPTION <https://github.com/iree-org/iree/pull/NNNNN>
+
+## Notable changes
+
+### Compiler
+
+### Runtime
+
+### Developer tools
+
+## New contributors
+
+## Full changelog
+
+**Commit history**: <https://github.com/iree-org/iree/compare/vA.BB.C...vX.YY.Z>