From ff76817be5b1f7893f68b9659b47f6752f00e662 Mon Sep 17 00:00:00 2001 From: Elijah ben Izzy Date: Fri, 24 Apr 2026 16:18:02 -0700 Subject: [PATCH] fix: address RC2 vote feedback (#745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add __init__.py to tests/ so pytest discovery works * fix: add ASF license header to tutorial.md * fix: update .rat-excludes for RC2 feedback - Fix patterns for burr/examples/ symlink duplicates (use .* prefix) - Add .github/ templates (YAML/MD with frontmatter) - Add image file extensions (.png, .gif, .ico, .jpg) * fix: include hello-world-counter in release artifacts The tracking server imports burr.examples.hello-world-counter.server (added in #675) but the release config still had it in the exclude list. Move it from exclude to include so the wheel ships with the module the server needs, preventing ModuleNotFoundError when running burr from a fresh install. Also bumps REQUIRED_EXAMPLES in scripts/apache_release.py and updates tests/test_release_config.py accordingly. * fix: use sys.executable for uvicorn subprocess to fix venv isolation The CLI spawned uvicorn as a bare command, which resolves via PATH. For users with pyenv installed, ~/.pyenv/shims/ is in PATH ahead of any venv's bin/, so bare `uvicorn` always hits a pyenv shim that routes to a pyenv environment — never the venv the user is running burr from. This means a fresh `pip install` into a non-pyenv venv fails to start the server. Using sys.executable -m uvicorn ensures uvicorn runs under the same Python interpreter that is running burr, regardless of PATH. * chore: add --skip-signing flag and CI smoke-test helper Preparation for the release-validation CI workflow. apache_release.py: - Add --skip-signing to archive/sdist/wheel/all subcommands - Split _sign_artifact into checksum (always) and GPG sign (skippable) - Drop gpg from required tools when --skip-signing is set ci_smoke_server.py: - Installs a built wheel into a fresh venv outside the source tree - Imports burr.tracking.server.run (catches missing-example bugs like #675) - Starts the burr server on a free port with a dedicated data dir - Runs a tracked app, verifies the server observes the project * ci: drop svn requirement from 'all' command when --no-upload is set CI runners don't have svn installed. The 'all' subcommand listed svn as a required tool unconditionally, but svn is only used during the upload step. Skip the requirement when --no-upload is passed. * ci: add release validation workflow Build the full release pipeline on every PR, plus RAT license check and an end-to-end smoke install of the wheel outside the source tree. This catches the failure modes that have broken recent RCs: - build-artifacts: runs apache_release.py all --skip-signing --no-upload, verifies the 3 artifacts exist, runs Apache RAT without --report-only so license violations fail the job. - install-and-smoke: on 3.10/3.11/3.12, installs the wheel into a fresh venv and runs scripts/ci_smoke_server.py. That helper imports the server module, boots the server, runs a tracked app, and asserts the server sees the project — so a missing example (like PR #675's hello-world-counter) would fail here. Only "build-artifacts" and "install-and-smoke (3.12)" are required checks in .asf.yaml; 3.10 and 3.11 are informational. * fix: rewrite .rat-excludes with basename patterns that actually work RAT's -E regex doesn't reliably match through path separators or symlinked directories. The previous patterns like 'examples/deep-researcher/prompts.py' never excluded anything because RAT effectively matches against basenames. We never noticed because all prior RAT runs used --report-only mode. Switch to basename patterns. The .tsx files are uniquely named, so basename matching is precise. prompts.py only exists once. utils.py matches 5 different files (all our own ASF code with headers); the practical risk of skipping their checks is low. Update the leading comment in .rat-excludes to document the constraint so future authors don't repeat the mistake. * chore: add .claude/ to .gitignore * fix: address review feedback (paths-ignore, basename collisions, count) - Workflow: drop '**/*.md' from paths-ignore. It would suppress runs whenever a bundled .md file (like the deployment tutorial.md) changes — exactly the case that triggered RC2 feedback. - .rat-excludes: document the basename collisions for utils.py and button.tsx so the next maintainer doesn't think they're unique. - test_release_config.py: fix '4 examples' string that should have been bumped to 5 when hello-world-counter was added. * fix: use os.path.lexists in _prepare_wheel_contents to handle broken symlinks On CI runners, burr/examples is a relative symlink ('../examples') that may not resolve from the process's working directory — os.path.exists returns False for a broken link while the link itself is still present on disk. That made _prepare_wheel_contents skip the removal branch and then fail on os.makedirs with 'File exists: burr/examples'. Switch to os.path.lexists, which returns True for any directory entry including broken symlinks, so the cleanup branch always runs when the link is present. --- .asf.yaml | 4 +- .github/workflows/release-validation.yml | 174 +++++++++++++ .gitignore | 3 + .rat-excludes | 53 +++- burr/cli/__main__.py | 2 +- examples/deployment/aws/terraform/tutorial.md | 19 ++ pyproject.toml | 2 +- scripts/apache_release.py | 123 ++++++--- scripts/ci_smoke_server.py | 234 ++++++++++++++++++ tests/__init__.py | 16 ++ tests/test_release_config.py | 7 +- 11 files changed, 587 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/release-validation.yml create mode 100644 scripts/ci_smoke_server.py create mode 100644 tests/__init__.py 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..cfa447319 --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,174 @@ +# +# 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/**' + # Note: do NOT use '**/*.md' here. Some bundled examples ship .md files + # (e.g. examples/deployment/aws/terraform/tutorial.md) and a missing + # ASF header on those would cause a real RAT failure we want to catch. + 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/.gitignore b/.gitignore index d9714f671..8a87fe7ac 100644 --- a/.gitignore +++ b/.gitignore @@ -201,3 +201,6 @@ examples/deployment/aws/terraform/*.tfstate examples/deployment/aws/terraform/*.tfstate.* examples/deployment/aws/terraform/.terraform.tfstate.lock.info examples/deployment/aws/terraform/*.tfplan + +# Claude Code session data +.claude/ diff --git a/.rat-excludes b/.rat-excludes index 5347d2139..dd0ea2b24 100644 --- a/.rat-excludes +++ b/.rat-excludes @@ -1,3 +1,12 @@ +# Apache RAT exclude patterns. +# +# IMPORTANT: RAT's -E flag matches each pattern as a regex, but only +# reliably matches against the file basename. Path-style patterns like +# 'examples/foo/bar.py' don't work, especially through symlinked dirs +# (burr/examples is a symlink to ../examples in the source tree). +# Use basename patterns. Be aware that broad basenames like 'utils.py' +# will skip license checks on every utils.py in the repo. + # Python marker file .*py\.typed @@ -25,13 +34,45 @@ robots\.txt # JSON config files (cannot contain comments) .*\.json -# Third-party MIT-licensed files (attributed in LICENSE) -examples/deep-researcher/prompts\.py -examples/deep-researcher/utils\.py -website/src/components/ui/.*\.tsx +# YAML config files in .github/ (templates with frontmatter, headers impractical) +labeler\.yml +config\.yml +bug_report\.md +feature_request\.md +PULL_REQUEST_TEMPLATE\.md + +# Third-party MIT-licensed files (attributed in LICENSE). +# Most names are unique within the repo so basename matching is safe. +# Known collisions: +# - utils.py: also matches our own ASF code in burr/tracking/, etc. +# (4 other utils.py files; all currently have ASF headers) +# - button.tsx: also matches telemetry/ui/src/components/common/button.tsx +# (our own ASF code with header) +# A future regression in any of those collision targets would silently pass +# RAT. Tracked as a follow-up to rename or restructure. +prompts\.py +utils\.py +animated-beam\.tsx +animated-shiny-text\.tsx +blur-fade\.tsx +border-beam\.tsx +button\.tsx +dot-pattern\.tsx +icon-cloud\.tsx +magic-card\.tsx +marquee\.tsx +number-ticker\.tsx +safari\.tsx +shimmer-button\.tsx + +# Tutorial markdown for the AWS terraform deployment example +tutorial\.md # SVG files (third-party logos and graphics, headers impractical) .*\.svg -# Examples pattern (legacy - keeping for compatibility) -apache_burr-.*/burr/examples +# Image files (binary, cannot contain headers) +.*\.png +.*\.gif +.*\.ico +.*\.jpg diff --git a/burr/cli/__main__.py b/burr/cli/__main__.py index 5202eeab1..5bbc99dd5 100644 --- a/burr/cli/__main__.py +++ b/burr/cli/__main__.py @@ -168,7 +168,7 @@ def _run_server( # TODO: Implement server running logic here # Example: Start a web server, configure ports, etc. logger.info(f"Starting server on port {port}") - cmd = f"uvicorn burr.tracking.server.run:app --port {port} --host {host}" + cmd = f"{sys.executable} -m uvicorn burr.tracking.server.run:app --port {port} --host {host}" if dev_mode: cmd += " --reload" diff --git a/examples/deployment/aws/terraform/tutorial.md b/examples/deployment/aws/terraform/tutorial.md index 93883f90d..11a033466 100644 --- a/examples/deployment/aws/terraform/tutorial.md +++ b/examples/deployment/aws/terraform/tutorial.md @@ -1,3 +1,22 @@ + + # Apache Burr AWS Tracking Infrastructure Tutorial This tutorial explains how to deploy Apache Burr tracking infrastructure on AWS using Terraform. All Terraform code lives in `examples/deployment/aws/terraform/`. It covers deployment with S3 only (polling mode), with S3 and SQS (event-driven mode), and local development without AWS. diff --git a/pyproject.toml b/pyproject.toml index 71b74dfa3..66b4aaf27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -265,6 +265,7 @@ include = [ "examples/multi-modal-chatbot/**", "examples/streaming-fastapi/**", "examples/deep-researcher/**", + "examples/hello-world-counter/**", ] exclude = [ "telemetry/ui/node_modules/**", @@ -295,7 +296,6 @@ exclude = [ "examples/deployment/**", "examples/hamilton-integration/**", "examples/haystack-integration/**", - "examples/hello-world-counter/**", "examples/image-telephone/**", "examples/instructor-gemini-flash/**", "examples/integrations/**", diff --git a/scripts/apache_release.py b/scripts/apache_release.py index 0c355ba62..fe6dfb86c 100644 --- a/scripts/apache_release.py +++ b/scripts/apache_release.py @@ -52,6 +52,7 @@ "multi-modal-chatbot", "streaming-fastapi", "deep-researcher", + "hello-world-counter", ] @@ -140,7 +141,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 +245,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 +325,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 +333,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 +357,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 +388,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 @@ -495,7 +516,11 @@ def _prepare_wheel_contents() -> tuple[bool, bool, Optional[str]]: was_symlink = False symlink_target = None - if os.path.exists(burr_examples_dir): + # Use lexists (not exists) so we detect broken symlinks too — CI runners + # sometimes check out burr/examples as a symlink whose relative target + # doesn't resolve from the working directory, and os.path.exists would + # return False for such a link while os.makedirs would still blow up on it. + if os.path.lexists(burr_examples_dir): if os.path.islink(burr_examples_dir): was_symlink = True symlink_target = os.readlink(burr_examples_dir) @@ -774,12 +799,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 +815,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 +835,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 +850,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 +913,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 +923,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 +937,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 +980,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 +1022,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() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..13a83393a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/test_release_config.py b/tests/test_release_config.py index f0f57ea52..7d2f82cf6 100644 --- a/tests/test_release_config.py +++ b/tests/test_release_config.py @@ -45,11 +45,12 @@ def test_examples_include_exclude_coverage(): 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: + For Apache releases, we only want to include 5 specific examples for voters to test: - email-assistant - multi-modal-chatbot - streaming-fastapi - deep-researcher + - hello-world-counter All other examples must be explicitly excluded. This test ensures the configuration stays in sync with the filesystem when examples are added/removed. @@ -125,8 +126,8 @@ def test_examples_include_exclude_coverage(): f"\n To fix: Add to pyproject.toml [tool.flit.sdist]:\n" f" - To INCLUDE in Apache release: add 'examples//**' to 'include' list\n" f" - To EXCLUDE from Apache release: add 'examples//**' 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" + f"\n Currently only these 5 examples should be included:\n" + f" email-assistant, multi-modal-chatbot, streaming-fastapi, deep-researcher, hello-world-counter\n" ) if extra_in_config: