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/19 18:02:02 UTC

(juneau) branch juneau-release-manager updated: Add the release CLI (scripts/release.py, jrm_release): a resumable 26-step Juneau release run with a prerequisites check before any prompt, a details form prefilled from --version and the GPG key, Java checked via JAVA_HOME and a gh version floor, and a dry-run mode; with tests.

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

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


The following commit(s) were added to refs/heads/juneau-release-manager by this push:
     new c25197c402 Add the release CLI (scripts/release.py, jrm_release): a resumable 26-step Juneau release run with a prerequisites check before any prompt, a details form prefilled from --version and the GPG key, Java checked via JAVA_HOME and a gh version floor, and a dry-run mode; with tests.
c25197c402 is described below

commit c25197c4021477903c072782a44efa383092abf1
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Oct 19 14:01:57 2026 -0400

    Add the release CLI (scripts/release.py, jrm_release): a resumable 26-step Juneau release run with a prerequisites check before any prompt, a details form prefilled from --version and the GPG key, Java checked via JAVA_HOME and a gh version floor, and a dry-run mode; with tests.
---
 README.md                             |  24 ++
 scripts/jrm_release/__init__.py       |   0
 scripts/jrm_release/askpass.py        |   7 +
 scripts/jrm_release/ctx.py            |  92 +++++++
 scripts/jrm_release/details.py        | 101 ++++++++
 scripts/jrm_release/emails.py         | 117 +++++++++
 scripts/jrm_release/gpgkeys.py        |  45 ++++
 scripts/jrm_release/inventory.py      |  78 ++++++
 scripts/jrm_release/nexus.py          |  98 ++++++++
 scripts/jrm_release/prereqs.py        | 128 ++++++++++
 scripts/jrm_release/promote.py        | 121 +++++++++
 scripts/jrm_release/rc.py             | 359 ++++++++++++++++++++++++++
 scripts/jrm_release/revert.py         |  87 +++++++
 scripts/jrm_release/runfile.py        | 100 ++++++++
 scripts/jrm_release/runner.py         |  57 +++++
 scripts/jrm_release/svn.py            |  26 ++
 scripts/jrm_release/vote.py           |  50 ++++
 scripts/release.py                    | 366 +++++++++++++++++++++++++++
 scripts/tests/README.md               |  23 ++
 scripts/tests/conftest.py             | 198 +++++++++++++++
 scripts/tests/test_release_cli.py     | 284 +++++++++++++++++++++
 scripts/tests/test_release_dryrun.py  |  66 +++++
 scripts/tests/test_release_emails.py  |  69 +++++
 scripts/tests/test_release_prereqs.py | 341 +++++++++++++++++++++++++
 scripts/tests/test_release_revert.py  |  82 ++++++
 scripts/tests/test_release_runfile.py |  37 +++
 scripts/tests/test_release_secrets.py |  72 ++++++
 scripts/tests/test_release_steps.py   | 462 ++++++++++++++++++++++++++++++++++
 28 files changed, 3490 insertions(+)

diff --git a/README.md b/README.md
index 2554b18091..213dfa4358 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,30 @@ mvn clean verify        # build + run tests + apache-rat license check
 ./stop.sh               # stop it
 ```
 
+## Release CLI
+
+`scripts/release.py` drives a release (RC, vote, promote) from the terminal. Smoke-test it with a dry run, which
+runs read-only commands, prints every mutation and writes no `runs/`:
+
+```bash
+JUNEAU_RUN_PY=~/git/apache/juneau/juneau-run/src/main/python/juneau_run.py \
+  uv run --native-tls python scripts/release.py --dry-run --version 9.2.1
+```
+
+- **Prerequisites run first**, before any prompt (branch, details, credentials), on a new run and on `--resume` alike,
+  dry run included. They check git, mvn (3.6+), java (17+), svn and svnmucc (1.10+), gpg (2.1+), gh and unzip on PATH,
+  plus credential-free reachability: Nexus, and an anonymous `svn ls` of dist/dev. Every failure is listed at once with
+  an install hint (for example `brew install subversion`), then release.py exits before asking anything. The
+  `prechecks` step that follows keeps only what depends on your answers (branch exists, RC tag absent).
+- The GPG key id prompt is prefilled from `gpg --list-secret-keys --with-colons` (read-only): the unexpired,
+  signing-capable key whose uid matches your Apache email (or `<availid>@apache.org`). Several matches become a
+  choice; none leaves the plain prompt. Outside a dry run it warns if the key is missing from the published KEYS file.
+- The release branch is asked first. With `--version`, its default is an existing `origin/juneau-X-branch` or
+  `origin/X.Y.x`, else `master`.
+- `--version` prefills the Version in the details form and derives the next development version from it.
+- Leave the development version blank to get the next patch; a bare `x.y.z` gets `-SNAPSHOT` appended.
+- A dry run logs to `~/.juneau-release-manager/attempts/dry-run/full.log` (the banner's `tail -f` target).
+
 ## License
 
 Apache License 2.0. See `LICENSE`.
diff --git a/scripts/jrm_release/__init__.py b/scripts/jrm_release/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/scripts/jrm_release/askpass.py b/scripts/jrm_release/askpass.py
new file mode 100755
index 0000000000..6d690560a6
--- /dev/null
+++ b/scripts/jrm_release/askpass.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+"""GIT_ASKPASS helper: answers git's Username/Password prompts from the environment, so no secret is on any argv."""
+import os
+import sys
+
+prompt = sys.argv[1] if len(sys.argv) > 1 else ""
+sys.stdout.write(os.environ.get("JRM_ASKPASS_USER" if "sername" in prompt else "JRM_ASKPASS_PASS", ""))
diff --git a/scripts/jrm_release/ctx.py b/scripts/jrm_release/ctx.py
new file mode 100644
index 0000000000..5c844f7559
--- /dev/null
+++ b/scripts/jrm_release/ctx.py
@@ -0,0 +1,92 @@
+"""The object every release step receives, and the error a step fails with."""
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Callable
+
+
+class StepError(RuntimeError):
+    """A step failed.  The message is the row's failure text."""
+
+
+SECRET_KEYS = ("credential.apache.password", "credential.gpg.passphrase", "credential.github.token")
+
+LABELS = {
+    "credential.apache.availid": "Apache id",
+    "credential.apache.email": "Apache email address",
+    "credential.apache.password": "Apache password",
+    "credential.gpg.keyid": "GPG key id",
+    "credential.gpg.passphrase": "GPG passphrase",
+    "credential.github.token": "GitHub token",
+}
+
+
+def _now():
+    return datetime.now().astimezone()
+
+
+@dataclass
+class Ctx:
+    args: Any                 # parsed CLI namespace
+    home: Path                # <JUNEAU_RELEASE_HOME>
+    run: Any                  # juneau_scripts.run (say, cmd, ...) or a fake
+    prompt: Any               # juneau_scripts.prompt or FakePrompts
+    runner: Any               # jrm_release.runner.Runner
+    nexus: Any                # jrm_release.nexus.Nexus or FakeNexus
+    svn: Any                  # jrm_release.svn.Svn
+    rf: Any                   # jrm_release.runfile.RunFile
+    pause: Any                # juneau_scripts.steps.Pause (an exception class)
+    http_ok: Callable[[str], bool] = lambda url: False
+    http_text: Callable[[str], Any] = lambda url: None   # fn(url) -> body text, or None when unreachable
+    sleep: Callable[[float], None] = lambda s: None
+    now: Callable[[], Any] = _now
+    creds: dict = field(default_factory=dict)
+    prereq: Any = None        # failures list from prereqs.check, once the early check has run
+
+    @property
+    def dry_run(self) -> bool:
+        return bool(getattr(self.args, "dry_run", False))
+
+    @property
+    def d(self) -> dict:
+        return self.rf.get("details") or {}
+
+    @property
+    def version(self) -> str:
+        return self.d["version"]
+
+    @property
+    def rc_tag(self) -> str:
+        return f"juneau-{self.version}-RC{self.d['rc']}"
+
+    @property
+    def run_dir(self) -> Path:
+        return self.home / "runs" / self.version
+
+    @property
+    def ws(self) -> Path:
+        return self.run_dir / "workspace"
+
+    def say(self, text="", level="info"):
+        self.run.say(text, level)
+
+    def fail(self, msg):
+        raise StepError(msg)
+
+    def cred(self, key, *, secret=False):
+        """A credential, asked once per process through the prompt API.  Secrets are not asked under --dry-run."""
+        if key in self.creds:
+            return self.creds[key]
+        if secret and self.dry_run:
+            value = "********"
+        else:
+            ask = self.prompt.secret if secret else self.prompt.text
+            value = ask(LABELS.get(key, key), key=key)
+            if value is None:
+                raise KeyboardInterrupt
+        self.creds[key] = value
+        return value
+
+    def secrets(self):
+        """Real secret values, for masking."""
+        return [v for k, v in self.creds.items() if k in SECRET_KEYS and v and v != "********"]
diff --git a/scripts/jrm_release/details.py b/scripts/jrm_release/details.py
new file mode 100644
index 0000000000..3066179817
--- /dev/null
+++ b/scripts/jrm_release/details.py
@@ -0,0 +1,101 @@
+"""The release details: branch first (so the pom default is read from that branch), then one form."""
+import dataclasses
+import os
+import re
+
+from . import inventory
+
+FIELDS = ("version", "developmentVersion", "rc", "summary", "highlights", "knownIssues", "acknowledgements")
+
+
+def pom_version(text):
+    """The project's own <version> (the first one outside <parent>), or None."""
+    m = re.search(r"<version>\s*([^<\s]+)\s*</version>", re.sub(r"<parent>.*?</parent>", "", text or "", flags=re.S))
+    return m.group(1) if m else None
+
+
+def validate(d):
+    """An error message for bad details, or None."""
+    v, dev, rc = d.get("version"), d.get("developmentVersion"), d.get("rc")
+    vt, dt = inventory.parse_version(v), inventory.parse_version(dev)
+    if not vt or (v or "").endswith("-SNAPSHOT"):
+        return "version must look like x.y.z"
+    if not dt or not (dev or "").endswith("-SNAPSHOT"):
+        return "developmentVersion must be x.y.z-SNAPSHOT"
+    if dt <= vt:
+        return "developmentVersion must be greater than version"
+    if not isinstance(rc, int) or rc < 1:
+        return "rc must be 1 or more"
+    return None
+
+
+def checkout():
+    return os.path.expanduser(inventory.JUNEAU_CHECKOUT)
+
+
+def pom_defaults(ctx, branch):
+    """(version, developmentVersion) read from the Juneau checkout's origin/<branch>:pom.xml, or (None, None)."""
+    r = ctx.runner.quiet(["git", "-C", checkout(), "show", f"origin/{branch}:pom.xml"], title="Read branch pom")
+    cur = pom_version(r.out) if r.ok else None
+    if not cur:
+        return None, None
+    v = cur.removesuffix("-SNAPSHOT")
+    return v, inventory.next_dev_version(v)
+
+
+def branch_default(ctx, version):
+    """The branch to default to for a given release version: an existing origin/juneau-X-branch, else origin/X.Y.x,
+    else master."""
+    t = inventory.parse_version(version)
+    if not t:
+        return "master"
+    wanted = [f"juneau-{version}-branch", f"{t[0]}.{t[1]}.x"]
+    r = ctx.runner.run(["git", "-C", checkout(), "for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"],
+                       title="List release branches", mutating=False)
+    have = {ln.strip().removeprefix("origin/") for ln in (r.out.splitlines() if r.ok else [])}
+    return next((b for b in wanted if b in have), "master")
+
+
+def normalize_dev(version, dev):
+    """The development version as typed: blank derives from version; a bare x.y.z gets -SNAPSHOT."""
+    dev = (dev or "").strip()
+    if not dev:
+        return inventory.next_dev_version(version)
+    return dev + "-SNAPSHOT" if inventory.parse_version(dev) and not dev.endswith("-SNAPSHOT") else dev
+
+
+def ask(ctx, version=None):
+    """Prompt for the branch, then the details; validate, record in run.json 'details', return the details dict.
+    version (from --version) prefills the Version default, derives the dev default and picks the branch default."""
+    P = ctx.prompt
+    default_branch = ctx.d.get("branch") or (branch_default(ctx, version) if version else "master")
+    branch = P.text("Release branch", default=default_branch, key="release.branch")
+    if branch is None:
+        raise KeyboardInterrupt
+    pom_v, pom_dev = pom_defaults(ctx, branch)
+    if version:
+        pom_v, pom_dev = version, inventory.next_dev_version(version)
+    F = P.Field
+    fields = [F("version", "Version", default=pom_v, required=True),
+              F("developmentVersion", "Next development version (blank = next patch, x.y.z-SNAPSHOT)", default=pom_dev),
+              F("rc", "Release candidate number", default="1", required=True, type="int"),
+              F("summary", "Summary", multiline=True, required=True),
+              F("highlights", "Highlights", multiline=True),
+              F("knownIssues", "Known issues", multiline=True),
+              F("acknowledgements", "Acknowledgements", multiline=True)]
+    while True:
+        got = P.form("Release details", fields, key="release.details")
+        if got is None:
+            raise KeyboardInterrupt
+        if got.get("developmentVersion") == pom_dev and got.get("version") != pom_v:
+            got["developmentVersion"] = None   # the prefilled dev version belongs to the old Version; re-derive
+        got["developmentVersion"] = normalize_dev(got.get("version"), got.get("developmentVersion"))
+        err = validate(got)
+        if not err:
+            break
+        ctx.say(f"❌ {err}", "error")
+        fields = [dataclasses.replace(f, default=str(got[f.name]) if got.get(f.name) not in (None, "") else f.default)
+                  for f in fields]
+    d = {"branch": branch, **{k: got.get(k) for k in FIELDS}}
+    ctx.rf.set("details", d)
+    return d
diff --git a/scripts/jrm_release/emails.py b/scripts/jrm_release/emails.py
new file mode 100644
index 0000000000..5bf32b420b
--- /dev/null
+++ b/scripts/jrm_release/emails.py
@@ -0,0 +1,117 @@
+"""Email text and the send flow.  Bodies come from the Java EmailService wording; VOTE from Juneau's release.py."""
+import urllib.parse
+
+from . import inventory
+
+MAILTO_LIMIT = 1800
+
+
+def propose(d):
+    sub = f"[PROPOSE] Release Apache Juneau {d['version']}"
+    body = (f"I propose to release Apache Juneau {d['version']} (RC{d['rc']}) from branch {d['branch']}.\n\n"
+            f"{d['summary']}\n\n"
+            "If there are last-minute blockers, please reply on this thread before the build starts.\n")
+    return inventory.DEV_LIST, sub, body
+
+
+def send(ctx, kind, to, subject, body, cc=()):
+    """Ask the prompt channel to open the mail.  Sent: recorded in run.json.  'Not yet': Pause(done=False)."""
+    sent = ctx.prompt.email(to=to, subject=subject, body=body, cc=cc, key=f"email.{kind}")
+    if sent is None:
+        raise KeyboardInterrupt
+    if "body=" not in mailto_url(to, subject, body):
+        ctx.say("body too long for a mailto link; copy it from the log")
+    if not sent:
+        raise ctx.pause(f"{kind} email not sent yet.  Re-run with --resume when it has been sent.", done=False)
+    emails = dict(ctx.rf.get("emails") or {})
+    emails[kind] = ctx.now().isoformat() if ctx.now else "sent"
+    ctx.rf.set("emails", emails)
+    return f"{kind} email sent"
+
+
+def mailto_url(to, subject, body):
+    """The mailto URL; the body is dropped when the URL would exceed MAILTO_LIMIT characters."""
+    q = urllib.parse.quote
+    full = f"mailto:{to}?subject={q(subject)}&body={q(body)}"
+    return full if len(full) <= MAILTO_LIMIT else f"mailto:{to}?subject={q(subject)}"
+
+
+def _optional(d):
+    parts = []
+    if d.get("highlights"):
+        parts.append(f"Highlights:\n{d['highlights']}")
+    if d.get("knownIssues"):
+        parts.append(f"Known issues:\n{d['knownIssues']}")
+    return "\n\n".join(parts)
+
+
+def vote(d, f):
+    v, rel = d["version"], f"juneau-{d['version']}-RC{d['rc']}"
+    opt = _optional(d)
+    body = f"""I am pleased to be calling this vote for the source release of Apache Juneau {v} RC{d['rc']}.
+{chr(10) + opt + chr(10) if opt else ''}
+The binaries are available at:
+
+{inventory.DIST_DEV}/binaries/{rel}/
+
+The release candidate to be voted over is available at:
+{inventory.DIST_DEV}/source/{rel}/
+
+SHA-512 checksums:
+
+apache-juneau-{v}-src.zip: 
+{f['src_sha']}
+
+apache-juneau-{v}-bin.zip: 
+{f['bin_sha']}
+
+Build the release candidate using:
+
+mvn clean install
+
+
+The release candidate is signed with a GPG key available at:
+{inventory.DIST_RELEASE}/KEYS
+
+A staged Maven repository is available for review at:
+{inventory.NEXUS_URL}/content/repositories/{f['repo']}/
+
+The Git commit for this release is...
+https://gitbox.apache.org/repos/asf?p=juneau.git;a=commit;h={f['commit']}
+
+Please vote on releasing this package as:
+Apache Juneau {v}
+
+This vote will be open until {f['deadline']} 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.
+"""
+    return inventory.DEV_LIST, f"[VOTE] Release Apache Juneau {v} RC{d['rc']}", body
+
+
+def result(d, outcome, tally):
+    v, rc = d["version"], d["rc"]
+    ack = f"\n\n{d['acknowledgements']}" if d.get("acknowledgements") else ""
+    body = (f"The vote to release Apache Juneau {v} RC{rc} has {outcome}.\n\n{tally}{ack}\n\n"
+            "Thanks to everyone who voted.\n")
+    return inventory.DEV_LIST, f"[RESULT][VOTE] Release Apache Juneau {v} RC{rc}", body
+
+
+def announce(d, github_url):
+    v = d["version"]
+    parts = [f"The Apache Juneau team is pleased to announce the release of Apache Juneau {v}.", d["summary"]]
+    if d.get("highlights"):
+        parts.append(f"Highlights:\n{d['highlights']}")
+    if d.get("knownIssues"):
+        parts.append(f"Known issues:\n{d['knownIssues']}")
+    parts.append(f"Downloads: {inventory.DIST_RELEASE}/{v}/\nGitHub Release: {github_url or 'n/a'}\n"
+                 "Release notes: https://juneau.apache.org/#release-notes")
+    if d.get("acknowledgements"):
+        parts.append(d["acknowledgements"])
+    parts.append("The Apache Juneau Team")
+    return inventory.ANNOUNCE_LIST, f"[ANNOUNCE] Apache Juneau {v} released", "\n\n".join(parts) + "\n"
diff --git a/scripts/jrm_release/gpgkeys.py b/scripts/jrm_release/gpgkeys.py
new file mode 100644
index 0000000000..3437d0cf15
--- /dev/null
+++ b/scripts/jrm_release/gpgkeys.py
@@ -0,0 +1,45 @@
+"""Read-only parsing of `gpg --list-secret-keys --with-colons`, to offer the user's own signing key as the default."""
+import re
+from dataclasses import dataclass, field
+
+
+@dataclass
+class Key:
+    keyid: str
+    fingerprint: str = ""
+    expires: int = 0            # epoch seconds, 0 = never
+    validity: str = ""
+    caps: str = ""
+    uids: list = field(default_factory=list)
+
+
+def parse(text):
+    """The secret (sec) keys in colon-listing text, each with its fingerprint and uids."""
+    keys, cur = [], None
+    for line in (text or "").splitlines():
+        f = line.split(":")
+        if f[0] == "sec" and len(f) > 11:
+            cur = Key(keyid=f[4], validity=f[1], caps=f[11], expires=int(f[6]) if f[6].isdigit() else 0)
+            keys.append(cur)
+        elif f[0] == "ssb":
+            cur = None
+        elif f[0] == "fpr" and cur is not None and not cur.fingerprint and len(f) > 9:
+            cur.fingerprint = f[9]
+        elif f[0] == "uid" and cur is not None and len(f) > 9:
+            cur.uids.append(f[9])
+    return keys
+
+
+def candidates(keys, email, now):
+    """Usable keys for this address: signing-capable, not revoked or expired, a uid carrying <email>."""
+    wanted = f"<{email}>".lower()
+    return [k for k in keys
+            if "s" in k.caps.lower() and k.validity not in ("r", "e", "i", "d")
+            and not (k.expires and k.expires <= now)
+            and any(wanted in u.lower() for u in k.uids)]
+
+
+def in_keys_file(text, key):
+    """True when KEYS text mentions the key's fingerprint or long id (spacing and case ignored)."""
+    flat = re.sub(r"\s+", "", text or "").upper()
+    return any(x and x.upper() in flat for x in (key.fingerprint, key.keyid))
diff --git a/scripts/jrm_release/inventory.py b/scripts/jrm_release/inventory.py
new file mode 100644
index 0000000000..ba90720365
--- /dev/null
+++ b/scripts/jrm_release/inventory.py
@@ -0,0 +1,78 @@
+"""Constants and the step table.  Endpoints mirror config/TargetProfile.prodDefault in the Java engine."""
+import re
+from collections import namedtuple
+
+GIT_URL = "https://gitbox.apache.org/repos/asf/juneau.git"
+GH_SLUG = "apache/juneau"
+NEXUS_URL = "https://repository.apache.org"
+NEXUS_PROFILE = "1a24bc7f954a70"
+DIST_DEV = "https://dist.apache.org/repos/dist/dev/juneau"
+DIST_RELEASE = "https://dist.apache.org/repos/dist/release/juneau"
+DIST_ROOT = "https://dist.apache.org/repos/dist"
+DOWNLOADS = "https://downloads.apache.org/juneau"
+DEV_LIST = "dev@juneau.apache.org"
+ANNOUNCE_LIST = "announce@apache.org"
+JUNEAU_CHECKOUT = "~/git/apache/juneau"
+MVN_CHECKSUMS = "-Daether.checksums.algorithms=MD5,SHA-1,SHA-512"
+
+Step = namedtuple("Step", "num phase name title label skippable")
+
+STEPS = [
+    Step(1, "rc", "prerequisites", "Prerequisites", "Checks", False),
+    Step(2, "rc", "prechecks", "Prechecks", "Checks", False),
+    Step(3, "rc", "compose-propose-email", "Propose email", "Email", False),
+    Step(4, "rc", "workspace", "Workspace", "Git", False),
+    Step(5, "rc", "build-verify", "Build verify", "Maven", False),
+    Step(6, "rc", "javadoc-verify", "Javadoc verify", "Maven", True),
+    Step(7, "rc", "test-workspace-verify", "Test-workspace verify", "Maven", True),
+    Step(8, "rc", "gpg-unlock", "Unlock GPG", "GPG", False),
+    Step(9, "rc", "deploy-snapshot", "Deploy snapshot", "Maven", False),
+    Step(10, "rc", "release-prepare", "Release prepare", "Maven", False),
+    Step(11, "rc", "release-diff-review", "Release diff review", "Git", False),
+    Step(12, "rc", "release-perform", "Release perform", "Maven", False),
+    Step(13, "rc", "nexus-close", "Close Nexus repository", "Nexus", False),
+    Step(14, "rc", "dist-stage", "Stage on dist/dev", "SVN", False),
+    Step(15, "rc", "dist-verify", "Verify dist/dev", "SVN", False),
+    Step(16, "rc", "compose-vote-email", "Vote email", "Email", False),
+    Step(17, "rc", "vote-gate", "Vote gate", "Vote", False),
+    Step(18, "vote", "vote-tally", "Tally vote", "Vote", False),
+    Step(19, "vote", "compose-result-email", "Result email", "Email", False),
+    Step(20, "promote", "nexus-release", "Release Nexus repository", "Nexus", False),
+    Step(21, "promote", "dist-promote", "Promote on dist/release", "SVN", False),
+    Step(22, "promote", "final-tag", "Final tag", "Git", False),
+    Step(23, "promote", "github-release", "GitHub release", "GitHub", False),
+    Step(24, "promote", "compose-announce-email", "Announce email", "Email", False),
+    Step(25, "promote", "followup-checklist", "Follow-up checklist", "Manual", False),
+    Step(26, "promote", "finalize", "Finalize", "Done", False),
+]
+NAMES = [s.name for s in STEPS]
+SKIPPABLE = [s.name for s in STEPS if s.skippable]
+PHASES = (("rc", "Release candidate"), ("vote", "Vote"), ("promote", "Promote"))
+
+
+def step(name):
+    return next(s for s in STEPS if s.name == name)
+
+
+def parse_version(v):
+    m = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(-SNAPSHOT)?", v or "")
+    return (int(m[1]), int(m[2]), int(m[3])) if m else None
+
+
+def next_dev_version(v):
+    """x.y.(z+1)-SNAPSHOT for a maintenance release (z > 0); None when z == 0 (the caller must ask)."""
+    t = parse_version(v)
+    return f"{t[0]}.{t[1]}.{t[2] + 1}-SNAPSHOT" if t and t[2] > 0 else None
+
+
+def prior_on_line(tags, v):
+    """Highest non-RC juneau-x.y.z tag on v's major.minor line strictly below v, as a version string, or None."""
+    t = parse_version(v)
+    best = None
+    for tag in tags:
+        m = re.fullmatch(r"juneau-(\d+)\.(\d+)\.(\d+)", tag.strip())
+        if m:
+            c = tuple(int(g) for g in m.groups())
+            if c[:2] == t[:2] and c < t and (best is None or c > best):
+                best = c
+    return ".".join(map(str, best)) if best else None
diff --git a/scripts/jrm_release/nexus.py b/scripts/jrm_release/nexus.py
new file mode 100644
index 0000000000..c684576858
--- /dev/null
+++ b/scripts/jrm_release/nexus.py
@@ -0,0 +1,98 @@
+"""Nexus 2 staging REST over urllib.  Every POST goes through Runner.gate, so --dry-run never mutates."""
+import base64
+import json
+import urllib.error
+import urllib.request
+
+from . import inventory
+
+BASE = inventory.NEXUS_URL + "/service/local/staging"
+
+
+class HttpError(RuntimeError):
+    def __init__(self, method, url, status):
+        super().__init__(f"{method} {url} -> {status}")
+        self.status = status
+
+
+def _default_opener(request):
+    try:
+        with urllib.request.urlopen(request, timeout=60) as r:   # noqa: S310 - fixed https endpoints
+            return r.status, r.read()
+    except urllib.error.HTTPError as e:
+        return e.code, e.read()
+
+
+class Nexus:
+    def __init__(self, runner, auth, opener=_default_opener):
+        self.runner, self.auth, self.opener = runner, auth, opener
+
+    def _request(self, method, url, body=None):
+        user, pw = self.auth()
+        req = urllib.request.Request(url, method=method, data=json.dumps(body).encode() if body is not None else None)
+        req.add_header("Accept", "application/json")
+        req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode())
+        if body is not None:
+            req.add_header("Content-Type", "application/json")
+        return self.opener(req)
+
+    def _get(self, url):
+        status, body = self._request("GET", url)
+        if status != 200:
+            raise HttpError("GET", url, status)
+        return json.loads(body or b"{}")
+
+    def repositories(self):
+        data = self._get(f"{BASE}/profile_repositories/{inventory.NEXUS_PROFILE}").get("data", [])
+        return [{"repositoryId": r["repositoryId"], "state": r.get("type") or r.get("state"),
+                 "created": r.get("created", ""), "transitioning": r.get("transitioning", False),
+                 "description": r.get("description", "")} for r in data]
+
+    def repo(self, rid):
+        r = self._get(f"{BASE}/repository/{rid}")
+        out = {"state": r.get("type") or r.get("state"), "transitioning": r.get("transitioning", False),
+               "notifications": []}
+        try:   # rule failures appear in the activity feed
+            for act in self._get(f"{BASE}/repository/{rid}/activity"):
+                for ev in act.get("events", []):
+                    if "failed" in ev.get("name", "").lower():
+                        out["notifications"] += [p.get("value", "") for p in ev.get("properties", [])
+                                                 if p.get("name") == "failureMessage"]
+        except Exception:   # noqa: BLE001 - activity is best effort
+            pass
+        return out
+
+    def _action(self, action, rid, desc):
+        if not self.runner.gate(f"POST {BASE}/bulk/{action} {rid}"):
+            return
+        status, _ = self._request("POST", f"{BASE}/bulk/{action}",
+                                  {"data": {"stagedRepositoryIds": [rid], "description": desc}})
+        if status not in (200, 201):
+            raise HttpError("POST", f"{BASE}/bulk/{action}", status)
+
+    def close(self, rid):
+        self._action("close", rid, "Close for Apache Juneau release candidate")
+
+    def drop(self, rid):
+        self._action("drop", rid, "Drop")
+
+    def promote(self, rid):
+        self._action("promote", rid, "Release Apache Juneau")
+
+    def walk(self, rid, path="org/apache/juneau/"):
+        """{file name: url} of every leaf under path in the staging repo (Nexus JSON directory listing)."""
+        base = f"{inventory.NEXUS_URL}/content/repositories/{rid}/{path}"
+        found = {}
+        for e in self._get(base).get("data", []):
+            if e.get("leaf"):
+                found[e["text"]] = e["resourceURI"]
+            else:
+                found.update(self.walk(rid, path + e["text"].rstrip("/") + "/"))
+        return found
+
+    def download(self, url, dest):
+        status, body = self._request("GET", url)
+        if status != 200:
+            raise HttpError("GET", url, status)
+        with open(dest, "wb") as f:
+            f.write(body)
diff --git a/scripts/jrm_release/prereqs.py b/scripts/jrm_release/prereqs.py
new file mode 100644
index 0000000000..0e903cc97e
--- /dev/null
+++ b/scripts/jrm_release/prereqs.py
@@ -0,0 +1,128 @@
+"""Prerequisites: everything release.py can verify before it asks a single question -- tools, versions and
+credential-free reachability.  Every failure is collected, so one run shows the whole list."""
+import os
+import re
+import shutil
+
+from . import inventory
+from .ctx import StepError
+
+# name -> (version argv or None, minimum (major, minor) or None, why the minimum, install hint)
+# Floors follow the commands release.py actually runs:
+#   mvn  3.6  release:prepare/perform and javadoc:aggregate run in batch mode (-B); the release plugin 3.x line needs
+#             Maven 3.6.3+ (checked to major.minor only).
+#   java 17   what Juneau itself builds on.
+#   svn/svnmucc 1.10  --password-from-stdin (added in 1.10).
+#   gpg  2.1  --pinentry-mode loopback with --passphrase-fd (both 2.1+).
+#   gh   2.28 `release create --verify-tag` plus `--latest=false`; both are in `gh release create --help` on
+#             2.89.0 (read locally).  The release in which each flag first appeared could not be looked up offline;
+#             2.28 is a conservative recollection (--latest and --verify-tag were both added in the 2.2x series).
+# A version that cannot be read is always a failure, never a silent pass.
+TOOLS = {
+    "git": (["git", "--version"], None, "", "brew install git"),
+    "mvn": (["mvn", "-version"], (3, 6), "", "brew install maven"),
+    "java": (["java", "-version"], (17, 0), "Juneau builds on Java 17", "brew install openjdk@17"),
+    "svn": (["svn", "--version", "--quiet"], (1, 10), "needs --password-from-stdin", "brew install subversion"),
+    "svnmucc": (["svnmucc", "--version", "--quiet"], (1, 10), "needs --password-from-stdin",
+                "brew install subversion"),
+    "gpg": (["gpg", "--version"], (2, 1), "needs --pinentry-mode loopback", "brew install gnupg"),
+    "gh": (["gh", "--version"], (2, 28), "needs release create --verify-tag and --latest=false", "brew install gh"),
+    "unzip": (None, None, "", "brew install unzip"),
+}
+TOOL_NAMES = tuple(TOOLS)
+
+
+def parse_version(text):
+    """(major, minor, dotted) from a tool's version output, or None.  A java banner's quoted version wins."""
+    m = re.search(r'version "(\d+)(?:\.(\d+))?[^"]*"', text or "") or re.search(r"(\d+)\.(\d+)(?:\.\d+)*", text or "")
+    if not m:
+        return None
+    return int(m[1]), int(m[2] or 0), m[0]
+
+
+def java_major(text):
+    """The major Java version from any `version "N...` line of `java -version` output (1.8.x is 8), or None.
+    Scans every line, because a wrapper may print its own banner first."""
+    m = re.search(r'version "(\d+)(?:\.(\d+))?', text or "")
+    if not m:
+        return None
+    return int(m[2] or 0) if m[1] == "1" else int(m[1])
+
+
+def java_binary():
+    """The java mvn will use: $JAVA_HOME/bin/java when it exists and is executable, else `java` on PATH."""
+    home = os.environ.get("JAVA_HOME")
+    if home:
+        exe = os.path.join(home, "bin", "java")
+        if os.path.isfile(exe) and os.access(exe, os.X_OK):
+            return exe
+    return "java"
+
+
+def _first_line(text):
+    return next((ln.strip() for ln in (text or "").splitlines() if ln.strip()), "(no output)")
+
+
+def _java_failure(ctx):
+    binary = java_binary()
+    if binary == "java" and shutil.which("java") is None:
+        return f"java: not on PATH ({TOOLS['java'][3]})"
+    out = ctx.runner.run([binary, "-version"], title="java version", mutating=False, merge_err=True).out
+    major = java_major(out)
+    if major is None:
+        return f"java: no version line in the output of {binary} -version; first line: {_first_line(out)}"
+    ctx.say(f"- java checked: {binary} (major {major})")
+    if major < TOOLS["java"][1][0]:
+        found = re.search(r'version "([^"]*)"', out)[1]
+        return f"java: 17+ required, found {found} at {binary}; {TOOLS['java'][2]} ({TOOLS['java'][3]})"
+    return None
+
+
+def _tool_failure(ctx, name):
+    """A failure string for one tool, or None.  A version that cannot be read is a failure too."""
+    argv, minimum, why, hint = TOOLS[name]
+    if name == "java":
+        return _java_failure(ctx)
+    if shutil.which(name) is None:
+        return f"{name}: not on PATH ({hint})"
+    if minimum is None or argv is None:
+        return None
+    out = ctx.runner.run(argv, title=f"{name} version", mutating=False).out
+    got = parse_version(out)
+    if got is None:
+        return f"{name}: cannot read a version from `{' '.join(argv)}`; output starts: {_first_line(out)}"
+    if (got[0], got[1]) < minimum:
+        ver = ".".join(map(str, minimum))
+        return f"{name}: {ver}+ required, found {got[2]}; {why} ({hint})"
+    return None
+
+
+def check(ctx):
+    """Run every answer-independent check, say each ✔/❌ line, and return the failure strings (empty = all good).
+    The result is kept on ctx.prereq so the Prerequisites step reports it without running anything again."""
+    failed = []
+
+    def report(name, failure):
+        ctx.say(f"{'❌' if failure else '✔'} {name}", "error" if failure else "info")
+        if failure:
+            failed.append(failure)
+
+    for name in TOOLS:
+        report(name, _tool_failure(ctx, name))
+    report("nexus reachable", None if ctx.http_ok(inventory.NEXUS_URL)
+           else f"nexus reachable: cannot reach {inventory.NEXUS_URL} (check your network or proxy)")
+    if shutil.which("svn") is None:
+        ctx.say("- dist/dev reachable: skipped (svn is missing)")
+    else:   # anonymous: no username, no password
+        report("dist/dev reachable", None if ctx.svn.ls(inventory.DIST_DEV + "/") is not None
+               else f"dist/dev reachable: cannot list {inventory.DIST_DEV} anonymously (check your network or proxy)")
+    ctx.prereq = failed
+    return failed
+
+
+def step(ctx):
+    """The Prerequisites step: the early result when release.py already ran it, else run it now."""
+    failed = ctx.prereq if ctx.prereq is not None else check(ctx)
+    if failed:
+        raise StepError("; ".join(failed))
+    return "all prerequisites met"
diff --git a/scripts/jrm_release/promote.py b/scripts/jrm_release/promote.py
new file mode 100644
index 0000000000..b1535f59a8
--- /dev/null
+++ b/scripts/jrm_release/promote.py
@@ -0,0 +1,121 @@
+"""Promote phase (spec 6.3 rows 19-25).  Irreversible from nexus-release on."""
+from . import emails, inventory
+from .ctx import StepError
+from .rc import dev_dir, git_env, wanted_files
+
+FUNCS = {}
+
+
+def step(fn):
+    FUNCS[fn.__name__.replace("_", "-")] = fn
+    return fn
+
+
+@step
+def nexus_release(ctx):
+    v, rid = ctx.version, ctx.rf.get("nexusRepoId")
+    if (ctx.rf.get("vote") or {}).get("outcome") != "passed":
+        raise StepError("the vote has not passed")
+    ok = ctx.prompt.confirm(f"Release Apache Juneau {v} to Maven Central?  This cannot be undone.  Type {v} to confirm.",
+                            key="confirm.nexus-release", expect=v, danger=True)
+    if ok is not True:
+        raise StepError("nexus-release not confirmed")
+    ctx.nexus.promote(rid)
+    ctx.rf.set("phase", "promote")
+    return f"{rid} released"
+
+
+@step
+def dist_promote(ctx):
+    v = ctx.version
+    tags = ctx.runner.run(["git", "-C", str(ctx.ws), "tag", "--list", "juneau-*"], title="Tags",
+                          mutating=False).out.split()
+    prior = inventory.prior_on_line(tags, v)
+    have = [e.rstrip("/") for e in ctx.svn.ls(inventory.DIST_RELEASE + "/") or []]
+    ops = []
+    if prior and prior in have:
+        ops.append(("rm", f"{inventory.DIST_RELEASE}/{prior}"))
+    ops.append(("mkdir", f"{inventory.DIST_RELEASE}/{v}"))
+    for new in wanted_files(v).values():
+        kind = "source" if "-src." in new else "binaries"
+        ops.append(("cp", "HEAD", f"{dev_dir(ctx, kind)}/{new}", f"{inventory.DIST_RELEASE}/{v}/{new}"))
+    ctx.svn.mucc(f"Release Apache Juneau {v}", ops, title="Promote on dist/release")
+    return f"promoted {v}" + (f", removed {prior}" if prior and prior in have else "")
+
+
+@step
+def final_tag(ctx):
+    tag, ws = f"juneau-{ctx.version}", str(ctx.ws)
+    present = ctx.runner.run(["git", "-C", ws, "ls-remote", "--tags", "origin", f"refs/tags/{tag}"],
+                             title="Tag check", mutating=False)
+    if present.out.strip():
+        return f"{tag} already exists; skipped"
+    ctx.runner.run(["git", "-C", ws, "tag", "-a", tag, ctx.rf.get("rcCommit") or "RC_COMMIT", "-m",
+                    f"Apache Juneau {ctx.version}"], title="Tag", mutating=True)
+    r = ctx.runner.run(["git", "-C", ws, "push", "origin", tag], title="Push tag", mutating=True,
+                       env=git_env(ctx), tty=False)
+    if not r.ok:
+        raise StepError("pushing the final tag failed")
+    return f"tagged {tag} on the voted RC commit"
+
+
+def _release_tags(ctx):
+    """Non-RC juneau-x.y.z tags in the workspace, as version tuples."""
+    out = ctx.runner.run(["git", "-C", str(ctx.ws), "tag", "--list", "juneau-*"], title="Tags", mutating=False).out
+    versions = []
+    for t in out.split():
+        parsed = inventory.parse_version(t.removeprefix("juneau-"))
+        if parsed and "-" not in t.removeprefix("juneau-"):
+            versions.append(parsed)
+    return versions
+
+
+@step
+def github_release(ctx):
+    v, tag = ctx.version, f"juneau-{ctx.version}"
+    argv = ["gh", "release", "create", tag, "--repo", inventory.GH_SLUG, "--verify-tag", "--title",
+            f"Apache Juneau {v}", "--notes", ctx.d["summary"]]
+    if any(t > inventory.parse_version(v) for t in _release_tags(ctx)):
+        argv.append("--latest=false")    # a patch on an older line must not become "Latest"
+    r = ctx.runner.run(argv, title="GitHub release", mutating=True, tool="github",
+                       env={"GH_TOKEN": ctx.cred("credential.github.token", secret=True)})
+    if not r.ok:
+        raise StepError("gh release create failed")
+    url = (r.out or "").strip().splitlines()[-1] if (r.out or "").strip() else None
+    ctx.rf.set("githubReleaseUrl", url)
+    return url or "release created"
+
+
+@step
+def compose_announce_email(ctx):
+    url = f"{inventory.DOWNLOADS}/{ctx.version}/"
+    polls = 0
+    while not ctx.http_ok(url):
+        if ctx.dry_run:
+            ctx.say(f"dry run: {url} is not published; the real run waits for it")
+            break
+        polls += 1
+        if polls % 12 == 0 and ctx.prompt.confirm(
+                "The release is not on downloads.apache.org yet.  Wait another 5 minutes?",
+                key="announce.wait") is not True:
+            raise ctx.pause("Announce later: re-run with --resume once downloads.apache.org has it.", done=False)
+        ctx.sleep(25)
+    to, sub, body = emails.announce(ctx.d, ctx.rf.get("githubReleaseUrl"))
+    return emails.send(ctx, "announce", to, sub, body, cc=(inventory.DEV_LIST,))
+
+
+@step
+def followup_checklist(ctx):
+    items = ["Update the release notes / website (juneau-docs).", "Update the Docker/Maven badges if any.",
+             "Close the release milestone / issues.", "Remove old releases from dist/release if policy requires."]
+    ctx.say("Follow-up checklist:\n- " + "\n- ".join(items))
+    if ctx.prompt.confirm("Have you done these follow-ups?", key="confirm.followup") is not True:
+        raise ctx.pause("Finish the follow-ups, then re-run with --resume.", done=False)
+    ctx.rf.set("followup", {"done": True})
+    return "follow-ups done"
+
+
+@step
+def finalize(ctx):
+    ctx.rf.update({"phase": "released", "last": "finalize"})
+    return f"Apache Juneau {ctx.version} released"
diff --git a/scripts/jrm_release/rc.py b/scripts/jrm_release/rc.py
new file mode 100644
index 0000000000..c45101a695
--- /dev/null
+++ b/scripts/jrm_release/rc.py
@@ -0,0 +1,359 @@
+"""RC phase steps (spec 6.3 rows 1-15).  Each step is fn(ctx) -> optional summary str; FUNCS maps name -> fn."""
+import hashlib
+import os
+from datetime import timedelta
+
+from . import details as details_mod
+from . import emails, gpgkeys, inventory, prereqs
+from .ctx import LABELS, StepError
+
+FUNCS = {}
+
+
+def step(fn=None, *, name=None):
+    def register(f):
+        FUNCS[name or f.__name__.replace("_", "-")] = f
+        return f
+    return register(fn) if fn else register
+
+
+@step
+def prerequisites(ctx):
+    return prereqs.step(ctx)
+
+
+def ensure_keyid(ctx):
+    """The GPG key id: offered from the keyring (read-only listing) when exactly one or several usable keys match
+    the Apache address, else the plain prompt."""
+    key = "credential.gpg.keyid"
+    if key in ctx.creds:
+        return ctx.creds[key]
+    email = ctx.creds.get("credential.apache.email") or (
+        f"{ctx.creds['credential.apache.availid']}@apache.org" if ctx.creds.get("credential.apache.availid") else None)
+    keys = []
+    if email:
+        r = ctx.runner.run(["gpg", "--list-secret-keys", "--with-colons", "--keyid-format", "long"],
+                           title="List GPG keys", mutating=False)
+        keys = gpgkeys.candidates(gpgkeys.parse(r.out), email, ctx.now().timestamp()) if r.ok else []
+    label = LABELS[key]
+    if len(keys) > 1:
+        options = [(k.keyid, f"{k.keyid}  {k.uids[0]}") for k in keys]
+        value = ctx.prompt.choice(label, options, default=keys[0].keyid, key=key)
+    else:
+        value = ctx.prompt.text(label, default=keys[0].keyid if keys else None, key=key)
+    if value is None:
+        raise KeyboardInterrupt
+    ctx.creds[key] = value
+    _warn_unpublished(ctx, next((k for k in keys if k.keyid == value), None))
+    return value
+
+
+def _warn_unpublished(ctx, chosen):
+    """Warn (never fail) when the chosen key is not in the published KEYS file.  Network, so never in a dry run."""
+    if chosen is None or ctx.dry_run:
+        return
+    text = ctx.http_text(inventory.DOWNLOADS + "/KEYS")
+    if text is not None and not gpgkeys.in_keys_file(text, chosen):
+        ctx.say(f"⚠ key {chosen.keyid} is not in {inventory.DOWNLOADS}/KEYS; add it before the vote", "warn")
+
+
+@step
+def prechecks(ctx):
+    cur = ctx.d
+    ctx.cred("credential.apache.availid")
+    ctx.cred("credential.apache.email")
+    ensure_keyid(ctx)
+    tag = ctx.rc_tag
+
+    def remote(ref):
+        return ctx.runner.run(["git", "ls-remote", inventory.GIT_URL, ref], title=f"ls-remote {ref}", mutating=False)
+
+    checks = [
+        ("branch exists", lambda c: (bool(remote(f"refs/heads/{cur['branch']}").out.strip()),
+                                     f"branch {cur['branch']} not on gitbox", "check the branch name")),
+        ("rc tag absent", lambda c: (not remote(f"refs/tags/{tag}").out.strip(),
+                                     f"tag {tag} already exists", "bump rc, or run --revert")),
+    ]
+    failed = []
+    for name, fn in checks:
+        ok, reason, hint = fn(ctx)
+        ctx.say(f"{'✔' if ok else '❌'} {name}", "info" if ok else "error")
+        if not ok:
+            failed.append(f"{name}: {reason} ({hint})")
+    if failed:
+        raise StepError("; ".join(failed))
+    return f"{len(checks)} checks passed"
+
+
+@step
+def compose_propose_email(ctx):
+    to, sub, body = emails.propose(ctx.d)
+    return emails.send(ctx, "propose", to, sub, body)
+
+
+@step
+def workspace(ctx):
+    d, ws = ctx.d, str(ctx.ws)
+    R = ctx.runner.run
+    if os.path.isdir(os.path.join(ws, ".git")):
+        R(["git", "-C", ws, "fetch", "--tags", "origin"], title="Fetch", mutating=True)
+        R(["git", "-C", ws, "checkout", d["branch"]], title="Checkout", mutating=True)
+        R(["git", "-C", ws, "pull", "--ff-only", "origin", d["branch"]], title="Pull", mutating=True)
+    else:
+        R(["git", "clone", "--branch", d["branch"], inventory.GIT_URL, ws], title="Clone", mutating=True)
+        R(["git", "-C", ws, "fetch", "--tags", "origin"], title="Fetch tags", mutating=True)
+    R(["git", "-C", ws, "config", "user.name", ctx.cred("credential.apache.availid")], title="user.name",
+      mutating=True)
+    R(["git", "-C", ws, "config", "user.email", ctx.cred("credential.apache.email")], title="user.email",
+      mutating=True)
+    return f"workspace on {d['branch']}"
+
+
+def _mvn(ctx, title, *args, tty=False, env=None):
+    r = ctx.runner.run(["mvn", "-B", *args], title=title, mutating=True, tool="maven", cwd=ctx.ws, tty=tty, env=env)
+    if not r.ok:
+        raise StepError(f"{title} failed (exit {r.code})")
+    return r
+
+
+@step
+def build_verify(ctx):
+    _mvn(ctx, "Build verify", "clean", "verify")
+    return "build verified"
+
+
+@step
+def javadoc_verify(ctx):
+    _mvn(ctx, "Javadoc", "javadoc:aggregate")
+    if ctx.prompt.confirm("Does the javadoc output look clean?", key="review.javadoc") is not True:
+        raise StepError("javadoc not accepted")
+    return "javadoc accepted"
+
+
+ZIPS = (("juneau-examples/juneau-examples-core", "juneau-examples-core"),
+        ("juneau-examples/juneau-examples-mcp", "juneau-examples-mcp"),
+        ("juneau-petstore/juneau-petstore-jetty", "juneau-petstore-jetty"),
+        ("juneau-sc/juneau-sc-server", "juneau-sc-server"))
+
+
+@step(name="test-workspace-verify")
+def verify_test_workspaces(ctx):
+    snap = f"{ctx.version}-SNAPSHOT"
+    made = 0
+    for module, art in ZIPS:
+        zip_path = ctx.ws / module / "target" / f"{art}-{snap}-bin.zip"
+        if not ctx.dry_run and not zip_path.exists():
+            ctx.say(f"skip {art}: {zip_path.name} not built on this branch")
+            continue
+        ctx.runner.run(["unzip", "-o", str(zip_path), "-d", str(ctx.ws / "target" / "workspace" / art)],
+                       title=f"Unzip {art}", mutating=True)
+        made += 1
+    if not made and not ctx.dry_run:
+        raise StepError("no workspace zips were built; run build-verify first")
+    if ctx.prompt.confirm("Do the workspaces import cleanly into Eclipse?", key="review.workspaces") is not True:
+        raise StepError("workspaces not accepted")
+    return f"{made} workspaces checked"
+
+
+@step
+def gpg_unlock(ctx):
+    pw = ctx.cred("credential.gpg.passphrase", secret=True)
+    key = ctx.cred("credential.gpg.keyid")
+    tmp = ctx.run_dir / "gpg-prime.txt"
+    if not ctx.dry_run:
+        tmp.parent.mkdir(parents=True, exist_ok=True)
+        tmp.write_text("prime")
+    r = ctx.runner.run(["gpg", "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "0",
+                        "--local-user", key, "--armor", "--detach-sign", "--output", str(tmp) + ".asc", str(tmp)],
+                       title="Prime gpg-agent", mutating=True, tool="gpg", input=(pw + "\n").encode())
+    if not r.ok:
+        raise StepError("gpg could not sign with that key and passphrase")
+    return "gpg agent primed"
+
+
+@step
+def deploy_snapshot(ctx):
+    _mvn(ctx, "Deploy snapshot", "deploy", inventory.MVN_CHECKSUMS)
+    return "snapshot deployed"
+
+
+def git_env(ctx):
+    """The environment for the one child that pushes: git asks the helper, which reads the secret from here."""
+    here = os.path.dirname(os.path.abspath(__file__))
+    return {"GIT_ASKPASS": os.path.join(here, "askpass.py"), "GIT_TERMINAL_PROMPT": "0",
+            "JRM_ASKPASS_USER": ctx.cred("credential.apache.availid"),
+            "JRM_ASKPASS_PASS": ctx.cred("credential.apache.password", secret=True)}
+
+
+@step
+def release_prepare(ctx):
+    pom = ctx.ws / "pom.xml"
+    if pom.exists():
+        cur = details_mod.pom_version(pom.read_text())
+        if not (cur or "").endswith("-SNAPSHOT"):
+            raise StepError(f"pom version is {cur}; release:prepare needs a -SNAPSHOT version")
+    tags = ctx.runner.run(["git", "-C", str(ctx.ws), "tag", "--list", ctx.rc_tag], title="Tag check", mutating=False)
+    if ctx.rc_tag in tags.out.split():
+        raise StepError(f"tag {ctx.rc_tag} already exists; bump rc or run --revert")
+    d = ctx.d
+    _mvn(ctx, "Release prepare", "release:prepare", "-DautoVersionSubmodules=true",
+         f"-DreleaseVersion={d['version']}", f"-Dtag={ctx.rc_tag}", f"-DdevelopmentVersion={d['developmentVersion']}",
+         env=git_env(ctx))
+    return f"prepared {ctx.rc_tag}"
+
+
+@step
+def release_diff_review(ctx):
+    r = ctx.runner.run(["git", "-C", str(ctx.ws), "diff", f"{ctx.rc_tag}^", ctx.rc_tag], title="Release diff",
+                       mutating=False)
+    ctx.say(r.out)
+    if ctx.prompt.confirm("Does the release diff look right?", key="review.diff") is not True:
+        raise StepError("release diff not accepted")
+    return "diff accepted"
+
+
+def _listing(ctx):
+    """The staging profile's repositories; a dry run has no password, so an unauthorised listing is empty."""
+    try:
+        return ctx.nexus.repositories()
+    except Exception as e:   # noqa: BLE001
+        if ctx.dry_run and getattr(e, "status", None) in (401, 403):
+            return []
+        raise
+
+
+@step
+def release_perform(ctx):
+    listing = _listing(ctx)
+    for repo in listing:
+        if repo.get("state") in ("open", "closed"):
+            q = f"Stale staging repository {repo['repositoryId']} ({repo.get('created', '?')}) is still there.  Drop it?"
+            if ctx.prompt.confirm(q, key="confirm.drop-stale") is True:
+                ctx.nexus.drop(repo["repositoryId"])
+    before = {r["repositoryId"] for r in listing}
+    _mvn(ctx, "Release perform", "release:perform", inventory.MVN_CHECKSUMS, env=git_env(ctx))
+    if ctx.dry_run:
+        ctx.rf.set("nexusRepoId", DRY_REPO)   # a stand-in, so the later rows can print what they would do
+        return "performed (dry run)"
+    new = [r for r in ctx.nexus.repositories() if r["repositoryId"] not in before]
+    if not new:
+        raise StepError("no new Nexus staging repository after release:perform")
+    repo = max(new, key=lambda r: r.get("created", ""))["repositoryId"]
+    ctx.rf.set("nexusRepoId", repo)
+    return f"staged in {repo}"
+
+
+DRY_REPO = "orgapachejuneau-DRYRUN"
+
+
+@step
+def nexus_close(ctx):
+    rid = ctx.rf.get("nexusRepoId")
+    if not rid:
+        raise StepError("no Nexus staging repository recorded; run release-perform first")
+    ctx.nexus.close(rid)
+    if ctx.dry_run:
+        return f"would close {rid}"
+    for _ in range(60):
+        r = ctx.nexus.repo(rid)
+        if r["state"] == "closed":
+            return f"{rid} closed"
+        if r.get("notifications"):
+            raise StepError("close failed: " + "; ".join(r["notifications"]))
+        ctx.sleep(5)
+    raise StepError(f"{rid} still not closed after 5 minutes")
+
+
+def dev_dir(ctx, kind):
+    return f"{inventory.DIST_DEV}/{kind}/{ctx.rc_tag}"
+
+
+def renamed(v):
+    return {f"juneau-{v}-source-release.zip": f"apache-juneau-{v}-src.zip",
+            f"juneau-distrib-{v}-bin.zip": f"apache-juneau-{v}-bin.zip"}
+
+
+def wanted_files(v):
+    """{Nexus file name: dist file name} for the two archives and their .asc / .sha512."""
+    out = {}
+    for old, new in renamed(v).items():
+        for suffix in ("", ".asc", ".sha512"):
+            out[old + suffix] = new + suffix
+    return out
+
+
+@step
+def dist_stage(ctx):
+    v, rid = ctx.version, ctx.rf.get("nexusRepoId")
+    want = wanted_files(v)
+    out = ctx.run_dir / "dist"
+    if ctx.dry_run and rid == DRY_REPO:   # nothing was staged for real, so there is nothing to download
+        ctx.say("dry run: skipping the staging-repository listing and download")
+    else:
+        available = ctx.nexus.walk(rid)
+        missing = [n for n in want if n not in available]
+        if missing:
+            raise StepError("staging repository lacks: " + ", ".join(missing))
+        out.mkdir(parents=True, exist_ok=True)
+        for old, new in want.items():
+            ctx.nexus.download(available[old], out / new)       # explicit names; sha1/md5 never fetched
+    line = ".".join(v.split(".")[:2]) + "."
+    ops = []
+    for kind in ("source", "binaries"):
+        for entry in ctx.svn.ls(f"{inventory.DIST_DEV}/{kind}/") or []:
+            name = entry.rstrip("/")
+            if name.startswith(f"juneau-{line}") and not name.startswith("."):
+                ctx.say(f"remove old {kind}/{name}")
+                ops.append(("rm", f"{inventory.DIST_DEV}/{kind}/{name}"))
+    ops += [("mkdir", dev_dir(ctx, "source")), ("mkdir", dev_dir(ctx, "binaries"))]
+    for new in want.values():
+        kind = "source" if "-src." in new else "binaries"
+        ops.append(("put", str(out / new), f"{dev_dir(ctx, kind)}/{new}"))
+    ctx.svn.mucc(ctx.rc_tag, ops, title="Stage on dist/dev")
+    return f"staged {ctx.rc_tag}"
+
+
+def _digest_recorded(text):
+    """The hex digest in a .sha512 file, whether it holds just the digest or 'digest  name' or a path then lines."""
+    lines = text.splitlines()
+    return "".join(lines[1:]) if len(lines) > 1 else (lines[0].split()[0] if lines and lines[0].split() else "")
+
+
+@step
+def dist_verify(ctx):
+    out, v = ctx.run_dir / "dist", ctx.version
+    for kind, name in (("source", f"apache-juneau-{v}-src.zip"), ("binaries", f"apache-juneau-{v}-bin.zip")):
+        listing = [e.rstrip("/") for e in ctx.svn.ls(dev_dir(ctx, kind) + "/") or []]
+        for suffix in ("", ".asc", ".sha512"):
+            if (name + suffix) not in listing and not ctx.dry_run:
+                raise StepError(f"dist/dev {kind} lacks {name + suffix}")
+        local = out / name
+        if local.exists():
+            digest = hashlib.sha512(local.read_bytes()).hexdigest()
+            if digest != _digest_recorded((out / (name + ".sha512")).read_text()):
+                raise StepError(f"{name}: SHA-512 does not match its .sha512 file")
+            r = ctx.runner.run(["gpg", "--batch", "--verify", str(out / (name + ".asc")), str(local)],
+                               title=f"Verify {name} signature", mutating=False, tool="gpg")
+            if not r.ok:
+                raise StepError(f"{name}: signature does not verify")
+    return "dist/dev verified"
+
+
+@step
+def compose_vote_email(ctx):
+    v = ctx.version
+    out = ctx.run_dir / "dist"
+
+    def sha(name):
+        f = out / (name + ".sha512")
+        return (_digest_recorded(f.read_text()) if f.exists() else "") or "SHA512_NOT_FOUND"
+    r = ctx.runner.run(["git", "-C", str(ctx.ws), "rev-parse", f"{ctx.rc_tag}^{{commit}}"], title="RC commit",
+                       mutating=False)
+    commit = r.out.strip() or "COMMIT_HASH_NOT_FOUND"
+    deadline = ctx.now() + timedelta(hours=72)
+    ctx.rf.update({"rcCommit": commit, "voteDeadline": deadline.isoformat()})
+    facts = dict(src_sha=sha(f"apache-juneau-{v}-src.zip"), bin_sha=sha(f"apache-juneau-{v}-bin.zip"), commit=commit,
+                 repo=ctx.rf.get("nexusRepoId") or "REPO_NOT_FOUND",
+                 deadline=deadline.astimezone().strftime("%d-%b-%Y %-I:%M%p %Z"))
+    to, sub, body = emails.vote(ctx.d, facts)
+    return emails.send(ctx, "vote", to, sub, body)
diff --git a/scripts/jrm_release/revert.py b/scripts/jrm_release/revert.py
new file mode 100644
index 0000000000..4f5446f7cb
--- /dev/null
+++ b/scripts/jrm_release/revert.py
@@ -0,0 +1,87 @@
+"""--revert: undo the current release candidate.  Each row checks remote state first, so a rerun is harmless."""
+from .ctx import StepError
+from .rc import dev_dir, git_env
+
+
+def revert_check(ctx):
+    if ctx.rf.get("phase") in ("promote", "released"):
+        raise StepError("the release is public (phase promote/released); revert is refused")
+    rc = ctx.d["rc"]
+    if ctx.prompt.confirm(f"Undo RC{rc} of {ctx.version}?  Type RC{rc} to confirm.", key="confirm.revert",
+                          expect=f"RC{rc}", danger=True) is not True:
+        raise StepError("revert not confirmed")
+    return f"confirmed RC{rc}"
+
+
+def revert_nexus(ctx):
+    rid = ctx.rf.get("nexusRepoId")
+    if not rid:
+        return "no staging repository recorded"
+    state = ctx.nexus.repo(rid).get("state")
+    if state in ("open", "closed"):
+        ctx.nexus.drop(rid)
+        return f"dropped {rid}"
+    return f"{rid} already {state}"
+
+
+def revert_dist(ctx):
+    ops = [("rm", dev_dir(ctx, k)) for k in ("source", "binaries") if ctx.svn.ls(dev_dir(ctx, k) + "/") is not None]
+    if not ops:
+        return "nothing on dist/dev"
+    ctx.svn.mucc(f"Remove {ctx.rc_tag}", ops, title="Remove RC from dist/dev")
+    return f"removed {len(ops)} dirs"
+
+
+def revert_tag(ctx):
+    ws, tag = str(ctx.ws), ctx.rc_tag
+    R = ctx.runner.run
+    local = R(["git", "-C", ws, "tag", "--list", tag], title="Local tag", mutating=False).out.strip()
+    if local:
+        R(["git", "-C", ws, "tag", "-d", tag], title="Delete local tag", mutating=True)
+    remote = R(["git", "-C", ws, "ls-remote", "--tags", "origin", f"refs/tags/{tag}"], title="Remote tag",
+               mutating=False).out.strip()
+    if remote:
+        R(["git", "-C", ws, "push", "origin", f":refs/tags/{tag}"], title="Delete remote tag", mutating=True,
+          env=git_env(ctx))
+    return "tag removed" if (local or remote) else "tag already absent"
+
+
+def revert_versions(ctx):
+    ws, d = str(ctx.ws), ctx.d
+    ctx.runner.run(["mvn", "-B", "release:update-versions", "-DautoVersionSubmodules=true",
+                    f"-DdevelopmentVersion={d['version']}-SNAPSHOT"], title="Reset versions", mutating=True,
+                   tool="maven", cwd=ctx.ws)
+    dirty = ctx.runner.run(["git", "-C", ws, "status", "--porcelain"], title="Status", mutating=False).out.strip()
+    if not dirty:
+        return "versions already reset"
+    if ctx.prompt.confirm(f"Commit and push the reset to {d['version']}-SNAPSHOT on {d['branch']}?",
+                          key="confirm.revert-versions") is not True:
+        return "version reset left uncommitted"
+    ctx.runner.run(["git", "-C", ws, "commit", "-a", "-m", f"Reset versions to {d['version']}-SNAPSHOT"],
+                   title="Commit", mutating=True)
+    ctx.runner.run(["git", "-C", ws, "push", "origin", d["branch"]], title="Push", mutating=True, env=git_env(ctx))
+    return "versions reset and pushed"
+
+
+def revert_record(ctx):
+    d = dict(ctx.d)
+    hist = list(ctx.rf.get("rcHistory") or [])
+    hist.append({"rc": d["rc"], "nexusRepoId": ctx.rf.get("nexusRepoId"), "commit": ctx.rf.get("rcCommit")})
+    d["rc"] += 1
+    ctx.rf.update({"details": d, "rcHistory": hist, "last": None, "nexusRepoId": None, "rcCommit": None,
+                   "voteDeadline": None, "vote": None, "phase": "rc"})
+    return f"ready for RC{d['rc']}"
+
+
+# (row name, title, console label); the registry in release.py runs them in this order.
+ROWS = [("revert-check", "Confirm revert", "Revert"), ("revert-nexus", "Drop the Nexus staging repository", "Nexus"),
+        ("revert-dist", "Remove the RC from dist/dev", "SVN"), ("revert-tag", "Delete the RC tag", "Git"),
+        ("revert-versions", "Reset the pom versions", "Maven"), ("revert-record", "Record the revert", "Done")]
+FUNCS = {"revert-check": revert_check, "revert-nexus": revert_nexus, "revert-dist": revert_dist,
+         "revert-tag": revert_tag, "revert-versions": revert_versions, "revert-record": revert_record}
+
+
+def run_all(ctx):
+    """Every row after the confirmation, in order (the Registry runs them one by one in a real revert)."""
+    for name, _, _ in ROWS[1:]:
+        ctx.say(f"{name}: {FUNCS[name](ctx)}")
diff --git a/scripts/jrm_release/runfile.py b/scripts/jrm_release/runfile.py
new file mode 100644
index 0000000000..c6046df79d
--- /dev/null
+++ b/scripts/jrm_release/runfile.py
@@ -0,0 +1,100 @@
+"""<home>/runs/<version>/run.json (schema 1) and the per-attempt log directories."""
+import copy
+import json
+import os
+import tempfile
+from datetime import datetime
+from pathlib import Path
+
+SCHEMA = 1
+
+
+class RunFileError(Exception):
+    pass
+
+
+def stamp(now=None):
+    return (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
+
+
+def new_attempt_dir(home, now=None):
+    """<home>/attempts/<YYYYMMDD-HHMMSS>-<NNN>, created with os.mkdir; a taken number is skipped."""
+    base = Path(home) / "attempts"
+    base.mkdir(parents=True, exist_ok=True)
+    n = 1
+    while True:
+        d = base / f"{stamp(now)}-{n:03d}"
+        try:
+            os.mkdir(d)
+            return d
+        except FileExistsError:
+            n += 1
+
+
+class RunFile:
+    """The release checkpoint.  Same get/set/update/clear/last() surface as state.Checkpoint (which steps.Registry
+    drives), atomic writes, and an in-memory copy when dry_run (a dry run never writes run.json)."""
+
+    def __init__(self, path, dry_run=False):
+        self.path, self.dry_run = Path(path), dry_run
+        self.data = {"schema": SCHEMA}
+        if self.path.exists():
+            try:
+                self.data = json.loads(self.path.read_text())
+            except (OSError, ValueError) as e:
+                raise RunFileError(f"cannot read {self.path}: {e}.  Inspect or delete it; a release never "
+                                   f"resumes from a guess.") from e
+            if not isinstance(self.data, dict) or self.data.get("schema") != SCHEMA:
+                raise RunFileError(f"{self.path} is not a schema-{SCHEMA} run file")
+        if dry_run:
+            self.data = copy.deepcopy(self.data)
+
+    def exists(self):
+        return self.path.exists()
+
+    def get(self, key, default=None):
+        return self.data.get(key, default)
+
+    def last(self):
+        """The last completed step's name, or None."""
+        value = self.data.get("last")
+        return value if isinstance(value, str) else None
+
+    def set(self, key, value):
+        self.update({key: value})
+
+    def update(self, mapping):
+        for k, v in mapping.items():
+            if v is None:
+                self.data.pop(k, None)
+            else:
+                self.data[k] = v
+        self._save()
+
+    def clear(self):
+        self.data = {"schema": SCHEMA}
+        self._save()
+
+    def _save(self):
+        if self.dry_run:
+            return
+        self.path.parent.mkdir(parents=True, exist_ok=True)
+        fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix="run.json.")
+        with os.fdopen(fd, "w") as f:
+            json.dump(self.data, f, indent=2, sort_keys=True)
+        os.replace(tmp, self.path)
+
+    def reset(self):
+        """Start the file over (in memory only; the next update saves it)."""
+        self.data = {"schema": SCHEMA}
+
+    def attempt_start(self, d, command=None):
+        a = list(self.data.get("attempts", []))
+        a.append({"n": len(a) + 1, "dir": str(d), "command": command, "status": "running", "started": stamp()})
+        self.update({"attempts": a})
+
+    def attempt_end(self, status):
+        a = list(self.data.get("attempts", []))
+        if a:
+            a[-1] = {**a[-1], "status": status, "ended": stamp()}
+            self.update({"attempts": a})
diff --git a/scripts/jrm_release/runner.py b/scripts/jrm_release/runner.py
new file mode 100644
index 0000000000..fd042b0cdf
--- /dev/null
+++ b/scripts/jrm_release/runner.py
@@ -0,0 +1,57 @@
+"""The one place a command runs.  Under --dry-run a mutating command is printed, never executed."""
+import subprocess
+from collections import namedtuple
+
+Outcome = namedtuple("Outcome", "ok code out")
+MASK = "********"
+
+
+class Runner:
+    def __init__(self, run_mod, dry_run=False, secrets=lambda: ()):
+        self.run_mod, self.dry_run, self.secrets = run_mod, dry_run, secrets
+
+    def mask(self, text):
+        for s in self.secrets():
+            text = text.replace(s, MASK)
+        return text
+
+    def gate(self, describe, mutating=True):
+        """True when the caller should really do it.  Under --dry-run a mutation prints and returns False."""
+        if self.dry_run and mutating:
+            self.run_mod.say(self.mask(f"would run: {describe}"))
+            return False
+        return True
+
+    def run(self, cmd, *, title, mutating, tool="generic", cwd=None, env=None, input=None, tty=False, capture=None,
+            merge_err=False):
+        """Run argv.  capture defaults to read-only commands (their stdout is the answer); pass capture=True for a
+        mutating command whose output the caller needs.  merge_err appends stderr to the answer, for tools like
+        `java -version` that print there (the toolkit's plain mode keeps the two apart)."""
+        argv = [str(c) for c in cmd]
+        if not self.gate(" ".join(argv), mutating):
+            return Outcome(True, 0, "")
+        child_env = getattr(self.run_mod, "child_env", None)
+        if child_env is not None:   # the toolkit's: this process's environment minus juneau-run's own variables
+            env = {**child_env(), **(env or {})}
+        r = self.run_mod.cmd(argv, title=title, tool=tool, cwd=cwd, env=env, input=input, tty=tty,
+                             capture=(not mutating) if capture is None else capture)
+        out = getattr(r, "out", "") or ""
+        if merge_err:
+            out += getattr(r, "err", "") or ""
+        return Outcome(r.ok, r.code, out)
+
+    def quiet(self, cmd, *, title, cwd=None):
+        """Run a read-only argv whose output the caller wants but the log does not (a ~1,000-line pom).  Never
+        echoed, so it is not a row and never reaches full.log; honors the same dry-run rule (reads always run)."""
+        argv = [str(c) for c in cmd]
+        hook = getattr(self.run_mod, "quiet_cmd", None)
+        if hook is not None:   # tests' fake
+            r = hook(argv, title=title, cwd=cwd)
+            return Outcome(r.ok, r.code, getattr(r, "out", "") or "")
+        child_env = getattr(self.run_mod, "child_env", None)
+        try:
+            r = subprocess.run(argv, cwd=cwd, env=child_env() if child_env else None, capture_output=True, text=True,
+                               check=False)
+        except OSError as e:
+            return Outcome(False, 127, str(e))
+        return Outcome(r.returncode == 0, r.returncode, r.stdout)
diff --git a/scripts/jrm_release/svn.py b/scripts/jrm_release/svn.py
new file mode 100644
index 0000000000..e7b78e1a04
--- /dev/null
+++ b/scripts/jrm_release/svn.py
@@ -0,0 +1,26 @@
+"""svn / svnmucc through the Runner.  The password is stdin only; flags are --non-interactive --no-auth-cache."""
+from .ctx import StepError
+
+BASE = ["--non-interactive", "--no-auth-cache"]
+
+
+class Svn:
+    def __init__(self, runner, user, password):
+        self.runner, self.user, self.password = runner, user, password
+
+    def ls(self, url):
+        """Entry names (trailing / kept), or None when the url cannot be listed."""
+        r = self.runner.run(["svn", "ls", *BASE, url], title=f"svn ls {url.rstrip('/').rsplit('/', 1)[-1]}",
+                            mutating=False, tool="svn")
+        return [line for line in r.out.splitlines() if line.strip()] if r.ok else None
+
+    def mucc(self, message, ops, *, title="svnmucc commit"):
+        """One svnmucc commit.  ops are argv fragments such as ['rm', url] or ['put', local, url]."""
+        pw = self.password()
+        argv = ["svnmucc", *BASE, "--username", self.user(), "--password-from-stdin", "-m", message]
+        for op in ops:
+            argv += list(op)
+        r = self.runner.run(argv, title=title, mutating=True, tool="svn", input=(pw + "\n").encode())
+        if not r.ok:
+            raise StepError(f"{title} failed (exit {r.code})")
+        return r
diff --git a/scripts/jrm_release/vote.py b/scripts/jrm_release/vote.py
new file mode 100644
index 0000000000..ab3bfde6d0
--- /dev/null
+++ b/scripts/jrm_release/vote.py
@@ -0,0 +1,50 @@
+"""Vote phase: the gate that pauses the run, the tally, and the RESULT email."""
+from datetime import datetime
+
+from . import emails
+
+FUNCS = {}
+
+
+def step(fn):
+    FUNCS[fn.__name__.replace("_", "-")] = fn
+    return fn
+
+
+@step
+def vote_gate(ctx):
+    ctx.rf.set("phase", "vote")
+    raise ctx.pause("Vote is open.  When it closes, run: release.py --resume", done=True)
+
+
+@step
+def vote_tally(ctx):
+    deadline = ctx.rf.get("voteDeadline")
+    if deadline and ctx.now() < datetime.fromisoformat(deadline):
+        ok = ctx.prompt.confirm(f"The vote is open until {deadline}.  Tally it anyway?", key="vote.early")
+        if ok is not True:
+            raise ctx.pause("Vote still open.  Re-run with --resume after it closes.", done=False)
+    F = ctx.prompt.Field
+    got = ctx.prompt.form("Vote tally", [F("plus1", "Binding +1 votes", required=True, type="int", default="0"),
+                                         F("zero", "0 votes", type="int", default="0"),
+                                         F("minus1", "-1 votes", type="int", default="0"),
+                                         F("binding", "Binding voters", required=True)], key="vote.tally")
+    if got is None:
+        raise KeyboardInterrupt
+    passed = got["plus1"] >= 3 and got["plus1"] > got["minus1"]
+    ctx.rf.set("vote", {**got, "outcome": "passed" if passed else "failed"})
+    return "vote passed" if passed else "vote failed"
+
+
+@step
+def compose_result_email(ctx):
+    t = ctx.rf.get("vote") or {}
+    outcome = "passed" if t.get("outcome") == "passed" else "failed"
+    tally = (f"Binding +1: {t.get('plus1', 0)} ({t.get('binding', '')})\n0: {t.get('zero', 0)}\n"
+             f"-1: {t.get('minus1', 0)}")
+    to, sub, body = emails.result(ctx.d, outcome, tally)
+    msg = emails.send(ctx, "result", to, sub, body)
+    if outcome == "failed":
+        ctx.rf.set("phase", "vote-failed")
+        raise ctx.pause("The vote failed.  Undo this candidate with: release.py --revert", done=True)
+    return msg
diff --git a/scripts/release.py b/scripts/release.py
new file mode 100644
index 0000000000..5573de8f04
--- /dev/null
+++ b/scripts/release.py
@@ -0,0 +1,366 @@
+#!/usr/bin/env python3
+"""
+Apache Juneau release, from a terminal: release candidate, vote, promote.
+
+    release.py [--resume | --start-step NAME] [--skip-step NAME ...] [--list-steps] [--revert] [--dry-run]
+               [--console full|condensed|none] [--home DIR]
+
+Exit codes: 0 ok or paused, 1 failed, 2 usage, 130 cancelled.  State: $JUNEAU_RELEASE_HOME or
+~/.juneau-release-manager (runs/<version>/run.json, attempts/<stamp>-<NNN>/full.log).
+"""
+import argparse
+import os
+import sys
+import time
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+
+REPO_DIR = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_DIR / "scripts"))
+
+import bootstrap  # noqa: E402
+from jrm_release import inventory  # noqa: E402
+from jrm_release.ctx import Ctx  # noqa: E402
+from jrm_release.runfile import RunFile, RunFileError, new_attempt_dir  # noqa: E402
+
+TITLE = "🚀 juneau-release-manager release"
+FINISHED = ("released",)
+
+
+def default_home():
+    return Path(os.environ.get("JUNEAU_RELEASE_HOME") or Path.home() / ".juneau-release-manager")
+
+
+def build_parser():
+    p = argparse.ArgumentParser(prog="release.py", description=__doc__.strip().splitlines()[0])
+    g = p.add_mutually_exclusive_group()
+    g.add_argument("--resume", action="store_true", help="continue the active run after its last finished step")
+    g.add_argument("--start-step", choices=inventory.NAMES, metavar="NAME",
+                   help="start (or restart) at this step; one of: " + ", ".join(inventory.NAMES))
+    p.add_argument("--skip-step", action="append", default=[], metavar="NAME",
+                   help="skip a step; only: " + ", ".join(inventory.SKIPPABLE))
+    p.add_argument("--list-steps", action="store_true", help="print the steps by phase, with finished ones marked")
+    p.add_argument("--version", help="x.y.z: with --list-steps, which run's marks to show; on a new release, prefill the details")
+    p.add_argument("--revert", action="store_true", help="undo the current release candidate")
+    p.add_argument("--dry-run", action="store_true", help="run read-only commands; print every mutation")
+    p.add_argument("--console", choices=("full", "condensed", "none"))
+    p.add_argument("--home", type=Path, default=None, help="state directory (default: $JUNEAU_RELEASE_HOME)")
+    p.add_argument("--attempt-dir", type=Path, help=argparse.SUPPRESS)
+    return p
+
+
+def parse(argv):
+    p = build_parser()
+    args = p.parse_args(argv)
+    bad = [s for s in args.skip_step if s not in inventory.SKIPPABLE]
+    if bad:
+        p.error(f"--skip-step {bad[0]} is not skippable; skippable steps: {', '.join(inventory.SKIPPABLE)}")
+    if args.revert and (args.resume or args.start_step or args.skip_step):
+        p.error("--revert cannot be combined with --resume, --start-step or --skip-step")
+    if args.version and not inventory.parse_version(args.version):
+        p.error(f"--version {args.version} must look like x.y.z")
+    args.home = args.home or default_home()
+    return args
+
+
+def list_steps(data, out=print):
+    last = data.get("last")
+    done = set()
+    if last in inventory.NAMES:
+        done = set(inventory.NAMES[:inventory.NAMES.index(last) + 1])
+    if data.get("phase") == "released":
+        done = set(inventory.NAMES)
+    for phase, title in inventory.PHASES:
+        out(title)
+        for s in (s for s in inventory.STEPS if s.phase == phase):
+            mark = "✔" if s.name in done else " "
+            skip = "  (skippable)" if s.skippable else ""
+            out(f"  {mark} {s.num:>2}. {s.name:<24} {s.title}{skip}")
+
+
+def find_run(home, version=None, active_only=False):
+    """(run.json path, data) of the named version's run, else the newest run (the newest active one when
+    active_only), else (None, {}).  A corrupt file raises RunFileError."""
+    runs = home / "runs"
+    if version:
+        paths = [runs / version / "run.json"]
+    else:
+        paths = sorted(runs.glob("*/run.json"), key=lambda p: p.stat().st_mtime, reverse=True) if runs.is_dir() else []
+    for path in paths:
+        if not path.exists():
+            continue
+        data = RunFile(path).data
+        if active_only and (not data.get("details") or data.get("phase") in FINISHED):
+            continue
+        return path, data
+    return None, {}
+
+
+def active_run_data(home, version=None):
+    return find_run(home, version)[1]
+
+
+def guard_start(name, data):
+    """A refusal message for --start-step NAME against run data, or None.  The irreversible line is nexus-release."""
+    idx, last = inventory.NAMES.index(name), data.get("last")
+    released = inventory.NAMES.index("nexus-release")
+    public = last in inventory.NAMES and inventory.NAMES.index(last) >= released
+    if public and idx < released:
+        return f"cannot start at {name}: nexus-release already ran (the release is public).  Continue from dist-promote."
+    step = inventory.step(name)
+    if step.phase == "promote" and not public and (data.get("vote") or {}).get("outcome") != "passed":
+        return f"cannot start at {name}: the vote has not passed (run vote-tally)."
+    if step.phase == "vote" and not (last in inventory.NAMES and
+                                      inventory.NAMES.index(last) >= inventory.NAMES.index("vote-gate")):
+        return f"cannot start at {name}: vote-gate has not been passed yet."
+    return None
+
+
+def plan_for(home, resume=False, start=None):
+    """The step names a run would execute, from the active run's state."""
+    data = find_run(home, active_only=True)[1]
+    last = data.get("last")
+    if start:
+        return inventory.NAMES[inventory.NAMES.index(start):]
+    i = inventory.NAMES.index(last) + 1 if resume and last in inventory.NAMES else 0
+    return inventory.NAMES[i:]
+
+
+def lock_busy_message(pid, attempt):
+    return f"Another release.py is running this release (pid {pid}, attempt #{attempt})."
+
+
+def precondition(args, active):
+    """(message, exit code) when the arguments cannot run against the active run's state, else None."""
+    d = active.get("details") or {}
+    if (args.revert or args.resume or args.start_step) and not active:
+        what = "revert" if args.revert else "resume" if args.resume else "restart"
+        return f"No active release to {what}; start one with: release.py", 2
+    if args.start_step:
+        problem = guard_start(args.start_step, active)
+        if problem:
+            return problem, 2
+    if not (args.resume or args.revert or args.start_step) and active:
+        rc = d.get("rc")
+        return (f"Run {d.get('version')} RC{rc} is in progress (phase {str(active.get('phase')).capitalize()}, "
+                f"last {active.get('last')}).  Use --resume, or --revert to drop RC{rc}."), 2
+    return None
+
+
+@dataclass
+class Deps:
+    """The toolkit and collaborators main() uses; tests swap in fakes."""
+    run: object
+    steps: object
+    prompt: object
+    lock: object
+    nexus: object = None        # a jrm_release.nexus.Nexus stand-in; None = the real client
+    nexus_opener: object = None # the real client's urllib opener (tests); None = urlopen
+    http_ok: object = None      # fn(url) -> bool; None = a real GET
+    http_text: object = None    # fn(url) -> str | None (None = unreachable); None = a real GET
+    sleep: object = None
+
+    @classmethod
+    def real(cls, args):
+        cache = REPO_DIR / "target" / "juneau-run"
+        bootstrap.load(REPO_DIR / "pom.xml", run=False, cache_dir=cache)
+        from juneau_scripts import lock, prompt, run, steps
+        run.export_console(args.console)   # before juneau_run loads: the flag beats JUNEAU_RUN_CONSOLE
+        bootstrap.load(REPO_DIR / "pom.xml", run=True, cache_dir=cache)
+        return cls(run, steps, prompt, lock)
+
+
+def _http_ok(url):
+    try:
+        with urllib.request.urlopen(url, timeout=20) as r:   # noqa: S310 - fixed https endpoints
+            return r.status == 200
+    except OSError:
+        return False
+
+
+def _http_text(url):
+    try:
+        with urllib.request.urlopen(url, timeout=20) as r:   # noqa: S310 - fixed https endpoints
+            return r.read().decode("utf-8", "replace")
+    except OSError:
+        return None
+
+
+def build_ctx(args, deps, rf):
+    from jrm_release.nexus import Nexus
+    from jrm_release.runner import Runner
+    from jrm_release.svn import Svn
+    ctx = Ctx(args=args, home=args.home, run=deps.run, prompt=deps.prompt, runner=None, nexus=None, svn=None, rf=rf,
+              pause=deps.steps.Pause, http_ok=deps.http_ok or _http_ok, http_text=deps.http_text or _http_text, sleep=deps.sleep or time.sleep)
+    ctx.runner = Runner(deps.run, dry_run=args.dry_run, secrets=ctx.secrets)
+    user = lambda: ctx.cred("credential.apache.availid")  # noqa: E731
+    password = lambda: ctx.cred("credential.apache.password", secret=True)  # noqa: E731
+    ctx.svn = Svn(ctx.runner, user, password)
+    ctx.nexus = deps.nexus or Nexus(ctx.runner, lambda: (user(), password()),
+                                    **({"opener": deps.nexus_opener} if deps.nexus_opener else {}))
+    return ctx
+
+
+def not_implemented(name):
+    def fn(ctx):
+        from jrm_release.ctx import StepError
+        raise StepError(f"step {name} is not implemented yet")
+    return fn
+
+
+def impl_map():
+    funcs = {}
+    for modname in ("rc", "vote", "promote"):
+        try:
+            mod = __import__(f"jrm_release.{modname}", fromlist=["FUNCS"])
+        except ImportError:
+            continue
+        funcs.update(mod.FUNCS)
+    return funcs
+
+
+def registry(deps, funcs):
+    reg = deps.steps.Registry()
+    reg._prog = "release.py"   # the resume hint's script name
+    for s in inventory.STEPS:
+        reg.step(s.name, s.title, label=s.label)(funcs.get(s.name) or not_implemented(s.name))
+    return reg
+
+
+def revert_registry(deps):
+    from jrm_release import revert
+    reg = deps.steps.Registry()
+    for name, title, label in revert.ROWS:
+        reg.step(name, title, label=label)(revert.FUNCS[name])
+    return reg
+
+
+def _command(args):
+    return "revert" if args.revert else "resume" if args.resume else "start-step" if args.start_step else "start"
+
+
+def _execute(args, deps, ctx, attempt):
+    """Run the registry (or the revert rows) under the open session; the exit code (0 or 1).  Ctrl-C propagates."""
+    rf = ctx.rf
+    if args.revert:
+        reg, checkpoint = revert_registry(deps), None
+        run_args = argparse.Namespace(list_steps=False, start_step=None, resume=False, skip_step=[])
+    else:
+        reg, checkpoint, run_args = registry(deps, impl_map()), rf, args
+    rf.attempt_start(attempt, _command(args))
+    try:
+        result = reg.run(run_args, ctx, checkpoint, show_unselected=not args.revert)
+    except KeyboardInterrupt:
+        rf.attempt_end("cancelled")
+        raise
+    except BaseException:
+        rf.attempt_end("failed")
+        raise
+    rf.attempt_end("paused" if result.status == "paused" else "ok" if result.ok else "failed")
+    return 0 if result.ok else (result.code or 1)
+
+
+def _locked(args, deps, ctx, attempt):
+    if args.dry_run:   # a dry run keeps its state in memory, so it cannot collide with a real one
+        return _execute(args, deps, ctx, attempt)
+    lock_path = ctx.run_dir / ".lock"
+    try:
+        with deps.lock.flock(lock_path, blocking=False):
+            lock_path.write_text(f"{os.getpid()} {len(ctx.rf.get('attempts', [])) + 1}")
+            return _execute(args, deps, ctx, attempt)
+    except deps.lock.LockBusy:
+        pid, att = (lock_path.read_text().split() + ["?", "?"])[:2]
+        print("❌ " + lock_busy_message(pid, att), file=sys.stderr)
+        return 1
+
+
+def _prerequisites(ctx):
+    """Run the answer-independent checks before any prompt; the exit code (1 with the whole list) or 0."""
+    from jrm_release import prereqs
+    failed = prereqs.check(ctx)
+    if not failed:
+        return 0
+    ctx.say(f"❌ {len(failed)} prerequisite(s) not met; fix them and re-run (nothing was asked or changed):", "error")
+    for f in failed:
+        ctx.say(f"   - {f}", "error")
+    return 1
+
+
+def _drive(args, deps, ctx, attempt, new):
+    from jrm_release import details
+    if not args.revert:   # before the branch, details and credential prompts, on a new run and on a resume alike
+        code = _prerequisites(ctx)
+        if code:
+            return code
+    if new:
+        try:
+            d = details.ask(ctx, version=args.version)
+        except KeyboardInterrupt:
+            raise
+        rf = RunFile(args.home / "runs" / d["version"] / "run.json", dry_run=args.dry_run)
+        rf.reset()
+        rf.update({"details": d, "phase": "rc"})
+        ctx.rf = rf
+    return _locked(args, deps, ctx, attempt)
+
+
+def run_release(args, deps, path, active):
+    """Attempt dir, ctx, session; returns the exit code."""
+    new = not (args.resume or args.revert or (args.start_step and active))
+    rf = RunFile(path, dry_run=args.dry_run) if not new else RunFile(args.home / "runs" / "_new" / "run.json",
+                                                                       dry_run=True)
+    ctx = build_ctx(args, deps, rf)
+    attempt = args.attempt_dir or (args.home / "attempts" / "dry-run" if args.dry_run else new_attempt_dir(args.home))
+    attempt.mkdir(parents=True, exist_ok=True)
+    (attempt / "full.log").touch()   # the banner's `tail -f` target; a dry run writes logs but never runs/
+    version = (active.get("details") or {}).get("version")
+    header = (f"drop RC{(active.get('details') or {}).get('rc')} of {version}" if args.revert
+              else version or "new release",)
+    plan = ([(n, t) for n, t, _ in __import__("jrm_release.revert", fromlist=["ROWS"]).ROWS] if args.revert
+            else [(s.name, s.title) for s in inventory.STEPS])
+    saved = os.environ.get("JUNEAU_RUN_FULL_LOG")
+    os.environ["JUNEAU_RUN_FULL_LOG"] = str(attempt / "full.log")
+    try:
+        with deps.run.session(TITLE, header=header, bell=True, silence_env="JRM_RELEASE_NO_BELL", mode="release",
+                              project="juneau-release-manager", repo=REPO_DIR, exit=False, plan=plan) as s:
+            code = _drive(args, deps, ctx, attempt, new)
+            if code:
+                raise SystemExit(code)
+    finally:
+        if saved is None:
+            os.environ.pop("JUNEAU_RUN_FULL_LOG", None)
+        else:
+            os.environ["JUNEAU_RUN_FULL_LOG"] = saved
+    return s.code
+
+
+def main(argv=None, deps=None):
+    try:
+        args = parse(argv)
+    except SystemExit as e:
+        return e.code if isinstance(e.code, int) else 2
+    try:
+        if args.list_steps:
+            list_steps(active_run_data(args.home, args.version))
+            return 0
+        path, active = find_run(args.home, active_only=True)
+        refusal = precondition(args, active)
+        if refusal:
+            print(f"❌ {refusal[0]}", file=sys.stderr)
+            return refusal[1]
+        if deps is None:
+            try:
+                deps = Deps.real(args)
+            except bootstrap.BootstrapError as e:
+                print(f"❌ ERROR: {e}", file=sys.stderr)
+                print("   (juneau-run 10.0.0-SNAPSHOT must be in ~/.m2: build Juneau first.)", file=sys.stderr)
+                return 1
+        return run_release(args, deps, path, active)
+    except RunFileError as e:
+        print(f"❌ {e}", file=sys.stderr)
+        return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/tests/README.md b/scripts/tests/README.md
index b9b8c7ef40..a02cc3aef3 100644
--- a/scripts/tests/README.md
+++ b/scripts/tests/README.md
@@ -41,3 +41,26 @@ python3 -m venv .venv
 
 Do **not** `pip install --user pytest` / `pip install pytest` on a Homebrew-managed `python3`
 — PEP 668 ("externally managed environment") will refuse it.
+
+## Release CLI tests
+
+`test_release_*.py` cover `scripts/release.py` and the `scripts/jrm_release/` package. They use fakes only
+(`conftest.py`: FakeRunner, FakePrompts, FakeNexus, FakeHttp), so no network, Nexus, SVN, GitHub, gpg key or
+real release is touched.
+
+| File | Covers |
+|---|---|
+| `test_release_runfile.py` | the `run.json` checkpoint (atomic write, corrupt-file refusal) |
+| `test_release_steps.py` | each rc, vote and promote step against the fakes |
+| `test_release_emails.py` | vote, result and announce emails, `mailto:` length handling |
+| `test_release_revert.py` | `--revert` checks and actions |
+| `test_release_cli.py` | arguments, guards, lock, resume, exit codes (needs `JUNEAU_RUN_PY`) |
+| `test_release_dryrun.py` | `--dry-run` runs read-only commands only and never writes `run.json` |
+| `test_release_secrets.py` | no secret on argv, in `run.json` or in logs; `askpass.py` |
+
+`test_release_cli.py` loads the shared toolkit through `scripts/bootstrap.py`, so point it at the Juneau checkout:
+
+```bash
+export JUNEAU_RUN_PY=$HOME/git/apache/juneau/juneau-run/src/main/python/juneau_run.py
+uv run --offline --with pytest pytest -q -p no:cacheprovider scripts/tests/
+```
diff --git a/scripts/tests/conftest.py b/scripts/tests/conftest.py
new file mode 100644
index 0000000000..e4f90c2afb
--- /dev/null
+++ b/scripts/tests/conftest.py
@@ -0,0 +1,198 @@
+import dataclasses
+import os
+import sys
+from argparse import Namespace
+from pathlib import Path
+
+import pytest
+
+SCRIPTS = Path(__file__).resolve().parent.parent
+if str(SCRIPTS) not in sys.path:
+    sys.path.append(str(SCRIPTS))
+
+from jrm_release.ctx import Ctx  # noqa: E402
+from jrm_release.runfile import RunFile  # noqa: E402
+from jrm_release.runner import Outcome, Runner  # noqa: E402
+from jrm_release.svn import Svn  # noqa: E402
+
+
+class PauseExc(Exception):
+    def __init__(self, message, *, done=True):
+        super().__init__(message)
+        self.message, self.done = message, done
+
+
+class FakeRunner:
+    """Stands in for juneau_scripts.run: records run.cmd calls, answers them from scripted rules."""
+
+    def __init__(self):
+        self.calls, self.rules, self.said = [], [], []
+
+    def script(self, *prefix, out="", ok=True):
+        self.rules.append((prefix, out, ok))
+
+    def cmd(self, cmd, *, title="", id=None, n=None, tool=None, cwd=None, env=None, capture=False, tty=False,
+            summarize=None, input=None):
+        rec = dict(argv=[str(c) for c in cmd], title=title, tool=tool, cwd=cwd, env=dict(env or {}), input=input,
+                   tty=tty, capture=capture)
+        self.calls.append(rec)
+        for prefix, out, ok in reversed(self.rules):
+            if tuple(rec["argv"][:len(prefix)]) == prefix:
+                return Outcome(ok, 0 if ok else 1, out)
+        return Outcome(True, 0, self.DEFAULT_VERSIONS.get(rec["argv"][-1], "") if len(rec["argv"]) <= 3 else "")
+
+    # What an unscripted `<tool> --version` prints, so prerequisites (which fail on unreadable output) pass by default.
+    DEFAULT_VERSIONS = {"-version": 'openjdk version "17.0.9" 2023-10-17\n', "--version": "9.9.9\n",
+                        "--quiet": "1.14.3\n"}
+
+    def quiet_cmd(self, cmd, *, title="", cwd=None):
+        """Runner.quiet's hook: answered like cmd, but flagged as kept out of the log."""
+        out = self.cmd(cmd, title=title, cwd=cwd, capture=True)
+        self.calls[-1]["quiet"] = True
+        return out
+
+    def say(self, text="", level="info"):
+        self.said.append(str(text))
+
+    def argvs(self):
+        return [c["argv"] for c in self.calls]
+
+    def ran(self, *prefix):
+        return [a for a in self.argvs() if tuple(a[:len(prefix)]) == prefix]
+
+
+@dataclasses.dataclass(frozen=True)
+class FakeField:
+    name: str
+    label: str
+    default: object = None
+    required: bool = False
+    secret: bool = False
+    multiline: bool = False
+    type: str = "text"
+
+
+class FakePrompts:
+    """answers: key -> value, or list of values (consumed in order; the last repeats).  An unexpected key raises."""
+    Field = FakeField
+
+    def __init__(self, answers=None):
+        self.answers, self.asked = dict(answers or {}), []
+
+    def _next(self, kind, key, msg, **kw):
+        self.asked.append((kind, key, msg, kw))
+        if key not in self.answers:
+            raise AssertionError(f"unexpected prompt {kind} key={key!r}: {msg}")
+        v = self.answers[key]
+        if isinstance(v, list):
+            return v.pop(0) if len(v) > 1 else v[0]
+        return v
+
+    def text(self, msg, *, default=None, key=None, title=None, error=None):
+        return self._next("text", key, msg, default=default)
+
+    def secret(self, msg, *, key=None, title=None):
+        return self._next("secret", key, msg)
+
+    def confirm(self, msg, *, default=False, key=None, title=None, expect=None, danger=False):
+        return self._next("confirm", key, msg, expect=expect, danger=danger)
+
+    def choice(self, msg, options, *, default=None, key=None, title=None):
+        return self._next("choice", key, msg, options=options)
+
+    def form(self, msg, fields, *, key=None, title=None):
+        got = self._next("form", key, msg, fields=fields)
+        if got is None:
+            return None
+        return {**{f.name: f.default for f in fields}, **got}
+
+    def email(self, *, to, subject, body, cc=(), key=None, title=None):
+        return self._next("email", key, subject, to=to, body=body, cc=cc)
+
+
+class FakeHttp:
+    """A urllib opener: opener(request) -> (status, bytes).  routes: (method, url-suffix) -> (status, body)."""
+
+    def __init__(self, routes=None):
+        self.routes, self.requests = dict(routes or {}), []
+
+    def __call__(self, request):
+        self.requests.append((request.get_method(), request.full_url, request.data, dict(request.headers)))
+        for (method, suffix), resp in self.routes.items():
+            if request.get_method() == method and request.full_url.endswith(suffix):
+                return resp
+        return 404, b""
+
+
+class FakeNexus:
+    def __init__(self):
+        self.repos, self.repos_after, self.states, self.posts, self.files = [], [], {}, [], {}
+        self._listed = 0
+
+    def repositories(self):
+        self._listed += 1
+        return list(self.repos) if self._listed == 1 else list(self.repos) + list(self.repos_after)
+
+    def repo(self, rid):
+        seq = self.states.get(rid, [{"state": "open"}])
+        return dict(seq.pop(0) if len(seq) > 1 else seq[0])
+
+    def close(self, rid):
+        self.posts.append(("close", rid))
+
+    def drop(self, rid):
+        self.posts.append(("drop", rid))
+        self.states[rid] = [{"state": "missing"}]
+
+    def promote(self, rid):
+        self.posts.append(("promote", rid))
+
+    def walk(self, rid):
+        return dict(self.files)
+
+    def download(self, url, dest):
+        Path(dest).write_bytes(b"data")
+
+
+@pytest.fixture
+def clean_env(monkeypatch):
+    """Scrub JUNEAU_RUN_* for the release tests (each opts in with pytestmark; test_push has its own scrub)."""
+    for k in list(os.environ):
+        if k.startswith("JUNEAU_RUN_") and k != "JUNEAU_RUN_PY":
+            monkeypatch.delenv(k)
+    monkeypatch.delenv("RUN_MARKERS", raising=False)
+    monkeypatch.setenv("JUNEAU_RUN_CONSOLE", "full")
+    monkeypatch.delenv("JUNEAU_RELEASE_HOME", raising=False)
+    sys.modules.pop("juneau_run", None)
+
+
+@pytest.fixture
+def home(tmp_path):
+    return tmp_path / "jrm-home"
+
+
+DETAILS = dict(version="9.2.1", developmentVersion="9.2.2-SNAPSHOT", rc=1, branch="juneau-9.2.1-branch",
+               summary="Bug fixes.", highlights="", knownIssues="", acknowledgements="")
+CREDS = {"credential.apache.availid": "jamesbognar", "credential.apache.email": "jamesbognar@apache.org",
+         "credential.apache.password": "s3cr3t-pw", "credential.gpg.keyid": "ABCD1234",
+         "credential.gpg.passphrase": "s3cr3t-gpg", "credential.github.token": "s3cr3t-gh"}
+
+
+@pytest.fixture
+def make_ctx(home):
+    def make(dry_run=False, answers=None, state=None, nexus=None, http_ok=None, with_details=True):
+        fake = FakeRunner()
+        runner = Runner(fake, dry_run=dry_run)
+        rf = RunFile(home / "runs" / "9.2.1" / "run.json", dry_run=dry_run)
+        if with_details:
+            rf.set("details", dict(DETAILS))
+        for k, v in (state or {}).items():
+            rf.set(k, v)
+        prompts = FakePrompts({**CREDS, **(answers or {})})
+        ctx = Ctx(args=Namespace(dry_run=dry_run), home=home, run=fake, prompt=prompts, runner=runner,
+                  nexus=nexus or FakeNexus(), svn=Svn(runner, lambda: "jamesbognar", lambda: "s3cr3t-pw"), rf=rf,
+                  pause=PauseExc, http_ok=http_ok or (lambda url: True))
+        ctx.creds.update(CREDS)
+        ctx.fake = fake
+        return ctx
+    return make
diff --git a/scripts/tests/test_release_cli.py b/scripts/tests/test_release_cli.py
new file mode 100644
index 0000000000..018a75fc01
--- /dev/null
+++ b/scripts/tests/test_release_cli.py
@@ -0,0 +1,284 @@
+import json
+import os
+import sys
+from pathlib import Path
+
+import pytest
+
+import release  # scripts/release.py, importable via conftest's sys.path
+
+pytestmark = [pytest.mark.usefixtures("clean_env"),
+              pytest.mark.skipif(not os.environ.get("JUNEAU_RUN_PY"), reason="needs JUNEAU_RUN_PY toolkit")]
+
+
+def run_main(argv, home, capsys, deps=None):
+    code = release.main(["--home", str(home), *argv], deps=deps)
+    cap = capsys.readouterr()
+    return code, cap.out + cap.err
+
+
+def seed(home, data):
+    p = home / "runs" / "9.2.1" / "run.json"
+    p.parent.mkdir(parents=True)
+    p.write_text(json.dumps({"schema": 1, **data}))
+
+
+def test_list_steps_phased_with_done_marks(home, capsys):
+    seed(home, {"phase": "rc", "last": "build-verify", "details": {"version": "9.2.1", "rc": 1}})
+    code, out = run_main(["--list-steps", "--version", "9.2.1"], home, capsys)
+    assert code == 0
+    assert "Release candidate" in out and "Vote" in out and "Promote" in out
+    assert "✔" in out.split("build-verify")[0].splitlines()[-1] + "build-verify"
+    assert out.index("prerequisites") < out.index("prechecks") < out.index("vote-tally") < out.index("finalize")
+
+
+def test_exit_codes(home, capsys):
+    assert run_main(["--list-steps"], home, capsys)[0] == 0
+    assert run_main(["--resume", "--start-step", "workspace"], home, capsys)[0] == 2   # mutually exclusive
+    assert run_main(["--start-step", "nonsense"], home, capsys)[0] == 2
+
+
+class RunProxy:
+    """The real juneau_scripts.run for session/say, with command execution replaced by a FakeRunner."""
+
+    child_env = None
+
+    def __init__(self, real, fake):
+        self._real, self.cmd, self.quiet_cmd = real, fake.cmd, fake.quiet_cmd
+
+    def __getattr__(self, name):
+        return getattr(self._real, name)
+
+
+def toolkit_deps(tmp_path, fake, prompts, **kw):
+    import bootstrap
+    bootstrap.load(release.REPO_DIR / "pom.xml", run=False, cache_dir=tmp_path / "tk")
+    from juneau_scripts import lock, run, steps
+    return release.Deps(RunProxy(run, fake), steps, prompts, lock, **kw)
+
+
+def new_run_setup(tmp_path, monkeypatch, answers):
+    from conftest import CREDS, FakePrompts, FakeNexus, FakeRunner
+    from jrm_release import prereqs
+    monkeypatch.setattr(prereqs.shutil, "which", lambda n: "/usr/bin/" + n)
+    fake, prompts = FakeRunner(), FakePrompts({**CREDS, **answers})
+    fake.script("git", "ls-remote", release.inventory.GIT_URL, "refs/heads/juneau-9.2.1-branch", out="abc\trefs/heads/x\n")
+    return fake, prompts, toolkit_deps(tmp_path, fake, prompts, nexus=FakeNexus(), http_ok=lambda u: True,
+                                       sleep=lambda s: None)
+
+
+def test_new_run_asks_details_then_prechecks(home, capsys, tmp_path, monkeypatch):
+    fake, prompts, deps = new_run_setup(tmp_path, monkeypatch, {
+        "release.branch": "juneau-9.2.1-branch", "email.propose": True,
+        "release.details": {"version": "9.2.1", "developmentVersion": "9.2.2-SNAPSHOT", "rc": 1, "summary": "x"}})
+    code, out = run_main(["--dry-run"], home, capsys, deps=deps)
+    keys = [a[1] for a in prompts.asked]
+    assert keys[:2] == ["release.branch", "release.details"]
+    assert code in (0, 1)                       # what follows depends on the steps implemented so far
+    assert "✔ rc tag absent" in out
+    assert not (home / "runs").exists()         # a dry run writes no run.json
+
+
+def test_new_run_checks_prerequisites_before_any_prompt(home, capsys, tmp_path, monkeypatch):
+    from jrm_release import prereqs
+    fake, prompts, deps = new_run_setup(tmp_path, monkeypatch, {})
+    monkeypatch.setattr(prereqs.shutil, "which", lambda n: None if n in ("svn", "svnmucc") else "/usr/bin/" + n)
+    code, out = run_main(["--dry-run"], home, capsys, deps=deps)
+    assert code == 1 and prompts.asked == []
+    assert "svn: not on PATH" in out and "svnmucc: not on PATH" in out and "brew install subversion" in out
+
+
+def test_resume_checks_prerequisites_before_any_prompt(home, capsys, tmp_path, monkeypatch):
+    from jrm_release import prereqs
+    seed(home, {"phase": "rc", "last": "workspace", "details": {"version": "9.2.1", "rc": 1, "branch": "b"}})
+    fake, prompts, deps = new_run_setup(tmp_path, monkeypatch, {})
+    monkeypatch.setattr(prereqs.shutil, "which", lambda n: None if n == "gh" else "/usr/bin/" + n)
+    code, out = run_main(["--resume"], home, capsys, deps=deps)
+    assert code == 1 and prompts.asked == [] and "gh: not on PATH" in out and "brew install gh" in out
+
+
+def test_start_step_nonexistent_prechecks_move_with_prerequisites_first():
+    assert release.inventory.NAMES[:3] == ["prerequisites", "prechecks", "compose-propose-email"]
+
+
+def test_refuses_new_run_when_active(home, capsys):
+    seed(home, {"phase": "rc", "last": "workspace", "details": {"version": "9.2.1", "rc": 1}})
+    code, out = run_main([], home, capsys)
+    assert code == 2 and "--resume" in out and "9.2.1" in out
+
+
+def test_resume_without_run_fails(home, capsys):
+    code, out = run_main(["--resume"], home, capsys)
+    assert code == 2 and "No active release" in out
+
+
+def test_resume_after_last(home, capsys):
+    seed(home, {"phase": "rc", "last": "workspace", "details": {"version": "9.2.1", "rc": 1, "branch": "b"}})
+    assert release.plan_for(home, resume=True)[0] == "build-verify"
+    assert release.plan_for(home, start="vote-tally")[0] == "vote-tally"
+
+
+def test_start_step_refuses_before_irreversible(home):
+    data = {"phase": "promote", "last": "nexus-release", "details": {"version": "9.2.1", "rc": 1}}
+    assert "nexus-release" in release.guard_start("build-verify", data)
+    assert release.guard_start("dist-promote", data) is None
+
+
+def test_start_step_refuses_promote_without_passed_vote(home):
+    data = {"phase": "vote", "last": "vote-gate", "details": {"version": "9.2.1", "rc": 1}}
+    assert "vote" in release.guard_start("nexus-release", data)
+    assert release.guard_start("nexus-release", {**data, "vote": {"outcome": "passed"}}) is None
+    assert "vote-gate" in release.guard_start("vote-tally", {"phase": "rc", "last": "dist-verify"})
+
+
+def test_start_step_refusal_exits_2(home, capsys):
+    seed(home, {"phase": "promote", "last": "nexus-release", "vote": {"outcome": "passed"},
+                "details": {"version": "9.2.1", "rc": 1}})
+    code, out = run_main(["--start-step", "build-verify"], home, capsys)
+    assert code == 2 and "nexus-release" in out
+
+
+def test_skip_step_only_skippable(home, capsys):
+    code, out = run_main(["--skip-step", "build-verify"], home, capsys)
+    assert code == 2 and "javadoc-verify" in out and "test-workspace-verify" in out
+
+
+def test_lock_busy_message(home):
+    assert release.lock_busy_message(pid=4242, attempt=3) == \
+        "Another release.py is running this release (pid 4242, attempt #3)."
+
+
+def test_second_process_is_refused_while_lock_held(home, capsys, tmp_path, monkeypatch):
+    import fcntl
+    from conftest import FakePrompts, FakeRunner
+    seed(home, {"phase": "rc", "last": "workspace", "details": {"version": "9.2.1", "rc": 1, "branch": "b"}})
+    lock_path = home / "runs" / "9.2.1" / ".lock"
+    lock_path.write_text("4242 3")
+    from jrm_release import prereqs
+    monkeypatch.setattr(prereqs.shutil, "which", lambda n: "/usr/bin/" + n)
+    fd = os.open(lock_path, os.O_RDWR)
+    fcntl.flock(fd, fcntl.LOCK_EX)
+    try:
+        deps = toolkit_deps(tmp_path, FakeRunner(), FakePrompts())
+        code, out = run_main(["--resume"], home, capsys, deps=deps)
+    finally:
+        os.close(fd)
+    assert code == 1 and "Another release.py is running this release (pid 4242, attempt #3)." in out
+
+
+def setup_deps(tmp_path, monkeypatch, answers, opener=None):
+    """(fake runner, prompts, nexus fake, deps).  With an opener the real Nexus client is used over it."""
+    from conftest import CREDS, FakeNexus, FakePrompts, FakeRunner
+    from jrm_release import prereqs
+    monkeypatch.setattr(prereqs.shutil, "which", lambda n: "/usr/bin/" + n)
+    fake, prompts, nexus = FakeRunner(), FakePrompts({**CREDS, **answers}), FakeNexus()
+    deps = toolkit_deps(tmp_path, fake, prompts, nexus=None if opener else nexus, nexus_opener=opener,
+                        http_ok=lambda u: True, sleep=lambda s: None)
+    return fake, prompts, nexus, deps
+
+
+def read_run(home):
+    return json.loads((home / "runs" / "9.2.1" / "run.json").read_text())
+
+
+ACTIVE = {"phase": "rc", "last": "nexus-close", "nexusRepoId": "r-1", "rcCommit": "abc123",
+          "details": {"version": "9.2.1", "developmentVersion": "9.2.2-SNAPSHOT", "rc": 1,
+                      "branch": "juneau-9.2.1-branch", "summary": "x"}}
+
+
+def test_revert_end_to_end(home, capsys, tmp_path, monkeypatch):
+    seed(home, ACTIVE)
+    fake, prompts, nexus, deps = setup_deps(tmp_path, monkeypatch, {"confirm.revert": True,
+                                                                    "confirm.revert-versions": False})
+    code, out = run_main(["--revert"], home, capsys, deps=deps)
+    assert code == 0, out
+    assert ("drop", "r-1") in nexus.posts
+    data = read_run(home)
+    assert data["details"]["rc"] == 2 and data["phase"] == "rc" and "last" not in data
+    assert data["attempts"][-1]["status"] == "ok" and data["attempts"][-1]["command"] == "revert"
+
+
+def test_revert_refused_in_promote_exits_1(home, capsys, tmp_path, monkeypatch):
+    seed(home, {**ACTIVE, "phase": "promote"})
+    fake, prompts, nexus, deps = setup_deps(tmp_path, monkeypatch, {})
+    code, out = run_main(["--revert"], home, capsys, deps=deps)
+    assert code == 1 and "public" in out and nexus.posts == []
+
+
+def test_cancel_exits_130(home, capsys, tmp_path, monkeypatch):
+    seed(home, ACTIVE)
+    fake, prompts, nexus, deps = setup_deps(tmp_path, monkeypatch, {"confirm.revert": None})
+    prompts.confirm = lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt())
+    code, out = run_main(["--revert"], home, capsys, deps=deps)
+    assert code == 130 and read_run(home)["attempts"][-1]["status"] == "cancelled"
+
+
+def test_resume_runs_to_vote_gate_and_pauses_with_exit_0(home, capsys, tmp_path, monkeypatch):
+    seed(home, {**ACTIVE, "last": "dist-verify"})
+    fake, prompts, nexus, deps = setup_deps(tmp_path, monkeypatch, {"email.vote": True})
+    code, out = run_main(["--resume"], home, capsys, deps=deps)
+    assert code == 0, out
+    data = read_run(home)
+    assert data["last"] == "vote-gate" and data["phase"] == "vote" and data["voteDeadline"]
+    assert data["attempts"][-1]["status"] == "paused"
+    assert "release.py --resume" in out
+    # the lock file exists but is no longer held: a second run is not refused
+    code2, out2 = run_main(["--resume"], home, capsys, deps=deps)
+    assert "Another release.py" not in out2
+
+
+def test_failed_step_exits_1_and_hints_resume(home, capsys, tmp_path, monkeypatch):
+    seed(home, {**ACTIVE, "last": "workspace", "nexusRepoId": None})
+    fake, prompts, nexus, deps = setup_deps(tmp_path, monkeypatch, {})
+    fake.script("mvn", "-B", ok=False)
+    code, out = run_main(["--resume"], home, capsys, deps=deps)
+    assert code == 1 and "Build verify failed" in out and "--start-step build-verify" in out
+    assert read_run(home)["last"] == "workspace" and read_run(home)["attempts"][-1]["status"] == "failed"
+
+
+def test_dry_run_goes_through_every_step_to_vote_gate(home, capsys, tmp_path, monkeypatch):
+    answers = {"release.branch": "juneau-9.2.1-branch", "email.propose": True, "review.javadoc": True,
+               "review.workspaces": True, "review.diff": True, "email.vote": True,
+               "release.details": {"version": "9.2.1", "developmentVersion": "9.2.2-SNAPSHOT", "rc": 1,
+                                   "summary": "Fixes."}}
+    from conftest import FakeHttp
+    http = FakeHttp({("GET", "/profile_repositories/1a24bc7f954a70"): (401, b"")})   # no password in a dry run
+    fake, prompts, nexus, deps = setup_deps(tmp_path, monkeypatch, answers, opener=http)
+    fake.script("git", "ls-remote", release.inventory.GIT_URL, "refs/heads/juneau-9.2.1-branch",
+                out="abc\trefs/heads/x\n")
+    code, out = run_main(["--dry-run"], home, capsys, deps=deps)
+    assert code == 0, out
+    assert not (home / "runs").exists()                       # nothing persisted
+    assert http.requests and all(r[0] == "GET" for r in http.requests)      # no Nexus POST
+    assert not fake.ran("svnmucc", "--non-interactive") and not fake.ran("mvn", "-B") and not fake.ran("gpg", "--batch")
+    for word in ("would run: mvn -B clean verify", "would run: git clone", "would run: svnmucc"):
+        assert word in out
+    assert "s3cr3t-pw" not in out and "s3cr3t-gpg" not in out
+    asked = [a[1] for a in prompts.asked]
+    assert "credential.apache.password" not in asked and "credential.gpg.passphrase" not in asked
+    assert "Vote is open" in out
+
+
+def test_version_flag_prefills_new_run(home, capsys, tmp_path, monkeypatch):
+    fake, prompts, deps = new_run_setup(tmp_path, monkeypatch, {
+        "release.branch": "juneau-9.2.1-branch", "email.propose": True, "release.details": {"rc": 1, "summary": "x"}})
+    fake.script("git", "-C", os.path.expanduser(release.inventory.JUNEAU_CHECKOUT), "for-each-ref",
+                out="origin/master\norigin/juneau-9.2.1-branch\n")
+    run_main(["--dry-run", "--version", "9.2.1"], home, capsys, deps=deps)
+    assert prompts.asked[0][3]["default"] == "juneau-9.2.1-branch"
+    f = {x.name: x.default for x in prompts.asked[1][3]["fields"]}
+    assert f["version"] == "9.2.1" and f["developmentVersion"] == "9.2.2-SNAPSHOT"
+
+
+def test_dry_run_creates_attempt_full_log_but_no_runs(home, capsys, tmp_path, monkeypatch):
+    fake, prompts, deps = new_run_setup(tmp_path, monkeypatch, {
+        "release.branch": "b", "email.propose": True,
+        "release.details": {"version": "9.2.1", "developmentVersion": "9.2.2-SNAPSHOT", "rc": 1, "summary": "x"}})
+    run_main(["--dry-run"], home, capsys, deps=deps)
+    assert (home / "attempts" / "dry-run" / "full.log").exists()
+    assert not (home / "runs").exists()
+
+
+def test_version_flag_must_look_like_x_y_z(home, capsys):
+    code, out = run_main(["--dry-run", "--version", "9.2"], home, capsys)
+    assert code == 2 and "x.y.z" in out
diff --git a/scripts/tests/test_release_dryrun.py b/scripts/tests/test_release_dryrun.py
new file mode 100644
index 0000000000..1e595ea2cb
--- /dev/null
+++ b/scripts/tests/test_release_dryrun.py
@@ -0,0 +1,66 @@
+import pytest
+
+pytestmark = pytest.mark.usefixtures("clean_env")
+
+
+def test_dry_run_runs_read_only_only(make_ctx):
+    ctx = make_ctx(dry_run=True)
+    ctx.runner.run(["git", "ls-remote", "origin"], title="r", mutating=False)
+    ctx.runner.run(["git", "push", "origin", "x"], title="w", mutating=True)
+    assert ctx.fake.argvs() == [["git", "ls-remote", "origin"]]
+
+
+def test_dry_run_prints_every_mutation(make_ctx):
+    ctx = make_ctx(dry_run=True)
+    r = ctx.runner.run(["mvn", "-B", "deploy"], title="w", mutating=True, tool="maven")
+    assert r.ok and r.code == 0
+    assert any(s == "would run: mvn -B deploy" for s in ctx.fake.said)
+
+
+def test_dry_run_never_writes_run_json(make_ctx, home):
+    ctx = make_ctx(dry_run=True)
+    ctx.rf.set("last", "workspace")
+    ctx.rf.update({"nexusRepoId": "r-1"})
+    assert not ctx.rf.path.exists()
+    assert ctx.rf.get("last") == "workspace"
+
+
+def test_dry_run_masks_secrets(make_ctx):
+    ctx = make_ctx(dry_run=True)
+    ctx.creds["credential.apache.password"] = "real-pw"          # as if a real run had cached it
+    ctx.runner.secrets = lambda: ["real-pw"]
+    ctx.runner.run(["svnmucc", "--password", "real-pw"], title="x", mutating=True)
+    assert all("real-pw" not in s for s in ctx.fake.said) and any("********" in s for s in ctx.fake.said)
+    asked = [a[1] for a in ctx.prompt.asked]
+    assert "credential.apache.password" not in asked and "credential.gpg.passphrase" not in asked
+
+
+def test_dry_run_does_not_ask_for_secrets(make_ctx):
+    from conftest import FakePrompts
+    ctx = make_ctx(dry_run=True)
+    ctx.creds.clear()
+    ctx.prompt = FakePrompts({"credential.apache.availid": "jb"})        # a secret prompt would raise
+    assert ctx.cred("credential.apache.password", secret=True) == "********"
+    assert ctx.cred("credential.apache.availid") == "jb"
+
+
+def test_dry_run_stops_at_vote_gate(make_ctx):
+    import pytest
+    from jrm_release import vote
+    ctx = make_ctx(dry_run=True)
+    with pytest.raises(ctx.pause) as e:
+        vote.vote_gate(ctx)
+    assert ctx.rf.get("phase") == "vote" and not ctx.rf.path.exists() and e.value.done is True
+
+
+def test_nexus_post_is_gated_by_dry_run(make_ctx):
+    from conftest import FakeHttp
+    from jrm_release.nexus import Nexus
+    http = FakeHttp({("POST", "/bulk/promote"): (201, b"")})
+    ctx = make_ctx(dry_run=True)
+    Nexus(ctx.runner, lambda: ("u", "p"), opener=http).promote("r-1")
+    assert http.requests == [] and any(s.startswith("would run: POST") for s in ctx.fake.said)
+    live = make_ctx()
+    Nexus(live.runner, lambda: ("u", "p"), opener=http).promote("r-1")
+    assert [r[0] for r in http.requests] == ["POST"]
+    assert http.requests[0][3]["Authorization"].startswith("Basic ")
diff --git a/scripts/tests/test_release_emails.py b/scripts/tests/test_release_emails.py
new file mode 100644
index 0000000000..e6f4794f76
--- /dev/null
+++ b/scripts/tests/test_release_emails.py
@@ -0,0 +1,69 @@
+import pytest
+
+from jrm_release import emails
+
+pytestmark = pytest.mark.usefixtures("clean_env")
+
+D = dict(version="9.2.1", rc=1, branch="b", summary="Bug fixes.", highlights="", knownIssues="",
+         acknowledgements="")
+FACTS = dict(src_sha="aaa", bin_sha="bbb", commit="abc123", repo="orgapachejuneau-1002",
+             deadline="22-Oct-2026 2:00PM EDT")
+
+
+def test_vote_body_matches_juneau_text():
+    to, sub, body = emails.vote(D, FACTS)
+    assert to == "dev@juneau.apache.org" and sub == "[VOTE] Release Apache Juneau 9.2.1 RC1"
+    for line in ["I am pleased to be calling this vote for the source release of Apache Juneau 9.2.1 RC1.",
+                 "https://dist.apache.org/repos/dist/dev/juneau/binaries/juneau-9.2.1-RC1/",
+                 "https://dist.apache.org/repos/dist/dev/juneau/source/juneau-9.2.1-RC1/",
+                 "apache-juneau-9.2.1-src.zip: \naaa", "apache-juneau-9.2.1-bin.zip: \nbbb",
+                 "mvn clean install", "https://dist.apache.org/repos/dist/release/juneau/KEYS",
+                 "https://repository.apache.org/content/repositories/orgapachejuneau-1002/",
+                 "https://gitbox.apache.org/repos/asf?p=juneau.git;a=commit;h=abc123",
+                 "This vote will be open until 22-Oct-2026 2:00PM EDT and passes if a majority of at least "
+                 "three +1 Apache Juneau PMC votes are cast.\n(needs to be at least 72 weekday hours)",
+                 "[ ] +1 Release this package", "[ ] -1 Do not release this package because...",
+                 "Anyone can participate in testing and voting, not just committers"]:
+        assert line in body, line
+
+
+def test_optional_sections_omitted():
+    _, _, plain = emails.vote(D, FACTS)
+    assert "Highlights" not in plain and "Known issues" not in plain
+    _, _, rich = emails.vote({**D, "highlights": "Faster.", "knownIssues": "None much."}, FACTS)
+    first_para_end = rich.index("RC1.") + 4
+    assert rich.index("Highlights") > first_para_end and rich.index("Known issues") > rich.index("Highlights")
+
+
+def test_mailto_drops_long_body():
+    assert emails.mailto_url("a@b", "s", "x" * 100).endswith("x" * 100)
+    long = emails.mailto_url("a@b", "s", "x" * 3000)
+    assert "body=" not in long and len(long) < 1800
+
+
+def test_not_yet_pauses_without_recording(make_ctx):
+    ctx = make_ctx(answers={"email.propose": False})
+    with pytest.raises(ctx.pause) as e:
+        emails.send(ctx, "propose", "dev@juneau.apache.org", "s", "b")
+    assert e.value.done is False and not ctx.rf.get("emails")
+
+
+def test_sent_is_recorded(make_ctx):
+    ctx = make_ctx(answers={"email.propose": True})
+    assert emails.send(ctx, "propose", "dev@juneau.apache.org", "s", "b") == "propose email sent"
+    assert "propose" in ctx.rf.get("emails")
+
+
+def test_cancelled_email_is_ctrl_c(make_ctx):
+    ctx = make_ctx(answers={"email.propose": None})
+    with pytest.raises(KeyboardInterrupt):
+        emails.send(ctx, "propose", "dev@juneau.apache.org", "s", "b")
+
+
+def test_result_and_announce_text():
+    to, sub, body = emails.result(D, "passed", "Binding +1: 3 (A, B, C)")
+    assert sub == "[RESULT][VOTE] Release Apache Juneau 9.2.1 RC1" and "has passed" in body
+    to, sub, body = emails.announce({**D, "acknowledgements": "Thanks all."}, "https://github.com/x/y")
+    assert to == "announce@apache.org" and sub == "[ANNOUNCE] Apache Juneau 9.2.1 released"
+    assert "https://downloads.apache.org/juneau/9.2.1/" in body or "dist/release/juneau/9.2.1/" in body
+    assert "https://github.com/x/y" in body and "Thanks all." in body
diff --git a/scripts/tests/test_release_prereqs.py b/scripts/tests/test_release_prereqs.py
new file mode 100644
index 0000000000..cafd48d2e1
--- /dev/null
+++ b/scripts/tests/test_release_prereqs.py
@@ -0,0 +1,341 @@
+import pytest
+
+from jrm_release import gpgkeys, inventory, prereqs, rc
+from jrm_release.ctx import StepError
+
+pytestmark = pytest.mark.usefixtures("clean_env")
+
+ALL = set(prereqs.TOOL_NAMES)
+
+
+def have(monkeypatch, *names):
+    monkeypatch.setattr(prereqs.shutil, "which", lambda n: "/usr/bin/" + n if n in names else None)
+
+
+def test_tool_names_cover_what_the_code_runs():
+    assert {"git", "mvn", "java", "svn", "svnmucc", "gpg", "gh", "unzip"} <= ALL
+
+
+def test_all_present_passes_and_runs_no_prompt(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("svn", "--version", out="1.14.3\n")
+    ctx.fake.script("svn", "ls", out="juneau-9.2.0/\n")
+    assert prereqs.check(ctx) == []
+    assert ctx.prompt.asked == []
+    assert ctx.fake.ran("svn", "ls", "--non-interactive", "--no-auth-cache", inventory.DIST_DEV + "/")
+
+
+def test_missing_tools_reported_all_at_once_with_hints(make_ctx, monkeypatch):
+    have(monkeypatch, "git", "mvn", "java", "gpg", "unzip")
+    ctx = make_ctx(with_details=False)
+    failed = prereqs.check(ctx)
+    text = "\n".join(failed)
+    assert "svn" in text and "svnmucc" in text and "gh" in text
+    assert "brew install subversion" in text and "brew install gh" in text
+    assert len([f for f in failed if f.startswith(("svn:", "svnmucc:", "gh:"))]) == 3
+    assert ctx.fake.ran("svn", "ls") == []        # no svn, so no anonymous listing to try
+
+
+def test_old_svn_fails_with_minimum(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("svn", "--version", out="1.9.7\n")
+    ctx.fake.script("svn", "ls", out="x/\n")
+    failed = prereqs.check(ctx)
+    assert len(failed) == 1 and failed[0].startswith("svn:") and "1.10" in failed[0] and "1.9.7" in failed[0]
+
+
+def test_unparseable_version_fails_naming_tool_and_output(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("svn", "--version", out="garbled nonsense\n")
+    ctx.fake.script("svn", "ls", out="x/\n")
+    failed = prereqs.check(ctx)
+    assert len(failed) == 1 and failed[0].startswith("svn:") and "garbled nonsense" in failed[0]
+
+
+@pytest.mark.parametrize("tool,argv", [("mvn", ("mvn", "-version")), ("gpg", ("gpg", "--version")),
+                                       ("gh", ("gh", "--version")), ("svnmucc", ("svnmucc", "--version"))])
+def test_unparseable_output_fails_for_every_versioned_tool(make_ctx, monkeypatch, tool, argv):
+    have(monkeypatch, *ALL)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script(*argv, out="no digits here\n")
+    ctx.fake.script("svn", "ls", out="x/\n")
+    failed = prereqs.check(ctx)
+    assert len(failed) == 1 and failed[0].startswith(tool + ":") and "no digits here" in failed[0]
+
+
+BANNER = "Some vendor banner line\nFind more information at example.invalid\n"
+
+
+def test_java_scans_past_a_banner_for_the_version_line(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    monkeypatch.delenv("JAVA_HOME", raising=False)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("java", "-version", out=BANNER + 'openjdk version "17.0.9" 2023-10-17\nOpenJDK Runtime\n')
+    ctx.fake.script("svn", "ls", out="x/\n")
+    assert prereqs.check(ctx) == []
+
+
+def test_java_old_behind_a_banner_fails(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    monkeypatch.delenv("JAVA_HOME", raising=False)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("java", "-version", out=BANNER + 'java version "1.8.0_392"\n')
+    ctx.fake.script("svn", "ls", out="x/\n")
+    failed = prereqs.check(ctx)
+    assert len(failed) == 1 and failed[0].startswith("java:") and "17" in failed[0] and "1.8.0_392" in failed[0]
+
+
+def test_java_without_a_version_line_fails_naming_binary_and_first_line(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    monkeypatch.delenv("JAVA_HOME", raising=False)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("java", "-version", out=BANNER)
+    ctx.fake.script("svn", "ls", out="x/\n")
+    failed = prereqs.check(ctx)
+    assert len(failed) == 1 and "java" in failed[0] and "Some vendor banner line" in failed[0]
+
+
+def test_java_home_binary_wins_and_is_reported(make_ctx, monkeypatch, tmp_path):
+    have(monkeypatch, *ALL)
+    jh = tmp_path / "jdk"
+    (jh / "bin").mkdir(parents=True)
+    exe = jh / "bin" / "java"
+    exe.write_text("")
+    exe.chmod(0o755)
+    monkeypatch.setenv("JAVA_HOME", str(jh))
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script(str(exe), "-version", out='openjdk version "21.0.1"\n')
+    ctx.fake.script("svn", "ls", out="x/\n")
+    assert prereqs.check(ctx) == []
+    assert ctx.fake.ran(str(exe), "-version") and not ctx.fake.ran("java", "-version")
+    assert any(str(exe) in s and "21" in s for s in ctx.fake.said)
+
+
+def test_java_home_without_a_binary_falls_back_to_path(make_ctx, monkeypatch, tmp_path):
+    have(monkeypatch, *ALL)
+    monkeypatch.setenv("JAVA_HOME", str(tmp_path / "missing"))
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("java", "-version", out='openjdk version "17.0.9"\n')
+    ctx.fake.script("svn", "ls", out="x/\n")
+    assert prereqs.check(ctx) == []
+    assert ctx.fake.ran("java", "-version")
+
+
+def test_parse_java_major():
+    assert prereqs.java_major('java version "1.8.0_392"') == 8
+    assert prereqs.java_major('openjdk version "17.0.9" 2023') == 17
+    assert prereqs.java_major('openjdk version "21"') == 21
+    assert prereqs.java_major("nothing") is None
+
+
+def test_old_java_fails(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    monkeypatch.delenv("JAVA_HOME", raising=False)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("java", "-version", out='openjdk version "11.0.2" 2019-01-15\n')
+    ctx.fake.script("svn", "ls", out="x/\n")
+    failed = prereqs.check(ctx)
+    assert len(failed) == 1 and failed[0].startswith("java:") and "17" in failed[0]
+
+
+def test_unreachable_nexus_and_dist_dev(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    ctx = make_ctx(with_details=False, http_ok=lambda url: False)
+    ctx.fake.script("svn", "ls", ok=False)
+    failed = prereqs.check(ctx)
+    assert any(f.startswith("nexus reachable") for f in failed)
+    assert any(f.startswith("dist/dev reachable") for f in failed)
+
+
+def test_nexus_probe_needs_no_credentials(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    seen = []
+    ctx = make_ctx(with_details=False, http_ok=lambda url: seen.append(url) or True)
+    ctx.fake.script("svn", "ls", out="x/\n")
+    ctx.nexus = None                  # the authenticated client must not be touched
+    assert prereqs.check(ctx) == []
+    assert seen == [inventory.NEXUS_URL]
+
+
+def test_runs_in_a_dry_run(make_ctx, monkeypatch):
+    have(monkeypatch, "git")
+    assert prereqs.check(make_ctx(dry_run=True, with_details=False))
+
+
+def test_step_raises_with_every_failure(make_ctx, monkeypatch):
+    have(monkeypatch, "git")
+    ctx = make_ctx(with_details=False)
+    with pytest.raises(StepError) as e:
+        rc.FUNCS["prerequisites"](ctx)
+    assert "svn" in str(e.value) and "gh" in str(e.value)
+
+
+def test_step_reuses_the_early_result(make_ctx, monkeypatch):
+    have(monkeypatch, *ALL)
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("svn", "ls", out="x/\n")
+    prereqs.check(ctx)
+    n = len(ctx.fake.calls)
+    assert "met" in rc.FUNCS["prerequisites"](ctx)
+    assert len(ctx.fake.calls) == n               # nothing ran twice
+
+
+def test_prechecks_keeps_only_answer_dependent_checks(make_ctx, monkeypatch):
+    have(monkeypatch)                              # no tools at all: prechecks must not care
+    ctx = make_ctx(http_ok=lambda url: False)
+    ctx.fake.script("git", "ls-remote", inventory.GIT_URL, "refs/heads/juneau-9.2.1-branch", out="abc\tx\n")
+    out = rc.FUNCS["prechecks"](ctx)
+    assert out == "2 checks passed"
+    assert ctx.fake.ran("svn") == []
+    said = "\n".join(ctx.fake.said)
+    assert "branch exists" in said and "rc tag absent" in said and "svn" not in said
+
+
+# ---- GPG key id prefill -------------------------------------------------------------------------------------
+
+def sec(keyid, fpr, uids, expires="", caps="scESC", validity="u"):
+    rows = [f"sec:{validity}:4096:1:{keyid}:1500000000:{expires}::u:::{caps}:::+:::23::0:",
+            f"fpr:::::::::{fpr}:"]
+    rows += [f"uid:{validity}::::1500000000::HASH{i}::{u}::::::::::0:" for i, u in enumerate(uids)]
+    rows += ["ssb:u:4096:1:SUBKEY0000000001:1500000000::::::e:::+:::23:"]
+    return "\n".join(rows) + "\n"
+
+
+MINE = sec("36292BD2BA7D3A86", "A" * 24 + "36292BD2BA7D3A86", ["James Bognar <ja...@apache.org>"])
+OTHER = sec("1111111111111111", "B" * 40, ["Someone <so...@example.com>"])
+NOW = 1_800_000_000
+
+
+def candidates(out, email="jamesbognar@apache.org"):
+    return gpgkeys.candidates(gpgkeys.parse(out), email, NOW)
+
+
+def test_one_match_gives_the_long_id():
+    assert [k.keyid for k in candidates(OTHER + MINE)] == ["36292BD2BA7D3A86"]
+
+
+def test_match_is_case_insensitive_on_email():
+    assert candidates(MINE, "JamesBognar@Apache.org")
+
+
+def test_no_match():
+    assert candidates(OTHER) == []
+
+
+def test_expired_key_is_skipped():
+    assert candidates(sec("2222222222222222", "C" * 40, ["J <ja...@apache.org>"], expires="1700000000")) == []
+
+
+def test_unexpired_dated_key_is_kept():
+    assert candidates(sec("2222222222222222", "C" * 40, ["J <ja...@apache.org>"], expires="1900000000"))
+
+
+def test_revoked_key_is_skipped():
+    assert candidates(sec("2222222222222222", "C" * 40, ["J <ja...@apache.org>"], validity="r")) == []
+
+
+def test_non_signing_key_is_skipped():
+    assert candidates(sec("2222222222222222", "C" * 40, ["J <ja...@apache.org>"], caps="e")) == []
+
+
+def test_several_matches():
+    two = sec("3333333333333333", "D" * 40, ["J <ja...@apache.org>"])
+    assert [k.keyid for k in candidates(MINE + two)] == ["36292BD2BA7D3A86", "3333333333333333"]
+
+
+def gpg_ctx(make_ctx, out, answers=None, **kw):
+    ctx = make_ctx(answers=answers, **kw)
+    del ctx.creds["credential.gpg.keyid"]
+    ctx.fake.script("gpg", "--list-secret-keys", out=out)
+    return ctx
+
+
+def test_keyid_prompt_defaults_to_the_match(make_ctx):
+    ctx = gpg_ctx(make_ctx, MINE, {"credential.gpg.keyid": "36292BD2BA7D3A86"})
+    assert rc.ensure_keyid(ctx) == "36292BD2BA7D3A86"
+    kind, key, _, kw = ctx.prompt.asked[0]
+    assert (kind, key, kw["default"]) == ("text", "credential.gpg.keyid", "36292BD2BA7D3A86")
+    assert ctx.fake.ran("gpg", "--list-secret-keys", "--with-colons")
+
+
+def test_keyid_choice_with_several(make_ctx):
+    two = sec("3333333333333333", "D" * 40, ["J <ja...@apache.org>"])
+    ctx = gpg_ctx(make_ctx, MINE + two, {"credential.gpg.keyid": "3333333333333333"})
+    assert rc.ensure_keyid(ctx) == "3333333333333333"
+    kind, _, _, kw = ctx.prompt.asked[0]
+    assert kind == "choice" and [o[0] for o in kw["options"]] == ["36292BD2BA7D3A86", "3333333333333333"]
+
+
+def test_keyid_plain_prompt_with_none(make_ctx):
+    ctx = gpg_ctx(make_ctx, OTHER, {"credential.gpg.keyid": "TYPED"})
+    assert rc.ensure_keyid(ctx) == "TYPED"
+    assert ctx.prompt.asked[0][0] == "text" and ctx.prompt.asked[0][3]["default"] is None
+
+
+def test_keyid_not_asked_twice(make_ctx):
+    ctx = make_ctx()
+    assert rc.ensure_keyid(ctx) == "ABCD1234" and ctx.prompt.asked == []
+
+
+def test_keyid_uses_availid_when_no_email_credential(make_ctx):
+    ctx = gpg_ctx(make_ctx, MINE, {"credential.gpg.keyid": "36292BD2BA7D3A86"})
+    del ctx.creds["credential.apache.email"]
+    ctx.creds["credential.apache.availid"] = "jamesbognar"
+    ctx.prompt.answers["credential.apache.email"] = "nobody@example.org"
+    rc.ensure_keyid(ctx)
+    assert ctx.prompt.asked[0][3]["default"] == "36292BD2BA7D3A86"
+
+
+def test_keyid_works_in_a_dry_run(make_ctx):
+    ctx = gpg_ctx(make_ctx, MINE, {"credential.gpg.keyid": "36292BD2BA7D3A86"}, dry_run=True)
+    rc.ensure_keyid(ctx)
+    assert ctx.fake.ran("gpg", "--list-secret-keys")
+
+
+def test_keys_file_warning_when_fingerprint_absent(make_ctx):
+    ctx = gpg_ctx(make_ctx, MINE, {"credential.gpg.keyid": "36292BD2BA7D3A86"})
+    ctx.http_text = lambda url: "pub 4096R/DEADBEEFDEADBEEF"
+    rc.ensure_keyid(ctx)
+    assert any("KEYS" in s and "36292BD2BA7D3A86" in s for s in ctx.fake.said)
+
+
+def test_keys_file_silent_when_listed_with_spaced_fingerprint(make_ctx):
+    ctx = gpg_ctx(make_ctx, MINE, {"credential.gpg.keyid": "36292BD2BA7D3A86"})
+    fpr = "A" * 24 + "36292BD2BA7D3A86"
+    ctx.http_text = lambda url: "Key fingerprint = " + " ".join(fpr[i:i + 4] for i in range(0, 40, 4))
+    rc.ensure_keyid(ctx)
+    assert not any("KEYS" in s for s in ctx.fake.said)
+
+
+def test_keys_file_skipped_in_a_dry_run(make_ctx):
+    ctx = gpg_ctx(make_ctx, MINE, {"credential.gpg.keyid": "36292BD2BA7D3A86"}, dry_run=True)
+    ctx.http_text = lambda url: pytest.fail("no network in a dry run")
+    rc.ensure_keyid(ctx)
+
+
+def test_keys_file_unreachable_is_silent(make_ctx):
+    ctx = gpg_ctx(make_ctx, MINE, {"credential.gpg.keyid": "36292BD2BA7D3A86"})
+    ctx.http_text = lambda url: None
+    rc.ensure_keyid(ctx)
+    assert not any("KEYS" in s for s in ctx.fake.said)
+
+
+# ---- quiet capture ------------------------------------------------------------------------------------------
+
+def test_branch_pom_is_read_quietly(make_ctx):
+    from jrm_release import details
+    ctx = make_ctx(with_details=False)
+    ctx.fake.script("git", "-C", details.checkout(), "show", out="<version>9.2.1-SNAPSHOT</version>")
+    details.pom_defaults(ctx, "x")
+    assert ctx.fake.calls[-1]["quiet"] is True
+
+
+def test_runner_merge_err_appends_stderr():
+    from types import SimpleNamespace
+    from jrm_release.runner import Runner
+    mod = SimpleNamespace(cmd=lambda argv, **kw: SimpleNamespace(ok=True, code=0, out="o", err="e"))
+    assert Runner(mod).run(["x"], title="t", mutating=False).out == "o"
+    assert Runner(mod).run(["x"], title="t", mutating=False, merge_err=True).out == "oe"
diff --git a/scripts/tests/test_release_revert.py b/scripts/tests/test_release_revert.py
new file mode 100644
index 0000000000..446f7265ae
--- /dev/null
+++ b/scripts/tests/test_release_revert.py
@@ -0,0 +1,82 @@
+import pytest
+
+from conftest import FakeNexus
+from jrm_release import revert
+from jrm_release.ctx import StepError
+
+pytestmark = pytest.mark.usefixtures("clean_env")
+
+RC = {"phase": "rc", "last": "nexus-close", "nexusRepoId": "r-1", "rcCommit": "abc123"}
+
+
+def test_revert_refused_in_promote(make_ctx):
+    for phase in ("promote", "released"):
+        ctx = make_ctx(state={**RC, "phase": phase})
+        with pytest.raises(StepError, match="public"):
+            revert.revert_check(ctx)
+
+
+def test_revert_typed_rc_confirm(make_ctx):
+    ctx = make_ctx(state=RC, answers={"confirm.revert": False})
+    with pytest.raises(StepError):
+        revert.revert_check(ctx)
+    assert ctx.prompt.asked[-1][3]["expect"] == "RC1" and ctx.prompt.asked[-1][3]["danger"] is True
+
+
+def test_revert_skips_absent_remote_state(make_ctx):
+    nx = FakeNexus()                    # no staging repo
+    ctx = make_ctx(nexus=nx, state={**RC, "nexusRepoId": None})
+    ctx.fake.script("svn", "ls", ok=False)                              # dist/dev dirs absent
+    ctx.fake.script("git", "-C", str(ctx.ws), "ls-remote", out="")      # tag absent
+    ctx.fake.script("git", "-C", str(ctx.ws), "status", out="")
+    revert.run_all(ctx)
+    assert nx.posts == [] and not ctx.fake.ran("svnmucc")
+    assert not [a for a in ctx.fake.argvs() if "push" in a]
+
+
+def test_revert_versions_commit_and_push_after_confirm(make_ctx):
+    ctx = make_ctx(state=RC, answers={"confirm.revert-versions": True})
+    ctx.fake.script("git", "-C", str(ctx.ws), "status", out=" M pom.xml\n")
+    revert.revert_versions(ctx)
+    argvs = ctx.fake.argvs()
+    assert any(a[:3] == ["mvn", "-B", "release:update-versions"] and "-DdevelopmentVersion=9.2.1-SNAPSHOT" in a
+               for a in argvs)
+    assert any("commit" in a for a in argvs)
+    assert any(a[-3:] == ["push", "origin", "juneau-9.2.1-branch"] for a in argvs)
+    ctx2 = make_ctx(state=RC, answers={"confirm.revert-versions": False})
+    ctx2.fake.script("git", "-C", str(ctx2.ws), "status", out=" M pom.xml\n")
+    revert.revert_versions(ctx2)
+    assert not [a for a in ctx2.fake.argvs() if "push" in a]
+
+
+def test_revert_record_bumps_rc_and_resets_last(make_ctx):
+    ctx = make_ctx(state={**RC, "rcHistory": []})
+    revert.revert_record(ctx)
+    assert ctx.rf.get("details")["rc"] == 2 and ctx.rf.get("last") is None
+    assert ctx.rf.get("nexusRepoId") is None and ctx.rf.get("rcHistory")[-1]["rc"] == 1
+    assert ctx.rf.get("phase") == "rc"
+
+
+def test_revert_rerun_idempotent(make_ctx):
+    ctx = make_ctx(state=RC)
+    nx = ctx.nexus
+    nx.repos = [{"repositoryId": "r-1", "state": "open"}]
+    ctx.fake.script("svn", "ls", ok=False)
+    ctx.fake.script("git", "-C", str(ctx.ws), "ls-remote", out="")
+    ctx.fake.script("git", "-C", str(ctx.ws), "status", out="")
+    revert.run_all(ctx)
+    ctx.rf.set("nexusRepoId", "r-1")             # as if the first run died before the record row
+    revert.run_all(ctx)                          # second run: the repository is already gone, no error
+    assert nx.posts == [("drop", "r-1")]
+
+
+def test_revert_removes_dev_dirs_and_remote_tag_when_present(make_ctx):
+    ctx = make_ctx(state=RC)
+    ctx.fake.script("svn", "ls", out="x\n")
+    ctx.fake.script("git", "-C", str(ctx.ws), "tag", "--list", out="juneau-9.2.1-RC1\n")
+    ctx.fake.script("git", "-C", str(ctx.ws), "ls-remote", out="abc\trefs/tags/juneau-9.2.1-RC1\n")
+    assert revert.revert_dist(ctx) == "removed 2 dirs"
+    assert revert.revert_tag(ctx) == "tag removed"
+    argvs = ctx.fake.argvs()
+    assert ["git", "-C", str(ctx.ws), "tag", "-d", "juneau-9.2.1-RC1"] in argvs
+    assert ["git", "-C", str(ctx.ws), "push", "origin", ":refs/tags/juneau-9.2.1-RC1"] in argvs
diff --git a/scripts/tests/test_release_runfile.py b/scripts/tests/test_release_runfile.py
new file mode 100644
index 0000000000..8f7447f58a
--- /dev/null
+++ b/scripts/tests/test_release_runfile.py
@@ -0,0 +1,37 @@
+import json
+
+import pytest
+
+from jrm_release.runfile import RunFile, RunFileError, new_attempt_dir
+
+pytestmark = pytest.mark.usefixtures("clean_env")
+
+
+def test_attempt_running_then_outcome(home):
+    rf = RunFile(home / "runs" / "9.2.1" / "run.json")
+    d = new_attempt_dir(home)
+    rf.attempt_start(d)
+    assert rf.get("attempts")[-1]["status"] == "running"
+    rf.attempt_end("failed")
+    a = rf.get("attempts")[-1]
+    assert a["status"] == "failed" and a["dir"] == str(d)
+    assert json.loads(rf.path.read_text())["attempts"][-1]["status"] == "failed"
+
+
+def test_attempt_dir_numbering_race(home, monkeypatch):
+    stamp = "20261019-120000"
+    monkeypatch.setattr("jrm_release.runfile.stamp", lambda now=None: stamp)
+    (home / "attempts").mkdir(parents=True)
+    (home / "attempts" / f"{stamp}-001").mkdir()      # a concurrent run already took 001
+    d = new_attempt_dir(home)
+    assert d.name == f"{stamp}-002" and d.is_dir()
+
+
+def test_corrupt_run_json_fails_never_guesses(home):
+    p = home / "runs" / "9.2.1" / "run.json"
+    p.parent.mkdir(parents=True)
+    p.write_text("{not json")
+    with pytest.raises(RunFileError) as e:
+        RunFile(p)
+    assert str(p) in str(e.value)
+    assert p.read_text() == "{not json"    # left alone for inspection
diff --git a/scripts/tests/test_release_secrets.py b/scripts/tests/test_release_secrets.py
new file mode 100644
index 0000000000..c50bc5b3ed
--- /dev/null
+++ b/scripts/tests/test_release_secrets.py
@@ -0,0 +1,72 @@
+import pytest
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from jrm_release import promote, rc, revert
+
+pytestmark = pytest.mark.usefixtures("clean_env")
+
+PW, GPG, GH = "s3cr3t-pw", "s3cr3t-gpg", "s3cr3t-gh"
+PASSED = {"vote": {"outcome": "passed"}, "nexusRepoId": "r-1", "rcCommit": "abc123", "phase": "vote"}
+
+
+def _drive_secret_steps(ctx):
+    for fn in (rc.gpg_unlock, rc.release_prepare, rc.release_perform, rc.dist_stage, promote.dist_promote,
+               promote.final_tag, promote.github_release, revert.revert_tag, revert.revert_versions):
+        try:
+            fn(ctx)
+        except Exception:   # noqa: BLE001 - only the recorded calls matter here
+            pass
+
+
+def test_no_secret_on_any_argv(make_ctx):
+    ctx = make_ctx(state=PASSED, answers={"confirm.revert-versions": True, "confirm.drop-stale": False})
+    _drive_secret_steps(ctx)
+    assert ctx.fake.calls
+    for c in ctx.fake.calls:
+        assert not any(s in " ".join(c["argv"]) for s in (PW, GPG, GH))
+
+
+def test_secret_env_only_on_its_child(make_ctx):
+    ctx = make_ctx(state=PASSED)
+    _drive_secret_steps(ctx)
+    for c in ctx.fake.calls:
+        env = c["env"]
+        if "GH_TOKEN" in env:
+            assert c["argv"][0] == "gh"
+        if "JRM_ASKPASS_PASS" in env:
+            assert c["argv"][0] in ("git", "mvn")
+    assert any(c["argv"][0] == "gh" and c["env"].get("GH_TOKEN") == GH for c in ctx.fake.calls)
+
+
+def test_secret_stdin_only_on_gpg_and_svnmucc(make_ctx):
+    ctx = make_ctx(state=PASSED)
+    _drive_secret_steps(ctx)
+    for c in ctx.fake.calls:
+        if c["input"] is not None:
+            assert c["argv"][0] in ("gpg", "svnmucc")
+
+
+def test_secrets_absent_from_run_json_and_full_log(make_ctx, home):
+    ctx = make_ctx(state=PASSED)
+    _drive_secret_steps(ctx)
+    ctx.rf.set("probe", True)
+    text = ctx.rf.path.read_text()
+    said = "\n".join(ctx.fake.said)
+    for s in (PW, GPG, GH):
+        assert s not in text and s not in said
+
+
+def test_askpass_answers_from_environment_only():
+    script = Path(__file__).resolve().parent.parent / "jrm_release" / "askpass.py"
+    assert os.access(script, os.X_OK)
+    env = {**os.environ, "JRM_ASKPASS_USER": "jamesbognar", "JRM_ASKPASS_PASS": PW}
+
+    def ask(prompt):
+        return subprocess.run([sys.executable, "-B", str(script), prompt], env=env, capture_output=True,
+                              text=True, check=True).stdout
+
+    assert ask("Username for 'https://gitbox.apache.org': ") == "jamesbognar"
+    assert ask("Password for 'https://jamesbognar@gitbox.apache.org': ") == PW
diff --git a/scripts/tests/test_release_steps.py b/scripts/tests/test_release_steps.py
new file mode 100644
index 0000000000..639e3bb946
--- /dev/null
+++ b/scripts/tests/test_release_steps.py
@@ -0,0 +1,462 @@
+import os
+
+import pytest
+
+from jrm_release import details, rc
+from jrm_release.ctx import StepError
+
+pytestmark = pytest.mark.usefixtures("clean_env")
+
+POM = "<project><parent><version>1</version></parent><version>9.2.1-SNAPSHOT</version></project>"
+
+
+def test_details_branch_first_then_pom_defaults(make_ctx):
+    ctx = make_ctx(with_details=False, answers={
+        "release.branch": "juneau-9.2.1-branch",
+        "release.details": {"version": "9.2.1", "developmentVersion": "9.2.2-SNAPSHOT", "rc": 1, "summary": "Fixes."}})
+    ctx.fake.script("git", "-C", details.checkout(), "show", out=POM)
+    d = details.ask(ctx)
+    assert [a[0] for a in ctx.prompt.asked] == ["text", "form"]
+    form = {f.name: f.default for f in ctx.prompt.asked[1][3]["fields"]}
+    assert form["version"] == "9.2.1" and form["developmentVersion"] == "9.2.2-SNAPSHOT"
+    assert ["git", "-C", details.checkout(), "show", "origin/juneau-9.2.1-branch:pom.xml"] in ctx.fake.argvs()
+    assert d["branch"] == "juneau-9.2.1-branch" and ctx.rf.get("details")["rc"] == 1
+
+
+def test_details_validation_rejects_bad_versions():
+    ok = dict(version="9.2.1", developmentVersion="9.2.2-SNAPSHOT", rc=1)
+    assert details.validate(ok) is None
+    assert "x.y.z" in details.validate({**ok, "version": "9.2"})
+    assert "SNAPSHOT" in details.validate({**ok, "developmentVersion": "9.2.2"})
+    assert "greater" in details.validate({**ok, "developmentVersion": "9.2.0-SNAPSHOT"})
+    assert "rc" in details.validate({**ok, "rc": 0})
+
+
+def test_details_reasks_after_invalid(make_ctx):
+    bad = {"version": "9.2", "developmentVersion": "9.2.2-SNAPSHOT", "rc": 1, "summary": "s"}
+    good = {**bad, "version": "9.2.1"}
+    ctx = make_ctx(with_details=False, answers={"release.branch": "b", "release.details": [bad, good]})
+    assert details.ask(ctx)["version"] == "9.2.1"
+    assert sum(1 for a in ctx.prompt.asked if a[0] == "form") == 2
+
+
+def test_workspace_argv(make_ctx):
+    ctx = make_ctx()
+    rc.workspace(ctx)
+    ws = str(ctx.ws)
+    assert ctx.fake.argvs()[0] == ["git", "clone", "--branch", "juneau-9.2.1-branch",
+                                   "https://gitbox.apache.org/repos/asf/juneau.git", ws]
+    assert ["git", "-C", ws, "fetch", "--tags", "origin"] in ctx.fake.argvs()
+    assert ["git", "-C", ws, "config", "user.email", "jamesbognar@apache.org"] in ctx.fake.argvs()
+
+
+def test_build_verify_and_javadoc_argv(make_ctx):
+    ctx = make_ctx(answers={"review.javadoc": True})
+    rc.build_verify(ctx)
+    rc.javadoc_verify(ctx)
+    assert ["mvn", "-B", "clean", "verify"] in ctx.fake.argvs()
+    assert ["mvn", "-B", "javadoc:aggregate"] in ctx.fake.argvs()
+    assert all(c["cwd"] == ctx.ws for c in ctx.fake.calls if c["argv"][0] == "mvn")
+
+
+def test_javadoc_not_accepted_fails(make_ctx):
+    ctx = make_ctx(answers={"review.javadoc": False})
+    with pytest.raises(StepError, match="javadoc"):
+        rc.javadoc_verify(ctx)
+
+
+def test_workspace_zips_skip_absent_modules(make_ctx):
+    ctx = make_ctx(answers={"review.workspaces": True})
+    zdir = ctx.ws / "juneau-examples" / "juneau-examples-core" / "target"
+    zdir.mkdir(parents=True)
+    (zdir / "juneau-examples-core-9.2.1-SNAPSHOT-bin.zip").write_bytes(b"z")
+    out = rc.FUNCS["test-workspace-verify"](ctx)
+    assert out == "1 workspaces checked"
+    assert len(ctx.fake.ran("unzip")) == 1
+    assert any("skip juneau-examples-mcp" in s for s in ctx.fake.said)
+
+
+def test_workspace_zips_fail_when_none_exist(make_ctx):
+    ctx = make_ctx(answers={"review.workspaces": True})
+    with pytest.raises(StepError, match="no workspace zips"):
+        rc.FUNCS["test-workspace-verify"](ctx)
+
+
+def test_gpg_unlock_passphrase_on_stdin_only(make_ctx):
+    ctx = make_ctx()
+    rc.gpg_unlock(ctx)
+    call = ctx.fake.calls[0]
+    assert call["argv"][:2] == ["gpg", "--batch"] and "--passphrase-fd" in call["argv"]
+    assert call["input"] == b"s3cr3t-gpg\n"
+    assert "s3cr3t-gpg" not in " ".join(call["argv"])
+
+
+def test_deploy_snapshot_argv(make_ctx):
+    ctx = make_ctx()
+    rc.deploy_snapshot(ctx)
+    assert ctx.fake.argvs()[-1] == ["mvn", "-B", "deploy", "-Daether.checksums.algorithms=MD5,SHA-1,SHA-512"]
+
+
+def _write_pom(ctx, version):
+    ctx.ws.mkdir(parents=True, exist_ok=True)
+    (ctx.ws / "pom.xml").write_text(f"<project><version>{version}</version></project>")
+
+
+def test_release_prepare_refuses_non_snapshot(make_ctx):
+    ctx = make_ctx()
+    _write_pom(ctx, "9.2.1")
+    with pytest.raises(StepError, match="SNAPSHOT"):
+        rc.release_prepare(ctx)
+    assert not ctx.fake.ran("mvn")
+
+
+def test_release_prepare_refuses_existing_tag(make_ctx):
+    ctx = make_ctx()
+    _write_pom(ctx, "9.2.1-SNAPSHOT")
+    ctx.fake.script("git", "-C", str(ctx.ws), "tag", "--list", out="juneau-9.2.1-RC1\n")
+    with pytest.raises(StepError, match="juneau-9.2.1-RC1"):
+        rc.release_prepare(ctx)
+    assert not ctx.fake.ran("mvn")
+
+
+def test_release_prepare_argv_and_askpass_env(make_ctx):
+    ctx = make_ctx()
+    _write_pom(ctx, "9.2.1-SNAPSHOT")
+    rc.release_prepare(ctx)
+    call = ctx.fake.calls[-1]
+    assert call["argv"] == ["mvn", "-B", "release:prepare", "-DautoVersionSubmodules=true",
+                            "-DreleaseVersion=9.2.1", "-Dtag=juneau-9.2.1-RC1",
+                            "-DdevelopmentVersion=9.2.2-SNAPSHOT"]
+    assert call["env"]["GIT_ASKPASS"].endswith("jrm_release/askpass.py")
+    assert call["env"]["GIT_TERMINAL_PROMPT"] == "0"
+    assert call["env"]["JRM_ASKPASS_PASS"] == "s3cr3t-pw" and "s3cr3t-pw" not in " ".join(call["argv"])
+
+
+def test_release_diff_review_needs_acceptance(make_ctx):
+    ctx = make_ctx(answers={"review.diff": False})
+    with pytest.raises(StepError, match="diff"):
+        rc.release_diff_review(ctx)
+    assert ["git", "-C", str(ctx.ws), "diff", "juneau-9.2.1-RC1^", "juneau-9.2.1-RC1"] in ctx.fake.argvs()
+
+
+def test_release_perform_offers_stale_repo_drop(make_ctx):
+    from conftest import FakeNexus
+    nx = FakeNexus()
+    nx.repos = [{"repositoryId": "orgapachejuneau-1001", "state": "open", "created": "2026-10-01"}]
+    nx.repos_after = [{"repositoryId": "orgapachejuneau-1002", "state": "open", "created": "2026-10-19"}]
+    ctx = make_ctx(nexus=nx, answers={"confirm.drop-stale": True})
+    rc.release_perform(ctx)
+    assert ("drop", "orgapachejuneau-1001") in nx.posts
+    assert ctx.rf.get("nexusRepoId") == "orgapachejuneau-1002"
+
+
+def test_nexus_close_polls_until_closed(make_ctx):
+    from conftest import FakeNexus
+    nx = FakeNexus()
+    nx.states["r-1"] = [{"state": "open", "transitioning": True}, {"state": "open", "transitioning": True},
+                        {"state": "closed", "transitioning": False}]
+    ctx = make_ctx(nexus=nx, state={"nexusRepoId": "r-1"})
+    rc.nexus_close(ctx)
+    assert nx.posts == [("close", "r-1")]
+
+
+def test_nexus_close_fails_on_rule_notifications(make_ctx):
+    from conftest import FakeNexus
+    nx = FakeNexus()
+    nx.states["r-1"] = [{"state": "open", "transitioning": False, "notifications": ["Missing signature: x.jar"]}]
+    ctx = make_ctx(nexus=nx, state={"nexusRepoId": "r-1"})
+    with pytest.raises(StepError, match="Missing signature"):
+        rc.nexus_close(ctx)
+
+
+WANTED = ["juneau-9.2.1-source-release.zip", "juneau-9.2.1-source-release.zip.asc",
+          "juneau-9.2.1-source-release.zip.sha512", "juneau-distrib-9.2.1-bin.zip",
+          "juneau-distrib-9.2.1-bin.zip.asc", "juneau-distrib-9.2.1-bin.zip.sha512"]
+
+
+def _staged(ctx, extra=(), missing=()):
+    ctx.nexus.files = {n: f"https://repository.apache.org/content/repositories/r-1/org/apache/juneau/x/{n}"
+                       for n in [*WANTED, *extra] if n not in missing}
+
+
+def test_dist_stage_lists_entries_no_glob(make_ctx):
+    ctx = make_ctx(state={"nexusRepoId": "r-1"})
+    _staged(ctx, extra=["juneau-9.2.1-source-release.zip.sha1", "juneau-9.2.1-source-release.zip.md5",
+                        "juneau-core-9.2.1.jar"])
+    ctx.fake.script("svn", "ls", out="juneau-9.2.0-RC1/\njuneau-9.1.0-RC2/\n.hidden/\n")
+    rc.dist_stage(ctx)
+    assert all("*" not in " ".join(a) for a in ctx.fake.argvs())
+    mucc = ctx.fake.ran("svnmucc")[0]
+    flat = " ".join(mucc)
+    assert "rm https://dist.apache.org/repos/dist/dev/juneau/source/juneau-9.2.0-RC1" in flat
+    assert "juneau-9.1.0-RC2" not in flat and ".hidden" not in flat      # other lines are left alone
+
+
+def test_dist_stage_renames_and_drops_sha1_md5(make_ctx):
+    ctx = make_ctx(state={"nexusRepoId": "r-1"})
+    _staged(ctx, extra=["juneau-9.2.1-source-release.zip.sha1", "juneau-9.2.1-source-release.zip.md5"])
+    rc.dist_stage(ctx)
+    local = sorted(p.name for p in (ctx.run_dir / "dist").iterdir())
+    assert local == ["apache-juneau-9.2.1-bin.zip", "apache-juneau-9.2.1-bin.zip.asc",
+                     "apache-juneau-9.2.1-bin.zip.sha512", "apache-juneau-9.2.1-src.zip",
+                     "apache-juneau-9.2.1-src.zip.asc", "apache-juneau-9.2.1-src.zip.sha512"]
+    mucc = ctx.fake.ran("svnmucc")[0]
+    assert mucc.count("put") == 6 and mucc.count("mkdir") == 2
+    assert "--password-from-stdin" in mucc and "s3cr3t-pw" not in " ".join(mucc)
+    call = next(c for c in ctx.fake.calls if c["argv"][0] == "svnmucc")
+    assert call["input"] == b"s3cr3t-pw\n"
+
+
+def test_dist_stage_refuses_missing_file(make_ctx):
+    ctx = make_ctx(state={"nexusRepoId": "r-1"})
+    _staged(ctx, missing=["juneau-distrib-9.2.1-bin.zip.asc"])
+    with pytest.raises(StepError, match="juneau-distrib-9.2.1-bin.zip.asc"):
+        rc.dist_stage(ctx)
+    assert not ctx.fake.ran("svnmucc")
+
+
+def _local_dist(ctx, digest_ok=True):
+    import hashlib
+    out = ctx.run_dir / "dist"
+    out.mkdir(parents=True, exist_ok=True)
+    for name in ("apache-juneau-9.2.1-src.zip", "apache-juneau-9.2.1-bin.zip"):
+        (out / name).write_bytes(b"payload " + name.encode())
+        digest = hashlib.sha512((out / name).read_bytes()).hexdigest()
+        (out / (name + ".sha512")).write_text(digest if digest_ok else "0" * 128)
+        (out / (name + ".asc")).write_text("sig")
+    listing = "".join(f"apache-juneau-9.2.1-{k}.zip{s}\n" for k in ("src", "bin") for s in ("", ".asc", ".sha512"))
+    ctx.fake.script("svn", "ls", out=listing)
+
+
+def test_dist_verify_checks_digest_and_signature(make_ctx):
+    ctx = make_ctx()
+    _local_dist(ctx)
+    assert rc.dist_verify(ctx) == "dist/dev verified"
+    assert len(ctx.fake.ran("gpg", "--batch", "--verify")) == 2
+    bad = make_ctx()
+    _local_dist(bad, digest_ok=False)
+    with pytest.raises(StepError, match="SHA-512"):
+        rc.dist_verify(bad)
+
+
+def test_dist_verify_refuses_listing_gap(make_ctx):
+    ctx = make_ctx()
+    ctx.fake.script("svn", "ls", out="apache-juneau-9.2.1-src.zip\n")
+    with pytest.raises(StepError, match="lacks"):
+        rc.dist_verify(ctx)
+
+
+def test_compose_vote_email_facts(make_ctx):
+    ctx = make_ctx(state={"nexusRepoId": "orgapachejuneau-1002"}, answers={"email.vote": True})
+    _local_dist(ctx)
+    ctx.fake.script("git", "-C", str(ctx.ws), "rev-parse", out="abc123\n")
+    assert rc.compose_vote_email(ctx) == "vote email sent"
+    kind, key, subject, kw = ctx.prompt.asked[-1]
+    assert key == "email.vote" and subject == "[VOTE] Release Apache Juneau 9.2.1 RC1"
+    assert "h=abc123" in kw["body"] and "orgapachejuneau-1002" in kw["body"]
+    assert "SHA512_NOT_FOUND" not in kw["body"]
+    assert ctx.rf.get("rcCommit") == "abc123" and ctx.rf.get("voteDeadline")
+
+
+# ---- vote phase
+
+from datetime import datetime, timedelta, timezone  # noqa: E402
+
+from jrm_release import promote, vote  # noqa: E402
+
+
+def test_vote_gate_pauses_phase_vote(make_ctx):
+    ctx = make_ctx()
+    with pytest.raises(ctx.pause) as e:
+        vote.vote_gate(ctx)
+    assert ctx.rf.get("phase") == "vote" and e.value.done is True
+    assert "--resume" in e.value.message
+
+
+def test_vote_tally_early_pause_not_done(make_ctx):
+    soon = (datetime.now(timezone.utc) + timedelta(hours=10)).isoformat()
+    ctx = make_ctx(state={"voteDeadline": soon}, answers={"vote.early": False})
+    ctx.now = lambda: datetime.now(timezone.utc)
+    with pytest.raises(ctx.pause) as e:
+        vote.vote_tally(ctx)
+    assert e.value.done is False
+
+
+def test_vote_tally_validates_passed(make_ctx):
+    past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
+    for tally, outcome in (({"plus1": 3, "zero": 0, "minus1": 0, "binding": "A, B, C"}, "passed"),
+                           ({"plus1": 2, "zero": 0, "minus1": 0, "binding": "A, B"}, "failed"),
+                           ({"plus1": 4, "zero": 0, "minus1": 4, "binding": "A"}, "failed")):
+        ctx = make_ctx(state={"voteDeadline": past}, answers={"vote.tally": tally})
+        ctx.now = lambda: datetime.now(timezone.utc)
+        vote.vote_tally(ctx)
+        assert ctx.rf.get("vote")["outcome"] == outcome
+
+
+def test_result_failed_sets_vote_failed(make_ctx):
+    ctx = make_ctx(state={"vote": {"outcome": "failed", "plus1": 2, "zero": 0, "minus1": 0, "binding": "A, B"}},
+                   answers={"email.result": True})
+    with pytest.raises(ctx.pause):
+        vote.compose_result_email(ctx)
+    assert ctx.rf.get("phase") == "vote-failed"
+
+
+def test_result_passed_continues(make_ctx):
+    ctx = make_ctx(state={"vote": {"outcome": "passed", "plus1": 3, "zero": 0, "minus1": 0, "binding": "A, B, C"}},
+                   answers={"email.result": True})
+    assert vote.compose_result_email(ctx) == "result email sent"
+    assert ctx.rf.get("phase") != "vote-failed"
+
+
+# ---- promote phase
+
+PASSED = {"vote": {"outcome": "passed"}, "nexusRepoId": "r-1", "rcCommit": "abc123", "phase": "vote"}
+
+
+def test_nexus_release_needs_typed_version(make_ctx):
+    from conftest import FakeNexus
+    nx = FakeNexus()
+    ctx = make_ctx(nexus=nx, state=PASSED, answers={"confirm.nexus-release": False})
+    with pytest.raises(StepError):
+        promote.nexus_release(ctx)
+    assert nx.posts == []
+    assert ctx.prompt.asked[-1][3]["expect"] == "9.2.1" and ctx.prompt.asked[-1][3]["danger"] is True
+    ctx2 = make_ctx(nexus=nx, state=PASSED, answers={"confirm.nexus-release": True})
+    promote.nexus_release(ctx2)
+    assert nx.posts == [("promote", "r-1")] and ctx2.rf.get("phase") == "promote"
+
+
+def test_nexus_release_refuses_without_passed_vote(make_ctx):
+    from conftest import FakeNexus
+    nx = FakeNexus()
+    ctx = make_ctx(nexus=nx, state={"nexusRepoId": "r-1"}, answers={"confirm.nexus-release": True})
+    with pytest.raises(StepError, match="vote"):
+        promote.nexus_release(ctx)
+    assert nx.posts == [] and not ctx.prompt.asked
+
+
+def test_dist_promote_single_svnmucc_with_prior_rm(make_ctx):
+    ctx = make_ctx(state=PASSED)
+    ctx.fake.script("git", "-C", str(ctx.ws), "tag", "--list", out="juneau-9.2.0\njuneau-9.1.5\njuneau-9.2.1-RC1\n")
+    ctx.fake.script("svn", "ls", out="9.2.0/\nKEYS\n")
+    promote.dist_promote(ctx)
+    mucc = ctx.fake.ran("svnmucc")
+    assert len(mucc) == 1
+    flat = " ".join(mucc[0])
+    assert flat.index("rm https://dist.apache.org/repos/dist/release/juneau/9.2.0") < flat.index("mkdir")
+    assert flat.count(" cp ") == 6
+
+
+def test_dist_promote_skips_prior_not_listed(make_ctx):
+    ctx = make_ctx(state=PASSED)
+    ctx.fake.script("git", "-C", str(ctx.ws), "tag", "--list", out="juneau-9.2.0\n")
+    ctx.fake.script("svn", "ls", out="KEYS\n")
+    promote.dist_promote(ctx)
+    assert " rm " not in " ".join(ctx.fake.ran("svnmucc")[0])
+
+
+def test_final_tag_on_rc_commit_and_skip_when_present(make_ctx):
+    ctx = make_ctx(state=PASSED)
+    promote.final_tag(ctx)
+    assert ["git", "-C", str(ctx.ws), "tag", "-a", "juneau-9.2.1", "abc123", "-m", "Apache Juneau 9.2.1"] in \
+        ctx.fake.argvs()
+    assert any(a[:3] == ["git", "-C", str(ctx.ws)] and "push" in a for a in ctx.fake.argvs())
+    ctx2 = make_ctx(state=PASSED)
+    ctx2.fake.script("git", "-C", str(ctx2.ws), "ls-remote", out="abc123\trefs/tags/juneau-9.2.1\n")
+    promote.final_tag(ctx2)
+    assert not [a for a in ctx2.fake.argvs() if "tag" in a and "-a" in a]
+
+
+def test_github_release_on_final_tag(make_ctx):
+    ctx = make_ctx(state=PASSED)
+    ctx.fake.script("git", "-C", str(ctx.ws), "tag", "--list", out="juneau-9.2.1\njuneau-9.2.0\njuneau-10.0.0\n")
+    ctx.fake.script("gh", "release", "create", out="https://github.com/apache/juneau/releases/tag/juneau-9.2.1\n")
+    promote.github_release(ctx)
+    call = ctx.fake.ran("gh")[0]
+    assert call[:4] == ["gh", "release", "create", "juneau-9.2.1"] and "--verify-tag" in call
+    assert "--latest=false" in call            # 10.0.0 exists and is higher
+    gh = next(c for c in ctx.fake.calls if c["argv"][0] == "gh")
+    assert gh["env"]["GH_TOKEN"] == "s3cr3t-gh" and "s3cr3t-gh" not in " ".join(gh["argv"])
+    assert ctx.rf.get("githubReleaseUrl").endswith("juneau-9.2.1")
+
+
+def test_github_release_latest_left_to_gh_when_highest(make_ctx):
+    ctx = make_ctx(state=PASSED)
+    ctx.fake.script("git", "-C", str(ctx.ws), "tag", "--list", out="juneau-9.2.1\njuneau-9.2.0\njuneau-9.2.1-RC1\n")
+    promote.github_release(ctx)
+    assert "--latest=false" not in ctx.fake.ran("gh")[0]
+
+
+def test_announce_waits_for_downloads(make_ctx):
+    seen = []
+    results = iter([False, False, True])
+    ctx = make_ctx(state=PASSED, answers={"email.announce": True},
+                   http_ok=lambda u: (seen.append(u), next(results))[1])
+    promote.compose_announce_email(ctx)
+    assert len(seen) == 3 and seen[0] == "https://downloads.apache.org/juneau/9.2.1/"
+
+
+def test_followup_pause_on_no(make_ctx):
+    ctx = make_ctx(state=PASSED, answers={"confirm.followup": False})
+    with pytest.raises(ctx.pause) as e:
+        promote.followup_checklist(ctx)
+    assert e.value.done is False
+
+
+def test_finalize_marks_released(make_ctx):
+    ctx = make_ctx(state=PASSED)
+    assert "released" in promote.finalize(ctx)
+    assert ctx.rf.get("phase") == "released" and ctx.rf.get("last") == "finalize"
+
+
+REFS = "origin/master\norigin/juneau-9.2.1-branch\norigin/9.3.x\n"
+FORM = {"rc": 1, "summary": "Fixes."}
+
+
+def _fields(ctx):
+    return {f.name: f for f in ctx.prompt.asked[1][3]["fields"]}
+
+
+def test_details_version_prefills_form_and_derives_dev(make_ctx):
+    ctx = make_ctx(with_details=False, answers={"release.branch": "master", "release.details": dict(FORM)})
+    ctx.fake.script("git", "-C", details.checkout(), "show", out="<version>10.0.0-SNAPSHOT</version>")
+    d = details.ask(ctx, version="9.2.1")
+    f = _fields(ctx)
+    assert f["version"].default == "9.2.1" and f["developmentVersion"].default == "9.2.2-SNAPSHOT"
+    assert d["version"] == "9.2.1" and d["developmentVersion"] == "9.2.2-SNAPSHOT"
+
+
+@pytest.mark.parametrize("refs,want", [
+    ("origin/master\norigin/juneau-9.2.1-branch\n", "juneau-9.2.1-branch"),
+    ("origin/master\norigin/9.2.x\n", "9.2.x"),
+    ("origin/master\norigin/9.3.x\n", "master"),
+    ("", "master")])
+def test_details_version_picks_branch_default(make_ctx, refs, want):
+    ctx = make_ctx(with_details=False, answers={"release.branch": want, "release.details": dict(FORM)})
+    ctx.fake.script("git", "-C", details.checkout(), "for-each-ref", out=refs)
+    details.ask(ctx, version="9.2.1")
+    assert ctx.prompt.asked[0][0] == "text" and ctx.prompt.asked[0][3]["default"] == want
+
+
+def test_details_blank_dev_derives_from_entered_version(make_ctx):
+    # the master pom is 10.0.0-SNAPSHOT: next_dev_version(10.0.0) is None, so no form default; user enters 9.2.1
+    ctx = make_ctx(with_details=False, answers={"release.branch": "master",
+                                                "release.details": {**FORM, "version": "9.2.1"}})
+    ctx.fake.script("git", "-C", details.checkout(), "show", out="<version>10.0.0-SNAPSHOT</version>")
+    d = details.ask(ctx)
+    assert _fields(ctx)["developmentVersion"].default is None
+    assert d["developmentVersion"] == "9.2.2-SNAPSHOT"
+
+
+def test_details_stale_dev_default_follows_edited_version(make_ctx):
+    ctx = make_ctx(with_details=False, answers={"release.branch": "b", "release.details": {
+        **FORM, "version": "9.4.1", "developmentVersion": "9.2.2-SNAPSHOT"}})
+    ctx.fake.script("git", "-C", details.checkout(), "show", out="<version>9.2.1-SNAPSHOT</version>")
+    assert details.ask(ctx)["developmentVersion"] == "9.4.2-SNAPSHOT"
+
+
+def test_details_bare_dev_version_gets_snapshot_suffix(make_ctx):
+    ctx = make_ctx(with_details=False, answers={"release.branch": "b", "release.details": {
+        **FORM, "version": "9.2.1", "developmentVersion": "9.2.2"}})
+    ctx.fake.script("git", "-C", details.checkout(), "show", out=POM)
+    assert details.ask(ctx)["developmentVersion"] == "9.2.2-SNAPSHOT"