This is an automated email from the ASF dual-hosted git repository. ebenizzy pushed a commit to branch asf-release-scripts in repository https://gitbox.apache.org/repos/asf/burr.git
commit c2e0027d269afbcb05f30ff5fef5bfce62d23ea9 Author: Elijah ben Izzy <[email protected]> AuthorDate: Sun Nov 16 21:07:31 2025 -0800 Adds scripts for releasing burr - uses flit as the build system - one script for helping with the release - the other for helping with the build of artifacts - tested out fairly extensively --- CONTRIBUTING.rst | 19 ++ __init__.py | 0 burr/cli/__main__.py | 31 ++- pyproject.toml | 107 +++++++--- scripts/README.md | 203 +++++++++++++++++++ scripts/build_artifacts.py | 290 +++++++++++++++++++++++++++ scripts/release_helper.py | 442 +++++++++++++++++++++++++++++++++++++++++ scripts/setup_keys.sh | 95 +++++++++ telemetry/ui/package-lock.json | 2 + telemetry/ui/package.json | 2 + tests/test_release_config.py | 127 ++++++++++++ 11 files changed, 1290 insertions(+), 28 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c22b7996..db48538f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,3 +1,22 @@ +.. + 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. + + ============ Contributing ============ diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/burr/cli/__main__.py b/burr/cli/__main__.py index cc89b06f..109eb835 100644 --- a/burr/cli/__main__.py +++ b/burr/cli/__main__.py @@ -27,7 +27,9 @@ import time import webbrowser from contextlib import contextmanager from importlib.resources import files +from pathlib import Path from types import ModuleType +from typing import Optional from burr import system, telemetry from burr.core.persistence import PersistedStateData @@ -54,7 +56,7 @@ def _telemetry_if_enabled(event: str): telemetry.create_and_send_cli_event(event) -def _command(command: str, capture_output: bool, addl_env: dict = None) -> str: +def _command(command: str, capture_output: bool, addl_env: dict | None = None) -> str: """Runs a simple command""" if addl_env is None: addl_env = {} @@ -78,7 +80,27 @@ def _command(command: str, capture_output: bool, addl_env: dict = None) -> str: def _get_git_root() -> str: - return _command("git rev-parse --show-toplevel", capture_output=True) + env_root = os.environ.get("BURR_PROJECT_ROOT") + if env_root: + return env_root + try: + return _command("git rev-parse --show-toplevel", capture_output=True) + except subprocess.CalledProcessError: + package_root = _locate_package_root() + if package_root is not None: + logger.warning("Not inside a git repository; using package root %s.", package_root) + return package_root + logger.warning("Not inside a git repository; defaulting to current directory.") + return os.getcwd() + + +def _locate_package_root() -> Optional[str]: + path = Path(__file__).resolve() + for candidate in (path.parent,) + tuple(path.parents): + telemetry_dir = candidate / "telemetry" / "ui" + if telemetry_dir.exists(): + return str(candidate) + return None def open_when_ready(check_url: str, open_url: str): @@ -118,13 +140,16 @@ def _build_ui(): # create a symlink so we can get packages inside it... cmd = "rm -rf burr/tracking/server/build" _command(cmd, capture_output=False) - cmd = "cp -R telemetry/ui/build burr/tracking/server/build" + cmd = "mkdir -p burr/tracking/server/build" + _command(cmd, capture_output=False) + cmd = "cp -a telemetry/ui/build/. burr/tracking/server/build/" _command(cmd, capture_output=False) @cli.command() def build_ui(): git_root = _get_git_root() + logger.info("UI build: using project root %s", git_root) with cd(git_root): _build_ui() diff --git a/pyproject.toml b/pyproject.toml index 7839fb3e..d1c3c878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,23 @@ +# 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. + [build-system] -requires = ["setuptools >= 61.0"] -build-backend = "setuptools.build_meta" +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" [project] name = "burr" @@ -216,21 +233,6 @@ ray = [ "ray[default]" ] -[tool.setuptools] -include-package-data = true - -[tool.setuptools.packages.find] -include = ["burr", "burr.*"] - -# we need to ensure this is there... -[tool.setuptools.package-data] -burr = [ - "burr/tracking/server/build/**/*", - "burr/tracking/server/demo_data/**/*", - "py.typed", -] - - [tool.aerich] tortoise_orm = "burr.tracking.server.s3.settings.TORTOISE_ORM" location = "./burr/tracking/server/s3/migrations" @@ -254,11 +256,66 @@ burr-test-case = "burr.cli.__main__:cli_test_case" name = "burr" [tool.flit.sdist] -include = ["LICENSE", "NOTICE", "DISCLAIMER", "burr/tracking/server/build/**/*", - "burr/tracking/server/demo_data/**/*", - "examples/email-assistant/*", - "examples/multi-modal-chatbot/*", - "examples/streaming-fastapi/*", - "examples/deep-researcher/*", - "py.typed",] -exclude = [] +include = [ + "LICENSE", + "NOTICE", + "DISCLAIMER", + "scripts/**", + "telemetry/ui/**", + "examples/__init__.py", + "examples/email-assistant/**", + "examples/multi-modal-chatbot/**", + "examples/streaming-fastapi/**", + "examples/deep-researcher/**", +] +exclude = [ + "telemetry/ui/node_modules/**", + "telemetry/ui/build/**", + "telemetry/ui/dist/**", + "telemetry/ui/.cache/**", + "telemetry/ui/.next/**", + "**/__pycache__/**", + "**/*.pyc", + ".git/**", + ".github/**", + "docs/**", + # Exclude unwanted examples - flit auto-includes examples/ as a package because of __init__.py, + # and exclude patterns take precedence over include patterns. We must explicitly exclude each + # unwanted example directory. Only email-assistant, multi-modal-chatbot, streaming-fastapi, + # and deep-researcher should be included (see include list above). + # NOTE: If you add/remove examples, update this list AND tests/test_release_config.py + "examples/README.md", + "examples/validate_examples.py", + "examples/adaptive-crag/**", + "examples/conversational-rag/**", + "examples/custom-serde/**", + "examples/deployment/**", + "examples/hamilton-integration/**", + "examples/haystack-integration/**", + "examples/hello-world-counter/**", + "examples/image-telephone/**", + "examples/instructor-gemini-flash/**", + "examples/integrations/**", + "examples/llm-adventure-game/**", + "examples/ml-training/**", + "examples/multi-agent-collaboration/**", + "examples/openai-compatible-agent/**", + "examples/opentelemetry/**", + "examples/other-examples/**", + "examples/parallelism/**", + "examples/pytest/**", + "examples/rag-lancedb-ingestion/**", + "examples/ray/**", + "examples/recursive/**", + "examples/simple-chatbot-intro/**", + "examples/simulation/**", + "examples/streaming-overview/**", + "examples/talks/**", + "examples/templates/**", + "examples/test-case-creation/**", + "examples/tool-calling/**", + "examples/tracing-and-spans/**", + "examples/typed-state/**", + "examples/web-server/**", + "examples/youtube-to-social-media-post/**", +] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..7c759be2 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,203 @@ +# Burr Release Scripts + +This directory contains helper scripts to automate the Apache release workflow. + +## Overview + +The release process has two phases: + +1. **Source-only release** (for Apache voting): Contains source code, build scripts, and UI source—but NO pre-built artifacts +2. **Wheel build** (for PyPI): Built from the source release, includes pre-built UI assets + +All packaging configuration lives in `pyproject.toml`: +- `[build-system]` uses `flit_core` as the build backend +- `[tool.flit.sdist]` controls what goes in the source tarball +- Wheel contents are controlled by what exists in `burr/` when `flit build --format wheel` runs + +## 1. Create the Source Release Candidate + +From the repo root: + +```bash +python scripts/release_helper.py --apache-id <your-id> --rc-num 0 [--dry-run] +``` + +Example: + +```bash +# Dry run (no git tag or SVN upload) +python scripts/release_helper.py --apache-id myid --rc-num 0 --dry-run + +# Real release +python scripts/release_helper.py --apache-id myid --rc-num 0 +``` + +**What it does:** +1. Reads version from `pyproject.toml` +2. Cleans `dist/` directory +3. **Removes `burr/tracking/server/build/`** to ensure no pre-built UI in source tarball +4. Runs `flit build --format sdist` + - Includes files specified in `[tool.flit.sdist] include` + - Excludes files specified in `[tool.flit.sdist] exclude` +5. Creates Apache-branded tarball with GPG signatures and SHA512 checksums +6. Tags git as `v{version}-incubating-RC{num}` (unless `--dry-run`) +7. Uploads to Apache SVN (unless `--dry-run`) + +**Output:** +- `dist/burr-<version>.tar.gz` — source-only tarball +- `dist/apache-burr-<version>-incubating.tar.gz` — ASF-branded tarball +- Signature (`.asc`) and checksum (`.sha512`) files + +## 2. Test the Source Release (Voter Simulation) + +This simulates what Apache voters and release managers will do when validating the release. + +**Automated testing:** + +```bash +bash scripts/simulate_release.sh +``` + +This script: +1. Cleans `/tmp/burr-release-test/` +2. Extracts the Apache tarball +3. Creates a fresh virtual environment +4. Builds UI artifacts and wheel (next step) +5. Verifies both packages and prints their locations + +**Manual testing:** + +```bash +cd /tmp +tar -xzf /path/to/dist/apache-burr-<version>-incubating.tar.gz +cd burr-<version> + +# Verify source contents +ls scripts/ # Build scripts should be present +ls telemetry/ui/ # UI source should be present +ls examples/ # Example directories should be present +ls burr/tracking/server/build/ # Should NOT exist (no pre-built UI) + +# Create clean environment +python -m venv venv && source venv/bin/activate +pip install -e . +pip install flit + +# Build artifacts and wheel (see step 3) +python scripts/build_artifacts.py all --clean +ls dist/*.whl +deactivate +``` + +## 3. Build Artifacts and Wheel + +The `build_artifacts.py` script has three subcommands: + +### Build everything (recommended): + +```bash +python scripts/build_artifacts.py all --clean +``` + +This runs both `artifacts` and `wheel` subcommands in sequence. + +### Build UI artifacts only: + +```bash +python scripts/build_artifacts.py artifacts [--skip-install] +``` + +**What it does:** +1. Checks for Node.js and npm +2. **Cleans `burr/tracking/server/build/`** to ensure fresh UI build +3. Installs burr from source: `pip install -e .` (unless `--skip-install`) +4. Runs `burr-admin-build-ui`: + - `npm install --prefix telemetry/ui` + - `npm run build --prefix telemetry/ui` + - **Creates `burr/tracking/server/build/`** and copies built UI into it +5. Verifies UI assets exist in `burr/tracking/server/build/` + +### Build wheel only (assumes artifacts exist): + +```bash +python scripts/build_artifacts.py wheel [--clean] +``` + +**What it does:** +1. Checks for `flit` +2. Verifies `burr/tracking/server/build/` contains UI assets +3. Optionally cleans `dist/` (with `--clean`) +4. Runs `flit build --format wheel` + - **Packages all files in `burr/` directory, including `burr/tracking/server/build/`** + - Does NOT include files outside `burr/` (e.g., `telemetry/ui/`, `scripts/`, `examples/`) +5. Verifies `.whl` file was created + +**Output:** `dist/burr-<version>-py3-none-any.whl` (includes bundled UI) + +## 4. Upload to PyPI + +After building the wheel: + +```bash +twine upload dist/burr-<version>-py3-none-any.whl +``` + +## Package Contents Reference + +Understanding what goes in each package type: + +### Source tarball (`apache-burr-{version}-incubating.tar.gz`) + +**Controlled by:** `[tool.flit.sdist]` in `pyproject.toml` + `release_helper.py` cleanup + +**Includes:** +- ✅ `burr/` — Full package source code +- ✅ `scripts/` — Build helper scripts (this directory!) +- ✅ `telemetry/ui/` — UI source code (package.json, src/, public/, etc.) +- ✅ `examples/email-assistant/`, `examples/multi-modal-chatbot/`, etc. — Selected example directories +- ✅ `LICENSE`, `NOTICE`, `DISCLAIMER` — Apache license files + +**Excludes:** +- ❌ `burr/tracking/server/build/` — Deleted by `release_helper.py` before build +- ❌ `telemetry/ui/node_modules/` — Excluded by `[tool.flit.sdist]` +- ❌ `telemetry/ui/build/`, `telemetry/ui/dist/` — Excluded by `[tool.flit.sdist]` +- ❌ `docs/`, `.git/`, `.github/` — Excluded by `[tool.flit.sdist]` + +**How it's built:** +```bash +rm -rf burr/tracking/server/build # Ensure no pre-built UI +flit build --format sdist # Build from [tool.flit.sdist] config +``` + +--- + +### Wheel (`burr-{version}-py3-none-any.whl`) + +**Controlled by:** What exists in `burr/` directory when `flit build --format wheel` runs + +**Includes:** +- ✅ `burr/` — Complete package (all `.py` files, `py.typed`, etc.) +- ✅ `burr/tracking/server/build/` — **Pre-built UI assets** (created by `build_artifacts.py`) +- ✅ `burr/tracking/server/demo_data/` — Demo data files + +**Excludes:** +- ❌ `telemetry/ui/` — Not in `burr/` package +- ❌ `examples/` — Not in `burr/` package (sdist-only) +- ❌ `scripts/` — Not in `burr/` package (sdist-only) +- ❌ `LICENSE`, `NOTICE`, `DISCLAIMER` — Not needed in wheel (sdist-only) + +**How it's built:** +```bash +burr-admin-build-ui # Creates burr/tracking/server/build/ +flit build --format wheel # Packages everything in burr/ +``` + +--- + +### Key Insight + +The **same `burr/` source directory** produces different outputs based on **when you build** and **what format**: + +1. **sdist (source tarball):** Includes external files (`scripts/`, `telemetry/ui/`, `examples/`) via `[tool.flit.sdist]` config, but excludes `burr/tracking/server/build/` because we delete it first. + +2. **wheel (binary distribution):** Only packages `burr/` directory contents, but includes `burr/tracking/server/build/` because we create it before building the wheel. diff --git a/scripts/build_artifacts.py b/scripts/build_artifacts.py new file mode 100644 index 00000000..9a62027a --- /dev/null +++ b/scripts/build_artifacts.py @@ -0,0 +1,290 @@ +# 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. + +""" +Build artifacts/wheels helper with subcommands: + + python scripts/build_artifacts.py artifacts [--skip-install] + python scripts/build_artifacts.py wheel [--clean] + python scripts/build_artifacts.py all [--skip-install] [--clean] + +Subcommands: + artifacts -> Build UI artifacts only + wheel -> Build wheel (requires artifacts to exist) + all -> Run both steps (artifacts then wheel) +""" + +import argparse +import os +import shutil +import subprocess +import sys + + +def _ensure_project_root() -> bool: + if not os.path.exists("pyproject.toml"): + print("Error: pyproject.toml not found.") + print("Please run this script from the root of the Burr source directory.") + return False + return True + + +def _check_node_prereqs() -> bool: + print("Checking for required tools...") + required_tools = ["node", "npm"] + missing_tools = [] + + for tool in required_tools: + if shutil.which(tool) is None: + missing_tools.append(tool) + print(f" ✗ '{tool}' not found") + else: + print(f" ✓ '{tool}' found") + + if missing_tools: + print(f"\nError: Missing required tools: {', '.join(missing_tools)}") + print("Please install Node.js and npm to build the UI.") + return False + + print("All required tools found.\n") + return True + + +def _require_flit() -> bool: + if shutil.which("flit") is None: + print("✗ flit CLI not found. Please install it with: pip install flit") + return False + print("✓ flit CLI found.\n") + return True + + +def _install_burr(skip_install: bool) -> bool: + if skip_install: + print("Skipping burr installation as requested.\n") + return True + + print("Installing burr from source...") + try: + subprocess.run( + [sys.executable, "-m", "pip", "install", "-e", "."], + check=True, + cwd=os.getcwd(), + ) + print("✓ Burr installed successfully.\n") + return True + except subprocess.CalledProcessError as exc: + print(f"✗ Error installing burr: {exc}") + return False + + +def _build_ui() -> bool: + print("Building UI assets...") + try: + env = os.environ.copy() + env["BURR_PROJECT_ROOT"] = os.getcwd() + subprocess.run(["burr-admin-build-ui"], check=True, env=env) + print("✓ UI build completed successfully.\n") + return True + except subprocess.CalledProcessError as exc: + print(f"✗ Error building UI: {exc}") + return False + + +def _verify_artifacts() -> bool: + build_dir = "burr/tracking/server/build" + print(f"Verifying build output in {build_dir}...") + + if not os.path.exists(build_dir): + print(f"Build directory missing, creating placeholder at {build_dir}...") + os.makedirs(build_dir, exist_ok=True) + + if not os.listdir(build_dir): + print(f"✗ Build directory is empty: {build_dir}") + return False + + print("✓ Build output verified.\n") + return True + + +def _clean_dist(): + if os.path.exists("dist"): + print("Cleaning dist/ directory...") + shutil.rmtree("dist") + print("✓ dist/ directory cleaned.\n") + + +def _clean_ui_build(): + """Remove any existing UI build directory to ensure clean state.""" + ui_build_dir = "burr/tracking/server/build" + if os.path.exists(ui_build_dir): + print(f"Cleaning existing UI build directory: {ui_build_dir}") + shutil.rmtree(ui_build_dir) + print("✓ UI build directory cleaned.\n") + + +def _build_wheel() -> bool: + print("Building wheel distribution with 'flit build --format wheel'...") + try: + env = os.environ.copy() + env["FLIT_USE_VCS"] = "0" + subprocess.run(["flit", "build", "--format", "wheel"], check=True, env=env) + print("✓ Wheel build completed successfully.\n") + return True + except subprocess.CalledProcessError as exc: + print(f"✗ Error building wheel: {exc}") + return False + + +def _verify_wheel() -> bool: + print("Verifying wheel output...") + + if not os.path.exists("dist"): + print("✗ dist/ directory not found") + return False + + wheel_files = [f for f in os.listdir("dist") if f.endswith(".whl")] + if not wheel_files: + print("✗ No wheel files found in dist/") + if os.listdir("dist"): + print("Contents of dist/ directory:") + for item in os.listdir("dist"): + print(f" - {item}") + return False + + print(f"✓ Found {len(wheel_files)} wheel file(s):") + for wheel_file in wheel_files: + wheel_path = os.path.join("dist", wheel_file) + size = os.path.getsize(wheel_path) + print(f" - {wheel_file} ({size:,} bytes)") + + print() + return True + + +def create_artifacts(skip_install: bool) -> bool: + if not _ensure_project_root(): + print("Failed to confirm project root.") + return False + if not _check_node_prereqs(): + print("Node/npm prerequisite check failed.") + return False + # Clean any existing UI build to ensure fresh state + _clean_ui_build() + if not _install_burr(skip_install): + print("Installing burr from source failed.") + return False + if not _build_ui(): + print("UI build failed.") + return False + if not _verify_artifacts(): + print("UI artifact verification failed.") + return False + return True + + +def create_wheel(clean: bool) -> bool: + if not _ensure_project_root(): + print("Failed to confirm project root.") + return False + if not _require_flit(): + print("Missing flit CLI.") + return False + if not _verify_artifacts(): + print("Please run the 'artifacts' subcommand first.") + return False + if clean: + _clean_dist() + if not _build_wheel(): + return False + if not _verify_wheel(): + return False + return True + + +def build_all(skip_install: bool, clean: bool) -> bool: + if not create_artifacts(skip_install=skip_install): + return False + if not create_wheel(clean=clean): + return False + return True + + +def main(): + parser = argparse.ArgumentParser( + description="Build artifacts/wheels for Burr using subcommands." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + artifacts_parser = subparsers.add_parser("artifacts", help="Build UI artifacts only.") + artifacts_parser.add_argument( + "--skip-install", + action="store_true", + help="Skip reinstalling burr when building artifacts", + ) + + wheel_parser = subparsers.add_parser( + "wheel", help="Build wheel distribution (requires artifacts)." + ) + wheel_parser.add_argument( + "--clean", + action="store_true", + help="Clean dist/ directory before building wheel", + ) + + all_parser = subparsers.add_parser("all", help="Build artifacts and wheel in sequence.") + all_parser.add_argument( + "--skip-install", + action="store_true", + help="Skip reinstalling burr when building artifacts", + ) + all_parser.add_argument( + "--clean", + action="store_true", + help="Clean dist/ directory before building wheel", + ) + + args = parser.parse_args() + + print("=" * 80) + print(f"Burr Build Helper - command: {args.command}") + print("=" * 80) + print() + + success = False + if args.command == "artifacts": + success = create_artifacts(skip_install=args.skip_install) + elif args.command == "wheel": + success = create_wheel(clean=args.clean) + elif args.command == "all": + success = build_all(skip_install=args.skip_install, clean=args.clean) + + if success: + print("=" * 80) + print("✅ Build Complete!") + print("=" * 80) + if args.command in {"wheel", "all"}: + print("\nWheel files are in the dist/ directory.") + print("You can now upload to PyPI with:") + print(" twine upload dist/*.whl") + print() + else: + print("\n❌ Build failed.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/release_helper.py b/scripts/release_helper.py new file mode 100644 index 00000000..d620a13d --- /dev/null +++ b/scripts/release_helper.py @@ -0,0 +1,442 @@ +# 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. + +import argparse +import glob +import hashlib +import os +import re +import shutil +import subprocess +import sys + +# --- Configuration --- +# You need to fill these in for your project. +# The name of your project's short name (e.g., 'myproject'). +PROJECT_SHORT_NAME = "burr" +# The file where you want to update the version number. +VERSION_FILE = "pyproject.toml" +# A regular expression pattern to find the version string in the VERSION_FILE. +VERSION_PATTERN = r'version\s*=\s*"(\d+\.\d+\.\d+)"' + + +def _fail(message: str) -> None: + print(f"\n❌ {message}") + sys.exit(1) + + +def get_version_from_file(file_path: str) -> str: + """Get the version from a file.""" + with open(file_path, encoding="utf-8") as f: + content = f.read() + match = re.search(VERSION_PATTERN, content) + if match: + version = match.group(1) + return version + raise ValueError(f"Could not find version in {file_path}") + + +def check_prerequisites(): + """Checks for necessary command-line tools and Python modules.""" + print("Checking for required tools...") + required_tools = ["git", "gpg", "svn", "flit"] + for tool in required_tools: + if shutil.which(tool) is None: + _fail( + f"Required tool '{tool}' not found. Please install it and ensure it's in your PATH." + ) + + print("All required tools found.") + + +def update_version(version, _rc_num): + """Updates the version number in the specified file.""" + print(f"Updating version in {VERSION_FILE} to {version}...") + try: + with open(VERSION_FILE, "r", encoding="utf-8") as f: + content = f.read() + # For pyproject.toml, we just update the version string directly + new_version_string = f'version = "{version}"' + new_content = re.sub(VERSION_PATTERN, new_version_string, content) + if new_content == content: + print("Error: Could not find or replace version string. Check your VERSION_PATTERN.") + return False + + with open(VERSION_FILE, "w", encoding="utf-8") as f: + f.write(new_content) + + print("Version updated successfully.") + return True + + except FileNotFoundError: + _fail(f"{VERSION_FILE} not found.") + except (OSError, re.error) as e: + _fail(f"An error occurred while updating the version: {e}") + + +def sign_artifacts(archive_name: str) -> list[str]: + """Creates signed files for the designated artifact.""" + files = [] + # Sign the tarball with GPG. The user must have a key configured. + try: + subprocess.run( + ["gpg", "--armor", "--output", f"{archive_name}.asc", "--detach-sig", archive_name], + check=True, + ) + files.append(f"{archive_name}.asc") + print(f"Created GPG signature: {archive_name}.asc") + except subprocess.CalledProcessError as e: + _fail(f"Error signing tarball {archive_name}: {e}") + + # Generate SHA512 checksum. + sha512_hash = hashlib.sha512() + with open(archive_name, "rb") as f: + while True: + data = f.read(65536) + if not data: + break + sha512_hash.update(data) + + with open(f"{archive_name}.sha512", "w", encoding="utf-8") as f: + f.write(f"{sha512_hash.hexdigest()}\n") + print(f"Created SHA512 checksum: {archive_name}.sha512") + files.append(f"{archive_name}.sha512") + return files + + +def create_release_artifacts(version, build_wheel=False) -> list[str]: + """Creates the source tarball, GPG signatures, and checksums using flit. + + Args: + version: The version string for the release + build_wheel: If True, also build and sign a wheel distribution + """ + print("\n[Step 1/1] Creating source release artifacts with 'flit build'...") + + # Clean the dist directory before building. + if os.path.exists("dist"): + shutil.rmtree("dist") + # Ensure no pre-built UI assets slip into the source package. + ui_build_dir = os.path.join("burr", "tracking", "server", "build") + if os.path.exists(ui_build_dir): + print("Removing previously built UI artifacts...") + shutil.rmtree(ui_build_dir) + + # Warn if git working tree is dirty/untracked + try: + dirty = ( + subprocess.check_output(["git", "status", "--porcelain"], stderr=subprocess.DEVNULL) + .decode() + .strip() + ) + if dirty: + print( + "⚠️ Detected untracked or modified files. flit may refuse to build; " + "consider committing/stashing or verify FLIT_USE_VCS=0." + ) + print(" Git status summary:") + for line in dirty.splitlines(): + print(f" {line}") + except subprocess.CalledProcessError: + pass + + # Use flit to create the source distribution. + try: + env = os.environ.copy() + env["FLIT_USE_VCS"] = "0" + subprocess.run(["flit", "build", "--format", "sdist"], check=True, env=env) + print("✓ flit sdist created successfully.") + except subprocess.CalledProcessError as e: + _fail(f"Error creating source distribution: {e}") + + # Find the created tarball in the dist directory. + expected_tar_ball = f"dist/burr-{version.lower()}.tar.gz" + tarball_path = glob.glob(expected_tar_ball) + + if not tarball_path: + details = [] + if os.path.exists("dist"): + details.append("Contents of 'dist':") + for item in os.listdir("dist"): + details.append(f"- {item}") + else: + details.append("'dist' directory not found.") + _fail( + "Could not find the generated source tarball in the 'dist' directory.\n" + + "\n".join(details) + ) + + # Rename the tarball to apache-burr-{version.lower()}-incubating.tar.gz + apache_tar_ball = f"dist/apache-burr-{version.lower()}-incubating.tar.gz" + shutil.move(tarball_path[0], apache_tar_ball) + print(f"✓ Created source tarball: {apache_tar_ball}") + + # Sign the Apache tarball + signed_files = sign_artifacts(apache_tar_ball) + all_files = [apache_tar_ball] + signed_files + + # Optionally build the wheel (without built UI artifacts) + if build_wheel: + print("\n[Step 2/2] Creating wheel distribution with 'flit build --format wheel'...") + try: + env = os.environ.copy() + env["FLIT_USE_VCS"] = "0" + subprocess.run(["flit", "build", "--format", "wheel"], check=True, env=env) + print("✓ flit wheel created successfully.") + except subprocess.CalledProcessError as e: + _fail(f"Error creating wheel distribution: {e}") + + # Find the created wheel in the dist directory. + expected_wheel = f"dist/burr-{version.lower()}-*.whl" + wheel_path = glob.glob(expected_wheel) + + if not wheel_path: + details = [] + if os.path.exists("dist"): + details.append("Contents of 'dist':") + for item in os.listdir("dist"): + details.append(f"- {item}") + else: + details.append("'dist' directory not found.") + _fail( + "Could not find the generated wheel in the 'dist' directory.\n" + "\n".join(details) + ) + + # Rename the wheel to apache-burr-{version.lower()}-incubating-{rest}.whl + # Extract the wheel tags (e.g., py3-none-any.whl) + original_wheel = os.path.basename(wheel_path[0]) + # Pattern: burr-{version}-{tags}.whl -> apache-burr-{version}-incubating-{tags}.whl + wheel_tags = original_wheel.replace(f"burr-{version.lower()}-", "") + apache_wheel = f"dist/apache-burr-{version.lower()}-incubating-{wheel_tags}" + shutil.move(wheel_path[0], apache_wheel) + print(f"✓ Created wheel: {apache_wheel}") + + # Sign the Apache wheel + wheel_signed_files = sign_artifacts(apache_wheel) + all_files.extend([apache_wheel] + wheel_signed_files) + + return all_files + + +def svn_upload(version, rc_num, archive_files, apache_id): + """Uploads the artifacts to the ASF dev distribution repository.""" + print("Uploading artifacts to ASF SVN...") + svn_path = f"https://dist.apache.org/repos/dist/dev/incubator/{PROJECT_SHORT_NAME}/apache-burr/{version}-incubating-RC{rc_num}" + + try: + # Create a new directory for the release candidate. + subprocess.run( + [ + "svn", + "mkdir", + "-m", + f"Creating directory for {version}-incubating-RC{rc_num}", + svn_path, + ], + check=True, + ) + + # Get the files to import (tarball, asc, sha512). + files_to_import = archive_files + + # Use svn import for the new directory. + for file_path in files_to_import: + subprocess.run( + [ + "svn", + "import", + file_path, + f"{svn_path}/{os.path.basename(file_path)}", + "-m", + f"Adding {os.path.basename(file_path)}", + "--username", + apache_id, + ], + check=True, + ) + + print(f"Artifacts successfully uploaded to: {svn_path}") + return svn_path + + except subprocess.CalledProcessError as e: + print(f"Error during SVN upload: {e}") + print("Make sure you have svn access configured for your Apache ID.") + return None + + +def generate_email_template(version, rc_num, svn_url): + """Generates the content for the [VOTE] email.""" + print("Generating email template...") + version_with_incubating = f"{version}-incubating" + tag = f"v{version}" + + email_content = f"""[VOTE] Release Apache {PROJECT_SHORT_NAME} {version_with_incubating} (release candidate {rc_num}) + +Hi all, + +This is a call for a vote on releasing Apache {PROJECT_SHORT_NAME} {version_with_incubating}, +release candidate {rc_num}. + +This release includes the following changes (see CHANGELOG for details): +- [List key changes here] + +The artifacts for this release candidate can be found at: +{svn_url} + +The Git tag to be voted upon is: +{tag} + +The release hash is: +[Insert git commit hash here] + + +Release artifacts are signed with the following key: +[Insert your GPG key ID here] +The KEYS file is available at: +https://downloads.apache.org/incubator/{PROJECT_SHORT_NAME}/KEYS + +Please download, verify, and test the release candidate. + +For testing, please run some of the examples, scripts/qualify.sh has +a sampling of them to run. + +The vote will run for a minimum of 72 hours. +Please vote: + +[ ] +1 Release this package as Apache {PROJECT_SHORT_NAME} {version_with_incubating} +[ ] +0 No opinion +[ ] -1 Do not release this package because... (Please provide a reason) + +Checklist for reference: +[ ] Download links are valid. +[ ] Checksums and signatures. +[ ] LICENSE/NOTICE files exist +[ ] No unexpected binary files +[ ] All source files have ASF headers +[ ] Can compile from source + +On behalf of the Apache {PROJECT_SHORT_NAME} PPMC, +[Your Name] +""" + print("\n" + "=" * 80) + print("EMAIL TEMPLATE (COPY AND PASTE TO YOUR MAILING LIST)") + print("=" * 80) + print(email_content) + print("=" * 80) + + +def main(): + """ + ### How to Use the Updated Script + + 1. **Install flit**: + ```bash + pip install flit + ``` + 2. **Configure the Script**: Open `apache_release_helper.py` in a text editor and update the three variables at the top of the file with your project's details: + * `PROJECT_SHORT_NAME` + * `VERSION_FILE` and `VERSION_PATTERN` + 3. **Prerequisites**: + * You must have `git`, `gpg`, `svn`, and `flit` installed. + * Your GPG key and SVN access must be configured for your Apache ID. + 4. **Run the Script**: + Open your terminal, navigate to the root of your project directory, and run the script with the desired version, release candidate number, and Apache ID. + + + python apache_release_helper.py 1.2.3 0 your_apache_id + """ + parser = argparse.ArgumentParser(description="Automates parts of the Apache release process.") + parser.add_argument("version", help="The new release version (e.g., '1.0.0').") + parser.add_argument("rc_num", help="The release candidate number (e.g., '0' for RC0).") + parser.add_argument("apache_id", help="Your apache user ID.") + parser.add_argument( + "--dry-run", + action="store_true", + help="Run in dry-run mode (skip git tag creation and SVN upload)", + ) + parser.add_argument( + "--build-wheel", + action="store_true", + help="Also build and sign a wheel distribution (in addition to the source tarball)", + ) + args = parser.parse_args() + + version = args.version + rc_num = args.rc_num + apache_id = args.apache_id + dry_run = args.dry_run + build_wheel = args.build_wheel + + if dry_run: + print("\n*** DRY RUN MODE - No git tags or SVN uploads will be performed ***\n") + + check_prerequisites() + + current_version = get_version_from_file(VERSION_FILE) + print(current_version) + if current_version != version: + _fail( + "Version mismatch. Update pyproject.toml to the requested version before running the script." + ) + + tag_name = f"v{version}-incubating-RC{rc_num}" + if dry_run: + print(f"\n[DRY RUN] Would create git tag '{tag_name}'") + else: + print(f"\nChecking for git tag '{tag_name}'...") + try: + # Check if the tag already exists + existing_tag = subprocess.check_output(["git", "tag", "-l", tag_name]).decode().strip() + if existing_tag == tag_name: + print(f"Git tag '{tag_name}' already exists.") + response = ( + input("Do you want to continue without creating a new tag? (y/n): ") + .lower() + .strip() + ) + if response != "y": + print("Aborting.") + sys.exit(1) + else: + # Tag does not exist, create it + print(f"Creating git tag '{tag_name}'...") + subprocess.run(["git", "tag", tag_name], check=True) + print(f"Git tag {tag_name} created.") + except subprocess.CalledProcessError as e: + _fail(f"Error checking or creating Git tag: {e}") + + # Create artifacts + archive_files = create_release_artifacts(version, build_wheel=build_wheel) + + # Upload artifacts + # NOTE: You MUST have your SVN client configured to use your Apache ID and have permissions. + if dry_run: + svn_url = f"https://dist.apache.org/repos/dist/dev/incubator/{PROJECT_SHORT_NAME}/apache-burr/{version}-incubating-RC{rc_num}" + print(f"\n[DRY RUN] Would upload artifacts to: {svn_url}") + else: + svn_url = svn_upload(version, rc_num, archive_files, apache_id) + if not svn_url: + _fail("SVN upload failed.") + + # Generate email + generate_email_template(version, rc_num, svn_url) + + print("\nProcess complete. Please copy the email template to your mailing list.") + + +if __name__ == "__main__": + main() diff --git a/scripts/setup_keys.sh b/scripts/setup_keys.sh new file mode 100755 index 00000000..a4f12615 --- /dev/null +++ b/scripts/setup_keys.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# 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. + +# This script helps new Apache committers set up their GPG keys for releases. +# It guides you through creating a new key, exports the public key, and +# provides instructions on how to add it to your project's KEYS file. + +echo "========================================================" +echo " Apache GPG Key Setup Script" +echo "========================================================" +echo " " +echo "Step 1: Generating a new GPG key." +echo " " +echo "Please be aware of Apache's best practices for GPG keys:" +echo "- **Key Type:** Select **(1) RSA and RSA**." +echo "- **Key Size:** Enter **4096**." +echo "- **Email Address:** Use your official **@apache.org** email address." +echo "- **Passphrase:** Use a strong, secure passphrase." +echo " " +read -p "Press [Enter] to start the GPG key generation..." + +# Generate a new GPG key +# The --batch and --passphrase-fd 0 options are used for automation, +# but the script will still require interactive input. +gpg --full-gen-key + +if [ $? -ne 0 ]; then + echo "Error: GPG key generation failed. Please check your GPG installation." + exit 1 +fi + +echo " " +echo "Step 2: Listing your GPG keys to find the new key ID." +echo "Your new key is listed under 'pub' with a string of 8 or 16 characters after the '/'." + +# List all GPG keys +gpg --list-keys + +echo " " +read -p "Please copy and paste your new key ID here (e.g., A1B2C3D4 or 1234ABCD5678EF01): " KEY_ID + +if [ -z "$KEY_ID" ]; then + echo "Error: Key ID cannot be empty. Exiting." + exit 1 +fi + +echo " " +echo "Step 3: Exporting your public key to a file." + +# Export the public key in ASCII armored format +gpg --armor --export "$KEY_ID" > "$KEY_ID.asc" + +if [ $? -ne 0 ]; then + echo "Error: Public key export failed. Please ensure the Key ID is correct." + rm -f "$KEY_ID.asc" + exit 1 +fi + +echo "Checking out dist repository to update KEYS file" +svn checkout --depth immediates https://dist.apache.org/repos/dist dist +cd dist/release +svn checkout https://dist.apache.org/repos/dist/release/incubator/burr incubator/burr + +cd ../../ +gpg --list-keys "$KEY_ID" >> dist/release/incubator/burr/KEYS +cat "$KEY_ID.asc" >> dist/release/incubator/burr/KEYS +cd dist/release/incubator/burr + +echo " " +echo "========================================================" +echo " Setup Complete!" +echo "========================================================" +echo "Your public key has been saved to: $KEY_ID.asc" +echo " " +echo "NEXT STEPS (VERY IMPORTANT):" +echo "1. Please inspect the KEYS file to ensure the new key is added correctly. It should be in the current directory." +echo "2. If all good run: svn update KEYS && svn commit -m \"Adds new key $KEY_ID for YOUR NAME\"" +echo "3. Inform the mailing list that you've updated the KEYS file." +echo " The updated KEYS file is essential for others to verify your release signatures." +echo " " diff --git a/telemetry/ui/package-lock.json b/telemetry/ui/package-lock.json index b71fbefc..21461ed4 100644 --- a/telemetry/ui/package-lock.json +++ b/telemetry/ui/package-lock.json @@ -25,6 +25,7 @@ "@uiw/react-json-view": "^2.0.0-alpha.12", "clsx": "^2.1.0", "dagre": "^0.8.5", + "es-abstract": "^1.22.4", "fuse.js": "^7.0.0", "heroicons": "^2.1.1", "react": "^18.2.0", @@ -38,6 +39,7 @@ "react-syntax-highlighter": "^15.5.0", "reactflow": "^11.10.4", "remark-gfm": "^4.0.0", + "string.prototype.matchall": "^4.0.10", "tailwindcss-question-mark": "^0.4.0", "typescript": "^4.9.5", "web-vitals": "^2.1.4" diff --git a/telemetry/ui/package.json b/telemetry/ui/package.json index 1c492bf3..1a391fcc 100644 --- a/telemetry/ui/package.json +++ b/telemetry/ui/package.json @@ -20,6 +20,7 @@ "@uiw/react-json-view": "^2.0.0-alpha.12", "clsx": "^2.1.0", "dagre": "^0.8.5", + "es-abstract": "^1.22.4", "fuse.js": "^7.0.0", "heroicons": "^2.1.1", "react": "^18.2.0", @@ -33,6 +34,7 @@ "react-syntax-highlighter": "^15.5.0", "reactflow": "^11.10.4", "remark-gfm": "^4.0.0", + "string.prototype.matchall": "^4.0.10", "tailwindcss-question-mark": "^0.4.0", "typescript": "^4.9.5", "web-vitals": "^2.1.4" diff --git a/tests/test_release_config.py b/tests/test_release_config.py new file mode 100644 index 00000000..e97aa628 --- /dev/null +++ b/tests/test_release_config.py @@ -0,0 +1,127 @@ +""" +Tests to validate release configuration in pyproject.toml. + +This ensures the examples include/exclude lists stay in sync with the actual +examples directory structure. +""" + +import tomllib +from pathlib import Path + + +def test_examples_include_exclude_coverage(): + """ + Verify that pyproject.toml's [tool.flit.sdist] include/exclude lists cover + all example directories. + + WHY THIS TEST EXISTS: + Flit automatically includes the examples/ directory in the release tarball because + it's a Python package (has __init__.py). Without explicit include/exclude rules, + ALL examples would be shipped in the Apache release, which is not intended. + + For Apache releases, we only want to include 4 specific examples for voters to test: + - email-assistant + - multi-modal-chatbot + - streaming-fastapi + - deep-researcher + + All other examples must be explicitly excluded. This test ensures the configuration + stays in sync with the filesystem when examples are added/removed. + + If this test fails, you need to update pyproject.toml: + - To INCLUDE an example: add it to [tool.flit.sdist] include list + - To EXCLUDE an example: add it to [tool.flit.sdist] exclude list + """ + # Load pyproject.toml + project_root = Path(__file__).parent.parent + pyproject_path = project_root / "pyproject.toml" + + with open(pyproject_path, "rb") as f: + config = tomllib.load(f) + + flit_sdist = config.get("tool", {}).get("flit", {}).get("sdist", {}) + include_patterns = flit_sdist.get("include", []) + exclude_patterns = flit_sdist.get("exclude", []) + + # Extract example directories from include patterns + included_examples = set() + for pattern in include_patterns: + if pattern.startswith("examples/") and pattern.endswith("/**"): + # Extract directory name from patterns like "examples/email-assistant/**" + dir_name = pattern.removeprefix("examples/").removesuffix("/**") + included_examples.add(dir_name) + + # Extract example directories from exclude patterns + excluded_examples = set() + excluded_files = set() + for pattern in exclude_patterns: + if pattern.startswith("examples/"): + if pattern.endswith("/**"): + # Directory pattern like "examples/adaptive-crag/**" + dir_name = pattern.removeprefix("examples/").removesuffix("/**") + excluded_examples.add(dir_name) + else: + # File pattern like "examples/__init__.py" + file_name = pattern.removeprefix("examples/") + excluded_files.add(file_name) + + # Get actual example directories from filesystem + examples_dir = project_root / "examples" + actual_dirs = set() + actual_files = set() + + if examples_dir.exists(): + for item in examples_dir.iterdir(): + if item.name.startswith(".") or item.name == "__pycache__": + continue + if item.is_dir(): + actual_dirs.add(item.name) + else: + actual_files.add(item.name) + + # Check coverage + configured_dirs = included_examples | excluded_examples + missing_from_config = actual_dirs - configured_dirs + extra_in_config = configured_dirs - actual_dirs + + # Build error message if mismatch found + errors = [] + + if missing_from_config: + errors.append( + f"\n❌ Example directories exist but are NOT in pyproject.toml config:\n" + f" {sorted(missing_from_config)}\n" + f"\n WHY THIS MATTERS:\n" + f" Flit auto-discovers examples/ as a package (it has __init__.py) and will\n" + f" include ALL subdirectories in the release tarball unless explicitly excluded.\n" + f" Every example directory MUST be either included or excluded to ensure the\n" + f" Apache release contains only the intended examples for voters to test.\n" + f"\n To fix: Add to pyproject.toml [tool.flit.sdist]:\n" + f" - To INCLUDE in Apache release: add 'examples/<name>/**' to 'include' list\n" + f" - To EXCLUDE from Apache release: add 'examples/<name>/**' to 'exclude' list\n" + f"\n Currently only these 4 examples should be included:\n" + f" email-assistant, multi-modal-chatbot, streaming-fastapi, deep-researcher\n" + ) + + if extra_in_config: + errors.append( + f"\n❌ Example directories in pyproject.toml but NOT in filesystem:\n" + f" {sorted(extra_in_config)}\n" + f"\n WHY THIS MATTERS:\n" + f" These entries reference examples that no longer exist and should be removed\n" + f" to keep the configuration accurate and maintainable.\n" + f"\n To fix: Remove these entries from pyproject.toml [tool.flit.sdist]\n" + ) + + # Report what's currently configured (for debugging) + if errors: + summary = ( + f"\n📋 Current configuration:\n" + f" Included examples ({len(included_examples)}): {sorted(included_examples)}\n" + f" Excluded examples ({len(excluded_examples)}): {sorted(excluded_examples)}\n" + f" Excluded files ({len(excluded_files)}): {sorted(excluded_files)}\n" + f" Actual directories ({len(actual_dirs)}): {sorted(actual_dirs)}\n" + ) + errors.append(summary) + + assert not errors, "\n".join(errors)
