You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/27 11:55:22 UTC

(juneau) branch master updated: Remove scripts/release.py (and its doc and tests) from 10.0; release.py stays on the 9.2.1 line.

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

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


The following commit(s) were added to refs/heads/master by this push:
     new d7e17c3460 Remove scripts/release.py (and its doc and tests) from 10.0; release.py stays on the 9.2.1 line.
d7e17c3460 is described below

commit d7e17c3460c75139fac001ffeb7a15898fc037eb
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Oct 27 07:55:16 2026 -0400

    Remove scripts/release.py (and its doc and tests) from 10.0; release.py stays on the 9.2.1 line.
---
 juneau-terminal/README.md          |   20 +-
 scripts/_juneau.py                 |    2 +-
 scripts/current-release.md         |    1 -
 scripts/prompt-pgp-passphrase.md   |    6 +-
 scripts/prompt-pgp-passphrase.py   |    2 +-
 scripts/push.py                    |    2 +-
 scripts/release.md                 |   76 --
 scripts/release.py                 | 1468 ------------------------------------
 scripts/repin-consumers.md         |    1 -
 scripts/tests/README.md            |    8 -
 scripts/tests/test_release_run.py  |  957 -----------------------
 scripts/tests/test_run_adoption.py |    2 +-
 12 files changed, 12 insertions(+), 2533 deletions(-)

diff --git a/juneau-terminal/README.md b/juneau-terminal/README.md
index c512dcec92..4f0507f3fd 100644
--- a/juneau-terminal/README.md
+++ b/juneau-terminal/README.md
@@ -90,7 +90,7 @@ off a terminal (`TERM=dumb` and `JUNEAU_TERMINAL_LIVE=0` count as off) it prints
 | Variable | Effect |
 |---|---|
 | `JUNEAU_TERMINAL_CONSOLE=full\|board\|none` | Where tool output goes. `board` turns the view on. `board` and `none` also make `bootstrap.load()` load juneau_terminal, so a tool's output goes to the logs and not the terminal; `none` draws nothing and emits no markers unless `TERMINAL_MARKERS=1`. |
-| `JUNEAU_TERMINAL_DETAIL=summary\|actionable\|modules\|all` | How much the view shows (default `actionable`). `--detail` on `push.py`, `test.py` and `release.py` wins. |
+| `JUNEAU_TERMINAL_DETAIL=summary\|actionable\|modules\|all` | How much the view shows (default `actionable`). `--detail` on `push.py` and `test.py` wins. |
 | `JUNEAU_TERMINAL_FULL_LOG`, `JUNEAU_TERMINAL_BOARD_LOG` | The raw log, and the view's rows as plain text. The header prints `tail -f` for each on a line of its own, to paste into another terminal: `Output: tail -f <full log>`, then `Board: tail -f <board log>`. With `board` or `none` and no full log, the full log defaults to `~/.cache/juneau-terminal/<script>-<YYYYMMDD-HHMMSS>.log`, and unless a board log is set (by argument or variable) a `.board.log` beside it, so another tool can fo [...]
 | `JUNEAU_TERMINAL_CANCEL` | A command that cancels the run (for example a pane launcher's own cancel command); the header shows it as a `Cancel: <command>` row after the log rows. Unset, there is no such row. |
 | `JUNEAU_TERMINAL_LOG_DIR` | Where those default logs go (default `~/.cache/juneau-terminal`). The tests point it at a temp dir. |
@@ -264,15 +264,15 @@ The `load_juneau_terminal` snippet above still works for a single-file adopter t
 
 ## In this repository
 
-`scripts/test.py`, `scripts/push.py` and `scripts/release.py` load the script straight from
+`scripts/test.py` and `scripts/push.py` load the script straight from
 `juneau-terminal/src/main/python/` (no Maven involved).
 - With `TERMINAL_MARKERS=1`, `push.py` and `test.py` print terminal-protocol markers: `push.py` owns the run (`run`, `done`,
   its own steps); `test.py` and anything it runs contribute steps only, because `run()` exports
-  `JUNEAU_TERMINAL_ACTIVE=1`. `release.py` emits no markers; it warns and ignores `TERMINAL_MARKERS`.
+  `JUNEAU_TERMINAL_ACTIVE=1`.
 - `test.py --console full|board|none --full-log PATH --board-log PATH` routes Maven output (the same settings
   can come from `JUNEAU_TERMINAL_CONSOLE`, `JUNEAU_TERMINAL_FULL_LOG`, `JUNEAU_TERMINAL_BOARD_LOG`; an argument wins; relative
-  paths resolve under `$TERMINAL_ARTIFACTS`). `push.py` and `release.py` take the console settings from the environment.
-- `--detail summary|actionable|modules|all` on all three scripts sets the console detail (see "Console view").
+  paths resolve under `$TERMINAL_ARTIFACTS`). `push.py` takes the console settings from the environment.
+- `--detail summary|actionable|modules|all` on both scripts sets the console detail (see "Console view").
   `push.py` passes it, and its label width, to the `test.py` it runs, and hands that `test.py` the terminal while it
   runs.
 - `push.py --test-only` runs the build and test gates only (no identity check, commit, push or docs follow-up). It
@@ -285,16 +285,6 @@ The `load_juneau_terminal` snippet above still works for a single-file adopter t
   `JUNEAU_PUSH_TIMING_MIN_SECONDS` (default `5`) slower than its median. The full table goes to `test-timings.txt`
   beside `test.jsonl`. With no records yet the Timing row says "no timing history yet" and no table is written; with
   `JUNEAU_TERMINAL_HISTORY=0` it says the run history is off.
-- `release.py` always writes its full log to `~/.juneau-release-logs/<release>-<YYYYMMDD-HHMMSS>.log`, whatever the
-  console mode. Its Maven commands run with `-B` under a PTY (for gpg), and `run_git_diff` pages the
-  `release:prepare` diff through `$PAGER` before `release:perform`. Quit the pager with `q`; `release:perform`
-  starts straight after. Ctrl-C in the pager belongs to the pager and does not cancel the release.
-- `-B` stops only the release plugin's own prompts (`release.py` passes every version with `-D`). git, svn and
-  gpg still prompt through the PTY, and the row shows the prompt after 20 s of silence. To avoid the prompts, set up
-  before a release: a git credential helper that knows your `gitbox.apache.org` credentials (see
-  `git help credential`), a `<server>` entry for `apache.releases.https` in `~/.m2/settings.xml`, svn's cached
-  credentials (run `svn info https://dist.apache.org/repos/dist/dev/juneau` once), and a primed gpg-agent (the PGP
-  step).
 - With markers off and the default console, `push.py` and `test.py` behave exactly as before.
 
 ## The side-pane scripts
diff --git a/scripts/_juneau.py b/scripts/_juneau.py
index d5f67ac61e..aeccfd2dda 100644
--- a/scripts/_juneau.py
+++ b/scripts/_juneau.py
@@ -15,7 +15,7 @@
 # limitations under the License.
 
 """
-The one loader push.py, test.py and release.py share: the in-tree juneau_terminal_scripts toolkit and juneau_terminal, from
+The one loader push.py and test.py share: the in-tree juneau_terminal_scripts toolkit and juneau_terminal, from
 juneau-terminal/src/main/python beside this directory.
 
     import _juneau
diff --git a/scripts/current-release.md b/scripts/current-release.md
index cabb21f7f5..6af3f138a7 100644
--- a/scripts/current-release.md
+++ b/scripts/current-release.md
@@ -41,5 +41,4 @@ Stdout is the version string only. Warnings and errors go to stderr.
 ## See also
 
 - `maven-version.py`
-- `release.py`
 - Docs site: Developer Info, Script: current-release (`04.02.05.ScriptCurrentRelease.md` in juneau-docs)
diff --git a/scripts/prompt-pgp-passphrase.md b/scripts/prompt-pgp-passphrase.md
index 604ecee1e0..d3e1091270 100644
--- a/scripts/prompt-pgp-passphrase.md
+++ b/scripts/prompt-pgp-passphrase.md
@@ -4,7 +4,7 @@ Makes a throwaway `gpg --clearsign` call so the PGP passphrase is asked for (and
 
 ## When to use
 
-- You are about to start a long build or release and want the passphrase prompt out of the way first. `push.py` and `release.py` call it for you.
+- You are about to start a long build or release and want the passphrase prompt out of the way first. `push.py` calls it for you.
 - To check that your signing key and passphrase work, run it by hand.
 
 ## Usage
@@ -37,11 +37,11 @@ Status lines on stdout, gpg's remaining stderr on stderr.
 
 ## Gotchas
 
-- It is best-effort. `push.py` and `release.py` treat a non-zero exit as a warning only and carry on.
+- It is best-effort. `push.py` treats a non-zero exit as a warning only and carry on.
 - A missing `gpg` or a timeout still exits 0, so exit 0 does not prove the passphrase was accepted.
 - It needs an interactive terminal for the passphrase prompt.
 
 ## See also
 
-- `push.py`, `release.py`
+- `push.py`
 - Docs site: Developer Info, Script: prompt-pgp-passphrase (`04.02.04.ScriptPromptPgpPassphrase.md` in juneau-docs)
diff --git a/scripts/prompt-pgp-passphrase.py b/scripts/prompt-pgp-passphrase.py
index a3c323f240..71c7038cb4 100755
--- a/scripts/prompt-pgp-passphrase.py
+++ b/scripts/prompt-pgp-passphrase.py
@@ -121,7 +121,7 @@ def prompt_pgp_passphrase():
             print(f"⚠ Could not prompt for PGP passphrase: {e}")
     except Exception as e:
         print(f"⚠ Could not set up PGP passphrase prompt: {e}")
-    # Best-effort: the callers (push.py, release.py) treat a non-zero exit as a warning only and never stop the build.
+    # Best-effort: the caller (push.py) treats a non-zero exit as a warning only and never stop the build.
     return gpg_ok
 
 
diff --git a/scripts/push.py b/scripts/push.py
index e71820826a..6ba1a8b2f4 100755
--- a/scripts/push.py
+++ b/scripts/push.py
@@ -43,7 +43,7 @@ import _juneau  # noqa: E402
 
 
 # git commit and git push run under a PTY so ssh, pinentry or a credential helper can prompt.  After this many silent
-# seconds, a partial line is shown on the board as a prompt (as in release.py).
+# seconds, a partial line is shown on the board as a prompt.
 WATCHDOG_SECONDS = 20
 
 
diff --git a/scripts/release.md b/scripts/release.md
deleted file mode 100644
index ada378700d..0000000000
--- a/scripts/release.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# release.py
-
-Walks through the Apache Juneau release steps: checks, build, `release:prepare` and `release:perform`, binary artifacts, SVN upload and distribution check.
-
-## When to use
-
-- It is deprecated. At startup it prints a warning that Juneau releases now run from the separate release manager project, and that this script will be removed after the next release.
-- Use it only to continue or revert a release that was started with it.
-
-## Usage
-
-```bash
-python3 scripts/release.py --list-steps
-python3 scripts/release.py
-python3 scripts/release.py --resume
-python3 scripts/release.py --start-step run_release_prepare --skip-step clean_maven_repo
-python3 scripts/release.py --revert
-```
-
-## Options
-
-| Option | Argument | Default | Meaning |
-|---|---|---|---|
-| `--list-steps` | none | off | List the step names in order and exit. |
-| `--start-step` | step name | first step | Start at this step, skipping the ones before it. Mutually exclusive with `--resume`. |
-| `--resume` | none | off | Start after the last step recorded in the checkpoint file. |
-| `--skip-step` | step name | none | Skip this step. May be repeated. |
-| `--revert` | none | off | Revert a release: delete the git tag, reset the Maven versions and remove the RC from SVN. |
-| `--console` | `full`, `board`, `none` | `full` | Maven output on the console. Also `JUNEAU_TERMINAL_CONSOLE`; the flag wins. |
-| `--detail` | `summary`, `actionable`, `modules`, `all` | `actionable` | Console detail. Also `JUNEAU_TERMINAL_DETAIL`. |
-
-## What it does
-
-The steps, in order (`RELEASE_STEPS`):
-
-1. `check_prerequisites`: requires `wget`, `gpg`, `svn`, `git`, `java` and `mvn` on the `PATH`.
-2. `check_java_version` and `check_maven_version`: Java 17 or newer, Maven 3 or newer.
-3. `clean_maven_repo`: moves `~/.m2/repository` aside and deletes it in the background, only on the first run for a version.
-4. `make_git_folder`: recreates the staging directory (default `~/tmp/dist-release-juneau`), deleting any existing one.
-5. `clone_juneau` and `configure_git`: clones the project from the Apache git server into staging and sets the committer name and email.
-6. `run_clean_verify`: `mvn clean verify`.
-7. `run_deploy`: `mvn deploy` with MD5, SHA-1 and SHA-512 checksums.
-8. `run_release_prepare`: requires a `-SNAPSHOT` version, then `mvn release:prepare` with the release version, tag and next development version.
-9. `run_git_diff`: shows `git diff <tag>` in a pager so you can review what `release:prepare` changed.
-10. `run_release_perform`: `mvn release:perform`, opens the Nexus staging page, and asks for the staging repository name after you close it by hand.
-11. `create_binary_artifacts`: checks out the SVN dev area, downloads the source and binary zips from the staging repository, renames them to `apache-juneau-<version>-src.zip` and `-bin.zip`, and commits them under the release candidate name.
-12. `verify_distribution`: checks the six expected files exist and are not empty, opens the SVN page, and prints a draft vote email.
-
-Before the steps it prompts for the release candidate number, staging directory, Apache username and email, git branch and `JAVA_HOME`, with the last answers as defaults. `--revert` runs four steps: pull, delete the tag, reset the versions with `release:update-versions`, and remove the RC directories from SVN (`revert_release`).
-
-## Output and exit codes
-
-A configuration banner, then a row per step with the full output written to `~/.juneau-release-logs/<release>-<timestamp>.log`. After a failure it prints the command that resumes the run.
-
-| Code | Meaning |
-|---|---|
-| 0 | Every selected step finished. |
-| 1 | A step failed, or a prerequisite is missing. |
-| 2 | An invalid option or step name. |
-| 130 | Cancelled with Ctrl-C or Ctrl-D at a prompt, or the revert confirmation was declined. |
-
-## Gotchas
-
-- The checkpoint is in `~/.juneau-release-state.json` and the per-version answers in `release-history-<version>.json` beside the script. A corrupt checkpoint stops the run so a release never resumes from a guess.
-- `release:perform` uploads to the public Apache staging repository, and the SVN steps commit to the public dev distribution area. Do not click Release in Nexus until the vote has passed.
-- `make_git_folder` deletes the whole staging directory on every run that includes it. Use `--start-step` or `--resume` to avoid redoing it.
-- `--revert` deletes the remote tag with `git push origin :<tag>` and can commit SVN deletions after asking.
-- Steps need GPG and SVN credentials. The script primes the gpg agent first with `prompt-pgp-passphrase.py`.
-- `TERMINAL_MARKERS` is ignored, with a warning.
-
-## See also
-
-- `prompt-pgp-passphrase.py`
-- `current-release.py`
-- `maven-version.py`
-- Docs site: Developer Info, Script: release (`04.02.03.ScriptRelease.md` in juneau-docs)
diff --git a/scripts/release.py b/scripts/release.py
deleted file mode 100755
index a93552058b..0000000000
--- a/scripts/release.py
+++ /dev/null
@@ -1,1468 +0,0 @@
-#!/usr/bin/env python3
-# ***************************************************************************************************************************
-# * 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.                                              *
-# ***************************************************************************************************************************
-"""
-Walks through the Apache Juneau release steps: checks, build, `release:prepare` and `release:perform`, binary artifacts, SVN upload and distribution check.
-
-Full docs: release.md
-"""
-
-USAGE = """
-Apache Juneau Release Script
-
-This script automates the release process for Apache Juneau, including:
-- Prerequisite checks
-- Maven repository cleanup
-- Git repository cloning
-- Build and verification
-- Maven deploy and release
-- Review of the release:prepare diff
-- Binary artifact creation
-- SVN distribution upload
-
-The script supports resuming from any step, allowing you to restart at arbitrary points
-in the release process.
-
-Usage:
-    python3 scripts/release.py [--start-step STEP_NAME | --resume] [--list-steps] [--skip-step STEP_NAME]
-                               [--revert] [--console MODE] [--detail LEVEL]
-
-Options:
-    --start-step STEP_NAME    Start execution from the specified step (skips all previous steps)
-    --list-steps              List all available steps and exit
-    --skip-step STEP_NAME     Skip a specific step (can be used multiple times)
-    --resume                  Resume from the last checkpoint (if available)
-    --revert                  Delete the git tag, revert the Maven versions and remove the RC from SVN
-    --console MODE            Maven output on the console: full (default), board or none (JUNEAU_TERMINAL_CONSOLE)
-    --detail LEVEL            Console detail: summary, actionable (default), modules or all (JUNEAU_TERMINAL_DETAIL)
-"""
-
-import argparse
-import importlib
-import json
-import os
-import re
-import shutil
-import subprocess
-import sys
-import urllib.request
-import xml.etree.ElementTree as ET
-from contextlib import contextmanager
-from datetime import datetime, timedelta
-from pathlib import Path
-
-# The shared loader (_juneau.py) sits beside this script; appended, so no script here shadows an installed module.
-if str(Path(__file__).resolve().parent) not in sys.path:
-    sys.path.append(str(Path(__file__).resolve().parent))
-import _juneau  # noqa: E402
-
-# Constants for duplicated string literals
-POM_XML = 'pom.xml'
-STAGING_DIR = '~/tmp/dist-release-juneau'
-SVN_DIST_URL = 'https://dist.apache.org/repos/dist/dev/juneau'
-RC_PATTERN = r'RC(\d+)'
-from typing import Dict, Optional, List
-
-# Minimum required versions
-MIN_JAVA_VERSION = 17
-MIN_MAVEN_VERSION = 3
-
-# State file for checkpoint/resume functionality
-STATE_FILE = Path.home() / '.juneau-release-state.json'
-
-# Full log of every release run: ~/.juneau-release-logs/<release>-<YYYYMMDD-HHMMSS>.log
-RELEASE_LOG_DIR = Path.home() / '.juneau-release-logs'
-
-# Maven and `svn commit` run under a PTY so gpg or svn can prompt.  After this many silent seconds, a partial
-# line is shown as a prompt.
-WATCHDOG_SECONDS = 20
-
-# Row label (at most 10 characters, so rows line up with push.py's and test.py's) and title of each release step.
-STEP_TITLES = {
-    'check_prerequisites': ('Tools', 'Checking prerequisites'),
-    'check_java_version': ('Java', 'Checking Java version'),
-    'check_maven_version': ('Maven', 'Checking Maven version'),
-    'clean_maven_repo': ('Clean .m2', 'Cleaning Maven repository'),
-    'make_git_folder': ('Staging', 'Making git folder'),
-    'clone_juneau': ('Clone', 'Cloning juneau.git'),
-    'configure_git': ('Git config', 'Configuring git'),
-    'run_clean_verify': ('Verify', 'Running clean verify'),
-    'run_deploy': ('Deploy', 'Running deploy'),
-    'run_release_prepare': ('Prepare', 'Running release:prepare'),
-    'run_git_diff': ('Diff', 'Reviewing the release:prepare diff'),
-    'run_release_perform': ('Perform', 'Running release:perform'),
-    'create_binary_artifacts': ('Artifacts', 'Creating binary artifacts'),
-    'verify_distribution': ('Dist check', 'Verifying distribution'),
-}
-
-# The steps in order.  Each is a ReleaseScript method of the same name, looked up when the step runs.
-RELEASE_STEPS = list(STEP_TITLES)
-
-# The command the resume hint names.
-RESUME_PROG = 'python3 scripts/release.py'
-
-# The checkpoint's key for the last finished step before juneau_terminal_scripts' Checkpoint, which calls it "last".
-LEGACY_LAST_STEP = 'last_step'
-
-REPO_ROOT = Path(__file__).resolve().parent.parent
-
-
-def toolkit(name):
-    """A juneau_terminal_scripts module (run, steps, state, prompt) from the in-tree toolkit."""
-    _juneau.load()
-    return importlib.import_module(f"juneau_terminal_scripts.{name}")
-
-
-def say(text='', level='info'):
-    """Status text: printed as before, or a filtered note when a console view owns the screen."""
-    toolkit('run').say(text, level)
-
-
-def ask(prompt, default=''):
-    """The stripped answer to a prompt, default when it is empty.  Ctrl-D cancels like Ctrl-C: it never means the
-    default."""
-    answer = toolkit('prompt').text(prompt, default=default)
-    if answer is None:
-        raise KeyboardInterrupt
-    return answer
-
-
-def build_registry(steps, call):
-    """A steps.Registry of the named steps, titled and labelled from STEP_TITLES.  call(name) gives a step's
-    function."""
-    registry = toolkit('steps').Registry()
-    for name in steps:
-        label, title = STEP_TITLES.get(name, (None, name))
-        registry.step(name, title, label=label)(call(name))
-    return registry
-
-
-def open_checkpoint():
-    """The release checkpoint, with a legacy last_step key read as "last".  A corrupt or unreadable file, or one the
-    migration cannot write back, warns and fails: a release never resumes from a guess, and the file is left for you
-    to inspect or delete."""
-    state = toolkit('state')
-    checkpoint = state.Checkpoint(STATE_FILE)
-    try:
-        legacy = checkpoint.get(LEGACY_LAST_STEP)
-        if checkpoint.get('last') is None and isinstance(legacy, str):
-            checkpoint.set('last', legacy)
-    except (state.CheckpointError, OSError) as e:
-        say(f"Warning: Could not load state file: {e}", 'warn')
-        sys.exit(1)
-    return checkpoint
-
-
-def tool_options(cmd):
-    """
-    (command, parser, tty) for run_command.  Maven gets -B and the maven parser.  Maven, `svn commit` and
-    `git push` get a PTY, because gpg signing or svn/gitbox credentials may prompt.
-    """
-    if cmd[:1] == ['mvn']:
-        return ['mvn', '-B', *cmd[1:]], 'maven', True
-    if cmd[:2] in (['svn', 'commit'], ['git', 'push']):
-        return list(cmd), 'generic', True
-    return list(cmd), 'generic', False
-
-
-def command_title(cmd, width=60):
-    """A command as a row title, cut to width."""
-    text = ' '.join(str(part) for part in cmd)
-    return text if len(text) <= width else text[:width - 1] + '…'
-
-
-def ask_rc(prompt, default=None) -> int:
-    """Ask for a release candidate number until you give one.  Enter takes the default when there is one."""
-    while True:
-        answer = ask(prompt)
-        if not answer and default is not None:
-            return int(default)
-        if not answer:
-            say("Release candidate number is required. Please enter a value.", 'warn')
-            continue
-        try:
-            return int(answer)
-        except ValueError:
-            say("Please enter a valid number.", 'warn')
-
-
-def release_log_path(release, now=None) -> Path:
-    """~/.juneau-release-logs/<release>-<YYYYMMDD-HHMMSS>.log"""
-    return RELEASE_LOG_DIR / f"{release}-{(now or datetime.now()):%Y%m%d-%H%M%S}.log"
-
-class ReleaseScript:
-    """Main release script class."""
-    
-    def __init__(self, rc: Optional[int] = None, start_step: Optional[str] = None, skip_steps: List[str] = None, resume: bool = False, load_env: bool = True):
-        self.jr = _juneau.load()[0]   # juneau_terminal; not self.run, which would hide the run() method
-        self.current = None      # the step whose row run_command's sub-rows go under
-        self._sub = 0            # sub-row counter within the current step
-        self.summary = None      # the current step's row text, set by the step method
-        self.full_log = None
-        self._versions = None    # (java -version, mvn -version) output, probed once
-        self.state = open_checkpoint()
-        self.rc = rc  # Will be set during _load_env if not provided
-        self.start_step = start_step
-        self.skip_steps = set(skip_steps or [])
-        self.resume = resume
-        
-        # Load environment variables (only if not just listing steps)
-        if load_env:
-            self._load_env()
-        
-        # All steps in order
-        self.steps = list(RELEASE_STEPS)
-    
-    def _get_version_from_pom(self, pom_path: Path) -> str:
-        """Extract version from pom.xml using current-release.py script."""
-        script_dir = Path(__file__).parent
-        current_release_script = script_dir / 'current-release.py'
-        
-        try:
-            result = subprocess.run(
-                [sys.executable, str(current_release_script)],
-                cwd=pom_path.parent,
-                capture_output=True,
-                text=True,
-                check=True
-            )
-            return result.stdout.strip()
-        except Exception as e:
-            self.fail(f"Could not determine version using current-release.py: {e}")
-    
-    def _increment_maintenance_version(self, version: str) -> str:
-        """Increment the maintenance version (e.g., 9.0.0 -> 9.0.1)."""
-        # Match version pattern: major.minor.maintenance
-        match = re.match(r'^(\d+)\.(\d+)\.(\d+)$', version)
-        if not match:
-            self.fail(f"Invalid version format: {version}. Expected format: X.Y.Z")
-        
-        major, minor, maintenance = map(int, match.groups())
-        next_version = f"{major}.{minor}.{maintenance + 1}-SNAPSHOT"
-        return next_version
-    
-    def _load_history(self, version: str) -> Dict:
-        """Load history file for the given version."""
-        script_dir = Path(__file__).parent
-        history_file = script_dir / f'release-history-{version}.json'
-        if history_file.exists():
-            try:
-                with open(history_file, 'r') as f:
-                    return json.load(f)
-            except Exception as e:
-                say(f"Warning: Could not load history file: {e}", 'warn')
-        return {}
-    
-    def _save_history(self, version: str, values: Dict):
-        """Save history file for the given version."""
-        script_dir = Path(__file__).parent
-        history_file = script_dir / f'release-history-{version}.json'
-        try:
-            # Add last-run date
-            history_data = values.copy()
-            history_data['last_run_date'] = datetime.now().isoformat()
-            
-            with open(history_file, 'w') as f:
-                json.dump(history_data, f, indent=2)
-        except Exception as e:
-            say(f"Warning: Could not save history file: {e}", 'warn')
-    
-    def _prompt_with_default(self, prompt: str, default: Optional[str] = None, required: bool = True) -> str:
-        """Prompt for a value with a default."""
-        full_prompt = f"{prompt} [{default}]: " if default else f"{prompt}: "
-        while True:
-            response = ask(full_prompt)
-            if response:
-                return response
-            if default:
-                return default
-            if not required:
-                return ""
-            say("This field is required. Please enter a value.", 'warn')
-    
-    def _load_env(self):
-        """Initialize environment variables from pom.xml and user prompts."""
-        # Get the Juneau root directory
-        script_dir = Path(__file__).parent
-        juneau_root = script_dir.parent
-        pom_path = juneau_root / POM_XML
-        
-        if not pom_path.exists():
-            self.fail(f"pom.xml not found at {pom_path}")
-        
-        # Get version from pom.xml
-        version = self._get_version_from_pom(pom_path)
-        next_version = self._increment_maintenance_version(version)
-        
-        # Load history for this version
-        history = self._load_history(version)
-        
-        # Check if this is the first run for this version (determines X_CLEANM2)
-        is_first_run = not history
-        
-        last_run = (datetime.fromisoformat(history['last_run_date']).strftime('%Y-%m-%d %H:%M:%S')
-                    if history.get('last_run_date') else "Never (first run for this version)")
-        self.jr.show(f"{'=' * 79}\nApache Juneau Release Configuration\n{'=' * 79}\n"
-                      f"Detected version from pom.xml: {version}\n"
-                      f"Calculated next version: {next_version}\n"
-                      f"Last run: {last_run}\n{'=' * 79}\n")
-        
-        # Prompt for RC number if not already set, defaulting to the last one used for this version
-        if self.rc is None:
-            rc_match = re.search(RC_PATTERN, history.get('X_RELEASE_CANDIDATE') or '')
-            default_rc = rc_match.group(1) if rc_match else None
-            rc_prompt = "Release candidate number" + (f" [{default_rc}]" if default_rc else "") + ": "
-            self.rc = ask_rc(rc_prompt, default_rc)
-        
-        release_candidate = f"RC{self.rc}"
-        
-        staging = self._prompt_with_default(
-            "Staging directory",
-            history.get('X_STAGING', STAGING_DIR),
-            required=True
-        )
-        
-        username = self._prompt_with_default(
-            "Apache username",
-            history.get('X_USERNAME', ''),
-            required=True
-        )
-        
-        email = self._prompt_with_default(
-            "Apache email",
-            history.get('X_EMAIL', ''),
-            required=True
-        )
-        
-        git_branch = self._prompt_with_default(
-            "Git branch",
-            history.get('X_GIT_BRANCH', 'master'),
-            required=True
-        )
-        
-        java_home = self._prompt_with_default(
-            "JAVA_HOME",
-            history.get('X_JAVA_HOME', os.environ.get('JAVA_HOME', '')),
-            required=True
-        )
-        
-        # Set environment variables
-        os.environ['X_VERSION'] = version
-        os.environ['X_NEXT_VERSION'] = next_version
-        os.environ['X_RELEASE'] = f"juneau-{version}-{release_candidate}"
-        os.environ['X_STAGING'] = staging
-        
-        # Save X_RELEASE to state for later retrieval
-        self.state.set('X_RELEASE', os.environ['X_RELEASE'])
-        os.environ['X_USERNAME'] = username
-        os.environ['X_EMAIL'] = email
-        os.environ['X_GIT_BRANCH'] = git_branch
-        os.environ['X_JAVA_HOME'] = java_home
-        os.environ['X_CLEANM2'] = 'Y' if is_first_run else 'N'
-        
-        # Set JAVA_HOME and PATH
-        if java_home:
-            os.environ['JAVA_HOME'] = java_home
-            os.environ['PATH'] = f"{java_home}/bin:{os.environ.get('PATH', '')}"
-        
-        # Save history for next time
-        history_values = {
-            'X_VERSION': version,
-            'X_RELEASE_CANDIDATE': release_candidate,
-            'X_STAGING': staging,
-            'X_USERNAME': username,
-            'X_EMAIL': email,
-            'X_GIT_BRANCH': git_branch,
-            'X_JAVA_HOME': java_home,
-        }
-        self._save_history(version, history_values)
-        
-        # Display settings
-        keys = ['X_VERSION', 'X_NEXT_VERSION', 'X_RELEASE', 'X_STAGING',
-                'X_USERNAME', 'X_EMAIL', 'X_CLEANM2', 'X_GIT_BRANCH', 'X_JAVA_HOME']
-        self.jr.show('--- Settings ' + '-' * 67 + '\n'
-                      + ''.join(f"{key}: {os.environ.get(key, 'NOT SET')}\n" for key in keys)
-                      + '-' * 80 + '\n')
-    
-    def fail(self, msg: str = None):
-        """Show the failure under the current step and exit 1.  _in_session ends the session as fail."""
-        self.jr.failure(f"❌ {msg}" if msg else "❌ Release failed", step=self.current)
-        sys.exit(1)
-    
-    def success(self):
-        """Every step ran: clear the checkpoint.  The session's final line reports the success."""
-        self.state.clear()
-    
-    def yprompt(self, prompt: str) -> bool:
-        """Yes/no prompt.  Enter means yes; Ctrl-D cancels."""
-        response = ask(f"{prompt} (Y/n): ")
-        return not response or response.lower() in ('y', 'yes')
-    
-    def run_command(self, cmd: List[str], cwd: Optional[Path] = None, check: bool = True,
-                    capture_output: bool = False) -> subprocess.CompletedProcess:
-        """
-        Run a command through run_tool as a sub-row of the current step.  Its output goes to the full log.  Maven
-        gets -B, the maven parser and a PTY with the watchdog, and so does `svn commit`.  With check, a non-zero exit
-        fails the release.  The result's stdout is the merged output when capture_output is set.
-        """
-        cmd, parser, tty = tool_options(cmd)
-        self._sub += 1
-        step_id = f"{self.current or 'release'}.{self._sub}"
-        result = self.jr.run_tool(cmd, parser, step_id, command_title(cmd), n=self._sub, parent=self.current,
-                                  cwd=cwd, capture=capture_output, tty=tty,
-                                  watchdog=WATCHDOG_SECONDS if tty else None)
-        if check and result.exit != 0:
-            self.fail(f"Command failed (exit {result.exit}): {' '.join(cmd)}")
-        return subprocess.CompletedProcess(cmd, result.exit, result.output, None)
-    
-    def check_prerequisites(self):
-        """Check that required tools are available."""
-        required_tools = {
-            'wget': 'wget',
-            'gpg': 'gpg',
-            'svn': 'svn',
-            'git': 'git',
-            'java': 'Java',
-            'mvn': 'Maven',
-        }
-        
-        missing = []
-        for cmd, name in required_tools.items():
-            result = subprocess.run(['which', cmd], capture_output=True)
-            if result.returncode != 0:
-                missing.append(name)
-        
-        if missing:
-            self.fail(f"Missing required tools: {', '.join(missing)}")
-        
-        # Set GPG_TTY
-        tty = subprocess.run(['tty'], capture_output=True, text=True)
-        if tty.returncode == 0:
-            os.environ['GPG_TTY'] = tty.stdout.strip()
-        
-        self.summary = f"{len(required_tools)} tools found"
-    
-    def check_java_version(self):
-        """Check Java version, from the java -version output the header already probed."""
-        java_version = self._parse_java_version(self._probe_versions()[0])
-        
-        if java_version is None:
-            self.fail("Could not determine Java version from output")
-        
-        if java_version < MIN_JAVA_VERSION:
-            self.fail(f"Java version {java_version} detected. Java {MIN_JAVA_VERSION} or higher is required.")
-        
-        self.summary = f"Java {self._java_version_text() or java_version}"
-    
-    def _parse_java_version(self, version_text: str) -> Optional[int]:
-        """Parse Java version from java -version output."""
-        # Try to find version patterns like:
-        # - "openjdk version "17.0.1""
-        # - "java version "17.0.1""
-        # - "openjdk version "1.8.0_292"" (Java 8)
-        # - "java version "1.8.0_292"" (Java 8)
-        
-        # Pattern 1: Modern format (Java 9+): "version "17.0.1""
-        match = re.search(r'version\s+"(\d+)\.(\d+)', version_text)
-        if match:
-            major = int(match.group(1))
-            minor = int(match.group(2))
-            
-            # If major is 1, it's old format (Java 8 and below)
-            # The actual version is in the minor number
-            if major == 1:
-                return minor
-            else:
-                # Java 9+ uses the major version directly
-                return major
-        
-        # Pattern 2: Try to find just a version number
-        match = re.search(r'(\d+)\.(\d+)', version_text)
-        if match:
-            major = int(match.group(1))
-            minor = int(match.group(2))
-            if major == 1:
-                return minor
-            else:
-                return major
-        
-        return None
-    
-    def check_maven_version(self):
-        """Check Maven version, from the mvn -version output the header already probed (maven-version.py if not)."""
-        match = re.search(r'Apache Maven (\d+)', self._probe_versions()[1])
-        maven_version = int(match.group(1)) if match else self._get_maven_version()
-        
-        if maven_version is None:
-            self.fail("Could not determine Maven version")
-        
-        if maven_version < MIN_MAVEN_VERSION:
-            self.fail(f"Maven version {maven_version} detected. Maven {MIN_MAVEN_VERSION} or higher is required.")
-        
-        self.summary = f"Maven {self._maven_version_text() or maven_version}"
-    
-    def _get_maven_version(self) -> Optional[int]:
-        """Get Maven version using maven-version.py script."""
-        script_dir = Path(__file__).parent
-        maven_version_script = script_dir / 'maven-version.py'
-        
-        try:
-            result = subprocess.run(
-                [sys.executable, str(maven_version_script)],
-                capture_output=True,
-                text=True,
-                check=True
-            )
-            return int(result.stdout.strip())
-        except Exception as e:
-            say(f"Warning: Could not determine Maven version using maven-version.py: {e}", 'warn')
-            return None
-    
-    def clean_maven_repo(self):
-        """Clean Maven repository."""
-        clean_m2 = os.environ.get('X_CLEANM2', 'N')
-        if clean_m2.upper() == 'N':
-            self.summary = "kept (X_CLEANM2=N)"
-        else:
-            m2_repo = Path.home() / '.m2' / 'repository'
-            if m2_repo.exists():
-                old_repo = Path.home() / '.m2' / 'repository-old'
-                if old_repo.exists():
-                    shutil.rmtree(old_repo)
-                m2_repo.rename(old_repo)
-                # Remove in background
-                subprocess.Popen(['rm', '-rf', str(old_repo)])
-                self.summary = "moved aside, deleting in the background"
-            else:
-                self.summary = "already empty"
-    
-    def make_git_folder(self):
-        """Create git staging folder."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        
-        # Clean up entire staging directory to start fresh (avoids issues from previous runs)
-        if staging.exists():
-            say(f"Removing existing staging directory: {staging}")
-            shutil.rmtree(staging)
-        
-        staging.mkdir(parents=True, exist_ok=True)
-        
-        git_dir = staging / 'git'
-        git_dir.mkdir(parents=True)
-        
-        self.summary = str(staging)
-    
-    def clone_juneau(self):
-        """Clone juneau.git repository."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        git_dir = staging / 'git'
-        
-        juneau_dir = git_dir / 'juneau'
-        # Since we clean up the staging directory in make_git_folder, this should always be a fresh clone
-        self.run_command(
-            ['git', 'clone', 'https://gitbox.apache.org/repos/asf/juneau.git'],
-            cwd=git_dir
-        )
-    
-    def configure_git(self):
-        """Configure git user name and email."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        juneau_dir = staging / 'git' / 'juneau'
-        
-        username = os.environ.get('X_USERNAME')
-        email = os.environ.get('X_EMAIL')
-        
-        if username:
-            self.run_command(['git', 'config', 'user.name', username], cwd=juneau_dir)
-        if email:
-            self.run_command(['git', 'config', 'user.email', email], cwd=juneau_dir)
-        
-        self.summary = f"{username or '?'} <{email or '?'}>"
-    
-    def run_clean_verify(self):
-        """Run Maven clean verify."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        juneau_dir = staging / 'git' / 'juneau'
-        
-        self.run_command(['mvn', 'clean', 'verify'], cwd=juneau_dir)
-    
-    def run_deploy(self):
-        """Run Maven deploy."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        juneau_dir = staging / 'git' / 'juneau'
-        
-        self.run_command(['mvn', 'deploy', '-Daether.checksums.algorithms=MD5,SHA-1,SHA-512'], cwd=juneau_dir)
-    
-    def run_release_prepare(self):
-        """Run Maven release:prepare."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        juneau_dir = staging / 'git' / 'juneau'
-        
-        version = os.environ.get('X_VERSION')
-        release = os.environ.get('X_RELEASE')
-        next_version = os.environ.get('X_NEXT_VERSION')
-        
-        # Check that the current POM version is a SNAPSHOT (required by release:prepare)
-        pom_path = juneau_dir / POM_XML
-        if pom_path.exists():
-            # Read version directly from POM (don't use current-release.py as it strips SNAPSHOT)
-            try:
-                import xml.etree.ElementTree as ET
-                tree = ET.parse(pom_path)
-                root = tree.getroot()
-                # Handle namespace
-                ns_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
-                if ns_uri:
-                    ns = {'maven': ns_uri}
-                    version_elem = root.find('maven:version', ns)
-                else:
-                    version_elem = root.find('version')
-                
-                if version_elem is not None and version_elem.text:
-                    current_version = version_elem.text.strip()
-                else:
-                    self.fail("Could not find version element in pom.xml")
-            except Exception as e:
-                self.fail(f"Could not parse version from pom.xml: {e}")
-            
-            if not current_version.endswith('-SNAPSHOT'):
-                self.fail(
-                    f"Current version in POM is '{current_version}', but release:prepare requires a SNAPSHOT version.\n"
-                    f"This usually means a previous release attempt already changed the version.\n"
-                    f"Please ensure the git repository is in the correct state (e.g., checkout the master branch)."
-                )
-            say(f"Current POM version is SNAPSHOT: {current_version}")
-        
-        # run_command will automatically fail if the command doesn't succeed (check=True by default)
-        self.run_command([
-            'mvn', 'release:prepare',
-            '-DautoVersionSubmodules=true',
-            f'-DreleaseVersion={version}',
-            f'-Dtag={release}',
-            f'-DdevelopmentVersion={next_version}'
-        ], cwd=juneau_dir)
-        
-        self.summary = f"tag {release}"
-    
-    def run_git_diff(self):
-        """Show what release:prepare changed against the release tag, in the pager, before release:perform."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        juneau_dir = staging / 'git' / 'juneau'
-        release = os.environ.get('X_RELEASE')
-        
-        result = subprocess.run(['git', 'diff', release], cwd=juneau_dir, capture_output=True, text=True,
-                                check=False)
-        if result.returncode != 0:
-            say(f"⚠ git diff {release} failed (exit {result.returncode}): {result.stderr.strip()}", 'warn')
-            self.summary = f"git diff failed (exit {result.returncode})"
-        else:
-            self.jr.show(result.stdout or f"No differences against {release}.", pager=True)
-            files = sum(1 for line in result.stdout.splitlines() if line.startswith('diff --git '))
-            self.summary = f"{files} file(s)" if files else "no changes"
-    
-    def run_release_perform(self):
-        """Run Maven release:perform."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        juneau_dir = staging / 'git' / 'juneau'
-        
-        self.run_command(['mvn', 'release:perform', '-Daether.checksums.algorithms=MD5,SHA-1,SHA-512'], cwd=juneau_dir)
-        
-        # Open Nexus staging repositories page
-        subprocess.Popen(['open', 'https://repository.apache.org/#stagingRepositories'])
-        
-        self.jr.show(
-            "On Apache's Nexus instance, locate the staging repository for the code you just released.\n"
-            "It should be called something like orgapachejuneau-1000.\n"
-            "Check the Updated time stamp and click to verify its Content.\n"
-            "IMPORTANT - When all artifacts to be deployed are in the staging repository, tick the box next to it and "
-            "click Close.\n"
-            "DO NOT CLICK RELEASE YET - the release candidate must pass [VOTE] emails on dev@juneau before we "
-            "release.\n"
-            "Once closing has finished (check with Refresh), browse to the URL of the staging repository which should "
-            "be something like https://repository.apache.org/content/repositories/orgapachejuneau-1000.\n")
-        
-        repo_input = ask("Enter the staging repository name AFTER CLOSING IT!!!: orgapachejuneau-")
-        repo_name = f"orgapachejuneau-{repo_input}"
-        
-        if not self.yprompt(f"X_REPO = {repo_name}.  Is this correct?"):
-            self.fail("Repository name confirmation failed")
-        
-        os.environ['X_REPO'] = repo_name
-        self.state.set('X_REPO', repo_name)
-        
-        self.summary = f"staging repo {repo_name}"
-    
-    def create_binary_artifacts(self):
-        """Create binary artifacts and upload to SVN."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        version = os.environ.get('X_VERSION')
-        release = os.environ.get('X_RELEASE')
-        repo = self.state.get('X_REPO') or os.environ.get('X_REPO')
-        
-        if not repo:
-            self.fail("X_REPO not set. Did you complete the release:perform step?")
-        
-        # Checkout SVN dist
-        dist_dir = staging / 'dist'
-        if dist_dir.exists():
-            shutil.rmtree(dist_dir)
-        
-        self.run_command(['svn', 'checkout', SVN_DIST_URL, 'dist'], cwd=staging)
-        
-        # Remove old files.  No shell runs these, so list the entries here: 'source/*' would reach svn as a literal *.
-        source_dir = dist_dir / 'source'
-        binaries_dir = dist_dir / 'binaries'
-        removed = self._svn_rm_contents(dist_dir, source_dir, binaries_dir)
-        
-        # Create release directories
-        release_source_dir = source_dir / release
-        release_binaries_dir = binaries_dir / release
-        release_source_dir.mkdir(parents=True)
-        release_binaries_dir.mkdir(parents=True)
-        
-        # Download source artifacts
-        repo_url = f"https://repository.apache.org/content/repositories/{repo}/org/apache/juneau/"
-        self.run_command([
-            'wget', '-e', 'robots=off', '--recursive', '--no-parent', '--no-directories',
-            '-A', '*-source-release*', repo_url
-        ], cwd=release_source_dir)
-        
-        # Rename source zip file
-        source_zip = release_source_dir / f"juneau-{version}-source-release.zip"
-        if source_zip.exists():
-            target_zip = release_source_dir / f"apache-juneau-{version}-src.zip"
-            
-            # Simply rename the zip file (docs is already excluded by maven-source-plugin)
-            say(f"Renaming source zip: {source_zip.name} -> {target_zip.name}")
-            source_zip.rename(target_zip)
-            
-            # Process .asc file
-            asc_file = release_source_dir / f"juneau-{version}-source-release.zip.asc"
-            if asc_file.exists():
-                asc_file.rename(release_source_dir / f"apache-juneau-{version}-src.zip.asc")
-            
-            # Process .sha512 file (copy as-is, same as .asc)
-            sha512_file = release_source_dir / f"juneau-{version}-source-release.zip.sha512"
-            if sha512_file.exists():
-                sha512_file.rename(release_source_dir / f"apache-juneau-{version}-src.zip.sha512")
-            
-            # Remove old hash files
-            for old_hash in release_source_dir.glob('*.sha1'):
-                old_hash.unlink()
-            for old_hash in release_source_dir.glob('*.md5'):
-                old_hash.unlink()
-        
-        # Download binary artifacts
-        self.run_command([
-            'wget', '-e', 'robots=off', '--recursive', '--no-parent', '--no-directories',
-            '-A', 'juneau-distrib*-bin.zip*', repo_url
-        ], cwd=release_binaries_dir)
-        
-        # Rename and process binary files
-        bin_zip = release_binaries_dir / f"juneau-distrib-{version}-bin.zip"
-        if bin_zip.exists():
-            target_bin = release_binaries_dir / f"apache-juneau-{version}-bin.zip"
-            bin_zip.rename(target_bin)
-            
-            # Process .asc file
-            bin_asc = release_binaries_dir / f"juneau-distrib-{version}-bin.zip.asc"
-            if bin_asc.exists():
-                bin_asc.rename(release_binaries_dir / f"apache-juneau-{version}-bin.zip.asc")
-            
-            # Process .sha512 file (copy as-is, same as .asc)
-            bin_sha512 = release_binaries_dir / f"juneau-distrib-{version}-bin.zip.sha512"
-            if bin_sha512.exists():
-                bin_sha512.rename(release_binaries_dir / f"apache-juneau-{version}-bin.zip.sha512")
-            
-            # Remove old hash files
-            for old_hash in release_binaries_dir.glob('*.sha1'):
-                old_hash.unlink()
-            for old_hash in release_binaries_dir.glob('*.md5'):
-                old_hash.unlink()
-        
-        # Add and commit to SVN
-        self.run_command(['svn', 'add', f'source/{release}'], cwd=dist_dir)
-        self.run_command(['svn', 'add', f'binaries/{release}'], cwd=dist_dir)
-        self.run_command(['svn', 'commit', '-m', release], cwd=dist_dir)
-        
-        self.summary = f"committed {release} to dist/dev" + (f" · removed {removed} old" if removed else "")
-    
-    def _svn_rm_contents(self, dist_dir: Path, *dirs: Path) -> int:
-        """
-        One svn rm for every entry under each existing directory in dirs (paths relative to dist_dir), skipping
-        dot-entries as a shell * would.  Returns how many it removed.
-        """
-        items = [str(item.relative_to(dist_dir)) for directory in dirs if directory.exists()
-                 for item in sorted(directory.iterdir()) if not item.name.startswith('.')]
-        if items:
-            self.run_command(['svn', 'rm', *items], cwd=dist_dir, check=False)
-        return len(items)
-    
-    def verify_distribution(self):
-        """Verify distribution files are available."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        version = os.environ.get('X_VERSION')
-        release = os.environ.get('X_RELEASE')
-        
-        # Checkout or update SVN dist to verify files are available
-        dist_dir = staging / 'dist'
-        if dist_dir.exists():
-            # Update existing checkout
-            self.run_command(['svn', 'update'], cwd=dist_dir, check=False)
-        else:
-            # Fresh checkout
-            self.run_command(['svn', 'checkout', SVN_DIST_URL, 'dist'], cwd=staging)
-        
-        # Expected files
-        source_dir = dist_dir / 'source' / release
-        binaries_dir = dist_dir / 'binaries' / release
-        
-        expected_files = [
-            # Source files
-            source_dir / f"apache-juneau-{version}-src.zip",
-            source_dir / f"apache-juneau-{version}-src.zip.asc",
-            source_dir / f"apache-juneau-{version}-src.zip.sha512",
-            # Binary files
-            binaries_dir / f"apache-juneau-{version}-bin.zip",
-            binaries_dir / f"apache-juneau-{version}-bin.zip.asc",
-            binaries_dir / f"apache-juneau-{version}-bin.zip.sha512",
-        ]
-        
-        # Verify all files exist and are not empty
-        missing_files = []
-        empty_files = []
-        
-        for file_path in expected_files:
-            if not file_path.exists():
-                missing_files.append(str(file_path.relative_to(dist_dir)))
-            elif file_path.stat().st_size == 0:
-                empty_files.append(str(file_path.relative_to(dist_dir)))
-        
-        if missing_files:
-            self.fail("Missing distribution files:\n  " + "\n  ".join(missing_files))
-        
-        if empty_files:
-            self.fail("Empty distribution files:\n  " + "\n  ".join(empty_files))
-        
-        # All files verified
-        total_mb = sum(file_path.stat().st_size for file_path in expected_files) / (1024 * 1024)
-        for file_path in expected_files:
-            size_mb = file_path.stat().st_size / (1024 * 1024)
-            say(f"✓ {file_path.relative_to(dist_dir)} ({size_mb:.2f} MB)")
-        
-        # Open browser for manual inspection
-        subprocess.Popen(['open', SVN_DIST_URL])
-        
-        say("Distribution verification successful. Voting can be started.")
-        self.summary = f"{len(expected_files)} files, {total_mb:.1f} MB"
-        
-        # Generate vote email
-        self._generate_vote_email(version, release, dist_dir)
-    
-    def _calculate_vote_end_date(self) -> str:
-        """Calculate vote end date (72 hours from now, minimum 3 days)."""
-        # Add 72 hours (3 days) to current time
-        end_date = datetime.now() + timedelta(hours=72)
-        # Format: "04-May-2016 1:30pm"
-        formatted = end_date.strftime('%d-%b-%Y %I:%M%p').lstrip('0')
-        # Convert to lowercase and fix spacing
-        formatted = formatted.replace(' 0', ' ').lower()
-        return formatted
-    
-    def _read_sha512_from_url(self, url: str) -> Optional[str]:
-        """Read SHA-512 checksum from Apache distribution URL and return formatted checksum."""
-        try:
-            with urllib.request.urlopen(url) as response:
-                content = response.read().decode('utf-8').strip()
-                # Parse the formatted checksum from the file
-                # Format: "/path/to/file:\nCHECKSUM_LINE_1\nCHECKSUM_LINE_2"
-                lines = [line.strip() for line in content.split('\n') if line.strip()]
-                if len(lines) >= 2:
-                    # First line is the path, rest are the checksum
-                    # Join checksum lines with newline to preserve formatting
-                    checksum = '\n'.join(lines[1:])
-                    return checksum
-                elif len(lines) == 1:
-                    # Single line format, extract just the checksum part
-                    parts = lines[0].split(':')
-                    if len(parts) > 1:
-                        # Format: "path: checksum"
-                        return parts[1].strip()
-                    return lines[0]
-        except Exception as e:
-            say(f"Warning: Could not read SHA-512 from {url}: {e}", 'warn')
-        return None
-    
-    def _get_git_commit_hash(self, release_tag: str) -> Optional[str]:
-        """Get git commit hash for a release tag."""
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        juneau_dir = staging / 'git' / 'juneau'
-        
-        try:
-            result = subprocess.run(
-                ['git', 'rev-parse', release_tag],
-                cwd=juneau_dir,
-                capture_output=True,
-                text=True,
-                check=True
-            )
-            return result.stdout.strip()
-        except Exception as e:
-            say(f"Warning: Could not get git commit hash for tag {release_tag}: {e}", 'warn')
-        return None
-    
-    def _generate_vote_email(self, version: str, release: str, dist_dir: Path):
-        """Generate vote email body for the release."""
-        # Read SHA-512 checksums from Apache distribution URLs
-        src_sha512_url = f"https://dist.apache.org/repos/dist/dev/juneau/source/{release}/apache-juneau-{version}-src.zip.sha512"
-        bin_sha512_url = f"https://dist.apache.org/repos/dist/dev/juneau/binaries/{release}/apache-juneau-{version}-bin.zip.sha512"
-        
-        src_sha512 = self._read_sha512_from_url(src_sha512_url) or "SHA512_NOT_FOUND"
-        bin_sha512 = self._read_sha512_from_url(bin_sha512_url) or "SHA512_NOT_FOUND"
-        
-        # Get git commit hash
-        git_commit = self._get_git_commit_hash(release) or "COMMIT_HASH_NOT_FOUND"
-        
-        # Get staging repository
-        repo = self.state.get('X_REPO') or os.environ.get('X_REPO') or "REPO_NOT_FOUND"
-        
-        # Calculate vote end date
-        vote_end_date = self._calculate_vote_end_date()
-        
-        # Extract RC number from release (e.g., "juneau-9.2.0-RC1" -> "RC1")
-        rc_match = re.search(RC_PATTERN, release)
-        rc_number = rc_match.group(1) if rc_match else "x"
-        
-        # Generate email body
-        email_body = f"""To: dev@juneau.apache.org
-
-[VOTE] Release Apache Juneau {version} RC{rc_number}
-
-I am pleased to be calling this vote for the source release of Apache Juneau {version} RC{rc_number}.
-
-The binaries are available at:
-
-https://dist.apache.org/repos/dist/dev/juneau/binaries/{release}/
-
-The release candidate to be voted over is available at:
-https://dist.apache.org/repos/dist/dev/juneau/source/{release}/
-
-SHA-512 checksums:
-
-apache-juneau-{version}-src.zip: 
-{src_sha512}
-
-apache-juneau-{version}-bin.zip: 
-{bin_sha512}
-
-Build the release candidate using:
-
-mvn clean install
-
-
-The release candidate is signed with a GPG key available at:
-https://dist.apache.org/repos/dist/release/juneau/KEYS
-
-A staged Maven repository is available for review at:
-https://repository.apache.org/content/repositories/{repo}/
-
-The Git commit for this release is...
-https://gitbox.apache.org/repos/asf?p=juneau.git;a=commit;h={git_commit}
-
-Please vote on releasing this package as:
-Apache Juneau {version}
-
-This vote will be open until {vote_end_date} and passes if a majority of at least three +1 Apache Juneau PMC votes are cast.
-(needs to be at least 72 weekday hours)
-
-[ ] +1 Release this package
-[ ] 0 I don't feel strongly about it, but don't object
-[ ] -1 Do not release this package because...
-
-Anyone can participate in testing and voting, not just committers, please feel free to try out the release candidate and provide your votes.
-"""
-        
-        # Display email to console
-        self.jr.show(f"{'=' * 79}\nVOTE EMAIL BODY:\n{'=' * 79}\n{email_body}{'=' * 79}")
-    
-    def revert_release(self):
-        """Revert a release by deleting the tag, reverting Maven versions, and cleaning up SVN files: one row each."""
-        # Get version and release from state or environment
-        version = self.state.get('X_VERSION') or os.environ.get('X_VERSION')
-        release = self.state.get('X_RELEASE') or os.environ.get('X_RELEASE')
-        
-        if not version or not release:
-            self.fail("X_VERSION and X_RELEASE must be set. Cannot determine what to revert.")
-        
-        # Confirm with user
-        if not self.yprompt(f"Are you sure you want to revert release {release}? This will delete the git tag and clean up SVN files."):
-            raise KeyboardInterrupt   # a cancel, like Ctrl-C: the session says so and exits 130
-        
-        staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
-        git_dir = staging / 'git' / 'juneau'
-        dist_dir = staging / 'dist'
-        has_clone = (git_dir / '.git').exists()
-        
-        with self._phase('revert_pull', 1, 'Pulling latest changes from git', 'Pull') as s:
-            if not has_clone:
-                s.skip()
-                self.summary = f"no clone at {git_dir}"
-            else:
-                try:
-                    self.run_command(['git', 'pull'], cwd=git_dir)
-                except Exception as e:
-                    say(f"Warning: Could not pull from git: {e}", 'warn')
-        
-        with self._phase('revert_tag', 2, f"Deleting git tag {release}", 'Tag') as s:
-            if not has_clone:
-                s.skip()
-                self.summary = f"no clone at {git_dir}"
-            else:
-                try:
-                    # Check if tag exists locally
-                    local = subprocess.run(['git', 'tag', '-l', release], cwd=git_dir, capture_output=True, text=True,
-                                           check=False)
-                    if local.stdout.strip():
-                        self.run_command(['git', 'tag', '-d', release], cwd=git_dir, check=False)
-                    pushed = self.run_command(['git', 'push', 'origin', f':{release}'], cwd=git_dir, check=False)
-                    self.summary = ("deleted" if pushed.returncode == 0
-                                    else f"remote tag not deleted (exit {pushed.returncode})")
-                except Exception as e:
-                    say(f"Warning: Could not delete git tag: {e}", 'warn')
-        
-        with self._phase('revert_versions', 3, 'Reverting Maven versions', 'Versions') as s:
-            development_version = f"{version}-SNAPSHOT"
-            if not (git_dir / POM_XML).exists():
-                s.skip()
-                self.summary = f"no {POM_XML} in {git_dir}"
-            else:
-                try:
-                    self.run_command([
-                        'mvn', 'release:update-versions',
-                        '-DautoVersionSubmodules=true',
-                        f'-DdevelopmentVersion={development_version}'
-                    ], cwd=git_dir)
-                    self.summary = f"back to {development_version}"
-                except Exception as e:
-                    say(f"Warning: Could not revert Maven versions: {e}", 'warn')
-        
-        with self._phase('revert_svn', 4, 'Cleaning up SVN files', 'SVN') as s:
-            if not (dist_dir / '.svn').exists():
-                s.skip()
-                self.summary = f"no checkout at {dist_dir}"
-            else:
-                try:
-                    self.summary = self._revert_svn(dist_dir, release)
-                except Exception as e:
-                    say(f"Warning: Could not clean up SVN files: {e}", 'warn')
-        
-        self.jr.show("Release revert complete.  You may need to manually:\n"
-                      "  - Reset your local git repository if needed\n"
-                      "  - Verify Maven versions were reverted correctly\n"
-                      "  - Check SVN repository for any remaining files")
-    
-    def _revert_svn(self, dist_dir: Path, release: str) -> str:
-        """svn rm every RC directory under binaries/ and source/, then commit if you say so.  Returns the row text."""
-        self.run_command(['svn', 'update'], cwd=dist_dir, check=False)
-        
-        removed = [item for sub in ('binaries', 'source') if (dist_dir / sub).exists()
-                   for item in sorted((dist_dir / sub).iterdir()) if item.is_dir() and 'RC' in item.name]
-        for item in removed:
-            self.run_command(['svn', 'rm', str(item.relative_to(dist_dir))], cwd=dist_dir, check=False)
-        if not removed:
-            return "no RC directories"
-        
-        status = subprocess.run(['svn', 'status'], cwd=dist_dir, capture_output=True, text=True, check=False)
-        if 'D' not in (status.stdout or ''):
-            return "no SVN changes to commit"
-        self.jr.show(f"SVN changes ready to commit:\n{status.stdout}")
-        if not self.yprompt("Commit SVN deletions?"):
-            return f"{len(removed)} removed, not committed"
-        self.run_command(['svn', 'commit', '-m', f'Remove {release} release candidate'], cwd=dist_dir)
-        return f"{len(removed)} removed and committed"
-    
-    def _probe_versions(self):
-        """(java -version, mvn -version) output, run once: the header and the two version steps share it."""
-        if self._versions is None:
-            def probe(cmd):
-                try:
-                    result = subprocess.run(cmd, capture_output=True, text=True, check=False)
-                except OSError:
-                    return ''
-                return (result.stderr or '') + (result.stdout or '')
-            self._versions = (probe(['java', '-version']), probe(['mvn', '-version']))
-        return self._versions
-    
-    def _java_version_text(self):
-        match = re.search(r'version\s+"([^"]+)"', self._probe_versions()[0])
-        return match.group(1) if match else None
-    
-    def _maven_version_text(self):
-        match = re.search(r'Apache Maven (\S+)', self._probe_versions()[1])
-        return match.group(1) if match else None
-    
-    def header_lines(self):
-        """Release, Java and Maven versions for the session header; session() adds the full log."""
-        release = os.environ.get('X_RELEASE') or self.state.get('X_RELEASE') or '?'
-        java, maven = self._java_version_text(), self._maven_version_text()
-        return [release] + ([f"Java {java}"] if java else []) + ([f"Maven {maven}"] if maven else [])
-    
-    def _in_session(self, title, body, header, plan=None):
-        """
-        Run body in one juneau_terminal_scripts session: done(ok|fail|cancelled), exit 130 on Ctrl-C or Ctrl-D, 1 on a
-        failure.  The full log always goes to ~/.juneau-release-logs/<release>-<ts>.log, whatever
-        JUNEAU_TERMINAL_CONSOLE says, and juneau_terminal puts its path in the header.
-        """
-        release = os.environ.get('X_RELEASE') or self.state.get('X_RELEASE') or 'juneau-release'
-        self.full_log = release_log_path(release)
-        self.full_log.parent.mkdir(parents=True, exist_ok=True)
-        self.full_log.touch()
-        os.environ['JUNEAU_TERMINAL_FULL_LOG'] = str(self.full_log)
-
-        def full_log_note():
-            # The final line names the full log only on fail (juneau_terminal's format_final), so say it first, at
-            # warn: info would be hidden at the default detail.
-            self.jr.say(f"full log: {self.full_log}", level='warn')
-
-        def guarded():
-            try:
-                body()
-            except (KeyboardInterrupt, EOFError) as e:   # Ctrl-C, or Ctrl-D at a prompt
-                full_log_note()
-                raise KeyboardInterrupt from e
-            except SystemExit as e:
-                if not e.code:
-                    full_log_note()
-                raise
-            else:
-                full_log_note()
-
-        with toolkit('run').session(title, header=header, repo=REPO_ROOT, exit=False, plan=plan,
-                                    bell=False) as session:
-            guarded()
-        if session.code:
-            sys.exit(session.code)
-
-    def selection(self):
-        """The step selection this script was built with, in the argparse shape steps.Registry reads."""
-        return argparse.Namespace(list_steps=False, start_step=self.start_step, resume=self.resume,
-                                  skip_step=sorted(self.skip_steps))
-
-    def registry(self):
-        """This run's steps, each calling the method looked up when it runs (a stub set after this still runs)."""
-        registry = build_registry(self.steps, self._step)
-        registry.add_arguments(argparse.ArgumentParser(prog=RESUME_PROG))   # names the resume hint's command
-        return registry
-
-    def _step(self, name):
-        """The registry function for step name: the method in a row, its sub-rows under it, self.summary its text.
-        The registry prints the resume hint after a failure, once, at error level."""
-        def call(ctx):
-            self.current, self._sub, self.summary = name, 0, None
-            try:
-                getattr(self, name)()
-            except KeyboardInterrupt:
-                self.jr.failure(f"Interrupted in {name}.", step=name)
-                raise
-            except Exception as e:  # NOSONAR python:S2221 - any step error fails the release
-                self.jr.failure(f"❌ Error in step {name}: {e}", step=name)
-                raise SystemExit(1) from e
-            finally:
-                self.current = None
-            return self.summary
-        return call
-
-    def run(self):
-        """Run the release steps in one console session."""
-        registry, selection = self.registry(), self.selection()
-        self._in_session('Release', lambda: self._run_steps(registry, selection), ['Juneau'] + self.header_lines(),
-                         plan=registry.plan(selection, self.state))
-
-    def run_revert(self):
-        """Run revert_release in its own console session."""
-        release = os.environ.get('X_RELEASE') or self.state.get('X_RELEASE') or '?'
-        self._in_session('Revert', self.revert_release, ['Juneau', release],
-                         plan=['revert_pull', 'revert_tag', 'revert_versions', 'revert_svn'])
-
-    def _prompt_pgp(self):
-        """Prime the gpg agent before any long step, on the real terminal: pinentry needs it."""
-        prompt_script = Path(__file__).parent / 'prompt-pgp-passphrase.py'
-        if not prompt_script.exists():
-            return
-        try:
-            result = self.jr.passthrough([sys.executable, str(prompt_script)], 'pgp', 'Prime the PGP agent',
-                                          label='PGP')
-        except OSError as e:
-            say(f"⚠ Could not run PGP passphrase prompt: {e}", 'warn')
-            return
-        if result.exit != 0:
-            say("⚠ PGP passphrase prompt failed; gpg will ask again when Maven signs.", 'warn')
-    
-    @contextmanager
-    def _phase(self, name, n, title, label):
-        """A step row.  run_command's sub-rows go under it, and self.summary becomes its row text."""
-        self.current, self._sub, self.summary = name, 0, None
-        try:
-            with self.jr.step(name, n, title, label=label) as s:
-                yield s
-                s.summary = self.summary
-        finally:
-            self.current = None
-    
-    def _run_steps(self, registry, selection):
-        """
-        The release steps in order, one row each, recording each finished one in the checkpoint.  A step that does
-        not run is a dim row saying why.  Every step done clears the checkpoint.
-        """
-        self._prompt_pgp()
-        if registry.run(selection, None, self.state, show_unselected=True).ok:
-            self.success()
-        else:
-            sys.exit(1)
-
-
-def main(argv=None):
-    parser = argparse.ArgumentParser(
-        description='Apache Juneau Release Script',
-        formatter_class=argparse.RawDescriptionHelpFormatter,
-        epilog=USAGE
-    )
-    # --list-steps, --start-step, --skip-step and --resume, validated against the step names
-    registry = build_registry(RELEASE_STEPS, lambda name: lambda ctx: None)   # only parses; never runs a step
-    registry.add_arguments(parser)
-    parser.add_argument(
-        '--revert',
-        action='store_true',
-        help='Revert a release by deleting the git tag, reverting Maven versions, and cleaning up SVN files'
-    )
-    parser.add_argument(
-        '--console',
-        choices=('full', 'board', 'none'),
-        help='Maven output on the console: full (default), board or none (also JUNEAU_TERMINAL_CONSOLE)'
-    )
-    parser.add_argument(
-        '--detail',
-        help='Console detail: summary, actionable (default), modules or all (also JUNEAU_TERMINAL_DETAIL)'
-    )
-    
-    args = parser.parse_args(argv)
-    
-    if args.console:
-        os.environ['JUNEAU_TERMINAL_CONSOLE'] = args.console   # an argument beats a value already in the environment
-    run = _juneau.load()[0]
-    run.warn("scripts/release.py is deprecated: Juneau releases run from juneau-release-manager "
-             "(scripts/release.py there, or its Release page). This script will be removed after the next release.")
-    if os.environ.pop('TERMINAL_MARKERS', None):
-        run.warn("release.py does not emit run markers; TERMINAL_MARKERS is ignored")
-    try:
-        run.export_detail(args.detail)
-    except ValueError as e:
-        parser.error(str(e))
-    
-    # If just listing steps, don't require RC
-    if args.list_steps:
-        registry.run(args, None)
-        return
-    
-    # If reverting, handle it separately
-    if args.revert:
-        # Try to determine RC and load state
-        rc = None
-        state_data = {}
-        
-        # Try to extract from X_RELEASE environment variable
-        x_release = os.environ.get('X_RELEASE')
-        if x_release:
-            rc_match = re.search(RC_PATTERN, x_release)
-            if rc_match:
-                rc = int(rc_match.group(1))
-                say(f"📌 Detected RC number from X_RELEASE: {rc}")
-        
-        # Try to extract from state file
-        state_file = STATE_FILE
-        if state_file.exists():
-            try:
-                with open(state_file, 'r') as f:
-                    state_data = json.load(f)
-                x_release = state_data.get('X_RELEASE')
-                if x_release and rc is None:
-                    rc_match = re.search(RC_PATTERN, x_release)
-                    if rc_match:
-                        rc = int(rc_match.group(1))
-                        say(f"📌 Detected RC number from state file: {rc}")
-                
-                # Set environment variables from state to avoid prompts
-                if state_data.get('X_VERSION'):
-                    os.environ['X_VERSION'] = state_data['X_VERSION']
-                if state_data.get('X_RELEASE'):
-                    os.environ['X_RELEASE'] = state_data['X_RELEASE']
-                if state_data.get('X_STAGING'):
-                    os.environ['X_STAGING'] = state_data['X_STAGING']
-            except Exception as e:
-                say(f"Warning: Could not load state file: {e}", 'warn')
-        
-        # Get version - from state, environment, or pom.xml
-        version = state_data.get('X_VERSION') or os.environ.get('X_VERSION')
-        release = None
-        
-        if not version:
-            # Try to get version from pom.xml
-            script_dir = Path(__file__).parent
-            juneau_root = script_dir.parent
-            pom_path = juneau_root / POM_XML
-            if pom_path.exists():
-                try:
-                    result = subprocess.run(
-                        ["mvn", "help:evaluate", "-Dexpression=project.version", "-q", "-DforceStdout"],
-                        cwd=pom_path.parent,
-                        capture_output=True,
-                        text=True,
-                        check=True
-                    )
-                    version = result.stdout.strip()
-                    if version.endswith('-SNAPSHOT'):
-                        version = version[:-9]
-                    say(f"📌 Detected version from pom.xml: {version}")
-                except Exception:
-                    pass
-        
-        # If we have version but no RC, try to get RC from history file
-        if version and rc is None:
-            script_dir = Path(__file__).parent
-            history_file = script_dir / f'release-history-{version}.json'
-            if history_file.exists():
-                try:
-                    with open(history_file, 'r') as f:
-                        history = json.load(f)
-                    release_candidate = history.get('X_RELEASE_CANDIDATE', '')
-                    if release_candidate:
-                        rc_match = re.search(RC_PATTERN, release_candidate)
-                        if rc_match:
-                            rc = int(rc_match.group(1))
-                            say(f"📌 Detected RC number from history file: {rc}")
-                except Exception as e:
-                    say(f"Warning: Could not load history file: {e}", 'warn')
-        
-        # If we still don't have RC, prompt for it
-        if version and rc is None:
-            rc = ask_rc("Release candidate number: ")
-        
-        # Construct X_RELEASE if we have version and RC
-        if version and rc:
-            release = f"juneau-{version}-RC{rc}"
-            os.environ['X_RELEASE'] = release
-            say(f"📌 Constructed release: {release}")
-        
-        # Set version in environment if we have it
-        if version:
-            os.environ['X_VERSION'] = version
-        
-        # Create script without loading env (we'll set what we need manually)
-        script = ReleaseScript(rc=rc, start_step=None, skip_steps=[], resume=False, load_env=False)
-        
-        # Ensure state and environment have the necessary info
-        if version:
-            script.state.set('X_VERSION', version)
-            os.environ['X_VERSION'] = version
-        if release:
-            script.state.set('X_RELEASE', release)
-            os.environ['X_RELEASE'] = release
-        elif state_data.get('X_RELEASE'):
-            # Use release from state if we couldn't construct it
-            release = state_data.get('X_RELEASE')
-            script.state.set('X_RELEASE', release)
-            os.environ['X_RELEASE'] = release
-        if state_data.get('X_STAGING'):
-            script.state.set('X_STAGING', state_data['X_STAGING'])
-            os.environ['X_STAGING'] = state_data['X_STAGING']
-        elif not os.environ.get('X_STAGING'):
-            # Set default staging if not set
-            os.environ['X_STAGING'] = STAGING_DIR
-        
-        script.run_revert()
-        return
-    
-    # Try to determine RC from context if resuming (will prompt if not found)
-    rc = None
-    if args.resume or args.start_step:
-        # Try to extract from X_RELEASE environment variable
-        x_release = os.environ.get('X_RELEASE')
-        if x_release:
-            rc_match = re.search(RC_PATTERN, x_release)
-            if rc_match:
-                rc = int(rc_match.group(1))
-                say(f"📌 Detected RC number from X_RELEASE: {rc}")
-        
-        # Try to extract from state file
-        if rc is None:
-            state_file = STATE_FILE
-            if state_file.exists():
-                try:
-                    with open(state_file, 'r') as f:
-                        state = json.load(f)
-                    x_release = state.get('X_RELEASE')
-                    if x_release:
-                        rc_match = re.search(RC_PATTERN, x_release)
-                        if rc_match:
-                            rc = int(rc_match.group(1))
-                            say(f"📌 Detected RC number from state file: {rc}")
-                except Exception:
-                    pass
-        
-        # Try to extract from history files (get latest version's RC)
-        if rc is None:
-            script_dir = Path(__file__).parent
-            juneau_root = script_dir.parent
-            pom_path = juneau_root / POM_XML
-            if pom_path.exists():
-                try:
-                    # Get version from pom
-                    result = subprocess.run(
-                        ["mvn", "help:evaluate", "-Dexpression=project.version", "-q", "-DforceStdout"],
-                        cwd=pom_path.parent,
-                        capture_output=True,
-                        text=True,
-                        check=True
-                    )
-                    version = result.stdout.strip()
-                    if version.endswith('-SNAPSHOT'):
-                        version = version[:-9]
-                    
-                    # Load history for this version
-                    history_file = script_dir / f'release-history-{version}.json'
-                    if history_file.exists():
-                        with open(history_file, 'r') as f:
-                            history = json.load(f)
-                        release_candidate = history.get('X_RELEASE_CANDIDATE', '')
-                        rc_match = re.search(RC_PATTERN, release_candidate)
-                        if rc_match:
-                            rc = int(rc_match.group(1))
-                            say(f"📌 Detected RC number from history file: {rc}")
-                except Exception:
-                    pass
-    
-    script = ReleaseScript(
-        rc=rc,
-        start_step=args.start_step,
-        skip_steps=args.skip_step,
-        resume=args.resume
-    )
-    
-    script.run()
-
-if __name__ == '__main__':
-    try:
-        main()
-    except (KeyboardInterrupt, EOFError):   # Ctrl-C or Ctrl-D at a prompt before the session starts
-        sys.exit(130)
-
diff --git a/scripts/repin-consumers.md b/scripts/repin-consumers.md
index 219c5afa63..b7c259e4a8 100644
--- a/scripts/repin-consumers.md
+++ b/scripts/repin-consumers.md
@@ -56,4 +56,3 @@ Progress lines, Maven's own output, then a `=== Summary ===` block.
 ## See also
 
 - `push.py`, whose unreviewed-changes gate this guard mirrors
-- `release.py`
diff --git a/scripts/tests/README.md b/scripts/tests/README.md
index b506ed57be..46aabe9833 100644
--- a/scripts/tests/README.md
+++ b/scripts/tests/README.md
@@ -62,14 +62,6 @@ Do **not** `pip install --user pytest` / `pip install pytest` on a Homebrew-mana
   relative and the absolute margin, faster modules get the `↓` mark, the env overrides, and the `--report` table
   file. Synthetic run-history records in a temp dir.
 
-- `test_release_run.py` -- `release.py` on juneau-terminal and juneau_terminal_scripts, with a fake juneau_terminal module installed in
-  `sys.modules` (where release.py and the toolkit both look it up): every prompt goes through `prompt.text` (Ctrl-D
-  cancels, never takes the default), Maven gets `-B` and a PTY, the full log is always written, the steps are a
-  `steps.Registry` (its CLI, resume and checkpoint, with a legacy `last_step` file still resuming), each step is a
-  labelled row (dim when skipped or done earlier), `run_git_diff` follows
-  `run_release_prepare` and pages through `show`, and the revert is four rows. `HOME` is a temp dir, so the state
-  file and logs never touch yours. Never runs mvn, svn or gpg.
-
 Shared fixtures in `conftest.py`: `run_history` (F1) turns run history on for one test under `tmp_path/history`,
 with `argv[0]` set so the writer's script name is `test`; `fake_reports` (F2) builds a scratch Surefire tree; and
 `history_record` (F3) builds one run-history record.
diff --git a/scripts/tests/test_release_run.py b/scripts/tests/test_release_run.py
deleted file mode 100644
index edd0446933..0000000000
--- a/scripts/tests/test_release_run.py
+++ /dev/null
@@ -1,957 +0,0 @@
-# ***************************************************************************************************************************
-# * 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.
-# ***************************************************************************************************************************
-"""
-scripts/release.py on juneau_terminal: commands go through run_tool (Maven with -B and a PTY), every prompt is run.ask,
-the full log is always written, steps are rows, and run_git_diff is wired in.  Hermetic: a FakeRun stands in for
-juneau_terminal except where a test says otherwise, HOME is a temp dir (so the state file and logs land there), and no
-real mvn, svn or gpg runs.
-"""
-
-from __future__ import annotations
-
-import ast
-import importlib.util
-import json
-import os
-import runpy
-import subprocess
-import sys
-from contextlib import contextmanager
-from pathlib import Path
-from types import SimpleNamespace
-
-import pytest
-
-SCRIPTS_DIR = Path(__file__).resolve().parent.parent
-RUN_VARIABLES = ("TERMINAL_MARKERS", "TERMINAL_ARTIFACTS", "JUNEAU_TERMINAL_ACTIVE", "JUNEAU_TERMINAL_N_BASE", "JUNEAU_TERMINAL_CONSOLE",
-                 "JUNEAU_TERMINAL_FULL_LOG", "JUNEAU_TERMINAL_BOARD_LOG", "JUNEAU_TERMINAL_SESSION", "JUNEAU_TERMINAL_DETAIL",
-                 "JUNEAU_TERMINAL_LABEL_WIDTH", "JUNEAU_TERMINAL_LIVE")
-RELEASE_VARIABLES = ("X_VERSION", "X_NEXT_VERSION", "X_RELEASE", "X_STAGING", "X_USERNAME", "X_EMAIL", "X_GIT_BRANCH",
-                     "X_JAVA_HOME", "X_CLEANM2", "X_REPO", "GPG_TTY")
-RELEASE = "juneau-9.9.9-RC1"
-JAVA_TEXT = 'openjdk version "17.0.12" 2024-07-16\n'
-MAVEN_TEXT = "Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937)\n"
-
-
-@pytest.fixture(autouse=True)
-def clean_env(monkeypatch, tmp_path):
-    saved = {key: os.environ.get(key) for key in RUN_VARIABLES + RELEASE_VARIABLES + ("JAVA_HOME", "PATH")}
-    for variable in RUN_VARIABLES + RELEASE_VARIABLES:
-        monkeypatch.delenv(variable, raising=False)
-    (tmp_path / "home").mkdir()
-    monkeypatch.setenv("HOME", str(tmp_path / "home"))
-    monkeypatch.setenv("JUNEAU_TERMINAL_LOG_DIR", str(tmp_path / "run-logs"))
-    sys.modules.pop("juneau_terminal", None)
-    yield
-    sys.modules.pop("juneau_terminal", None)
-    for key, value in saved.items():   # release.py and session() write os.environ directly
-        if value is None:
-            os.environ.pop(key, None)
-        else:
-            os.environ[key] = value
-
-
-@pytest.fixture
-def release(clean_env):
-    """release.py, loaded after HOME points at the temp dir, so STATE_FILE and RELEASE_LOG_DIR are under it."""
-    spec = importlib.util.spec_from_file_location("_release_test", SCRIPTS_DIR / "release.py")
-    module = importlib.util.module_from_spec(spec)
-    spec.loader.exec_module(module)
-    return module
-
-
-class FakeStep:
-    def __init__(self):
-        self.status, self.exit, self.summary = "ok", None, None
-
-    def fail(self, exit=None):
-        self.status, self.exit = "fail", exit
-
-    def skip(self):
-        self.status = "skip"
-
-
-class FakeRun:
-    """Records what release.py asks of juneau_terminal.  answers feed ask(); tool(cmd) gives run_tool's (exit, output)."""
-
-    def __init__(self, answers=()):
-        self.answers = list(answers)
-        self.tool = lambda cmd: (0, "")
-        self.calls = []
-        self.rows = []   # (kind, id, label, status, summary) as each row ends
-        self.numbers = []   # (id, n) as each step opens
-
-    def of(self, kind):
-        return [(args, kwargs) for name, args, kwargs in self.calls if name == kind]
-
-    def _record(self, kind, *args, **kwargs):
-        self.calls.append((kind, args, kwargs))
-
-    def ask(self, prompt):
-        self._record("ask", prompt)
-        assert self.answers, f"unexpected prompt: {prompt!r}"
-        return self.answers.pop(0)
-
-    def say(self, text="", level="info", step=None):
-        self._record("say", text, level=level)
-
-    def show(self, text, pager=False):
-        self._record("show", text, pager=pager)
-
-    def failure(self, text, step=None):
-        self._record("failure", text, step=step)
-
-    def warn(self, text):
-        self._record("warn", text)
-
-    def session(self, title, header=(), *, lines=(), detail=None, sinks=None, repo="."):
-        self._record("session", title, list(header))
-        self.console = True
-
-    # What juneau_terminal_scripts' session, step and prompt ask of juneau_terminal besides the above.
-    console = False
-
-    def enabled(self):
-        return False
-
-    def console_active(self):
-        return self.console
-
-    def history_start(self, repo=".", history=None):
-        pass
-
-    def Sinks(self):  # noqa: N802 - mirrors juneau_terminal.Sinks
-        default = not os.environ.get("JUNEAU_TERMINAL_FULL_LOG") and os.environ.get("JUNEAU_TERMINAL_CONSOLE", "full") == "full"
-        return SimpleNamespace(is_default=default)
-
-    def plan(self, ids):
-        self._record("plan", list(ids))
-
-    def done(self, status, commit=None):
-        self._record("done", status)
-
-    def export_detail(self, flag=None):
-        if flag not in (None, "summary", "actionable", "modules", "all"):
-            raise ValueError(f"detail must be one of summary, actionable, modules, all, not {flag!r}")
-        self._record("export_detail", flag)
-        return flag or "actionable"
-
-    def passthrough(self, cmd, step_id, title, **kwargs):
-        self._record("passthrough", cmd, step_id, title, **kwargs)
-        return SimpleNamespace(exit=0, ms=0)
-
-    def run_tool(self, cmd, parser, step_id, title, **kwargs):
-        self._record("run_tool", cmd, parser, step_id, title, **kwargs)
-        exit, output = self.tool(cmd)
-        return SimpleNamespace(exit=exit, ms=0, summary={}, output=output if kwargs.get("capture") else None)
-
-    @contextmanager
-    def _row(self, kind, id, label):
-        s = FakeStep()
-        try:
-            yield s
-        except BaseException:
-            s.status = "fail"
-            raise
-        finally:
-            self.rows.append((kind, id, label, s.status, s.summary))
-
-    def step(self, id, n, title, *, parent=None, label=None):
-        self.numbers.append((id, n))
-        return self._row("step", id, label)
-
-    def row(self, id, title, *, label=None):
-        return self._row("row", id, label)
-
-
-def install(monkeypatch, fake):
-    """fake as the loaded juneau_terminal: release.py and juneau_terminal_scripts both look it up in sys.modules."""
-    monkeypatch.setitem(sys.modules, "juneau_terminal", fake)
-    return fake
-
-
-def make(release, monkeypatch, fake, **kwargs):
-    install(monkeypatch, fake)
-    script = release.ReleaseScript(load_env=False, **kwargs)
-    script._versions = (JAVA_TEXT, MAVEN_TEXT)   # never probe the real java/mvn
-    return script
-
-
-def calls_to(name, allowed=()):
-    """Line numbers of calls to the bare function `name` in release.py outside the functions named in allowed."""
-    tree = ast.parse((SCRIPTS_DIR / "release.py").read_text(encoding="utf-8"))
-    found = []
-
-    def visit(node, function):
-        for child in ast.iter_child_nodes(node):
-            inner = child.name if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) else function
-            if (isinstance(child, ast.Call) and isinstance(child.func, ast.Name) and child.func.id == name
-                    and inner not in allowed):
-                found.append(child.lineno)
-            visit(child, inner)
-
-    visit(tree, None)
-    return found
-
-
-# ---- plumbing ------------------------------------------------------------------------------------------------------
-def test_release_py_compiles():
-    path = SCRIPTS_DIR / "release.py"
-    compile(path.read_text(encoding="utf-8"), str(path), "exec")   # no .pyc written, unlike py_compile
-
-
-def test_maven_gets_batch_mode_a_pty_and_the_watchdog(release, monkeypatch, tmp_path):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    script.current = "run_clean_verify"
-    script.run_command(["mvn", "clean", "verify"], cwd=tmp_path)
-    [(args, kwargs)] = fake.of("run_tool")
-    assert args == (["mvn", "-B", "clean", "verify"], "maven", "run_clean_verify.1", "mvn -B clean verify")
-    assert kwargs == {"n": 1, "parent": "run_clean_verify", "cwd": tmp_path, "capture": False, "tty": True,
-                      "watchdog": 20}
-
-
-def test_svn_commit_and_git_push_get_a_pty_and_other_commands_do_not(release, monkeypatch, tmp_path):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    script.current = "create_binary_artifacts"
-    script.run_command(["svn", "add", "source/x"], cwd=tmp_path)
-    script.run_command(["svn", "commit", "-m", "x"], cwd=tmp_path)
-    script.run_command(["git", "push", "origin", ":x"], cwd=tmp_path)
-    (add_args, add), (commit_args, commit), (push_args, push) = fake.of("run_tool")
-    assert (add_args[1], add["tty"], add["watchdog"]) == ("generic", False, None)
-    assert (commit_args[0], commit["tty"], commit["watchdog"]) == (["svn", "commit", "-m", "x"], True, 20)
-    assert (push_args[0], push["tty"], push["watchdog"]) == (["git", "push", "origin", ":x"], True, 20)
-    assert [args[2] for args, _ in fake.of("run_tool")] == ["create_binary_artifacts.1", "create_binary_artifacts.2",
-                                                            "create_binary_artifacts.3"]
-
-
-def test_a_failed_command_fails_the_release(release, monkeypatch, tmp_path):
-    fake = FakeRun()
-    fake.tool = lambda cmd: (128, "")
-    script = make(release, monkeypatch, fake)
-    script.current = "clone_juneau"
-    with pytest.raises(SystemExit) as exit_info:
-        script.run_command(["git", "clone", "x"], cwd=tmp_path)
-    assert exit_info.value.code == 1
-    assert fake.of("failure") == [(("❌ Command failed (exit 128): git clone x",), {"step": "clone_juneau"})]
-
-
-def test_check_false_returns_the_exit_and_capture_returns_the_output(release, monkeypatch, tmp_path):
-    fake = FakeRun()
-    fake.tool = lambda cmd: (1, "some output\n")
-    script = make(release, monkeypatch, fake)
-    result = script.run_command(["svn", "update"], cwd=tmp_path, check=False, capture_output=True)
-    assert (result.returncode, result.stdout) == (1, "some output\n")
-    assert fake.of("run_tool")[0][1]["capture"] is True
-    assert fake.of("run_tool")[0][0][2] == "release.1"   # no current step: a top-level row
-    assert fake.of("failure") == []
-
-
-# ---- prompts -------------------------------------------------------------------------------------------------------
-def test_no_input_calls_remain():
-    assert calls_to("input") == []
-
-
-def test_prompt_with_default_goes_through_ask(release, monkeypatch):
-    fake = FakeRun(answers=["", "", "alice"])
-    script = make(release, monkeypatch, fake)
-    assert script._prompt_with_default("Git branch", "master") == "master"
-    assert script._prompt_with_default("Apache username", "") == "alice"
-    assert [args[0] for args, _ in fake.of("ask")] == ["Git branch [master]: ", "Apache username: ",
-                                                        "Apache username: "]
-    assert fake.of("say") == [(("This field is required. Please enter a value.",), {"level": "warn"})]
-
-
-def test_yprompt_goes_through_ask_and_defaults_to_yes(release, monkeypatch):
-    fake = FakeRun(answers=["", "n", "YES"])
-    script = make(release, monkeypatch, fake)
-    assert [script.yprompt("Go?") for _ in range(3)] == [True, False, True]
-    assert {args[0] for args, _ in fake.of("ask")} == {"Go? (Y/n): "}
-
-
-def test_ask_rc_retries_until_it_gets_a_number(release, monkeypatch):
-    fake = install(monkeypatch, FakeRun(answers=["", "two", "3"]))
-    assert release.ask_rc("Release candidate number: ") == 3
-    assert [kwargs["level"] for _, kwargs in fake.of("say")] == ["warn", "warn"]
-    install(monkeypatch, FakeRun(answers=[""]))
-    assert release.ask_rc("Release candidate number [2]: ", "2") == 2
-
-
-@pytest.mark.parametrize("ask", [
-    lambda release, script: script.yprompt("Go?"),
-    lambda release, script: script._prompt_with_default("Git branch", "master"),
-    lambda release, script: release.ask_rc("Release candidate number: ", "2"),
-])
-def test_ctrl_d_at_any_prompt_is_a_cancel_never_the_default(release, monkeypatch, ask):
-    class EofRun(FakeRun):
-        def ask(self, prompt):
-            raise EOFError
-
-    script = make(release, monkeypatch, EofRun())
-    with pytest.raises(KeyboardInterrupt):
-        ask(release, script)
-
-
-def test_the_staging_repo_prompt_goes_through_ask(release, monkeypatch, tmp_path):
-    monkeypatch.setenv("X_STAGING", str(tmp_path / "staging"))
-    monkeypatch.setattr(release.subprocess, "Popen", lambda *a, **k: None)   # the browser `open` (:800)
-    fake = FakeRun(answers=["1234", "y"])
-    script = make(release, monkeypatch, fake)
-    script.run_release_perform()
-    assert [args[0] for args, _ in fake.of("ask")] == [
-        "Enter the staging repository name AFTER CLOSING IT!!!: orgapachejuneau-",
-        "X_REPO = orgapachejuneau-1234.  Is this correct? (Y/n): "]
-    assert os.environ["X_REPO"] == "orgapachejuneau-1234"
-    assert fake.of("run_tool")[0][0][0][:3] == ["mvn", "-B", "release:perform"]
-
-
-def test_the_revert_rc_prompt_goes_through_ask(release, monkeypatch):
-    monkeypatch.setenv("X_VERSION", "9.9.9")   # skips the `mvn help:evaluate` probe (:1408)
-    fake = FakeRun(answers=["x", "4", "n"])
-    install(monkeypatch, fake)
-    with pytest.raises(SystemExit) as exit_info:   # declining the revert is a cancel
-        release.main(["--revert"])
-    assert exit_info.value.code == 130
-    assert [args[0] for args, _ in fake.of("ask")] == [
-        "Release candidate number: ", "Release candidate number: ",
-        "Are you sure you want to revert release juneau-9.9.9-RC4? This will delete the git tag and clean up SVN "
-        "files. (Y/n): "]
-    assert os.environ["X_RELEASE"] == "juneau-9.9.9-RC4"
-    assert fake.of("done") == [(("cancelled",), {})]
-
-
-# ---- session, full log, --detail -----------------------------------------------------------------------------------
-@pytest.mark.parametrize("body, status, code", [
-    (lambda: None, "ok", None),
-    (lambda: sys.exit(1), "fail", 1),
-    (lambda: (_ for _ in ()).throw(KeyboardInterrupt()), "cancelled", 130),
-    (lambda: (_ for _ in ()).throw(RuntimeError("boom")), "fail", 1),
-])
-def test_the_session_ends_with_done(release, monkeypatch, body, status, code):
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    if code is None:
-        script._in_session("Release", body, [RELEASE])
-    else:
-        with pytest.raises(SystemExit) as exit_info:
-            script._in_session("Release", body, [RELEASE])
-        assert exit_info.value.code == code
-    assert fake.of("session") == [(("Release", [RELEASE]), {})]
-    assert fake.of("done") == [((status,), {})]
-    log = Path(os.environ["JUNEAU_TERMINAL_FULL_LOG"])
-    assert log.parent == Path.home() / ".juneau-release-logs" and log.name.startswith(f"{RELEASE}-")
-    assert log.exists()
-    # ok and cancelled final lines carry no log path (juneau_terminal's format_final), so it is said just before done, at
-    # warn: info would hide it at the default detail
-    said = [call for call in fake.of("say") if call[0][0].startswith("full log")]
-    assert said == ([] if status == "fail" else [((f"full log: {log}",), {"level": "warn"})])
-
-
-def test_ctrl_d_at_a_prompt_is_cancelled_like_ctrl_c(release, monkeypatch):
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-
-    def eof():
-        raise EOFError
-
-    with pytest.raises(SystemExit) as exit_info:
-        script._in_session("Release", eof, [RELEASE])
-    assert exit_info.value.code == 130
-    assert fake.of("done") == [(("cancelled",), {})]
-
-
-def test_ctrl_d_before_the_session_exits_quietly(release, monkeypatch, capsys):
-    def eof(self, *args, **kwargs):
-        raise EOFError
-
-    monkeypatch.setattr(release.argparse.ArgumentParser, "parse_args", eof)
-    monkeypatch.setattr(sys, "argv", ["release.py"])
-    with pytest.raises(SystemExit) as exit_info:
-        runpy.run_path(str(SCRIPTS_DIR / "release.py"), run_name="__main__")
-    assert exit_info.value.code == 130
-    assert "Traceback" not in capsys.readouterr().err
-
-
-@pytest.mark.parametrize("console", ["full", "board", "none"])
-def test_the_full_log_is_written_in_every_console_mode(release, monkeypatch, tmp_path, console):
-    """The real juneau_terminal: a command's output lands in the release log."""
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    monkeypatch.setenv("JUNEAU_TERMINAL_CONSOLE", console)
-    # No PGP prompt: no prompt-pgp-passphrase.py beside it, and _prompt_pgp stubbed out.  That helper's getpass reads
-    # /dev/tty, which would hang the test run.
-    monkeypatch.setattr(release, "__file__", str(tmp_path / "release.py"))
-    script = release.ReleaseScript(load_env=False)
-    script._versions = (JAVA_TEXT, MAVEN_TEXT)
-    script._prompt_pgp = lambda: None
-    script.steps = ["echo_step"]
-    script.echo_step = lambda: script.run_command(["/bin/sh", "-c", "echo hello-from-release"])
-    try:
-        script.run()
-    finally:
-        sys.modules["juneau_terminal"]._end_session()
-    [log] = (Path.home() / ".juneau-release-logs").glob(f"{RELEASE}-*.log")
-    assert "hello-from-release" in log.read_text(encoding="utf-8", errors="replace")
-
-
-def test_detail_is_exported_and_an_unknown_level_is_a_usage_error(release, monkeypatch, capsys):
-    fake = FakeRun()
-    install(monkeypatch, fake)
-    release.main(["--list-steps", "--detail", "summary"])
-    assert fake.of("export_detail") == [(("summary",), {})]
-    with pytest.raises(SystemExit) as exit_info:
-        release.main(["--list-steps", "--detail", "loud"])
-    assert exit_info.value.code == 2
-    assert "detail must be one of summary, actionable, modules, all" in capsys.readouterr().err
-
-
-def test_console_flag_sets_the_env_var_and_beats_it(release, monkeypatch, capsys):
-    install(monkeypatch, FakeRun())
-    monkeypatch.setenv("JUNEAU_TERMINAL_CONSOLE", "board")
-    release.main(["--list-steps", "--console", "none"])
-    assert os.environ["JUNEAU_TERMINAL_CONSOLE"] == "none"
-    with pytest.raises(SystemExit) as exit_info:
-        release.main(["--list-steps", "--console", "loud"])
-    assert exit_info.value.code == 2
-
-
-DEPRECATION = ("scripts/release.py is deprecated: Juneau releases run from juneau-release-manager "
-               "(scripts/release.py there, or its Release page). This script will be removed after the next release.")
-
-
-def test_run_markers_is_ignored_with_a_warning(release, monkeypatch):
-    monkeypatch.setenv("TERMINAL_MARKERS", "1")
-    fake = FakeRun()
-    install(monkeypatch, fake)
-    release.main(["--list-steps"])
-    assert fake.of("warn") == [((DEPRECATION,), {}),
-                               (("release.py does not emit run markers; TERMINAL_MARKERS is ignored",), {})]
-    assert "TERMINAL_MARKERS" not in os.environ
-
-
-def test_deprecation_notice_first(release, monkeypatch):
-    fake = FakeRun()
-    install(monkeypatch, fake)
-    release.main(["--list-steps"])
-    assert fake.of("warn")[0] == ((DEPRECATION,), {})
-
-
-# ---- steps ---------------------------------------------------------------------------------------------------------
-RELEASE_STEPS = ["check_prerequisites", "check_java_version", "check_maven_version", "clean_maven_repo",
-                 "make_git_folder", "clone_juneau", "configure_git", "run_clean_verify", "run_deploy",
-                 "run_release_prepare", "run_git_diff", "run_release_perform", "create_binary_artifacts",
-                 "verify_distribution"]
-
-
-def stub_steps(script, monkeypatch, behaviour=None):
-    """Replace every step method with a recorder.  behaviour maps a step name to what it does instead."""
-    ran = []
-    for name in script.steps:
-        def stub(name=name):
-            ran.append(name)
-            (behaviour or {}).get(name, lambda: None)()
-        monkeypatch.setattr(script, name, stub)
-    script._prompt_pgp = lambda: None
-    return ran
-
-
-def test_the_steps_are_in_order_with_run_git_diff_after_prepare(release, monkeypatch):
-    fake = install(monkeypatch, FakeRun())
-    release.main(["--list-steps"])
-    assert [args[0] for args, _ in fake.of("say")] == [f"{n:>2}. {name:<24} {release.STEP_TITLES[name][1]}"
-                                                       for n, name in enumerate(RELEASE_STEPS, 1)]
-
-
-def test_every_step_runs_in_a_labelled_row(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    ran = stub_steps(script, monkeypatch)
-    script.run()
-    assert ran == RELEASE_STEPS
-    assert [row[:4] for row in fake.rows] == [("step", name, release.STEP_TITLES[name][0], "ok")
-                                              for name in RELEASE_STEPS]
-    assert all(len(label) <= 10 for label, _ in release.STEP_TITLES.values())
-    assert set(release.STEP_TITLES) == set(RELEASE_STEPS)
-    [((title, header), _)] = fake.of("session")
-    assert (title, header[0]) == ("Release", "Juneau")
-    assert fake.of("done") == [(("ok",), {})]
-
-
-def test_resume_shows_the_finished_steps_as_done_earlier(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake, resume=True)
-    script.state.set("last", "configure_git")
-    ran = stub_steps(script, monkeypatch)
-    script.run()
-    assert ran == RELEASE_STEPS[7:]
-    assert fake.rows[:7] == [("step", name, release.STEP_TITLES[name][0], "skip", "(done earlier)")
-                             for name in RELEASE_STEPS[:7]]
-    assert [row[3] for row in fake.rows[7:]] == ["ok"] * 7
-
-
-def test_a_skipped_step_is_a_dim_row(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake, skip_steps=["run_deploy"])
-    ran = stub_steps(script, monkeypatch)
-    script.run()
-    assert "run_deploy" not in ran
-    assert ("step", "run_deploy", "Deploy", "skip", "(skipped)") in fake.rows
-
-
-def hint_lines(fake, name):
-    """Every say or failure line carrying the resume hint for step name, with its kind and level."""
-    hint = f"To resume: python3 scripts/release.py --start-step {name}"
-    return [(kind, kwargs.get("level")) for kind, args, kwargs in fake.calls
-            if kind in ("say", "failure") and hint in args[0]]
-
-
-def test_a_failed_step_shows_the_resume_hint(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    stub_steps(script, monkeypatch, {"run_clean_verify": lambda: script.fail("Command failed (exit 1): mvn")})
-    with pytest.raises(SystemExit) as exit_info:
-        script.run()
-    assert exit_info.value.code == 1
-    assert fake.rows[-1][:4] == ("step", "run_clean_verify", "Verify", "fail")
-    assert [args[0] for args, _ in fake.of("failure")] == ["❌ Command failed (exit 1): mvn"]
-    assert fake.of("done") == [(("fail",), {})]
-    # one hint, at error level, so every detail level (summary included) shows it exactly once
-    assert hint_lines(fake, "run_clean_verify") == [("say", "error")]
-
-
-def test_ctrl_c_in_a_step_is_cancelled_with_the_resume_hint(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-
-    def interrupt():
-        raise KeyboardInterrupt
-
-    stub_steps(script, monkeypatch, {"run_deploy": interrupt})
-    with pytest.raises(SystemExit) as exit_info:
-        script.run()
-    assert exit_info.value.code == 130
-    assert fake.of("failure") == [(("Interrupted in run_deploy.",), {"step": "run_deploy"})]
-    assert hint_lines(fake, "run_deploy") == [("say", "error")]
-    assert fake.of("done") == [(("cancelled",), {})]
-
-
-def test_the_pgp_prompt_runs_on_the_real_terminal(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    monkeypatch.setattr(release.subprocess, "run", lambda *a, **k: pytest.fail(f"gpg run outside passthrough: {a}"))
-    script._prompt_pgp()
-    assert fake.of("passthrough") == [(([sys.executable, str(SCRIPTS_DIR / "prompt-pgp-passphrase.py")], "pgp",
-                                        "Prime the PGP agent"), {"label": "PGP"})]
-
-
-def test_the_header_names_the_release_java_and_maven(release, monkeypatch):
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    script = make(release, monkeypatch, FakeRun())
-    assert script.header_lines() == [RELEASE, "Java 17.0.12", "Maven 3.9.9"]
-
-
-def test_the_version_checks_reuse_the_header_probe(release, monkeypatch):
-    script = make(release, monkeypatch, FakeRun())
-    monkeypatch.setattr(release.subprocess, "run", lambda *a, **k: pytest.fail(f"unexpected subprocess {a}"))
-    script.check_java_version()
-    assert script.summary == "Java 17.0.12"
-    script.check_maven_version()
-    assert script.summary == "Maven 3.9.9"
-
-
-def test_an_old_java_fails_the_step(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    script._versions = ('openjdk version "11.0.2" 2019-01-15\n', MAVEN_TEXT)
-    with pytest.raises(SystemExit):
-        script.check_java_version()
-    assert fake.of("failure")[0][0][0] == "❌ Java version 11 detected. Java 17 or higher is required."
-
-
-def test_run_git_diff_pages_the_diff_against_the_tag(release, monkeypatch, tmp_path):
-    clone = tmp_path / "staging" / "git" / "juneau"
-    clone.mkdir(parents=True)
-
-    def git(*args):
-        subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@example.org", "-c", "commit.gpgsign=false",
-                        *args], cwd=clone, check=True, capture_output=True)
-
-    git("init", "-q")
-    (clone / "pom.xml").write_text("<version>9.9.9</version>\n")
-    git("add", "pom.xml")
-    git("commit", "-qm", "release")
-    git("tag", RELEASE)
-    (clone / "pom.xml").write_text("<version>9.9.10-SNAPSHOT</version>\n")
-    monkeypatch.setenv("X_STAGING", str(tmp_path / "staging"))
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    script.run_git_diff()
-    [(args, kwargs)] = fake.of("show")
-    assert kwargs == {"pager": True}
-    assert "+<version>9.9.10-SNAPSHOT</version>" in args[0]
-    assert script.summary == "1 file(s)"
-
-    git("commit", "-qam", "next")
-    git("tag", "-f", RELEASE)
-    script.run_git_diff()
-    assert fake.of("show")[-1] == ((f"No differences against {RELEASE}.",), {"pager": True})
-    assert script.summary == "no changes"
-
-
-def test_a_failing_git_diff_is_a_warning_not_no_differences(release, monkeypatch, tmp_path):
-    monkeypatch.setenv("X_STAGING", str(tmp_path / "staging"))
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    monkeypatch.setattr(release.subprocess, "run",
-                        lambda cmd, **k: subprocess.CompletedProcess(cmd, 128, "", "fatal: bad revision\n"))
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    script.run_git_diff()
-    assert fake.of("say")[0][1] == {"level": "warn"} and "failed (exit 128)" in fake.of("say")[0][0][0]
-    assert "No differences" not in "".join(args[0] for args, _ in fake.of("show"))
-    assert script.summary == "git diff failed (exit 128)"
-
-
-def test_old_dist_entries_are_removed_in_one_svn_rm(release, monkeypatch, tmp_path):
-    dist = tmp_path / "dist"
-    for path in ("source/juneau-9.9.8-RC1", "source/juneau-9.9.8-RC2", "binaries/juneau-9.9.8-RC2", "source/.svn"):
-        (dist / path).mkdir(parents=True)
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    assert script._svn_rm_contents(dist, dist / "source", dist / "binaries", dist / "missing") == 3
-    assert [args[0] for args, _ in fake.of("run_tool")] == [
-        ["svn", "rm", "source/juneau-9.9.8-RC1", "source/juneau-9.9.8-RC2", "binaries/juneau-9.9.8-RC2"]]
-
-
-def test_nothing_is_svn_rm_ed_from_empty_directories(release, monkeypatch, tmp_path):
-    (tmp_path / "dist" / "source").mkdir(parents=True)
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    assert script._svn_rm_contents(tmp_path / "dist", tmp_path / "dist" / "source") == 0
-    assert fake.of("run_tool") == []
-
-
-@pytest.mark.parametrize("old, summary", [(2, f"committed {RELEASE} to dist/dev \u00b7 removed 2 old"),
-                                          (0, f"committed {RELEASE} to dist/dev")])
-def test_create_binary_artifacts_removes_old_source_and_binaries(release, monkeypatch, tmp_path, old, summary):
-    staging = tmp_path / "staging"
-    monkeypatch.setenv("X_STAGING", str(staging))
-    monkeypatch.setenv("X_VERSION", "9.9.9")
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    monkeypatch.setenv("X_REPO", "orgapachejuneau-1000")
-    script = make(release, monkeypatch, FakeRun())
-    commands = []
-
-    def run_command(cmd, cwd=None, check=True, **kwargs):
-        commands.append(cmd)
-        if cmd[:2] == ["svn", "checkout"]:
-            for sub in ("source", "binaries"):
-                (staging / "dist" / sub).mkdir(parents=True)
-            for sub, name in (("source", "old-a"), ("binaries", "old-b"))[:old]:
-                (staging / "dist" / sub / name).mkdir()
-        return SimpleNamespace(returncode=0)
-
-    monkeypatch.setattr(script, "run_command", run_command)
-    rm_calls = []
-    real = script._svn_rm_contents
-    monkeypatch.setattr(script, "_svn_rm_contents", lambda dist, *dirs: rm_calls.append(dirs) or real(dist, *dirs))
-    script.create_binary_artifacts()
-    assert rm_calls == [(staging / "dist" / "source", staging / "dist" / "binaries")]
-    assert script.summary == summary
-
-
-def test_create_test_workspace_is_gone(release):
-    assert not hasattr(release.ReleaseScript, "create_test_workspace")
-    assert "create_test_workspace" not in (SCRIPTS_DIR / "release.py").read_text(encoding="utf-8")
-
-
-def test_status_text_goes_through_say_and_show():
-    assert calls_to("print") == []
-
-
-def test_revert_is_four_rows_and_skips_what_is_not_there(release, monkeypatch, tmp_path):
-    monkeypatch.setenv("X_STAGING", str(tmp_path / "staging"))
-    monkeypatch.setenv("X_VERSION", "9.9.9")
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    fake = FakeRun(answers=["y"])
-    script = make(release, monkeypatch, fake)
-    script.run_revert()
-    assert [row[1:4] for row in fake.rows] == [("revert_pull", "Pull", "skip"), ("revert_tag", "Tag", "skip"),
-                                               ("revert_versions", "Versions", "skip"), ("revert_svn", "SVN", "skip")]
-    assert fake.rows[3][4] == f"no checkout at {tmp_path / 'staging' / 'dist'}"
-    assert fake.of("session") == [(("Revert", ["Juneau", RELEASE]), {})]
-    assert fake.of("done") == [(("ok",), {})]
-
-
-def test_revert_removes_the_rc_directories_from_svn(release, monkeypatch, tmp_path):
-    dist = tmp_path / "staging" / "dist"
-    for path in (".svn", f"binaries/{RELEASE}", f"source/{RELEASE}", "source/KEYS-dir-without-rc"):
-        (dist / path).mkdir(parents=True)
-    monkeypatch.setenv("X_STAGING", str(tmp_path / "staging"))
-    monkeypatch.setenv("X_VERSION", "9.9.9")
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    status = f"D       binaries/{RELEASE}\nD       source/{RELEASE}\n"
-    monkeypatch.setattr(release.subprocess, "run", lambda cmd, **k: subprocess.CompletedProcess(cmd, 0, status, ""))
-    fake = FakeRun(answers=["y", "y"])
-    script = make(release, monkeypatch, fake)
-    script.run_revert()
-    assert [args[0] for args, _ in fake.of("run_tool")] == [
-        ["svn", "update"], ["svn", "rm", f"binaries/{RELEASE}"], ["svn", "rm", f"source/{RELEASE}"],
-        ["svn", "commit", "-m", f"Remove {RELEASE} release candidate"]]
-    assert fake.of("show")[0][0][0] == f"SVN changes ready to commit:\n{status}"
-    assert fake.rows[3][1:] == ("revert_svn", "SVN", "ok", "2 removed and committed")
-
-
-def test_an_unexpected_exception_in_a_step_is_reported_with_the_resume_hint(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-
-    def boom():
-        raise RuntimeError("boom")
-
-    stub_steps(script, monkeypatch, {"run_deploy": boom})
-    with pytest.raises(SystemExit) as exit_info:
-        script.run()
-    assert exit_info.value.code == 1
-    assert not [args for args, _ in fake.of("say") if "ERROR" in args[0]]   # reported once, as the failure below
-    assert fake.of("failure") == [(("\u274c Error in step run_deploy: boom",), {"step": "run_deploy"})]
-    assert hint_lines(fake, "run_deploy") == [("say", "error")]
-    assert fake.of("done") == [(("fail",), {})]
-
-
-def test_start_step_shows_the_earlier_steps_as_done_earlier(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake, start_step="run_deploy")
-    ran = stub_steps(script, monkeypatch)
-    script.run()
-    assert ran == RELEASE_STEPS[8:]
-    assert fake.rows[:8] == [("step", name, release.STEP_TITLES[name][0], "skip", "(done earlier)")
-                             for name in RELEASE_STEPS[:8]]
-
-
-@pytest.mark.parametrize("missing", ["java", "mvn"])
-def test_a_missing_java_or_maven_probes_as_empty(release, monkeypatch, missing):
-    def run(cmd, **kwargs):
-        if cmd[0] == missing:
-            raise FileNotFoundError(cmd[0])
-        return subprocess.CompletedProcess(cmd, 0, "", JAVA_TEXT if cmd[0] == "java" else MAVEN_TEXT)
-
-    script = make(release, monkeypatch, FakeRun())
-    script._versions = None
-    monkeypatch.setattr(release.subprocess, "run", run)
-    java, maven = script._probe_versions()
-    assert (java == "") == (missing == "java") and (maven == "") == (missing == "mvn")
-    assert script._probe_versions() is script._versions
-
-
-# ---- the toolkit CLI (Registry), checkpoint and session plan --------------------------------------------------------
-def test_resume_after_a_step_starts_at_the_next_and_shows_done_earlier_rows(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake, resume=True)
-    script.state.set("last", "configure_git")
-    stub_steps(script, monkeypatch)
-    script.run()
-    assert (("Resuming after configure_git: starting at run_clean_verify.",), {"level": "info"}) in fake.of("say")
-    assert [row[4] for row in fake.rows[:7]] == ["(done earlier)"] * 7
-
-
-def test_an_unknown_start_step_is_a_usage_error(release, monkeypatch, capsys):
-    install(monkeypatch, FakeRun())
-    monkeypatch.setattr(release, "ReleaseScript", lambda *a, **k: pytest.fail("no release for a usage error"))
-    with pytest.raises(SystemExit) as exit_info:
-        release.main(["--start-step", "no_such_step"])
-    assert exit_info.value.code == 2
-    assert "invalid choice: 'no_such_step'" in capsys.readouterr().err
-
-
-def test_start_step_and_resume_are_mutually_exclusive(release, monkeypatch, capsys):
-    install(monkeypatch, FakeRun())
-    with pytest.raises(SystemExit) as exit_info:
-        release.main(["--start-step", "run_deploy", "--resume"])
-    assert exit_info.value.code == 2
-    assert "not allowed with argument" in capsys.readouterr().err
-
-
-def test_list_steps_uses_the_registry_format(release, monkeypatch):
-    fake = install(monkeypatch, FakeRun())
-    release.main(["--list-steps"])
-    lines = [args[0] for args, _ in fake.of("say")]
-    assert lines[0] == " 1. check_prerequisites      Checking prerequisites"
-    assert lines[9] == "10. run_release_prepare      Running release:prepare"
-
-
-def test_state_file_keys(release, monkeypatch):
-    assert release.STATE_FILE == Path.home() / ".juneau-release-state.json"
-    script = make(release, monkeypatch, FakeRun())
-    stub_steps(script, monkeypatch, {"check_java_version": lambda: script.fail("old java")})
-    with pytest.raises(SystemExit):
-        script.run()
-    import json
-    assert json.loads(release.STATE_FILE.read_text(encoding="utf-8")) == {"last": "check_prerequisites"}
-
-
-def test_registry_calls_the_method_looked_up_at_run_time(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    registry = script.registry()
-    ran = stub_steps(script, monkeypatch)   # after the registry is built
-    assert registry.run(script.selection(), None, show_unselected=True).ok
-    assert ran == RELEASE_STEPS
-
-
-def test_a_step_summary_is_its_row_text(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-
-    def deploy():
-        script.summary = "deployed"
-
-    stub_steps(script, monkeypatch, {"run_deploy": deploy})
-    script.run()
-    assert ("step", "run_deploy", "Deploy", "ok", "deployed") in fake.rows
-
-
-def test_checkpoint_records_each_finished_step_as_last(release, monkeypatch):
-    script = make(release, monkeypatch, FakeRun())
-    seen = []
-    behaviour = {name: (lambda: seen.append(script.state.last())) for name in RELEASE_STEPS[1:4]}
-    behaviour["run_deploy"] = lambda: script.fail("deploy failed")
-    stub_steps(script, monkeypatch, behaviour)
-    with pytest.raises(SystemExit):
-        script.run()
-    assert seen == RELEASE_STEPS[0:3]
-    assert script.state.last() == "run_clean_verify"
-
-
-def test_a_legacy_last_step_state_file_still_resumes(release, monkeypatch):
-    import json
-    release.STATE_FILE.write_text(json.dumps({"last_step": "configure_git", "X_RELEASE": RELEASE}), encoding="utf-8")
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake, resume=True)
-    ran = stub_steps(script, monkeypatch)
-    script.run()
-    assert ran == RELEASE_STEPS[7:]
-    assert (("Resuming after configure_git: starting at run_clean_verify.",), {"level": "info"}) in fake.of("say")
-
-
-def test_success_clears_the_checkpoint(release, monkeypatch):
-    script = make(release, monkeypatch, FakeRun())
-    script.state.set("X_RELEASE", RELEASE)
-    stub_steps(script, monkeypatch)
-    script.run()
-    assert not release.STATE_FILE.exists()
-
-
-def test_a_corrupt_checkpoint_warns_and_fails(release, monkeypatch):
-    release.STATE_FILE.write_text("{not json", encoding="utf-8")
-    fake = install(monkeypatch, FakeRun())
-    with pytest.raises(SystemExit) as exit_info:
-        release.ReleaseScript(load_env=False)
-    assert exit_info.value.code == 1
-    [(args, kwargs)] = fake.of("say")
-    assert args[0].startswith("Warning: Could not load state file:") and kwargs == {"level": "warn"}
-    assert release.STATE_FILE.read_text(encoding="utf-8") == "{not json"   # never cleared on a guess
-
-
-def test_an_unwritable_checkpoint_during_migration_warns_and_fails(release, monkeypatch):
-    """Migrating a legacy last_step file writes it back; if that write fails it is the corrupt-file path: a warning and
-    exit 1 before the session, with the file left as it was."""
-    release.STATE_FILE.write_text(json.dumps({"last_step": "clone_juneau"}), encoding="utf-8")
-    directory = release.STATE_FILE.parent
-    mode = directory.stat().st_mode
-    directory.chmod(0o500)
-    try:
-        fake = install(monkeypatch, FakeRun())
-        with pytest.raises(SystemExit) as exit_info:
-            release.ReleaseScript(load_env=False)
-    finally:
-        directory.chmod(mode)
-    assert exit_info.value.code == 1
-    [(args, kwargs)] = fake.of("say")
-    assert args[0].startswith("Warning: Could not load state file:") and kwargs == {"level": "warn"}
-    assert json.loads(release.STATE_FILE.read_text(encoding="utf-8")) == {"last_step": "clone_juneau"}
-    assert fake.of("session") == []
-
-
-def test_release_steps_are_numbered_from_one_in_order_even_when_started_later(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake, start_step="clone_juneau")
-    stub_steps(script, monkeypatch)
-    script.run()
-    assert fake.numbers == [(name, n) for n, name in enumerate(RELEASE_STEPS, 1)]
-
-
-def test_release_session_plan_is_the_selected_steps(release, monkeypatch):
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake, start_step="clone_juneau", skip_steps=["run_deploy"])
-    stub_steps(script, monkeypatch)
-    script.run()
-    assert fake.of("plan") == [(([n for n in RELEASE_STEPS[5:] if n != "run_deploy"],), {})]
-
-
-def test_revert_session_plan_is_its_four_rows(release, monkeypatch, tmp_path):
-    monkeypatch.setenv("X_STAGING", str(tmp_path / "staging"))
-    monkeypatch.setenv("X_VERSION", "9.9.9")
-    monkeypatch.setenv("X_RELEASE", RELEASE)
-    fake = FakeRun(answers=["y"])
-    script = make(release, monkeypatch, fake)
-    script.run_revert()
-    assert fake.of("plan") == [((["revert_pull", "revert_tag", "revert_versions", "revert_svn"],), {})]
-
-
-def test_yprompt_ctrl_d_is_a_cancel_today(release, monkeypatch):
-    monkeypatch.setenv("X_VERSION", "9.9.9")
-
-    class EofRun(FakeRun):
-        def ask(self, prompt):
-            if not self.answers:
-                self._record("ask", prompt)
-                raise EOFError
-            return super().ask(prompt)
-
-    fake = EofRun(answers=["4"])
-    install(monkeypatch, fake)
-    with pytest.raises(SystemExit) as exit_info:
-        release.main(["--revert"])
-    assert exit_info.value.code == 130
-    assert fake.of("ask")[-1][0][0].startswith("Are you sure you want to revert release juneau-9.9.9-RC4?")
-    assert fake.of("run_tool") == []
-    assert fake.of("done") == [(("cancelled",), {})]
-
-
-def test_the_release_session_never_rings_the_bell_or_plays_a_sound(release, monkeypatch):
-    script = make(release, monkeypatch, FakeRun())
-    stub_steps(script, monkeypatch)
-    notify = release.toolkit("notify")
-    monkeypatch.setattr(notify, "bell", lambda **k: pytest.fail("bell"))
-    monkeypatch.setattr(notify, "sound", lambda *a, **k: pytest.fail("sound"))
-    script.run()
-
-
-def test_each_phase_command_is_a_sub_row_of_its_phase(release, monkeypatch, tmp_path):
-    """
-    A phase command runs as a sub-row (parent=) of its phase, so the phase row draws a unit line for it; a Maven
-    command (parser "maven") hands that line to its reactor's modules instead.  What each draws is pinned in
-    juneau-terminal's test_unit_model; this pins the call shape that selects it.
-    """
-    fake = FakeRun()
-    script = make(release, monkeypatch, fake)
-    script.current = "create_binary_artifacts"
-    script.run_command(["svn", "add", "source/x"], cwd=tmp_path)
-    script.current = "run_clean_verify"
-    script.run_command(["mvn", "clean", "verify"], cwd=tmp_path)
-    (svn_args, svn), (mvn_args, mvn) = fake.of("run_tool")
-    assert (svn_args[1], svn["parent"]) == ("generic", "create_binary_artifacts")
-    assert (mvn_args[1], mvn["parent"]) == ("maven", "run_clean_verify")
diff --git a/scripts/tests/test_run_adoption.py b/scripts/tests/test_run_adoption.py
index 8b00afe83b..fbf7acc90e 100644
--- a/scripts/tests/test_run_adoption.py
+++ b/scripts/tests/test_run_adoption.py
@@ -1112,7 +1112,7 @@ def test_push_plan_puts_the_opt_in_gates_first_and_forced_browser_tests_after_te
         "docs-smoke", "docs-commit", "docs-push"]  
 
 
-# The one loader push.py, test.py and release.py share (scripts/_juneau.py).
+# The one loader push.py and test.py share (scripts/_juneau.py).
 
 @pytest.fixture
 def juneau_loader():