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"