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