blob: 5321ee38a5cb2795871350128e13413b83e75d00 [file]
#!/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.
# ***************************************************************************************************************************
"""
Re-pin the downstream consumers (Release Manager, Support Console) to a fresh local Apache
Juneau build.
MECHANISM. Release Manager (~/git/apache/release-manager) and the Sandbox Support Console
(~/git/sandbox/sandbox-support-console) both declare a floating
`<juneau.version>10.0.0-SNAPSHOT</juneau.version>` that Maven resolves from the shared ~/.m2
local repository. There is no version property to bump and no per-project pin file to edit:
the old isolated `.m2-pinned` / `juneau-pinned` mechanism is retired (see the console's
pom.xml comment and its README.md "Dependency status" section -- its `.mvn/maven.config` is
now a 0-byte file, and release-manager has no `.mvn/` at all). "Re-pin" therefore means exactly
one thing: rebuild Juneau's HEAD and `mvn install` it into ~/.m2, so every consumer that
resolves `org.apache.juneau:*:10.0.0-SNAPSHOT` from there picks up the new build the next time
it builds. `-nsu` (no-snapshot-update) matters here: without it, Maven can pull an Apache CI
timestamped SNAPSHOT through the global settings.xml `apache.snapshots` repository and shadow
the local build this script just installed, silently undoing the whole point of running it.
THE GUARD THIS SCRIPT EXISTS FOR. Installing Juneau while its working tree is dirty bakes
unaccepted/uncommitted work into the machine-wide ~/.m2 -- and every concurrent session on this
machine then silently resolves that build, with no indication anything unreviewed rode along.
This is a documented recurring hazard. So before touching ~/.m2, this script verifies the
Juneau tree is completely clean (no modified tracked files, no staged changes, no untracked
non-ignored files -- i.e. `git status --porcelain` is empty) and REFUSES -- nonzero exit,
offending paths printed -- if it is not. This mirrors scripts/push.py's own
unreviewed-changes gate (see check_unreviewed_changes() there): staged-but-uncommitted is not
an exception, since a staged path can still be reverted before commit and is not "the same
thing reviewed" as what the operator is asking this script to publish machine-wide.
There is deliberately no way to skip the guard. A documented --allow-dirty escape hatch was
considered and OMITTED: the one case that flag would ever be reached for is a dirty tree the
operator is not looking straight at, since if they were, the sanctioned move is to `git
stash`, commit, or discard the change -- not teach a script to skip the one check it exists
to enforce. If a genuine scratch-directory use case shows up later, add --allow-dirty
deliberately then; the safe default here is hard refusal.
WHAT THIS SCRIPT DOES, IN ORDER:
1. Resolve the Juneau repo root (default ~/git/apache/juneau; --juneau-dir overrides).
2. GUARD: refuse (exit 1) unless `git status --porcelain` is empty in that tree.
3. Print the commit about to be installed (short hash + subject), and a warning if HEAD
is not identical to the locally known origin/master (unpushed work is about to be baked
into ~/.m2 too -- allowed, since "clean" and "pushed" are different questions, but worth
a loud warning). This does NOT run `git fetch`, so it can miss commits pushed elsewhere
since the last fetch; the warning notes that.
4. Run `mvn -B -nsu clean install -DskipTests` in the Juneau tree, streaming output live and
aborting on a nonzero exit. JAVA_HOME defaults to ~/jdk/default if that path exists, else
whatever is already in the environment.
5. Optional --verify-consumers: after a successful install, run
`mvn -B -nsu clean test-compile` in release-manager and the support console (paths
overridable via --release-manager-dir / --support-console-dir) and report pass/fail for
each. This never runs any git command in either consumer tree -- mvn only.
6. Print a final summary: the installed version (read from Juneau's own pom.xml), the
commit just installed, and where it landed (~/.m2/repository/org/apache/juneau/).
Usage:
./scripts/repin-consumers.py
./scripts/repin-consumers.py --verify-consumers
./scripts/repin-consumers.py --juneau-dir ~/git/apache/juneau-9.2.1 --verify-consumers
./scripts/repin-consumers.py --help
Exit status:
0 Install succeeded (and, if --verify-consumers was given, both consumers compiled --
see --verify-consumers above for how a consumer failure is reported instead).
1 The dirty-tree guard refused. Nothing was built or installed.
2 Usage error, or the Juneau tree / its pom.xml could not be found or read.
3 The Maven install itself failed (nonzero exit). ~/.m2 may or may not have been
partially updated by Maven; fix the build and re-run.
4 The install succeeded but at least one --verify-consumers check failed to compile.
The install already happened; this is reported, not treated as an install failure.
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
JUNEAU_DIR_DEFAULT = Path.home() / "git" / "apache" / "juneau"
RELEASE_MANAGER_DIR_DEFAULT = Path.home() / "git" / "apache" / "release-manager"
SUPPORT_CONSOLE_DIR_DEFAULT = Path.home() / "git" / "sandbox" / "sandbox-support-console"
JDK_DEFAULT = Path.home() / "jdk" / "default"
MVN_INSTALL_ARGV = ["mvn", "-B", "-nsu", "clean", "install", "-DskipTests"]
MVN_VERIFY_ARGV = ["mvn", "-B", "-nsu", "clean", "test-compile"]
def check_dirty(juneau_dir: Path) -> list[str]:
"""
Return the `git status --porcelain` lines for juneau_dir -- empty means clean.
Covers all three things the guard cares about: modified tracked files, staged changes, and
untracked non-ignored files all show up as porcelain lines, so an empty list is the only
way this tree is safe to install from.
"""
result = subprocess.run(
["git", "-C", str(juneau_dir), "status", "--porcelain"],
capture_output=True,
text=True,
check=True,
)
return [line for line in result.stdout.splitlines() if line.strip()]
def _git_out(repo_dir: Path, argv: list[str]) -> str:
result = subprocess.run(
["git", "-C", str(repo_dir), *argv],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def describe_head(juneau_dir: Path) -> tuple[str, str, bool | None]:
"""
Return (short_hash, subject, unpushed) for the commit about to be installed.
unpushed is True if HEAD differs from the locally known origin/master, False if identical,
or None if origin/master could not be resolved at all (no such remote-tracking ref) --
reported as a note, never a refusal: this script's guard is about the working TREE being
clean, not about whether HEAD happens to be pushed yet.
"""
short_hash = _git_out(juneau_dir, ["rev-parse", "--short", "HEAD"])
subject = _git_out(juneau_dir, ["log", "-1", "--format=%s"])
unpushed: bool | None
try:
head_sha = _git_out(juneau_dir, ["rev-parse", "HEAD"])
origin_sha = _git_out(juneau_dir, ["rev-parse", "origin/master"])
unpushed = head_sha != origin_sha
except subprocess.CalledProcessError:
unpushed = None
return short_hash, subject, unpushed
def read_pom_version(pom_path: Path) -> str | None:
"""
Best-effort read of the reactor root <version> from pom.xml, without a full XML parse.
Matches the root project's own artifactId/version pair (`<artifactId>juneau</artifactId>`
immediately followed by its `<version>`), which is unambiguous in Juneau's own pom.xml
layout. Returns None (reported as "unknown") rather than guessing if that shape ever
changes or the file can't be read.
"""
try:
text = pom_path.read_text(encoding="utf-8")
except OSError:
return None
match = re.search(r"<artifactId>juneau</artifactId>\s*<version>([^<]+)</version>", text)
return match.group(1) if match else None
def resolve_java_home() -> str | None:
"""~/jdk/default if it exists, else whatever JAVA_HOME is already set to (or None)."""
if JDK_DEFAULT.exists():
return str(JDK_DEFAULT)
return os.environ.get("JAVA_HOME")
def run_streaming(argv: list[str], cwd: Path, env: dict) -> int:
"""Run argv with output streamed live -- never captured, never shell=True, always an argv list."""
print(f"$ {' '.join(argv)} (cwd={cwd})")
result = subprocess.run(argv, cwd=str(cwd), env=env, check=False)
return result.returncode
def verify_consumer(label: str, repo_dir: Path, env: dict) -> bool:
"""
Run MVN_VERIFY_ARGV in repo_dir and report pass/fail.
Never touches git state in repo_dir -- the only subprocess this runs is mvn.
"""
print(f"\n--- Verifying {label} ({repo_dir}) ---")
if not repo_dir.is_dir():
print(f"SKIP: {repo_dir} does not exist.")
return False
returncode = run_streaming(MVN_VERIFY_ARGV, cwd=repo_dir, env=env)
if returncode == 0:
print(f"PASS: {label} compiled against the freshly installed Juneau build.")
return True
print(f"FAIL: {label} did not compile (mvn exit {returncode}).")
return False
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Rebuild Apache Juneau's HEAD and install it into the shared ~/.m2, so the "
"downstream consumers (Release Manager, Support Console) pick it up on their next "
"build. Refuses to run if the Juneau tree is not completely clean -- see this "
"script's module docstring for why there is no override flag."
),
epilog=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--juneau-dir",
type=Path,
default=JUNEAU_DIR_DEFAULT,
metavar="PATH",
help=f"Juneau repo root to build and install (default: {JUNEAU_DIR_DEFAULT}).",
)
parser.add_argument(
"--verify-consumers",
action="store_true",
help=(
"After a successful install, run `mvn -B -nsu clean test-compile` in "
"release-manager and the support console (see --release-manager-dir / "
"--support-console-dir) and report pass/fail for each. Never touches either "
"consumer's git state."
),
)
parser.add_argument(
"--release-manager-dir",
type=Path,
default=RELEASE_MANAGER_DIR_DEFAULT,
metavar="PATH",
help=(
"Release Manager repo root, used only with --verify-consumers "
f"(default: {RELEASE_MANAGER_DIR_DEFAULT})."
),
)
parser.add_argument(
"--support-console-dir",
type=Path,
default=SUPPORT_CONSOLE_DIR_DEFAULT,
metavar="PATH",
help=(
"Support Console repo root, used only with --verify-consumers "
f"(default: {SUPPORT_CONSOLE_DIR_DEFAULT})."
),
)
args = parser.parse_args()
juneau_dir = args.juneau_dir.expanduser().resolve()
if not juneau_dir.is_dir():
print(f"ERROR: {juneau_dir} does not exist or is not a directory.", file=sys.stderr)
return 2
if not (juneau_dir / ".git").exists():
print(f"ERROR: {juneau_dir} is not a git working tree (no .git found).", file=sys.stderr)
return 2
# THE GUARD. Nothing above this point runs a build; nothing below it runs unless the tree
# is provably clean. See the module docstring for why there is no override flag.
try:
dirty = check_dirty(juneau_dir)
except subprocess.CalledProcessError as exc:
print(f"ERROR: could not run `git status` in {juneau_dir}: {exc}", file=sys.stderr)
return 2
if dirty:
print(f"REFUSED: {juneau_dir} is not clean. Installing it now would bake the following", file=sys.stderr)
print("uncommitted/unreviewed changes into the machine-wide ~/.m2, where every concurrent", file=sys.stderr)
print("session on this machine would silently pick them up:", file=sys.stderr)
for line in dirty:
print(f" {line}", file=sys.stderr)
print("", file=sys.stderr)
print("Commit, stash, or discard the above, then re-run. There is no --allow-dirty escape", file=sys.stderr)
print("hatch by design -- see this script's module docstring for why.", file=sys.stderr)
return 1
try:
short_hash, subject, unpushed = describe_head(juneau_dir)
except subprocess.CalledProcessError as exc:
print(f"ERROR: could not read HEAD in {juneau_dir}: {exc}", file=sys.stderr)
return 2
print(f"About to install: {short_hash} {subject}")
if unpushed is True:
print("WARNING: HEAD differs from the locally known origin/master -- this commit has NOT")
print(" been pushed. It will still be installed into ~/.m2 and resolved by every")
print(" concurrent session on this machine. (No `git fetch` was run, so this may")
print(" also be stale if origin/master moved since the last fetch.)")
elif unpushed is None:
print("NOTE: could not resolve origin/master to compare against HEAD -- skipping that check.")
java_home = resolve_java_home()
env = dict(os.environ)
if java_home:
env["JAVA_HOME"] = java_home
print(f"JAVA_HOME={env.get('JAVA_HOME', '(unset -- relying on ambient environment)')}")
print("\n--- Installing Juneau into ~/.m2 ---")
returncode = run_streaming(MVN_INSTALL_ARGV, cwd=juneau_dir, env=env)
if returncode != 0:
print(f"\nERROR: `{' '.join(MVN_INSTALL_ARGV)}` failed (exit {returncode}).", file=sys.stderr)
return 3
print("\nInstall succeeded.")
consumer_failures: list[str] = []
if args.verify_consumers:
print("\n=== Verifying downstream consumers ===")
if not verify_consumer("release-manager", args.release_manager_dir.expanduser().resolve(), env):
consumer_failures.append("release-manager")
if not verify_consumer("sandbox-support-console", args.support_console_dir.expanduser().resolve(), env):
consumer_failures.append("sandbox-support-console")
version = read_pom_version(juneau_dir / "pom.xml")
m2_repo = Path.home() / ".m2" / "repository" / "org" / "apache" / "juneau"
print("\n=== Summary ===")
print(f"Installed version: {version or '(could not be determined from pom.xml)'}")
print(f"Commit: {short_hash} {subject}")
print(f"Installed to: {m2_repo}")
if args.verify_consumers:
if consumer_failures:
print(f"Consumer verify: FAILED ({', '.join(consumer_failures)})")
else:
print("Consumer verify: PASS (release-manager, sandbox-support-console)")
if consumer_failures:
return 4
return 0
if __name__ == "__main__":
sys.exit(main())