diff --git a/.asf.yaml b/.asf.yaml index 8b6ed5bf2..1c342d43d 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -63,7 +63,9 @@ github: # strict means "Require branches to be up to date before merging". strict: false # contexts are the names of checks that must pass - # contexts: + contexts: + - "Release Validation / build-artifacts" + - "Release Validation / install-and-smoke (3.12)" required_pull_request_reviews: dismiss_stale_reviews: false require_code_owner_reviews: false diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml new file mode 100644 index 000000000..fb110f2a1 --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,172 @@ +# +# Validates the Apache release pipeline on every PR: builds the real release +# artifacts (git archive, sdist, wheel) using the release script with +# --skip-signing, runs Apache RAT on the source tarball, then installs the +# wheel into a fresh venv outside the source tree and smoke-tests the server. +# +# This is designed to catch the class of bugs that have broken recent RCs: +# license/header issues (RAT), examples missing from the wheel (smoke test), +# and general "voter tries to install this and it breaks" failures. + +name: Release Validation + +on: + push: + branches: + - main + tags: + - 'v*.*.*-incubating-RC*' + pull_request: + types: [opened, synchronize, reopened] + paths-ignore: + - 'docs/**' + - 'website/**' + - '**/*.md' + workflow_dispatch: + +concurrency: + group: release-validation-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-artifacts: + name: build-artifacts + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v4 + with: + python-version: '3.12' + cache: pip + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: telemetry/ui/package-lock.json + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Install system deps + run: sudo apt-get install -y --no-install-recommends graphviz + + - name: Install Python build deps + run: pip install flit twine + + - name: Cache Apache RAT + id: cache-rat + uses: actions/cache@v4 + with: + path: ~/.cache/apache-rat + key: apache-rat-0.16.1 + + - name: Download Apache RAT if not cached + if: steps.cache-rat.outputs.cache-hit != 'true' + run: | + mkdir -p ~/.cache/apache-rat + curl -fL -o ~/.cache/apache-rat/apache-rat-0.16.1.jar \ + https://repo1.maven.org/maven2/org/apache/rat/apache-rat/0.16.1/apache-rat-0.16.1.jar + + - name: Extract version + id: version + run: | + VERSION=$(python -c 'import re; print(re.search(r"version\s*=\s*\"([^\"]+)\"", open("pyproject.toml").read()).group(1))') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "BURR_VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Build release artifacts (no signing, no upload) + run: | + python scripts/apache_release.py all "$BURR_VERSION" 0 ci-runner \ + --skip-signing --no-upload + + - name: Verify all 3 artifacts exist + run: | + test -f "dist/apache-burr-${BURR_VERSION}-incubating-src.tar.gz" + test -f "dist/apache-burr-${BURR_VERSION}-incubating-sdist.tar.gz" + test -f "dist/apache_burr-${BURR_VERSION}-py3-none-any.whl" + + - name: Run Apache RAT on source and sdist tarballs + run: | + python scripts/verify_apache_artifacts.py licenses \ + --rat-jar ~/.cache/apache-rat/apache-rat-0.16.1.jar \ + --artifacts-dir dist + + - name: Upload release artifacts + uses: actions/upload-artifact@v4 + with: + name: release-artifacts + path: | + dist/*.tar.gz + dist/*.whl + dist/*.sha512 + dist/rat-report-*.xml + dist/rat-report-*.txt + retention-days: 14 + + install-and-smoke: + name: install-and-smoke + needs: build-artifacts + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + # 3.9 is skipped because burr/cli/__main__.py uses PEP 604 union syntax + # (dict | None) which requires Python 3.10+. Tracked separately. + python-version: ['3.10', '3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: release-artifacts + path: dist + + - name: Run smoke test + env: + BURR_VERSION: ${{ needs.build-artifacts.outputs.version }} + run: | + python scripts/ci_smoke_server.py \ + --wheel "dist/apache_burr-${BURR_VERSION}-py3-none-any.whl" + + - name: Upload smoke workspace on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: smoke-workspace-${{ matrix.python-version }} + path: /tmp/burr-smoke-* + retention-days: 7 + if-no-files-found: ignore diff --git a/.rat-excludes b/.rat-excludes index 5347d2139..cf5f2d7e3 100644 --- a/.rat-excludes +++ b/.rat-excludes @@ -26,12 +26,24 @@ robots\.txt .*\.json # Third-party MIT-licensed files (attributed in LICENSE) -examples/deep-researcher/prompts\.py -examples/deep-researcher/utils\.py +.*examples/deep-researcher/prompts\.py +.*examples/deep-researcher/utils\.py website/src/components/ui/.*\.tsx # SVG files (third-party logos and graphics, headers impractical) .*\.svg +# GitHub templates and config (YAML/MD with frontmatter, headers impractical) +\.github/.* + +# Image files (binary, cannot contain headers) +.*\.png +.*\.gif +.*\.ico +.*\.jpg + +# Markdown files at the deployment tutorial path (frontmatter-style content) +examples/deployment/aws/terraform/tutorial\.md + # Examples pattern (legacy - keeping for compatibility) apache_burr-.*/burr/examples diff --git a/scripts/apache_release.py b/scripts/apache_release.py index 0c355ba62..708621411 100644 --- a/scripts/apache_release.py +++ b/scripts/apache_release.py @@ -140,7 +140,15 @@ def _validate_environment_for_command(args) -> None: "verify": ["git", "gpg", "twine"], } - required_tools = command_requirements.get(args.command, ["git", "gpg"]) + required_tools = list(command_requirements.get(args.command, ["git", "gpg"])) + + # Drop gpg if user opted out of signing + if getattr(args, "skip_signing", False) and "gpg" in required_tools: + required_tools.remove("gpg") + + # Drop svn if user opted out of upload (svn is only used for upload) + if getattr(args, "no_upload", False) and "svn" in required_tools: + required_tools.remove("svn") # Check for RAT if needed if hasattr(args, "check_licenses") or hasattr(args, "check_licenses_report"): @@ -236,30 +244,35 @@ def _check_git_working_tree() -> None: # ============================================================================ -def _sign_artifact(artifact_path: str) -> tuple[str, str]: - """Sign artifact with GPG and create SHA512 checksum.""" - signature_path = f"{artifact_path}.asc" +def _checksum_artifact(artifact_path: str) -> str: + """Create SHA512 checksum for artifact.""" checksum_path = f"{artifact_path}.sha512" - - # GPG signature - _run_command( - ["gpg", "--armor", "--output", signature_path, "--detach-sig", artifact_path], - description="", - error_message="Error signing artifact", - capture_output=False, - ) - print(f" ✓ Created GPG signature: {signature_path}") - - # SHA512 checksum sha512_hash = hashlib.sha512() with open(artifact_path, "rb") as f: while chunk := f.read(65536): sha512_hash.update(chunk) - with open(checksum_path, "w", encoding="utf-8") as f: f.write(f"{sha512_hash.hexdigest()}\n") print(f" ✓ Created SHA512 checksum: {checksum_path}") + return checksum_path + + +def _sign_artifact(artifact_path: str, skip_signing: bool = False) -> tuple[Optional[str], str]: + """Sign artifact with GPG (unless skipped) and create SHA512 checksum.""" + signature_path: Optional[str] = None + if not skip_signing: + signature_path = f"{artifact_path}.asc" + _run_command( + ["gpg", "--armor", "--output", signature_path, "--detach-sig", artifact_path], + description="", + error_message="Error signing artifact", + capture_output=False, + ) + print(f" ✓ Created GPG signature: {signature_path}") + else: + print(" ⊘ Skipping GPG signature (--skip-signing)") + checksum_path = _checksum_artifact(artifact_path) return (signature_path, checksum_path) @@ -311,7 +324,7 @@ def _verify_artifact_checksum(artifact_path: str, checksum_path: str) -> bool: return False -def _verify_artifact_complete(artifact_path: str) -> bool: +def _verify_artifact_complete(artifact_path: str, skip_signing: bool = False) -> bool: """Verify artifact and its signature/checksum files.""" print(f"\nVerifying artifact: {os.path.basename(artifact_path)}") @@ -319,12 +332,18 @@ def _verify_artifact_complete(artifact_path: str) -> bool: print(f" ✗ Artifact not found: {artifact_path}") return False - # Verify signature and checksum - signature_path = f"{artifact_path}.asc" + # Verify signature (unless skipped) and checksum checksum_path = f"{artifact_path}.sha512" + checksum_valid = _verify_artifact_checksum(artifact_path, checksum_path) + if skip_signing: + if checksum_valid: + print(f" ✓ Checks passed for {os.path.basename(artifact_path)} (signing skipped)\n") + return True + return False + + signature_path = f"{artifact_path}.asc" sig_valid = _verify_artifact_signature(artifact_path, signature_path) - checksum_valid = _verify_artifact_checksum(artifact_path, checksum_path) if sig_valid and checksum_valid: print(f" ✓ All checks passed for {os.path.basename(artifact_path)}\n") @@ -337,7 +356,9 @@ def _verify_artifact_complete(artifact_path: str) -> bool: # ============================================================================ -def _create_git_archive(version: str, rc_num: str, output_dir: str = "dist") -> str: +def _create_git_archive( + version: str, rc_num: str, output_dir: str = "dist", skip_signing: bool = False +) -> str: """Create git archive tar.gz for voting.""" print(f"Creating git archive for version {version}-incubating...") @@ -366,12 +387,11 @@ def _create_git_archive(version: str, rc_num: str, output_dir: str = "dist") -> file_size = os.path.getsize(archive_path) print(f" ✓ Archive size: {file_size:,} bytes") - # Sign the archive - print("Signing archive...") - _sign_artifact(archive_path) + print("Signing archive..." if not skip_signing else "Creating checksum for archive...") + _sign_artifact(archive_path, skip_signing=skip_signing) # Verify - if not _verify_artifact_complete(archive_path): + if not _verify_artifact_complete(archive_path, skip_signing=skip_signing): _fail("Archive verification failed!") return archive_path @@ -774,12 +794,15 @@ def _generate_vote_email(version: str, rc_num: str, svn_url: str) -> str: def cmd_archive(args) -> bool: """Handle 'archive' subcommand.""" _print_section(f"Creating Git Archive - v{args.version}-RC{args.rc_num}") + skip_signing = getattr(args, "skip_signing", False) _verify_project_root() _validate_version(args.version) _check_git_working_tree() - archive_path = _create_git_archive(args.version, args.rc_num, args.output_dir) + archive_path = _create_git_archive( + args.version, args.rc_num, args.output_dir, skip_signing=skip_signing + ) print(f"\n✅ Archive created: {archive_path}") return True @@ -787,16 +810,17 @@ def cmd_archive(args) -> bool: def cmd_sdist(args) -> bool: """Handle 'sdist' subcommand.""" _print_section(f"Building Source Distribution - v{args.version}-RC{args.rc_num}") + skip_signing = getattr(args, "skip_signing", False) _verify_project_root() _validate_version(args.version) sdist_path = _build_sdist_from_git(args.version, args.output_dir) - _print_step(2, 2, "Signing sdist") - _sign_artifact(sdist_path) + _print_step(2, 2, "Signing sdist" if not skip_signing else "Checksumming sdist") + _sign_artifact(sdist_path, skip_signing=skip_signing) - if not _verify_artifact_complete(sdist_path): + if not _verify_artifact_complete(sdist_path, skip_signing=skip_signing): _fail("sdist verification failed!") print(f"\n✅ Source distribution created: {sdist_path}") @@ -806,6 +830,7 @@ def cmd_sdist(args) -> bool: def cmd_wheel(args) -> bool: """Handle 'wheel' subcommand.""" _print_section(f"Building Wheel - v{args.version}-RC{args.rc_num}") + skip_signing = getattr(args, "skip_signing", False) _verify_project_root() _validate_version(args.version) @@ -820,10 +845,10 @@ def cmd_wheel(args) -> bool: if not _verify_wheel(wheel_path): _fail("Wheel verification failed!") - print("\nSigning wheel...") - _sign_artifact(wheel_path) + print("\nSigning wheel..." if not skip_signing else "\nChecksumming wheel...") + _sign_artifact(wheel_path, skip_signing=skip_signing) - if not _verify_artifact_complete(wheel_path): + if not _verify_artifact_complete(wheel_path, skip_signing=skip_signing): _fail("Wheel signature/checksum verification failed!") print(f"\n✅ Wheel created and verified: {os.path.basename(wheel_path)}") @@ -883,6 +908,9 @@ def cmd_all(args) -> bool: if args.dry_run: print("*** DRY RUN MODE ***\n") + skip_signing = getattr(args, "skip_signing", False) + if skip_signing: + print("*** SKIP SIGNING MODE ***\n") _verify_project_root() _validate_version(args.version) @@ -890,13 +918,13 @@ def cmd_all(args) -> bool: # Step 1: Git Archive _print_step(1, 4, "Creating git archive") - _create_git_archive(args.version, args.rc_num, args.output_dir) + _create_git_archive(args.version, args.rc_num, args.output_dir, skip_signing=skip_signing) # Step 2: Build sdist _print_step(2, 4, "Building sdist") sdist_path = _build_sdist_from_git(args.version, args.output_dir) - _sign_artifact(sdist_path) - if not _verify_artifact_complete(sdist_path): + _sign_artifact(sdist_path, skip_signing=skip_signing) + if not _verify_artifact_complete(sdist_path, skip_signing=skip_signing): _fail("sdist verification failed!") # Step 3: Build wheel @@ -904,8 +932,10 @@ def cmd_all(args) -> bool: wheel_path = _build_wheel_from_current_dir(args.version, args.output_dir) if not _verify_wheel_with_twine(wheel_path): _fail("Twine verification failed!") - _sign_artifact(wheel_path) - if not _verify_wheel(wheel_path) or not _verify_artifact_complete(wheel_path): + _sign_artifact(wheel_path, skip_signing=skip_signing) + if not _verify_wheel(wheel_path) or not _verify_artifact_complete( + wheel_path, skip_signing=skip_signing + ): _fail("Wheel verification failed!") # Step 4: Upload (if not disabled) @@ -945,18 +975,25 @@ def main(): archive_parser.add_argument("version", help="Version (e.g., '0.41.0')") archive_parser.add_argument("rc_num", help="RC number (e.g., '0')") archive_parser.add_argument("--output-dir", default="dist", help="Output directory") + archive_parser.add_argument( + "--skip-signing", + action="store_true", + help="Skip GPG signing (for CI). SHA512 checksum is still generated.", + ) # sdist subcommand sdist_parser = subparsers.add_parser("sdist", help="Build source distribution") sdist_parser.add_argument("version", help="Version") sdist_parser.add_argument("rc_num", help="RC number") sdist_parser.add_argument("--output-dir", default="dist") + sdist_parser.add_argument("--skip-signing", action="store_true") # wheel subcommand wheel_parser = subparsers.add_parser("wheel", help="Build wheel") wheel_parser.add_argument("version", help="Version") wheel_parser.add_argument("rc_num", help="RC number") wheel_parser.add_argument("--output-dir", default="dist") + wheel_parser.add_argument("--skip-signing", action="store_true") # upload subcommand upload_parser = subparsers.add_parser("upload", help="Upload to SVN") @@ -980,6 +1017,11 @@ def main(): all_parser.add_argument("--output-dir", default="dist") all_parser.add_argument("--dry-run", action="store_true") all_parser.add_argument("--no-upload", action="store_true") + all_parser.add_argument( + "--skip-signing", + action="store_true", + help="Skip GPG signing (for CI). SHA512 checksum is still generated.", + ) args = parser.parse_args() diff --git a/scripts/ci_smoke_server.py b/scripts/ci_smoke_server.py new file mode 100644 index 000000000..ba51d6fa4 --- /dev/null +++ b/scripts/ci_smoke_server.py @@ -0,0 +1,234 @@ +#!/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. + +""" +End-to-end smoke test for a built Burr wheel. + +Installs the wheel into a fresh venv (outside the source tree), starts the +`burr` tracking server, runs a simple tracked application, and verifies the +server observes it via the HTTP API. + +Fails fast with clear output if any step breaks. Designed to run in CI. + +Usage: + python scripts/ci_smoke_server.py --wheel dist/apache_burr-0.42.0-py3-none-any.whl + +The `burr.examples.hello-world-counter` bug (missing module at server import +time) would be caught here because starting the server triggers the module- +level importlib.import_module calls in burr/tracking/server/run.py. +""" + +import argparse +import json +import os +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + + +def _free_port() -> int: + """Pick an available localhost port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _log(msg: str) -> None: + print(f"[smoke] {msg}", flush=True) + + +def _fail(msg: str) -> "None": + print(f"[smoke] FAIL: {msg}", flush=True) + sys.exit(1) + + +def _poll_url(url: str, timeout_s: int = 30, server_proc: "subprocess.Popen | None" = None) -> bool: + """Poll URL until 200 or timeout. Fails fast if server process dies.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + if server_proc is not None and server_proc.poll() is not None: + return False + try: + with urllib.request.urlopen(url, timeout=2) as resp: + if resp.status == 200: + return True + except (urllib.error.URLError, ConnectionResetError, TimeoutError): + pass + time.sleep(1) + return False + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", required=True, help="Path to the wheel to smoke-test") + parser.add_argument( + "--python", + default=sys.executable, + help="Python interpreter to use for the venv (default: current)", + ) + parser.add_argument( + "--port", + type=int, + default=0, + help="Port for the burr server (0 = auto-pick free port)", + ) + parser.add_argument( + "--timeout", + type=int, + default=45, + help="Seconds to wait for the server to become ready", + ) + args = parser.parse_args() + + wheel_path = Path(args.wheel).resolve() + if not wheel_path.is_file(): + _fail(f"Wheel not found: {wheel_path}") + + port = args.port if args.port else _free_port() + + # Fresh working dirs, outside of any source tree + work_dir = Path(tempfile.mkdtemp(prefix="burr-smoke-")) + venv_dir = work_dir / "venv" + burr_data_dir = work_dir / "burr-data" + burr_data_dir.mkdir() + server_log = work_dir / "server.log" + app_script = work_dir / "tracked_app.py" + + _log(f"Workspace: {work_dir}") + _log(f"Python: {args.python}") + _log(f"Wheel: {wheel_path}") + + server_proc = None + try: + # 1. Create venv + _log("Creating venv...") + subprocess.run([args.python, "-m", "venv", str(venv_dir)], check=True) + + venv_py = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + venv_burr = venv_dir / ("Scripts/burr.exe" if os.name == "nt" else "bin/burr") + + # 2. Install wheel + _log("Installing wheel (with [learn] extras)...") + subprocess.run( + [str(venv_py), "-m", "pip", "install", "--upgrade", "pip", "--quiet"], check=True + ) + subprocess.run( + [str(venv_py), "-m", "pip", "install", f"{wheel_path}[learn]", "--quiet"], + check=True, + ) + + # 3. Smoke check: import the server module. This is the minimal check that + # would have caught the hello-world-counter regression without needing to + # actually start uvicorn. + _log("Importing burr.tracking.server.run (catches missing-example bugs)...") + subprocess.run( + [str(venv_py), "-c", "import burr.tracking.server.run"], + check=True, + cwd=str(work_dir), + ) + + # 4. Start server from outside the source tree so CWD can't shadow the install. + _log(f"Starting burr server on port {port}...") + env = os.environ.copy() + env["burr_path"] = str(burr_data_dir) + env["PYTHONUNBUFFERED"] = "1" + with open(server_log, "w") as log_fh: + server_proc = subprocess.Popen( + [str(venv_burr), "--port", str(port), "--no-open"], + cwd=str(work_dir), + env=env, + stdout=log_fh, + stderr=subprocess.STDOUT, + ) + + base_url = f"http://127.0.0.1:{port}" + _log(f"Waiting up to {args.timeout}s for {base_url}/ready ...") + if not _poll_url(f"{base_url}/ready", timeout_s=args.timeout, server_proc=server_proc): + if server_proc.poll() is not None: + _log(f"Server process exited with code {server_proc.returncode}") + _log("--- server log ---") + print(server_log.read_text(), flush=True) + _log("--- end server log ---") + _fail("Server did not become ready") + _log("Server is up") + + # 5. Run a tracked Burr app as a separate process using the venv. + _log("Running tracked Burr app...") + app_script.write_text( + f"""\ +from burr.core import ApplicationBuilder, State, default +from burr.core.action import action +from burr.tracking import LocalTrackingClient + + +@action(reads=["count"], writes=["count"]) +def inc(state: State) -> State: + return state.update(count=state["count"] + 1) + + +tracker = LocalTrackingClient(project="ci-smoke-test", storage_dir={str(burr_data_dir)!r}) + +app = ( + ApplicationBuilder() + .with_actions(inc) + .with_transitions(("inc", "inc", default)) + .with_state(count=0) + .with_entrypoint("inc") + .with_tracker(tracker) + .build() +) + +for _ in range(3): + app.step() + +print(f"count={{app.state['count']}} app_id={{app.uid}}") +""" + ) + subprocess.run([str(venv_py), str(app_script)], check=True, cwd=str(work_dir), env=env) + + # 6. Verify the server sees the project. + _log("Verifying server sees project 'ci-smoke-test'...") + time.sleep(2) # give the server a moment to pick up the filesystem change + with urllib.request.urlopen(f"{base_url}/api/v0/projects", timeout=5) as resp: + data = json.loads(resp.read().decode("utf-8")) + names = [p.get("name") for p in data] + if "ci-smoke-test" not in names: + _fail(f"Project 'ci-smoke-test' not found. Projects seen: {names}") + _log(f"Projects: {names}") + + _log("SUCCESS") + finally: + if server_proc is not None and server_proc.poll() is None: + _log("Stopping server...") + server_proc.send_signal(signal.SIGTERM) + try: + server_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + server_proc.kill() + # Leave work_dir intact in CI (uploadable as artifact); also leave it locally + # on failure for easier debugging. Caller can rm -rf /tmp/burr-smoke-* to clean up. + + +if __name__ == "__main__": + main()