8 files modified
1 files added
9 files renamed
| | |
| | | ### From Source |
| | | ```bash |
| | | # Clone the repository |
| | | git clone https://github.com/joelgrun/archbuild |
| | | cd archbuild |
| | | git clone https://github.com/joelgrun/archrepobuild |
| | | cd archrepobuild |
| | | |
| | | # Set up virtual environment and install |
| | | python -m venv .venv |
| | |
| | | ```bash |
| | | python scripts/build_binary.py |
| | | ``` |
| | | The binary will be available at `dist/archbuild-bin`. |
| | | The binary will be available at `dist/archrepobuild-bin`. |
| | | |
| | | ## Quick Start |
| | | |
| | |
| | | |
| | | 2. **Initialize repository**: |
| | | ```bash |
| | | archbuild -c config.yaml init |
| | | archrepobuild -c config.yaml init |
| | | ``` |
| | | |
| | | 3. **Add packages**: |
| | | ```bash |
| | | archbuild add yay paru |
| | | archrepobuild add yay paru |
| | | ``` |
| | | |
| | | 4. **Build all packages**: |
| | | ```bash |
| | | archbuild build-all |
| | | archrepobuild build-all |
| | | ``` |
| | | |
| | | 5. **Build a specific package**: |
| | | ```bash |
| | | archbuild build <package> |
| | | archrepobuild build <package> |
| | | ``` |
| | | |
| | | ## Commands |
| | |
| | | ## Migration from Bash Version |
| | | |
| | | ```bash |
| | | archbuild migrate-config vars.sh -o config.yaml |
| | | archrepobuild migrate-config vars.sh -o config.yaml |
| | | ``` |
| | | |
| | | ## Systemd Timer |
| | | |
| | | Create `/etc/systemd/system/archbuild.service`: |
| | | Create `/etc/systemd/system/archrepobuild.service`: |
| | | ```ini |
| | | [Unit] |
| | | Description=Build AUR packages |
| | | |
| | | [Service] |
| | | Type=oneshot |
| | | ExecStart=/usr/bin/archbuild -c /etc/archbuild/config.yaml build-all |
| | | ExecStart=/usr/bin/archrepobuild -c /etc/archrepobuild/config.yaml build-all |
| | | User=builduser |
| | | ``` |
| | | |
| | | Create `/etc/systemd/system/archbuild.timer`: |
| | | Create `/etc/systemd/system/archrepobuild.timer`: |
| | | ```ini |
| | | [Unit] |
| | | Description=Run archbuild daily |
| | | Description=Run archrepobuild daily |
| | | |
| | | [Timer] |
| | | OnCalendar=daily |
| | |
| | | # Recipient email address |
| | | to: "" |
| | | # Sender email address |
| | | from: "archbuild@localhost" |
| | | from: "archrepobuild@localhost" |
| | | # SMTP server settings |
| | | smtp_host: "localhost" |
| | | smtp_port: 25 |
| | |
| | | # Logging configuration |
| | | log_level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL |
| | | # Uncomment to also log to file: |
| | | # log_file: "/var/log/archbuild.log" |
| | | # log_file: "/var/log/archrepobuild.log" |
| | |
| | | build-backend = "hatchling.build" |
| | | |
| | | [project] |
| | | name = "archbuild" |
| | | name = "archrepobuild" |
| | | version = "2.0.0" |
| | | description = "Automatic AUR package building and repository management for Arch Linux" |
| | | readme = "README.md" |
| | |
| | | ] |
| | | |
| | | [project.scripts] |
| | | archbuild = "archbuild.cli:main" |
| | | archrepobuild = "archrepobuild.cli:main" |
| | | |
| | | [tool.hatch.build.targets.wheel] |
| | | packages = ["src/archbuild"] |
| | | packages = ["src/archrepobuild"] |
| | | |
| | | [tool.ruff] |
| | | target-version = "py311" |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | Script to build a standalone executable for archbuild using PyInstaller. |
| | | Script to build a standalone executable for archrepobuild using PyInstaller. |
| | | """ |
| | | |
| | | import subprocess |
| | |
| | | src = root / "src" |
| | | |
| | | # Create a temporary entry script |
| | | entry_script = root / "archbuild_entry.py" |
| | | entry_script.write_text("from archbuild.cli import main\nif __name__ == '__main__':\n main()\n") |
| | | entry_script = root / "archrepobuild_entry.py" |
| | | entry_script.write_text("from archrepobuild.cli import main\nif __name__ == '__main__':\n main()\n") |
| | | |
| | | # PyInstaller command |
| | | pyinstaller_exe = shutil.which("pyinstaller") |
| | |
| | | |
| | | cmd += [ |
| | | "--onefile", |
| | | "--name", "archbuild-bin", |
| | | "--name", "archrepobuild", |
| | | "--paths", str(src), |
| | | "--clean", |
| | | "--collect-all", "archbuild", |
| | | "--collect-all", "archrepobuild", |
| | | "--collect-all", "rich", |
| | | str(entry_script) |
| | | ] |
| | |
| | | try: |
| | | result = subprocess.run(cmd, cwd=root) |
| | | if result.returncode == 0: |
| | | print("\nSuccessfully built executable: dist/archbuild-bin") |
| | | print("\nSuccessfully built executable: dist/archrepobuild") |
| | | else: |
| | | print("\nBuild failed!") |
| | | sys.exit(result.returncode) |
| File was renamed from src/archbuild/aur.py |
| | |
| | | |
| | | import aiohttp |
| | | |
| | | from archbuild.logging import get_logger |
| | | from archrepobuild.logging import get_logger |
| | | |
| | | logger = get_logger("aur") |
| | | |
| File was renamed from src/archbuild/builder.py |
| | |
| | | from concurrent.futures import ProcessPoolExecutor |
| | | from typing import Any |
| | | |
| | | from archbuild.aur import AURClient |
| | | from archbuild.config import Config, PackageOverride |
| | | from archbuild.logging import get_logger |
| | | from archbuild.resolver import DependencyResolver |
| | | from archrepobuild.aur import AURClient |
| | | from archrepobuild.config import Config, PackageOverride |
| | | from archrepobuild.logging import get_logger |
| | | from archrepobuild.resolver import DependencyResolver |
| | | |
| | | logger = get_logger("builder") |
| | | |
| | |
| | | sign: bool = False, |
| | | key: str = "", |
| | | clean: bool = True, |
| | | force: bool = False, |
| | | skip_checksums: bool = False, |
| | | extra_args: list[str] | None = None, |
| | | env_overrides: dict[str, str] | None = None, |
| | |
| | | cmd.append("-c") |
| | | if sign and key: |
| | | cmd.extend(["--sign", "--key", key]) |
| | | if force: |
| | | cmd.append("-f") |
| | | if skip_checksums: |
| | | cmd.append("--skipchecksums") |
| | | if extra_args: |
| | |
| | | ) |
| | | |
| | | if result.returncode != 0: |
| | | return False, result.stderr or result.stdout, [] |
| | | error = result.stderr or result.stdout |
| | | if "A package has already been built" in error: |
| | | logger.info("Package already built, treating as success") |
| | | # Find built packages anyway |
| | | artifacts = list(package_dir.glob("*.pkg.tar.*")) |
| | | artifacts = [a for a in artifacts if not a.name.endswith(".sig")] |
| | | return True, "", artifacts |
| | | return False, error, [] |
| | | |
| | | # Find built packages |
| | | artifacts = list(package_dir.glob("*.pkg.tar.*")) |
| | |
| | | self.config.signing.enabled, |
| | | self.config.signing.key, |
| | | self.config.building.clean, |
| | | force or is_vcs, |
| | | override.skip_checksums, |
| | | override.extra_args, |
| | | override.env, |
| | |
| | | # Resolve dependencies |
| | | build_order = await self.resolver.resolve([package]) |
| | | |
| | | if package not in build_order.packages: |
| | | logger.info(f"Package {package} does not need to be built") |
| | | return BuildResult( |
| | | package=package, |
| | | status=BuildStatus.SKIPPED, |
| | | ) |
| | | |
| | | # Build dependencies first |
| | | results: list[BuildResult] = [] |
| | | for dep in build_order: |
| File was renamed from src/archbuild/cli.py |
| | |
| | | from rich.console import Console |
| | | from rich.table import Table |
| | | |
| | | from archbuild import __version__ |
| | | from archbuild.aur import AURClient |
| | | from archbuild.builder import Builder, BuildStatus |
| | | from archbuild.config import Config, load_config, migrate_vars_sh, save_config |
| | | from archbuild.logging import console as log_console, setup_logging |
| | | from archbuild.notifications import NotificationManager |
| | | from archbuild.repo import RepoManager |
| | | from archrepobuild import __version__ |
| | | from archrepobuild.aur import AURClient |
| | | from archrepobuild.builder import Builder, BuildStatus |
| | | from archrepobuild.config import Config, load_config, migrate_vars_sh, save_config |
| | | from archrepobuild.logging import console as log_console, setup_logging |
| | | from archrepobuild.notifications import NotificationManager |
| | | from archrepobuild.repo import RepoManager |
| | | |
| | | console = Console() |
| | | |
| | |
| | | default=Path("config.yaml"), |
| | | help="Path to configuration file", |
| | | ) |
| | | @click.version_option(__version__, prog_name="archbuild") |
| | | @click.version_option(__version__, prog_name="archrepobuild") |
| | | @click.pass_context |
| | | def cli(ctx: click.Context, config: Path) -> None: |
| | | """Archbuild - Automatic AUR package building and repository management. |
| | |
| | | if result.status == BuildStatus.SUCCESS: |
| | | repo.add_packages(result) |
| | | console.print(f"[green]✓[/] {package} added successfully") |
| | | elif result.status == BuildStatus.SKIPPED: |
| | | console.print(f"[yellow]⏭[/] {package} skipped (already in official repos or installed)") |
| | | else: |
| | | console.print(f"[red]✗[/] {package} failed: {result.error}") |
| | | |
| | |
| | | |
| | | if all_official: |
| | | # Find packages now in official repos |
| | | from archbuild.resolver import DependencyResolver |
| | | from archrepobuild.resolver import DependencyResolver |
| | | resolver = DependencyResolver(aur) |
| | | |
| | | for pkg in repo.list_packages(): |
| | |
| | | |
| | | async def _check() -> None: |
| | | async with AURClient() as aur: |
| | | from archbuild.resolver import DependencyResolver |
| | | from archrepobuild.resolver import DependencyResolver |
| | | resolver = DependencyResolver(aur) |
| | | repo = RepoManager(config) |
| | | |
| | |
| | | |
| | | interval = click.prompt("How often should builds run? (systemd Calendar spec, e.g., 12h, daily)", default="12h") |
| | | |
| | | # Get absolute path to archbuild executable |
| | | # Get absolute path to archrepobuild executable |
| | | import shutil |
| | | archbuild_path = shutil.which("archbuild") |
| | | if not archbuild_path: |
| | | archrepobuild_path = shutil.which("archrepobuild") |
| | | if not archrepobuild_path: |
| | | # Fallback to current sys.executable if running as module or in venv |
| | | archbuild_path = f"{sys.executable} -m archbuild.cli" |
| | | archrepobuild_path = f"{sys.executable} -m archrepobuild.cli" |
| | | |
| | | user_systemd_dir = Path.home() / ".config" / "systemd" / "user" |
| | | user_systemd_dir.mkdir(parents=True, exist_ok=True) |
| | |
| | | |
| | | [Service] |
| | | Type=oneshot |
| | | ExecStart={archbuild_path} build-all |
| | | ExecStart={archrepobuild_path} build-all |
| | | Environment="PATH={Path.home()}/.local/bin:/usr/bin:/bin" |
| | | """ |
| | | |
| | |
| | | WantedBy=timers.target |
| | | """ |
| | | |
| | | service_file = user_systemd_dir / "archbuild.service" |
| | | timer_file = user_systemd_dir / "archbuild.timer" |
| | | service_file = user_systemd_dir / "archrepobuild.service" |
| | | timer_file = user_systemd_dir / "archrepobuild.timer" |
| | | |
| | | service_file.write_text(service_content) |
| | | timer_file.write_text(timer_content) |
| | | |
| | | try: |
| | | subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) |
| | | subprocess.run(["systemctl", "--user", "enable", "--now", "archbuild.timer"], check=True) |
| | | subprocess.run(["systemctl", "--user", "enable", "--now", "archrepobuild.timer"], check=True) |
| | | console.print(f"[green]✓[/] Systemd timer enabled (running every {interval})") |
| | | except subprocess.CalledProcessError as e: |
| | | console.print(f"[red]✗[/] Failed to enable systemd timer: {e}") |
| File was renamed from src/archbuild/logging.py |
| | |
| | | handlers=handlers, |
| | | ) |
| | | |
| | | return logging.getLogger("archbuild") |
| | | return logging.getLogger("archrepobuild") |
| | | |
| | | |
| | | def get_logger(name: str) -> logging.Logger: |
| | |
| | | Returns: |
| | | Logger instance |
| | | """ |
| | | return logging.getLogger(f"archbuild.{name}") |
| | | return logging.getLogger(f"archrepobuild.{name}") |
| File was renamed from src/archbuild/notifications.py |
| | |
| | | |
| | | import aiohttp |
| | | |
| | | from archbuild.builder import BuildResult, BuildStatus |
| | | from archbuild.config import Config, EmailConfig, WebhookConfig |
| | | from archbuild.logging import get_logger |
| | | from archrepobuild.builder import BuildResult, BuildStatus |
| | | from archrepobuild.config import Config, EmailConfig, WebhookConfig |
| | | from archrepobuild.logging import get_logger |
| | | |
| | | logger = get_logger("notifications") |
| | | |
| | |
| | | |
| | | try: |
| | | msg = MIMEMultipart() |
| | | msg["From"] = self.config.from_addr or f"archbuild@localhost" |
| | | msg["From"] = self.config.from_addr or f"archrepobuild@localhost" |
| | | msg["To"] = self.config.to |
| | | msg["Subject"] = f"Build Errors - {config.repository.name}" |
| | | |
| File was renamed from src/archbuild/repo.py |
| | |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | from archbuild.builder import BuildResult, BuildStatus, FileLock |
| | | from archbuild.config import Config |
| | | from archbuild.logging import get_logger |
| | | from archrepobuild.builder import BuildResult, BuildStatus, FileLock |
| | | from archrepobuild.config import Config |
| | | from archrepobuild.logging import get_logger |
| | | |
| | | logger = get_logger("repo") |
| | | |
| File was renamed from src/archbuild/resolver.py |
| | |
| | | from dataclasses import dataclass, field |
| | | from enum import Enum |
| | | |
| | | from archbuild.aur import AURClient, Package |
| | | from archbuild.logging import get_logger |
| | | from archrepobuild.aur import AURClient, Package |
| | | from archrepobuild.logging import get_logger |
| | | |
| | | logger = get_logger("resolver") |
| | | |
| | |
| | | """Refresh cache of packages available from official repos.""" |
| | | try: |
| | | result = subprocess.run( |
| | | ["pacman", "-Ssq"], |
| | | ["pacman", "-Slq"], |
| | | capture_output=True, |
| | | text=True, |
| | | check=True, |
| | |
| | | Raises: |
| | | ValueError: If package not found or circular dependency |
| | | """ |
| | | logger.info(f"Resolving dependencies for: {', '.join(package_names)}") |
| | | # Filter out packages already in official repos or installed |
| | | aur_package_names = [] |
| | | for name in package_names: |
| | | if self.is_in_official_repos(name): |
| | | logger.info(f"Package {name} found in official repositories, skipping AUR lookup") |
| | | continue |
| | | if self.is_installed(name): |
| | | logger.info(f"Package {name} is already installed, skipping AUR lookup") |
| | | continue |
| | | aur_package_names.append(name) |
| | | |
| | | if not aur_package_names: |
| | | return BuildOrder() |
| | | |
| | | logger.info(f"Resolving dependencies for: {', '.join(aur_package_names)}") |
| | | |
| | | # Fetch requested packages |
| | | packages_list = await self.aur_client.get_packages(package_names) |
| | | if len(packages_list) != len(package_names): |
| | | packages_list = await self.aur_client.get_packages(aur_package_names) |
| | | if len(packages_list) != len(aur_package_names): |
| | | found = {p.name for p in packages_list} |
| | | missing = set(package_names) - found |
| | | missing = set(aur_package_names) - found |
| | | raise ValueError(f"Packages not found in AUR: {missing}") |
| | | |
| | | # Build dependency graph |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | Integration test script for archbuild. |
| | | Integration test script for archrepobuild. |
| | | |
| | | This script creates a temporary repository, initializes it with a basic config, |
| | | adds test packages, and verifies they build and are added correctly. |
| | |
| | | # Add src to path for development |
| | | sys.path.insert(0, str(Path(__file__).parent.parent / "src")) |
| | | |
| | | from archbuild.aur import AURClient |
| | | from archbuild.builder import Builder, BuildStatus |
| | | from archbuild.config import Config, RepositoryConfig, BuildingConfig, SigningConfig, PackageOverride |
| | | from archbuild.logging import setup_logging, console |
| | | from archbuild.repo import RepoManager |
| | | from archrepobuild.aur import AURClient |
| | | from archrepobuild.builder import Builder, BuildStatus |
| | | from archrepobuild.config import Config, RepositoryConfig, BuildingConfig, SigningConfig, PackageOverride |
| | | from archrepobuild.logging import setup_logging, console |
| | | from archrepobuild.repo import RepoManager |
| | | |
| | | |
| | | # Test packages - real packages that exist in the AUR |
| | |
| | | self.failed = 0 |
| | | |
| | | def _run_cli(self, command: str, *args: str) -> subprocess.CompletedProcess: |
| | | """Run archbuild CLI command via subprocess.""" |
| | | """Run archrepobuild CLI command via subprocess.""" |
| | | if not self.config_path: |
| | | raise RuntimeError("Config path not set") |
| | | |
| | | env = os.environ.copy() |
| | | |
| | | if self.use_binary: |
| | | binary_path = Path(__file__).parent.parent / "dist" / "archbuild-bin" |
| | | binary_path = Path(__file__).parent.parent / "dist" / "archrepobuild" |
| | | if not binary_path.exists(): |
| | | raise RuntimeError(f"Binary not found at {binary_path}. Run scripts/build_binary.py first.") |
| | | |
| | |
| | | else: |
| | | # Add src to PYTHONPATH so the CLI can find the package |
| | | env["PYTHONPATH"] = str(Path(__file__).parent.parent / "src") |
| | | cmd = [sys.executable, "-m", "archbuild.cli", "-c", str(self.config_path), command] + list(args) |
| | | cmd = [sys.executable, "-m", "archrepobuild.cli", "-c", str(self.config_path), command] + list(args) |
| | | |
| | | return subprocess.run(cmd, capture_output=True, text=True, env=env) |
| | | |
| | |
| | | console.print("\n[bold blue]═══ Setting up test environment ═══[/]") |
| | | |
| | | # Create temp directory |
| | | self.temp_dir = Path(tempfile.mkdtemp(prefix="archbuild_test_")) |
| | | self.temp_dir = Path(tempfile.mkdtemp(prefix="archrepobuild_test_")) |
| | | console.print(f" Created temp directory: {self.temp_dir}") |
| | | |
| | | # Create subdirectories |
| | |
| | | ) |
| | | |
| | | # Save config to disk for CLI to use |
| | | from archbuild.config import save_config |
| | | from archrepobuild.config import save_config |
| | | self.config_path = self.temp_dir / "config.yaml" |
| | | save_config(self.config, self.config_path) |
| | | |
| | |
| | | |
| | | if artifacts: |
| | | # Create a mock build result for add_packages |
| | | from archbuild.builder import BuildResult |
| | | from archrepobuild.builder import BuildResult |
| | | mock_result = BuildResult( |
| | | package=pkg_name, |
| | | status=BuildStatus.SUCCESS, |
| | |
| | | from unittest.mock import AsyncMock, patch, MagicMock |
| | | from datetime import datetime |
| | | |
| | | from archbuild.aur import AURClient, Package |
| | | from archrepobuild.aur import AURClient, Package |
| | | |
| | | |
| | | @pytest.fixture |
| | |
| | | |
| | | def test_parse_simple(self): |
| | | """Test parsing simple dependency.""" |
| | | from archbuild.resolver import Dependency |
| | | from archrepobuild.resolver import Dependency |
| | | dep = Dependency.parse("package") |
| | | assert dep.name == "package" |
| | | assert dep.version_constraint is None |
| | | |
| | | def test_parse_with_version(self): |
| | | """Test parsing dependency with version.""" |
| | | from archbuild.resolver import Dependency |
| | | from archrepobuild.resolver import Dependency |
| | | dep = Dependency.parse("package>=1.0") |
| | | assert dep.name == "package" |
| | | assert dep.version_constraint == ">=1.0" |
| | | |
| | | def test_parse_exact_version(self): |
| | | """Test parsing dependency with exact version.""" |
| | | from archbuild.resolver import Dependency |
| | | from archrepobuild.resolver import Dependency |
| | | dep = Dependency.parse("package=2.0") |
| | | assert dep.name == "package" |
| | | assert dep.version_constraint == "=2.0" |
| | |
| | | from pathlib import Path |
| | | from tempfile import NamedTemporaryFile |
| | | |
| | | from archbuild.config import ( |
| | | from archrepobuild.config import ( |
| | | Config, |
| | | load_config, |
| | | migrate_vars_sh, |
| | |
| | | import pytest |
| | | from unittest.mock import AsyncMock, patch |
| | | |
| | | from archbuild.resolver import DependencyResolver, Dependency, DependencyType, BuildOrder |
| | | from archrepobuild.resolver import DependencyResolver, Dependency, DependencyType, BuildOrder |
| | | |
| | | |
| | | class TestDependency: |
| | |
| | | cycles = resolver.detect_cycles(graph) |
| | | assert len(cycles) > 0 |
| | | |
| | | @patch("archbuild.resolver.subprocess.run") |
| | | @patch("archrepobuild.resolver.subprocess.run") |
| | | def test_is_in_official_repos(self, mock_run, mock_aur_client): |
| | | """Test checking official repos.""" |
| | | mock_run.return_value.returncode = 0 |
| New file |
| | |
| | | import asyncio |
| | | from pathlib import Path |
| | | import os |
| | | import sys |
| | | |
| | | # Add src to path |
| | | sys.path.insert(0, str(Path(__file__).parent / "src")) |
| | | |
| | | from archrepobuild.builder import _run_makepkg |
| | | |
| | | async def test_already_built(): |
| | | print("Testing 'already built' message handling...") |
| | | |
| | | # Create a mock PKGBUILD directory |
| | | test_dir = Path("test_build_logic") |
| | | test_dir.mkdir(exist_ok=True) |
| | | pkgbuild = test_dir / "PKGBUILD" |
| | | pkgbuild.write_text("pkgname=testpkg\npkgver=1.0\npkgrel=1\narch=('any')\n") |
| | | |
| | | # Manually create a dummy package file to trigger the error if we run twice |
| | | package_file = test_dir / "testpkg-1.0-1-any.pkg.tar.zst" |
| | | package_file.write_text("dummy") |
| | | |
| | | # Run _run_makepkg. It should fail with returncode != 0 but we should catch it. |
| | | # Note: We can't easily mock the subprocess output of makepkg here |
| | | # but we can check if it behaves correctly when real makepkg is called. |
| | | |
| | | success, error, artifacts = _run_makepkg(test_dir, force=False) |
| | | |
| | | print(f"Success: {success}") |
| | | print(f"Error: {error}") |
| | | print(f"Artifacts: {artifacts}") |
| | | |
| | | if success and "testpkg-1.0-1-any.pkg.tar.zst" in [a.name for a in artifacts]: |
| | | print("✓ Successfully handled 'already built' message") |
| | | else: |
| | | print("✗ Failed to handle 'already built' message (or makepkg didn't report it)") |
| | | |
| | | # Cleanup |
| | | shutil = __import__('shutil') |
| | | shutil.rmtree(test_dir) |
| | | |
| | | if __name__ == "__main__": |
| | | asyncio.run(test_already_built()) |