35 changed files with 6888 additions and 247 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"WebFetch(domain:community.chocolatey.org)",
"WebFetch(domain:github.com)",
"WebFetch(domain:jrsoftware.org)"
]
}
}
@@ -0,0 +1,312 @@
# Multi-platform build + packaging workflow
# Thin GitHub Actions wrapper around Prefect + Dagger pipeline.
#
# Architecture:
# - Windows build runs natively on windows-latest (cannot containerize)
# - Linux builds run via Dagger containers orchestrated by Prefect
# - Signing, release creation, and R2 upload handled by Prefect tasks
# - Container runtime: Podman (Dagger connects via Podman socket)
#
# Local equivalent:
# poetry run python -m ci.prefect_flow full \
# --gpg-passphrase "$GPG_PASSPHRASE" \
# --github-token "$GITHUB_TOKEN"
name: Build Multi-Platform Binaries
on:
workflow_dispatch:
permissions:
contents: read
packages: read
concurrency:
group: release-workflow
cancel-in-progress: true
env:
CI_CD: true
CI_CD_PAT: ${{ secrets.CI_CD_PAT }}
jobs:
# ── Windows Build (native runner — cannot containerize) ──────────────
build-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Ensure Poetry is on PATH
shell: pwsh
run: |
$poetryPath = Join-Path $env:USERPROFILE ".local\bin"
Write-Output $poetryPath >> $Env:GITHUB_PATH
- name: Install dependencies
run: poetry install
- name: Build Windows executable
run: poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- name: Install Inno Setup 6.7.1
shell: pwsh
run: |
choco install innosetup --version 6.7.1 -y --no-progress
# Refresh PATH so ISCC.exe is available immediately
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
- name: Build Windows installer (Inno Setup)
shell: pwsh
run: |
$version = (poetry version -s).Trim()
Write-Output "Building installer for version $version"
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
"scripts\windows\android-file-handler-setup.iss" `
"/DMyAppVersion=$version"
- name: Import GPG key
shell: pwsh
run: |
$env:GPG_TTY = "not a tty"
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
gpg --list-secret-keys
- name: Sign and hash Windows artifacts
shell: pwsh
run: |
$passphraseFile = New-TemporaryFile
try {
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
# Sign and hash standalone executable
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" |
Select-Object -First 1 -ExpandProperty FullName
if (-not $exePath) { Write-Error "Standalone executable not found"; exit 1 }
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath"
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
"$hash $(Split-Path -Leaf $exePath)" |
Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline
# Sign and hash installer
$setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" |
Select-Object -First 1 -ExpandProperty FullName
if (-not $setupPath) { Write-Error "Installer not found"; exit 1 }
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath"
$setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower()
"$setupHash $(Split-Path -Leaf $setupPath)" |
Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline
}
finally {
if (Test-Path $passphraseFile) { Remove-Item $passphraseFile -Force }
}
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: windows-binary
path: |
dist/android-file-handler-windows.exe
dist/android-file-handler-windows.exe.asc
dist/android-file-handler-windows.sha256
dist/android-file-handler-setup.exe
dist/android-file-handler-setup.exe.asc
dist/android-file-handler-setup.sha256
# ── Linux Builds (Prefect + Dagger with Podman backend) ─────────────
build-linux:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install project + CI dependencies
run: poetry install --with ci
- name: Set up Podman
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq podman
# Start rootful Podman socket for Dagger compatibility
sudo systemctl enable --now podman.socket
echo "DOCKER_HOST=unix:///run/podman/podman.sock" >> "$GITHUB_ENV"
- name: Install Dagger CLI
uses: dagger/dagger-for-github@v7
with:
verb: version
- name: Log in to GHCR (Podman)
run: |
echo "${{ secrets.GITHUB_TOKEN }}" |
podman login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Build all Linux distros (Prefect + Dagger)
run: poetry run python -m ci.prefect_flow build-linux
- name: Import GPG key
run: |
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
- name: Sign Linux artifacts
run: |
poetry run python -m ci.prefect_flow sign \
--gpg-passphrase "${{ secrets.GPG_PASSPHRASE }}"
- name: Upload Debian package
uses: actions/upload-artifact@v4
with:
name: debian-package
path: |
dist/android-file-handler_*.deb
dist/android-file-handler_*.deb.asc
dist/android-file-handler-debian.sha256
pkg_dist_debian/**
- name: Upload Arch package
uses: actions/upload-artifact@v4
with:
name: arch-package
path: |
dist/*.pkg.tar.*
dist/android-file-handler-arch.sha256
pkg_dist_arch/**
- name: Upload RHEL package
uses: actions/upload-artifact@v4
with:
name: rhel-package
path: |
dist/*.rpm
dist/*.rpm.asc
dist/android-file-handler-rhel.sha256
pkg_dist_rhel/**
# ── Release + R2 Upload (Prefect) ───────────────────────────────────
do-release:
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install project + CI dependencies
run: poetry install --with ci
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
path: ./dist
- name: Create GitHub release (Prefect)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
poetry run python -m ci.prefect_flow release \
--github-token "$GITHUB_TOKEN"
upload-r2:
runs-on: ubuntu-latest
needs: do-release
if: needs.do-release.result == 'success'
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install project + CI dependencies
run: poetry install --with ci
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
path: ./release-files
- name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
- name: Upload artifacts to Cloudflare R2
run: |
poetry run python -m ci.prefect_flow upload-r2 \
--gcp-project-id "${{ secrets.GCP_PROJECT_ID }}" \
--run-id "${{ github.run_id }}" \
--release-dir "./release-files"
sync-wiki:
needs: do-release
if: needs.do-release.result == 'success'
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/sync-wiki.yml
with:
branch: main
secrets: inherit
+37 -19
View File
@@ -68,6 +68,15 @@ jobs:
# Use the Windows spec file so packaging is consistent and reproducible
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- name: Build Windows installer (Inno Setup)
shell: pwsh
run: |
$version = (poetry version -s).Trim()
Write-Output "Building installer for version $version"
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
"scripts\windows\android-file-handler-setup.iss" `
"/DMyAppVersion=$version"
- name: Import GPG key
shell: pwsh
run: |
@@ -75,23 +84,37 @@ jobs:
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
gpg --list-secret-keys
- name: Sign and hash Windows executable
- name: Sign and hash Windows artifacts
shell: pwsh
run: |
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler.exe" -Recurse | Select-Object -First 1 -ExpandProperty FullName
if (-not $exePath) {
Write-Error "Executable not found"
exit 1
}
Write-Output "Found executable: $exePath"
# Create temporary file for passphrase
$passphraseFile = New-TemporaryFile
try {
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
# Sign with GPG using passphrase file
# Sign and hash the standalone executable
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" | Select-Object -First 1 -ExpandProperty FullName
if (-not $exePath) {
Write-Error "Standalone executable not found"
exit 1
}
Write-Output "Signing executable: $exePath"
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath"
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
"$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline
Write-Output "Executable SHA-256: $hash"
# Sign and hash the Inno Setup installer
$setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" | Select-Object -First 1 -ExpandProperty FullName
if (-not $setupPath) {
Write-Error "Installer not found"
exit 1
}
Write-Output "Signing installer: $setupPath"
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath"
$setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower()
"$setupHash $(Split-Path -Leaf $setupPath)" | Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline
Write-Output "Installer SHA-256: $setupHash"
}
finally {
# Clean up passphrase file
@@ -100,22 +123,17 @@ jobs:
}
}
# Generate SHA-256 hash
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
$hashFile = "dist/android-file-handler-windows.sha256"
"$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath $hashFile -Encoding ASCII -NoNewline
Write-Output "SHA-256: $hash"
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: windows-binary
path: |
dist/**/android-file-handler*.exe
dist/**/android-file-handler*.exe.asc
dist/android-file-handler.exe
dist/android-file-handler.exe.asc
dist/android-file-handler-windows.exe
dist/android-file-handler-windows.exe.asc
dist/android-file-handler-windows.sha256
dist/android-file-handler-setup.exe
dist/android-file-handler-setup.exe.asc
dist/android-file-handler-setup.sha256
build-debian:
permissions:
+3 -1
View File
@@ -15,4 +15,6 @@ pkg*
# Python cache files
__pycache__/
*.py[cod]
*$py.class
*$py.class
.vagrant/
vagrant_test_results/
+59 -16
View File
@@ -58,21 +58,39 @@ poetry run mypy src/
poetry run python scripts/build_package_linux.py
```
#### Docker Compose Build (Recommended for Linux)
#### Prefect + Dagger Build (CI Pipeline Locally)
```sh
# Build all distributions (Debian, Arch, RHEL)
docker compose up --build
# Install CI dependencies
poetry install --with ci
# Build all Linux distros via Dagger containers
poetry run python -m ci.prefect_flow build-linux
# Sign artifacts
poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE"
# Full pipeline (build + sign + release)
poetry run python -m ci.prefect_flow full \
--gpg-passphrase "$GPG_PASSPHRASE" \
--github-token "$GITHUB_TOKEN"
# Upload artifacts to Cloudflare R2 (standalone)
poetry run python -m ci.prefect_flow upload-r2 \
--gcp-project-id "$GCP_PROJECT_ID"
```
#### Podman Compose Build (Recommended for Linux)
```sh
# Build all distributions
podman-compose up --build
# Build specific distribution
docker compose up --build debian
docker compose up --build arch
docker compose up --build rhel
# Build all in parallel
docker compose up --build --parallel
podman-compose up --build debian
podman-compose up --build arch
podman-compose up --build rhel
# Clean build artifacts
docker compose down -v && rm -rf dist pkg_dist_* dist_*
podman-compose down -v && rm -rf dist pkg_dist_* dist_*
```
See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker build documentation.
@@ -82,6 +100,10 @@ See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker bui
# Windows executable (PyInstaller)
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
# Windows installer (Inno Setup, after PyInstaller build)
# Inno Setup 6.7.1 is installed by the CI/CD workflow via Chocolatey (pinned version)
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss /DMyAppVersion=0.1.1
# Linux packages use distro-specific spec files:
# - android-file-handler-debian.spec
# - android-file-handler-arch.spec
@@ -116,6 +138,13 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- **scripts/**: Build and packaging scripts
- `build_package_linux.py`: Unified Linux packaging script (uses DISTRO_TYPE env var)
- `spec_scripts/`: PyInstaller spec files for each platform
- `windows/android-file-handler-setup.iss`: Inno Setup installer configuration
- **ci/**: CI/CD pipeline orchestration
- `config.py`: Shared build configuration (distro configs, image references)
- `dagger_pipeline.py`: Dagger container build definitions for Linux
- `prefect_flow.py`: Prefect flow orchestration and CLI entry point
- `signing.py`: GPG signing and SHA-256 hashing utilities
- **tests/**: Test suite mirroring src/ structure
@@ -135,12 +164,26 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
## CI/CD
The project uses GitHub Actions for multi-platform builds (`.github/workflows/release.yml`):
- Runs tests on Linux and Windows
- Builds binaries for Windows, Debian, Arch, and RHEL
- Packages using PyInstaller + fpm
- Supports manual workflow dispatch with configurable jobs
- Optional GitHub release creation and S3 upload
The project uses a **Prefect + Dagger** pipeline wrapped by GitHub Actions (`.github/workflows/release-prefect-dagger.yml`):
- **Dagger** runs containerized Linux builds (Debian, Arch, RHEL) using pre-built builder images
- **Prefect** orchestrates the pipeline: build → sign → release → R2 upload
- **GitHub Actions** provides the runner infrastructure and Windows build (cannot containerize)
- **Podman** is the container runtime (Dagger connects via Podman socket)
Pipeline structure:
1. `build-windows` — Native Windows build on `windows-latest`
2. `build-linux` — All Linux distros built in parallel via Prefect + Dagger
3. `do-release` — Creates GitHub release with all artifacts
4. `upload-r2` — Uploads artifacts to Cloudflare R2 (credentials from GCP Secrets Manager)
5. `sync-wiki` — Wiki synchronization
The CI pipeline modules live in `ci/`:
- `ci/config.py` — Shared build configuration
- `ci/dagger_pipeline.py` — Dagger container build definitions
- `ci/prefect_flow.py` — Prefect flow orchestration and CLI
- `ci/r2_upload.py` — Cloudflare R2 upload with GCP Secrets Manager integration
- `ci/signing.py` — GPG signing and SHA-256 hashing utilities
## Coding Standards
+1
View File
@@ -0,0 +1 @@
"""CI/CD pipeline orchestration using Prefect and Dagger."""
+88
View File
@@ -0,0 +1,88 @@
"""Shared configuration for CI/CD pipeline."""
from dataclasses import dataclass, field
from pathlib import Path
@dataclass(frozen=True)
class PythonBuildConfig:
"""Configuration for building Python from source."""
version: str = "3.14.3"
source_url: str = "https://www.python.org/ftp/python/3.14.3/Python-3.14.3.tgz"
sha256: str = "d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd"
@dataclass(frozen=True)
class DistroConfig:
"""Configuration for a Linux distribution build."""
name: str
distro_type: str
container_image: str
bin_path: str
pkg_type: str
architecture: str
postinstall: str | None = None
@dataclass(frozen=True)
class PipelineConfig:
"""Top-level pipeline configuration."""
fpm_version: str = "1.16.0"
project_root: Path = field(
default_factory=lambda: Path(__file__).parent.parent.resolve()
)
python_build: PythonBuildConfig = field(default_factory=PythonBuildConfig)
# Base container images (Python and tools are installed by the pipeline)
debian_image: str = "debian:13"
arch_image: str = "archlinux:latest"
rhel_image: str = "fedora:42"
@property
def distros(self) -> list[DistroConfig]:
"""Return all Linux distribution build configurations."""
return [
DistroConfig(
name="Debian",
distro_type="debian",
container_image=self.debian_image,
bin_path="usr/local/bin",
pkg_type="deb",
architecture="amd64",
postinstall="scripts/debian_postinst.sh",
),
DistroConfig(
name="Arch",
distro_type="arch",
container_image=self.arch_image,
bin_path="usr/bin",
pkg_type="pacman",
architecture="x86_64",
postinstall=None,
),
DistroConfig(
name="RHEL",
distro_type="rhel",
container_image=self.rhel_image,
bin_path="usr/bin",
pkg_type="rpm",
architecture="x86_64",
postinstall="scripts/rhel_postinst.sh",
),
]
# Artifact patterns for each distro
artifact_patterns: dict[str, list[str]] = field(
default_factory=lambda: {
"debian": ["dist/android-file-handler_*.deb"],
"arch": ["dist/android-file-handler-*.pkg.tar.zst"],
"rhel": ["dist/android-file-handler-*.rpm"],
"windows": [
"dist/android-file-handler-windows.exe",
"dist/android-file-handler-setup.exe",
],
}
)
+339
View File
@@ -0,0 +1,339 @@
"""Dagger pipeline for building Linux distribution packages.
Uses the Dagger Python SDK to run containerized builds for each
Linux distribution (Debian, Arch, RHEL) starting from base OS images,
downloading and compiling Python from source with SHA256 verification,
and building the application packages.
"""
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownVariableType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownParameterType=false
import asyncio
import os
import sys
from pathlib import Path
import dagger # type: ignore[import-not-found]
from ci.config import DistroConfig, PipelineConfig
def _get_registry_token() -> str | None:
"""Read GHCR token from GITHUB_TOKEN or GHCR_TOKEN environment variable."""
return os.environ.get("GITHUB_TOKEN") or os.environ.get("GHCR_TOKEN")
def _get_system_deps_cmd(distro_type: str) -> list[str]:
"""Get the shell command to install system build dependencies.
Args:
distro_type: One of 'debian', 'arch', 'rhel'.
Returns:
Shell command as list for with_exec.
"""
if distro_type == "debian":
return [
"sh",
"-c",
"apt-get update && apt-get install -y --no-install-recommends "
"curl git build-essential ruby ruby-dev gcc make "
"zlib1g-dev ca-certificates tcl-dev tk-dev "
"libx11-6 libxext6 libxrender1 libxcb1 "
"libbz2-dev libreadline-dev libsqlite3-dev libssl-dev libffi-dev "
"wget tar liblzma-dev patch && "
"apt-get clean && rm -rf /var/lib/apt/lists/*",
]
elif distro_type == "arch":
return [
"sh",
"-c",
"pacman -Syu --noconfirm "
"ruby ruby-bundler ruby-rake base-devel curl git tar "
"ca-certificates ca-certificates-utils "
"tk tcl libx11 libxext libxrender libxcb "
"gcc make zlib bzip2 readline sqlite openssl libffi "
"wget xz patch && "
"update-ca-trust && pacman -Scc --noconfirm",
]
elif distro_type == "rhel":
return [
"sh",
"-c",
"dnf -y update && dnf -y install "
"gcc make zlib-devel bzip2 bzip2-devel readline-devel "
"sqlite-devel openssl-devel libffi-devel wget tar git curl "
"ruby rubygems rpm-build redhat-rpm-config gcc-c++ patch which "
"xz-devel tk-devel tcl-devel libX11-devel libXext-devel "
"libXrender-devel && dnf clean all",
]
raise ValueError(f"Unknown distro type: {distro_type}")
def _get_python_configure_env(distro_type: str) -> str:
"""Get distro-specific LDFLAGS and CPPFLAGS for Python configure.
Args:
distro_type: One of 'debian', 'arch', 'rhel'.
Returns:
String with environment variable exports for the configure step.
"""
if distro_type == "debian":
return 'LDFLAGS="-L/usr/lib/x86_64-linux-gnu" CPPFLAGS="-I/usr/include/tcl8.6"'
elif distro_type == "arch":
return 'LDFLAGS="-L/usr/lib" CPPFLAGS="-I/usr/include"'
elif distro_type == "rhel":
return 'LDFLAGS="-L/usr/lib64" CPPFLAGS="-I/usr/include"'
return ""
def _install_fpm(
container: dagger.Container,
distro_type: str,
fpm_version: str,
) -> dagger.Container:
"""Install fpm (Effing Package Management) in the container.
Args:
container: Dagger container to install fpm in.
distro_type: One of 'debian', 'arch', 'rhel'.
fpm_version: Version of fpm to install.
Returns:
Container with fpm installed.
"""
if distro_type == "arch":
container = container.with_exec(
["gem", "install", "--no-document", "erb"]
).with_exec(
[
"sh",
"-c",
f'gem install --no-document -v "{fpm_version}" fpm && '
"GEM_BIN_DIR=$(ruby -e 'puts Gem.user_dir')/bin && "
'ln -sf "${GEM_BIN_DIR}/fpm" /usr/local/bin/fpm',
]
)
else:
container = container.with_exec(
["gem", "install", "--no-document", "-v", fpm_version, "fpm"]
)
return container
async def build_linux_distro(
client: dagger.Client,
config: PipelineConfig,
distro: DistroConfig,
registry_token: str | None = None,
) -> dict[str, Path]:
"""Build a single Linux distribution package inside a Dagger container.
Starts from a base OS image, compiles Python from source with SHA256
verification, installs build tools (Poetry, fpm), and builds the package.
Args:
client: Active Dagger client connection.
config: Pipeline configuration.
distro: Distribution-specific build configuration.
registry_token: Optional registry auth token.
Returns:
Dictionary mapping artifact names to their local output paths.
"""
print(f"[dagger] Starting {distro.name} build using {distro.container_image}")
source = client.host().directory(
str(config.project_root),
exclude=[".venv", "__pycache__", "dist", "dist_*", "pkg_dist_*", ".git"],
)
python = config.python_build
configure_env = _get_python_configure_env(distro.distro_type)
base = client.container()
if registry_token:
secret = client.set_secret("ghcr_token", registry_token)
base = base.with_registry_auth("ghcr.io", "_token", secret)
# Start from base image and install system dependencies
container = base.from_(distro.container_image).with_exec(
_get_system_deps_cmd(distro.distro_type)
)
# Download Python source and verify SHA256 against python.org
print(f"[dagger] Downloading Python {python.version} and verifying SHA256")
container = container.with_exec(
["wget", "-q", python.source_url, "-O", f"/tmp/Python-{python.version}.tgz"]
).with_exec(
[
"sh",
"-c",
f'echo "{python.sha256} /tmp/Python-{python.version}.tgz" '
f"| sha256sum -c -",
]
)
# Build and install Python from source
print(f"[dagger] Compiling Python {python.version} from source")
container = (
container.with_exec(
["tar", "xzf", f"/tmp/Python-{python.version}.tgz", "-C", "/tmp"]
)
.with_exec(
[
"sh",
"-c",
f"cd /tmp/Python-{python.version} && "
f"{configure_env} ./configure --enable-shared "
f"--with-ensurepip=install --prefix=/usr/local && "
f"make -j$(nproc) && "
f"make install",
]
)
.with_exec(
[
"sh",
"-c",
'echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && ldconfig',
]
)
.with_exec(["ln", "-sf", "/usr/local/bin/python3", "/usr/local/bin/python"])
.with_exec(
[
"sh",
"-c",
f"rm -rf /tmp/Python-{python.version} "
f"/tmp/Python-{python.version}.tgz",
]
)
.with_exec(
[
"python3",
"-c",
"import tkinter; import _tkinter; print('tkinter support verified')",
]
)
)
# Install Poetry
container = (
container.with_exec(
[
"sh",
"-c",
"curl -sSL https://install.python-poetry.org | python3 - --yes",
]
)
.with_env_variable(
"PATH", "/root/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
)
.with_exec(["poetry", "--version"])
)
# Install fpm
container = _install_fpm(container, distro.distro_type, config.fpm_version)
# Mount workspace and run build
container = (
container.with_directory("/workspace", source)
.with_workdir("/workspace")
.with_env_variable("CI_CD", "true")
.with_env_variable("DISTRO_TYPE", distro.distro_type)
.with_env_variable("FPM_VERSION", config.fpm_version)
.with_env_variable("POETRY_VIRTUALENVS_IN_PROJECT", "false")
.with_env_variable("POETRY_VIRTUALENVS_PATH", "/tmp/poetry-cache")
.with_exec(["poetry", "install", "--no-interaction"])
.with_exec(["poetry", "run", "python", "scripts/build_package_linux.py"])
)
# Export build artifacts back to host
dist_output = config.project_root / "dist"
pkg_dist_output = config.project_root / f"pkg_dist_{distro.distro_type}"
await container.directory("/workspace/dist").export(str(dist_output))
await container.directory(f"/workspace/pkg_dist_{distro.distro_type}").export(
str(pkg_dist_output)
)
print(
f"[dagger] {distro.name} build complete — artifacts exported to {dist_output}"
)
return {
"dist": dist_output,
"pkg_dist": pkg_dist_output,
}
async def build_all_linux(
config: PipelineConfig | None = None,
) -> dict[str, dict[str, Path]]:
"""Build all Linux distribution packages in parallel via Dagger.
Args:
config: Pipeline configuration. Uses defaults if not provided.
Returns:
Dictionary mapping distro names to their artifact paths.
"""
if config is None:
config = PipelineConfig()
results: dict[str, dict[str, Path]] = {}
token = _get_registry_token()
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
tasks = {
distro.distro_type: build_linux_distro(
client, config, distro, registry_token=token
)
for distro in config.distros
}
# Run all distro builds concurrently
completed = await asyncio.gather(*tasks.values(), return_exceptions=True)
for distro_type, result in zip(tasks.keys(), completed):
if isinstance(result, Exception):
print(f"[dagger] ERROR: {distro_type} build failed: {result}")
raise result
results[distro_type] = result # type: ignore[assignment]
return results
async def build_single_linux(
distro_type: str, config: PipelineConfig | None = None
) -> dict[str, Path]:
"""Build a single Linux distribution package.
Args:
distro_type: One of 'debian', 'arch', 'rhel'.
config: Pipeline configuration. Uses defaults if not provided.
Returns:
Dictionary of artifact paths for the built distro.
"""
if config is None:
config = PipelineConfig()
distro = next((d for d in config.distros if d.distro_type == distro_type), None)
if distro is None:
raise ValueError(
f"Unknown distro type '{distro_type}'. Valid: debian, arch, rhel"
)
token = _get_registry_token()
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
return await build_linux_distro(client, config, distro, registry_token=token)
if __name__ == "__main__":
asyncio.run(build_all_linux())
+366
View File
@@ -0,0 +1,366 @@
"""Prefect orchestration flow for the CI/CD release pipeline.
Coordinates Dagger-based Linux builds, GPG signing, GitHub release
creation, and Cloudflare R2 artifact upload.
Usage:
# Build all Linux distros (CI)
poetry run python -m ci.prefect_flow build-linux
# Sign artifacts in dist/
poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE"
# Create GitHub release + upload R2
poetry run python -m ci.prefect_flow release --github-token "$GITHUB_TOKEN"
# Full pipeline (build + sign + release + R2)
poetry run python -m ci.prefect_flow full --gpg-passphrase "$GPG_PASSPHRASE" \\
--github-token "$GITHUB_TOKEN"
"""
import asyncio
import argparse
import os
import subprocess
from pathlib import Path
from prefect import flow, task
from ci.config import PipelineConfig
from ci.dagger_pipeline import build_all_linux
from ci.r2_upload import upload_to_r2
from ci.signing import sign_and_hash
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@task(name="build-linux-distros", retries=1, retry_delay_seconds=30)
def task_build_linux(config: PipelineConfig) -> dict[str, dict[str, Path]]: # type: ignore[type-arg]
"""Build all Linux distribution packages via Dagger containers."""
print("=== Building Linux packages via Dagger ===")
results = asyncio.run(build_all_linux(config))
print(f"Linux builds completed: {list(results.keys())}")
return results
@task(name="sign-artifacts")
def task_sign_artifacts(
dist_dir: Path,
gpg_passphrase: str,
patterns: list[str] | None = None,
) -> list[Path]:
"""Sign and hash all release artifacts matching the given glob patterns.
Args:
dist_dir: Directory containing artifacts.
gpg_passphrase: GPG key passphrase.
patterns: Glob patterns to match artifacts. Defaults to common package types.
Returns:
List of generated signature and hash file paths.
"""
if patterns is None:
patterns = ["*.exe", "*.deb", "*.rpm", "*.pkg.tar.zst"]
generated_files: list[Path] = []
for pattern in patterns:
for match in dist_dir.glob(pattern):
print(f"Signing: {match.name}")
sig_path, hash_path = sign_and_hash(match, gpg_passphrase)
generated_files.extend([sig_path, hash_path])
if not generated_files:
print(f"Warning: no artifacts matched patterns {patterns} in {dist_dir}")
return generated_files
@task(name="get-version")
def task_get_version() -> str:
"""Read the project version from pyproject.toml via Poetry."""
result = subprocess.run(
["poetry", "version", "-s"],
capture_output=True,
text=True,
check=True,
)
version = result.stdout.strip()
if not version:
raise RuntimeError("Version is empty in pyproject.toml")
print(f"Project version: {version}")
return version
@task(name="prepare-release-files")
def task_prepare_release_files(dist_dir: Path, release_dir: Path) -> list[Path]:
"""Collect all release artifacts into a single directory.
Args:
dist_dir: Source directory containing built artifacts.
release_dir: Target directory for release files.
Returns:
List of files copied into the release directory.
"""
release_dir.mkdir(parents=True, exist_ok=True)
extensions = ["*.exe", "*.deb", "*.rpm", "*.pkg.tar.*", "*.asc", "*.sha256"]
copied: list[Path] = []
for ext in extensions:
for src in dist_dir.glob(ext):
dst = release_dir / src.name
if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime:
import shutil
shutil.copy2(src, dst)
copied.append(dst)
print(f" {src.name}")
print(f"Prepared {len(copied)} release files in {release_dir}")
return copied
@task(name="create-github-release")
def task_create_github_release(
version: str,
release_dir: Path,
github_token: str,
) -> None:
"""Create a GitHub release with artifacts using gh CLI.
Args:
version: Semantic version string (e.g. '0.1.1').
release_dir: Directory containing release files.
github_token: GitHub token for authentication.
"""
tag = f"v{version}"
files = list(release_dir.iterdir())
if not files:
raise RuntimeError(f"No files found in {release_dir}")
env = {**os.environ, "GH_TOKEN": github_token}
cmd = [
"gh",
"release",
"create",
tag,
"--title",
f"Release {tag}",
"--latest",
] + [str(f) for f in files]
print(f"Creating GitHub release {tag} with {len(files)} files")
subprocess.run(cmd, check=True, env=env)
print(f"GitHub release {tag} created successfully")
@task(name="upload-r2")
def task_upload_r2(
release_dir: Path,
gcp_project_id: str,
run_id: str,
) -> list[str]:
"""Upload release artifacts to Cloudflare R2.
Credentials are fetched from GCP Secrets Manager at runtime.
Args:
release_dir: Directory containing release files.
gcp_project_id: GCP project ID for Secrets Manager lookups.
run_id: Unique identifier for this build run.
Returns:
List of uploaded R2 object keys.
"""
if not gcp_project_id:
print("GCP_PROJECT_ID not set; skipping R2 upload")
return []
print(f"Uploading to Cloudflare R2 (build {run_id})")
return upload_to_r2(release_dir, gcp_project_id, run_id)
# ---------------------------------------------------------------------------
# Flows
# ---------------------------------------------------------------------------
@flow(name="build-linux-flow", log_prints=True)
def flow_build_linux() -> dict[str, dict[str, Path]]: # type: ignore[type-arg]
"""Build all Linux distribution packages."""
config = PipelineConfig()
return task_build_linux(config) # type: ignore[return-value]
@flow(name="sign-flow", log_prints=True)
def flow_sign(gpg_passphrase: str) -> list[Path]:
"""Sign all artifacts in the dist/ directory."""
config = PipelineConfig()
return task_sign_artifacts(config.project_root / "dist", gpg_passphrase)
@flow(name="release-flow", log_prints=True)
def flow_release(
github_token: str,
gcp_project_id: str = "",
run_id: str = "",
) -> None:
"""Create a GitHub release and optionally upload to Cloudflare R2."""
config = PipelineConfig()
version = task_get_version()
release_dir = config.project_root / "release-files"
task_prepare_release_files(config.project_root / "dist", release_dir)
task_create_github_release(version, release_dir, github_token)
if gcp_project_id:
task_upload_r2(release_dir, gcp_project_id, run_id or "local")
@flow(name="upload-r2-flow", log_prints=True)
def flow_upload_r2(
gcp_project_id: str,
run_id: str = "",
release_dir: str = "",
) -> list[str]:
"""Upload release artifacts to Cloudflare R2 (standalone).
Args:
gcp_project_id: GCP project ID for Secrets Manager lookups.
run_id: Build run identifier for R2 path.
release_dir: Path to directory containing artifacts. Defaults to
<project_root>/release-files.
Returns:
List of uploaded R2 object keys.
"""
config = PipelineConfig()
target_dir = Path(release_dir) if release_dir else config.project_root / "release-files"
return task_upload_r2(target_dir, gcp_project_id, run_id or "local") # type: ignore[return-value]
@flow(name="full-pipeline", log_prints=True)
def flow_full_pipeline(
gpg_passphrase: str = "",
github_token: str = "",
gcp_project_id: str = "",
run_id: str = "",
skip_build: bool = False,
skip_sign: bool = False,
skip_release: bool = False,
) -> None:
"""Run the complete CI/CD pipeline: build → sign → release → R2.
Args:
gpg_passphrase: GPG key passphrase for signing.
github_token: GitHub token for release creation.
gcp_project_id: GCP project ID for R2 credential lookup.
run_id: Build run identifier for R2 path.
skip_build: Skip the Linux build step.
skip_sign: Skip the signing step.
skip_release: Skip the release + R2 step.
"""
config = PipelineConfig()
# Step 1: Build Linux distros
if not skip_build:
task_build_linux(config)
# Step 2: Sign artifacts
if not skip_sign:
if not gpg_passphrase:
raise ValueError("--gpg-passphrase is required for signing")
task_sign_artifacts(config.project_root / "dist", gpg_passphrase)
# Step 3: Release
if not skip_release:
if not github_token:
raise ValueError("--github-token is required for release")
version = task_get_version()
release_dir = config.project_root / "release-files"
task_prepare_release_files(config.project_root / "dist", release_dir)
task_create_github_release(version, release_dir, github_token)
if gcp_project_id:
task_upload_r2(release_dir, gcp_project_id, run_id or "local")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
"""CLI entry point for running pipeline actions."""
parser = argparse.ArgumentParser(
description="CI/CD pipeline orchestration via Prefect + Dagger"
)
subparsers = parser.add_subparsers(dest="action", required=True)
# build-linux
subparsers.add_parser("build-linux", help="Build all Linux distribution packages")
# sign
sign_parser = subparsers.add_parser("sign", help="Sign artifacts in dist/")
sign_parser.add_argument("--gpg-passphrase", required=True, help="GPG passphrase")
# release
release_parser = subparsers.add_parser("release", help="Create GitHub release")
release_parser.add_argument("--github-token", required=True, help="GitHub token")
release_parser.add_argument("--gcp-project-id", default="", help="GCP project ID for R2 credentials")
release_parser.add_argument("--run-id", default="", help="Build run ID")
# upload-r2
r2_parser = subparsers.add_parser("upload-r2", help="Upload artifacts to Cloudflare R2")
r2_parser.add_argument("--gcp-project-id", required=True, help="GCP project ID for R2 credentials")
r2_parser.add_argument("--run-id", default="", help="Build run ID")
r2_parser.add_argument("--release-dir", default="", help="Path to artifact directory")
# full
full_parser = subparsers.add_parser("full", help="Run full pipeline")
full_parser.add_argument("--gpg-passphrase", default="", help="GPG passphrase")
full_parser.add_argument("--github-token", default="", help="GitHub token")
full_parser.add_argument("--gcp-project-id", default="", help="GCP project ID for R2 credentials")
full_parser.add_argument("--run-id", default="", help="Build run ID")
full_parser.add_argument("--skip-build", action="store_true")
full_parser.add_argument("--skip-sign", action="store_true")
full_parser.add_argument("--skip-release", action="store_true")
args = parser.parse_args()
if args.action == "build-linux":
flow_build_linux()
elif args.action == "sign":
flow_sign(gpg_passphrase=args.gpg_passphrase)
elif args.action == "release":
flow_release(
github_token=args.github_token,
gcp_project_id=args.gcp_project_id,
run_id=args.run_id,
)
elif args.action == "upload-r2":
flow_upload_r2(
gcp_project_id=args.gcp_project_id,
run_id=args.run_id,
release_dir=args.release_dir,
)
elif args.action == "full":
flow_full_pipeline(
gpg_passphrase=args.gpg_passphrase,
github_token=args.github_token,
gcp_project_id=args.gcp_project_id,
run_id=args.run_id,
skip_build=args.skip_build,
skip_sign=args.skip_sign,
skip_release=args.skip_release,
)
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
"""Cloudflare R2 artifact upload with credentials from GCP Secrets Manager."""
import mimetypes
from typing import Any
from pathlib import Path
import boto3 # type: ignore[import-untyped]
from google.cloud import secretmanager # type: ignore[import-untyped]
# GCP Secret Manager secret names for R2 credentials
_R2_ACCESS_KEY_SECRET = "r2-access-key-id"
_R2_SECRET_KEY_SECRET = "r2-secret-access-key"
_R2_ENDPOINT_SECRET = "r2-endpoint-url"
_R2_BUCKET_SECRET = "r2-bucket-name"
def _fetch_secret(client: Any, project_id: str, secret_id: str) -> str:
"""Fetch the latest version of a secret from GCP Secrets Manager.
Args:
client: Secret Manager client.
project_id: GCP project ID.
secret_id: Name of the secret to retrieve.
Returns:
The secret value as a string.
Raises:
google.api_core.exceptions.NotFound: If the secret does not exist.
"""
name = f"projects/{project_id}/secrets/{secret_id}/versions/latest"
response = client.access_secret_version(request={"name": name})
return response.payload.data.decode("utf-8")
def get_r2_credentials(gcp_project_id: str) -> dict[str, str]:
"""Retrieve all Cloudflare R2 credentials from GCP Secrets Manager.
Args:
gcp_project_id: GCP project ID containing the secrets.
Returns:
Dictionary with keys: access_key_id, secret_access_key,
endpoint_url, bucket_name.
"""
client: Any = secretmanager.SecretManagerServiceClient() # pyright: ignore
return {
"access_key_id": _fetch_secret(client, gcp_project_id, _R2_ACCESS_KEY_SECRET),
"secret_access_key": _fetch_secret(
client, gcp_project_id, _R2_SECRET_KEY_SECRET
),
"endpoint_url": _fetch_secret(client, gcp_project_id, _R2_ENDPOINT_SECRET),
"bucket_name": _fetch_secret(client, gcp_project_id, _R2_BUCKET_SECRET),
}
def upload_to_r2(
release_dir: Path,
gcp_project_id: str,
run_id: str,
) -> list[str]:
"""Upload release artifacts to Cloudflare R2.
Fetches R2 credentials from GCP Secrets Manager, then uploads all
files in the release directory to the R2 bucket under a builds/<run_id>/
prefix.
Args:
release_dir: Directory containing release files to upload.
gcp_project_id: GCP project ID for Secrets Manager lookups.
run_id: Unique identifier for this build run.
Returns:
List of uploaded R2 object keys.
Raises:
FileNotFoundError: If release_dir does not exist.
botocore.exceptions.ClientError: If R2 upload fails.
"""
if not release_dir.is_dir():
raise FileNotFoundError(f"Release directory not found: {release_dir}")
credentials = get_r2_credentials(gcp_project_id)
s3_client: Any = boto3.client( # pyright: ignore
"s3",
endpoint_url=credentials["endpoint_url"],
aws_access_key_id=credentials["access_key_id"],
aws_secret_access_key=credentials["secret_access_key"],
)
bucket = credentials["bucket_name"]
prefix = f"builds/{run_id}"
uploaded_keys: list[str] = []
for file_path in sorted(release_dir.iterdir()):
if not file_path.is_file():
continue
key = f"{prefix}/{file_path.name}"
content_type, _ = mimetypes.guess_type(str(file_path))
extra_args: dict[str, str] = {}
if content_type:
extra_args["ContentType"] = content_type
print(f" Uploading {file_path.name} → {key}")
s3_client.upload_file( # pyright: ignore[reportUnknownMemberType]
str(file_path),
bucket,
key,
ExtraArgs=extra_args,
)
uploaded_keys.append(key)
print(
f"Uploaded {len(uploaded_keys)} files to R2 bucket '{bucket}' under '{prefix}/'"
)
return uploaded_keys
+94
View File
@@ -0,0 +1,94 @@
"""GPG signing and SHA-256 hashing utilities for release artifacts."""
import hashlib
import subprocess
import tempfile
from pathlib import Path
def gpg_sign_file(file_path: Path, passphrase: str) -> Path:
"""Create a detached ASCII-armored GPG signature for a file.
Args:
file_path: Path to the file to sign.
passphrase: GPG key passphrase.
Returns:
Path to the generated .asc signature file.
Raises:
subprocess.CalledProcessError: If GPG signing fails.
FileNotFoundError: If the input file does not exist.
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
sig_path = file_path.with_suffix(file_path.suffix + ".asc")
with tempfile.NamedTemporaryFile(mode="w", suffix=".pass", delete=True) as passfile:
passfile.write(passphrase)
passfile.flush()
subprocess.run(
[
"gpg",
"--batch",
"--yes",
"--passphrase-file",
passfile.name,
"--detach-sign",
"--armor",
str(file_path),
],
check=True,
capture_output=True,
text=True,
)
print(f"Signed: {sig_path}")
return sig_path
def sha256_hash_file(file_path: Path) -> tuple[str, Path]:
"""Compute SHA-256 hash of a file and write a .sha256 checksum file.
Args:
file_path: Path to the file to hash.
Returns:
Tuple of (hex digest, path to .sha256 file).
Raises:
FileNotFoundError: If the input file does not exist.
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
sha256 = hashlib.sha256()
with open(file_path, "rb") as fh:
for chunk in iter(lambda: fh.read(8192), b""):
sha256.update(chunk)
digest = sha256.hexdigest()
hash_line = f"{digest} {file_path.name}"
hash_path = file_path.parent / f"{file_path.stem}.sha256"
hash_path.write_text(hash_line, encoding="ascii")
print(f"SHA-256 ({file_path.name}): {digest}")
return digest, hash_path
def sign_and_hash(file_path: Path, passphrase: str) -> tuple[Path, Path]:
"""Sign a file with GPG and generate its SHA-256 checksum.
Args:
file_path: Path to the artifact to sign and hash.
passphrase: GPG key passphrase.
Returns:
Tuple of (signature path, hash file path).
"""
sig_path = gpg_sign_file(file_path, passphrase)
_, hash_path = sha256_hash_file(file_path)
return sig_path, hash_path
+13 -4
View File
@@ -1,9 +1,18 @@
# Docker Compose configuration for local multi-platform builds
# Matches the exact images and configurations from .github/workflows/release.yml
# DEPRECATED: This file is kept for backward compatibility.
# Use podman-compose.yml instead:
# podman-compose -f podman-compose.yml up --build
#
# Docker Compose will also read podman-compose.yml if you symlink:
# ln -sf podman-compose.yml docker-compose.yml
#
# --- Original configuration follows (mirrors podman-compose.yml) ---
# Podman Compose / Docker Compose configuration for local multi-platform builds
# Matches the exact images and configurations from .github/workflows/release-prefect-dagger.yml
#
# Usage:
# Build all distributions: docker-compose up --build
# Build specific distro: docker-compose up --build debian
# Build all distributions: podman-compose up --build
# Build specific distro: podman-compose up --build debian
#
# Each service builds a distribution package and outputs to:
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
+72
View File
@@ -0,0 +1,72 @@
# Podman Compose configuration for local multi-platform builds
# Compatible with podman-compose and docker-compose (via podman socket)
#
# Usage:
# Build all distributions: podman-compose up --build
# Build specific distro: podman-compose up --build debian
#
# Each service builds a distribution package and outputs to:
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
# - pkg_dist_{distro}/ - Staging directory for package contents
# - dist_{distro}/ - PyInstaller build output
services:
debian:
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
build:
context: .
dockerfile: scripts/docker/Dockerfile.debian
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace:Z
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=debian
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
arch:
image: ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
build:
context: .
dockerfile: scripts/docker/Dockerfile.arch
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace:Z
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=arch
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
rhel:
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
build:
context: .
dockerfile: scripts/docker/Dockerfile.rhel
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace:Z
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=rhel
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
Generated
+4006 -15
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -39,6 +39,15 @@ pytest-cov = "^7.0.0"
[tool.poetry.group.build.dependencies]
pyinstaller = "^6.1.0"
[tool.poetry.group.ci]
optional = true
[tool.poetry.group.ci.dependencies]
dagger-io = ">=0.15.0"
prefect = ">=3.0.0"
boto3 = ">=1.35.0"
google-cloud-secret-manager = ">=2.21.0"
[tool.black]
line-length = 88
target-version = ['py313']
+20 -7
View File
@@ -7,7 +7,7 @@ import sys
import re
from pathlib import Path
from enum import Enum
from typing import List
from typing import List, TypedDict
class DistroType(Enum):
@@ -16,7 +16,19 @@ class DistroType(Enum):
RHEL = "rhel"
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess:
class DistroConfigDict(TypedDict):
"""Type definition for distro configuration."""
name: str
bin_path: str
pkg_suffix: str
spec_file: str
pkg_type: str
architecture: str
postinstall: str | None
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess[bytes]:
"""Run command and handle errors."""
print(f"Running: {' '.join(cmd)}")
try:
@@ -29,9 +41,9 @@ def run_command(cmd: list[str], check: bool = True, working_dir: str | None = No
sys.exit(1)
def get_distro_config(distro_type: DistroType) -> dict:
def get_distro_config(distro_type: DistroType) -> DistroConfigDict:
"""Get configuration for specific distro type."""
configs = {
configs: dict[DistroType, DistroConfigDict] = {
DistroType.DEBIAN: {
"name": "Debian",
"bin_path": "usr/local/bin",
@@ -131,10 +143,11 @@ def package_with_fpm(distro_type: DistroType, version: str, project_root: Path)
# Add distro-specific options
if distro_type == DistroType.DEBIAN:
output_file = dist_dir / f"android-file-handler_{version}_{config['architecture']}.deb"
postinstall = config["postinstall"]
fpm_cmd.extend([
"--deb-user", "root",
"--deb-group", "root",
"--after-install", config["postinstall"],
*(["--after-install", postinstall] if postinstall else []),
"-p", str(output_file)
])
elif distro_type == DistroType.ARCH:
@@ -144,9 +157,9 @@ def package_with_fpm(distro_type: DistroType, version: str, project_root: Path)
])
elif distro_type == DistroType.RHEL:
output_file = dist_dir / f"android-file-handler-{version}.{config['architecture']}.rpm"
postinstall = config["postinstall"]
fpm_cmd.extend([
"--prefix", "/usr/bin",
"--after-install", config["postinstall"],
*(["--after-install", postinstall] if postinstall else []),
"-p", str(output_file)
])
+252
View File
@@ -0,0 +1,252 @@
# bootstrap.ps1 — Windows developer environment bootstrapper for Android File Handler
# - Installs Chocolatey (if missing)
# - Installs Vagrant (Chocolatey or direct from HashiCorp)
# - Installs pyenv-win (if missing)
# - Installs Python build dependencies (Chocolatey)
# - Installs zlib from source (https://zlib.net/current/zlib.tar.gz)
# - Installs libffi from GitHub releases (https://github.com/libffi/libffi)
# - Installs latest Python (from pyproject.toml)
# - Sets that Python as global default
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Write-Host "=== Android File Handler Windows Dev Bootstrap ===" -ForegroundColor Cyan
# --- Install Chocolatey ---
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host "Installing Chocolatey..." -ForegroundColor Yellow
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
# --- Install Vagrant ---
$vagrantInstalled = Get-Command vagrant -ErrorAction SilentlyContinue
if (-not $vagrantInstalled) {
Write-Host "Installing Vagrant..." -ForegroundColor Yellow
$chocoAvailable = Get-Command choco -ErrorAction SilentlyContinue
if ($chocoAvailable) {
Write-Host " Installing via Chocolatey..." -ForegroundColor Yellow
choco install vagrant -y --no-progress
}
else {
Write-Host " Chocolatey not available — downloading from HashiCorp..." -ForegroundColor Yellow
$vagrantVersion = "2.4.9"
$msiUrl = "https://releases.hashicorp.com/vagrant/$vagrantVersion/vagrant_${vagrantVersion}_windows_amd64.msi"
$msiPath = "$env:TEMP\vagrant_${vagrantVersion}_windows_amd64.msi"
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Write-Host " Downloading $msiUrl ..." -ForegroundColor Yellow
(New-Object System.Net.WebClient).DownloadFile($msiUrl, $msiPath)
Write-Host " Installing MSI..." -ForegroundColor Yellow
$msiProc = Start-Process -FilePath "msiexec.exe" `
-ArgumentList "/i", $msiPath, "/qn", "/norestart" `
-Wait -PassThru
if ($msiProc.ExitCode -ne 0) {
Write-Host "ERROR: Vagrant MSI install failed with exit code $($msiProc.ExitCode)" -ForegroundColor Red
exit 1
}
Remove-Item $msiPath -Force -ErrorAction SilentlyContinue
}
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
Write-Host " Vagrant installed: $(vagrant --version)" -ForegroundColor Green
}
else {
Write-Host "Vagrant already installed: $(vagrant --version)" -ForegroundColor Green
}
# --- Install pyenv-win ---
$pyenvRoot = "$env:USERPROFILE\.pyenv"
$pyenvBin = "$pyenvRoot\pyenv-win\bin\pyenv.bat"
if (-not (Test-Path $pyenvBin)) {
Write-Host "Installing pyenv-win..." -ForegroundColor Yellow
choco install pyenv-win -y --no-progress
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
else {
Write-Host "pyenv-win already installed." -ForegroundColor Green
}
# --- Install Python build dependencies ---
# Package IDs verified against https://community.chocolatey.org/packages
# zlib has no Chocolatey package; libffi has no Chocolatey package — both omitted
$buildDeps = @('visualcpp-build-tools', 'make', 'openssl.light', 'bzip2', 'SQLite')
$installedPkgs = choco list --no-progress --limit-output 2>&1 |
Where-Object { $_ -match '\|' } |
ForEach-Object { ($_ -split '\|')[0].ToLower() }
$toInstall = @($buildDeps | Where-Object { $_.ToLower() -notin $installedPkgs })
if ($toInstall.Count -gt 0) {
Write-Host "Installing Python build dependencies: $($toInstall -join ', ')..." -ForegroundColor Yellow
choco install -y --no-progress @toInstall
} else {
Write-Host "Python build dependencies already installed." -ForegroundColor Green
}
# Shared install root for libraries not on Chocolatey
$devLibsRoot = "$env:USERPROFILE\.devlibs"
New-Item -ItemType Directory -Path $devLibsRoot -Force | Out-Null
# --- Install zlib from source (https://zlib.net/current/zlib.tar.gz) ---
$zlibInstallDir = "$devLibsRoot\zlib"
if (-not (Test-Path "$zlibInstallDir\include\zlib.h")) {
Write-Host "Installing zlib from source..." -ForegroundColor Yellow
# Locate MSVC build environment via vswhere
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path $vswhere)) {
Write-Host "ERROR: vswhere.exe not found — Visual Studio Build Tools required to build zlib." -ForegroundColor Red
exit 1
}
$vsPath = (& $vswhere -latest -products * -requires Microsoft.VisualCpp.Tools.HostX86.TargetX64 -property installationPath 2>&1).Trim()
$vcvarsall = "$vsPath\VC\Auxiliary\Build\vcvarsall.bat"
if (-not (Test-Path $vcvarsall)) {
Write-Host "ERROR: vcvarsall.bat not found at $vcvarsall" -ForegroundColor Red
exit 1
}
# Download source tarball
$zlibTar = "$env:TEMP\zlib.tar.gz"
Write-Host " Downloading zlib source..." -ForegroundColor Yellow
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
(New-Object System.Net.WebClient).DownloadFile('https://zlib.net/current/zlib.tar.gz', $zlibTar)
# Extract (tar.exe ships with Windows 10 1803+)
$zlibBuildDir = "$env:TEMP\zlib-build"
if (Test-Path $zlibBuildDir) { Remove-Item $zlibBuildDir -Recurse -Force }
New-Item -ItemType Directory -Path $zlibBuildDir | Out-Null
tar -xzf $zlibTar -C $zlibBuildDir --strip-components=1
# Build with MSVC nmake via a wrapper cmd script
Write-Host " Building zlib with MSVC nmake..." -ForegroundColor Yellow
$buildCmd = "$env:TEMP\zlib-build.cmd"
"@echo off`r`ncall `"$vcvarsall`" x64`r`ncd /d `"$zlibBuildDir`"`r`nnmake -f win32\Makefile.msc`r`n" |
Set-Content $buildCmd -Encoding ASCII
$proc = Start-Process cmd.exe -ArgumentList "/c `"$buildCmd`"" -Wait -PassThru -NoNewWindow
Remove-Item $buildCmd -Force -ErrorAction SilentlyContinue
if ($proc.ExitCode -ne 0) {
Write-Host "ERROR: zlib build failed (exit $($proc.ExitCode))" -ForegroundColor Red
exit 1
}
# Install headers and libs
New-Item -ItemType Directory -Path "$zlibInstallDir\include" -Force | Out-Null
New-Item -ItemType Directory -Path "$zlibInstallDir\lib" -Force | Out-Null
New-Item -ItemType Directory -Path "$zlibInstallDir\bin" -Force | Out-Null
Copy-Item "$zlibBuildDir\zlib.h" "$zlibInstallDir\include\"
Copy-Item "$zlibBuildDir\zconf.h" "$zlibInstallDir\include\"
Copy-Item "$zlibBuildDir\zlib.lib" "$zlibInstallDir\lib\"
Copy-Item "$zlibBuildDir\zdll.lib" "$zlibInstallDir\lib\" -ErrorAction SilentlyContinue
Copy-Item "$zlibBuildDir\zlib1.dll" "$zlibInstallDir\bin\" -ErrorAction SilentlyContinue
# Cleanup
Remove-Item $zlibTar -Force -ErrorAction SilentlyContinue
Remove-Item $zlibBuildDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "zlib installed to $zlibInstallDir" -ForegroundColor Green
} else {
Write-Host "zlib already installed at $zlibInstallDir" -ForegroundColor Green
}
# Add zlib bin dir to user PATH (persists across sessions; also updates current session)
$zlibBinDir = "$zlibInstallDir\bin"
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath -notmatch [regex]::Escape($zlibBinDir)) {
[System.Environment]::SetEnvironmentVariable('Path', "$userPath;$zlibBinDir", 'User')
Write-Host " Added $zlibBinDir to user PATH" -ForegroundColor Green
}
$env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('Path', 'User')
# --- Install libffi from GitHub releases (https://github.com/libffi/libffi) ---
$libffiInstallDir = "$devLibsRoot\libffi"
if (-not (Test-Path "$libffiInstallDir\include\ffi.h")) {
Write-Host "Fetching latest libffi release from GitHub..." -ForegroundColor Yellow
$apiResponse = Invoke-RestMethod `
-Uri 'https://api.github.com/repos/libffi/libffi/releases/latest' `
-Headers @{ 'User-Agent' = 'android-file-handler-bootstrap' }
$libffiTag = $apiResponse.tag_name # e.g. "v3.5.2"
$libffiVersion = $libffiTag -replace '^v', '' # e.g. "3.5.2"
Write-Host " Latest libffi: $libffiTag" -ForegroundColor Yellow
$zipName = "libffi-$libffiVersion-x86-64bit-msvc-binaries.zip"
$downloadUrl = "https://github.com/libffi/libffi/releases/download/$libffiTag/$zipName"
$zipPath = "$env:TEMP\$zipName"
Write-Host " Downloading $zipName..." -ForegroundColor Yellow
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
(New-Object System.Net.WebClient).DownloadFile($downloadUrl, $zipPath)
# Extract — zip contains a single top-level subdirectory
$extractTemp = "$env:TEMP\libffi-extract"
if (Test-Path $extractTemp) { Remove-Item $extractTemp -Recurse -Force }
Expand-Archive -Path $zipPath -DestinationPath $extractTemp -Force
if (Test-Path $libffiInstallDir) { Remove-Item $libffiInstallDir -Recurse -Force }
$innerDir = Get-ChildItem $extractTemp -Directory | Select-Object -First 1
if ($innerDir) {
Move-Item $innerDir.FullName $libffiInstallDir
} else {
# Flat zip — use the extract root directly
Move-Item $extractTemp $libffiInstallDir
}
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
if (Test-Path $extractTemp) { Remove-Item $extractTemp -Recurse -Force -ErrorAction SilentlyContinue }
Write-Host "libffi $libffiVersion installed to $libffiInstallDir" -ForegroundColor Green
} else {
Write-Host "libffi already installed at $libffiInstallDir" -ForegroundColor Green
}
# --- Parse Python version from pyproject.toml ---
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..\..")
$pyprojectPath = Join-Path $repoRoot "pyproject.toml"
if (-not (Test-Path $pyprojectPath)) {
Write-Host "ERROR: pyproject.toml not found at $pyprojectPath" -ForegroundColor Red
exit 1
}
$pyproject = Get-Content $pyprojectPath -Raw
$pyverMatch = $pyproject | Select-String -Pattern 'requires-python\s*=\s*"([^"]+)"' | ForEach-Object { $_.Matches[0].Groups[1].Value }
if (-not $pyverMatch) {
Write-Host "ERROR: Could not find requires-python in pyproject.toml" -ForegroundColor Red
exit 1
}
# Use the highest version in the range (e.g. ">=3.13, <3.15" → 3.14.x)
$pyver = $pyverMatch -replace '[^0-9.,<>= ]', ''
$maxVer = ($pyver -split ',') | Where-Object { $_ -match '<' } | ForEach-Object { $_ -replace '[^0-9.]', '' } | Sort-Object -Descending | Select-Object -First 1
if ($maxVer) {
$major, $minor = $maxVer -split '\.'
$targetMinor = [int]$minor - 1
$targetVer = "$major.$targetMinor.x"
} else {
$targetVer = $pyver -replace '[^0-9.]', ''
}
Write-Host "Target Python version: $targetVer" -ForegroundColor Cyan
# --- Install and set global Python version ---
$pyenv = "pyenv"
if (-not (Get-Command $pyenv -ErrorAction SilentlyContinue)) {
$pyenv = "$pyenvBin"
}
$versionPrefix = $targetVer -replace '\.x$', ''
$installedVersions = (& $pyenv versions 2>&1) | Out-String
if ($installedVersions -notmatch [regex]::Escape($versionPrefix)) {
Write-Host "Installing Python $targetVer via pyenv-win..." -ForegroundColor Yellow
& $pyenv install $targetVer -q
} else {
Write-Host "Python $targetVer already installed via pyenv." -ForegroundColor Green
}
$currentGlobal = ((& $pyenv global 2>&1) | Out-String).Trim()
if ($currentGlobal -notmatch [regex]::Escape($versionPrefix)) {
& $pyenv global $targetVer
Write-Host "pyenv-win global version set to $targetVer" -ForegroundColor Green
} else {
Write-Host "pyenv-win global already set to $currentGlobal" -ForegroundColor Green
}
Write-Host "=== Bootstrap complete ===" -ForegroundColor Cyan
+19 -15
View File
@@ -9,8 +9,10 @@
# For reproducibility, pin to a specific date tag like: archlinux:base-20251016
FROM archlinux:latest
# Set build argument for fpm version (can be overridden at build time)
# Set build arguments
ARG FPM_VERSION=1.16.0
ARG PYTHON_VERSION=3.14.3
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
# Install system dependencies (Arch) including Python build dependencies
RUN pacman -Syu --noconfirm \
@@ -56,24 +58,26 @@ RUN gem install --no-document -v "${FPM_VERSION}" fpm && \
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
# Download Python source and verify SHA256 checksum against python.org
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
# Install Python 3.12 via pyenv with tkinter support
# The tk and tcl packages must be installed before this step for _tkinter to be compiled
RUN eval "$(pyenv init -)" && \
# Build and install Python from source
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
cd Python-${PYTHON_VERSION} && \
LDFLAGS="-L/usr/lib" \
CPPFLAGS="-I/usr/include" \
PYTHON_CONFIGURE_OPTS="--enable-shared" \
pyenv install 3.13 && \
pyenv global 3.13 && \
pyenv rehash
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
make -j$(nproc) && \
make install && \
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
ldconfig && \
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Update PATH to include Python installation
ENV PATH="/usr/local/bin:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
+19 -15
View File
@@ -8,8 +8,10 @@
# Use Debian 13 "Trixie" (latest stable release)
FROM debian:13
# Set build argument for fpm version (can be overridden at build time)
# Set build arguments
ARG FPM_VERSION=1.16.0
ARG PYTHON_VERSION=3.14.3
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
# Install system dependencies including Python build dependencies
RUN apt-get update && \
@@ -41,24 +43,26 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
# Download Python source and verify SHA256 checksum against python.org
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
# Install Python 3.12 via pyenv with tkinter support
# The tk8.6-dev package must be installed before this step for _tkinter to be compiled
RUN eval "$(pyenv init -)" && \
# Build and install Python from source
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
cd Python-${PYTHON_VERSION} && \
LDFLAGS="-L/usr/lib/x86_64-linux-gnu" \
CPPFLAGS="-I/usr/include/tcl8.6" \
PYTHON_CONFIGURE_OPTS="--enable-shared" \
pyenv install 3.13 && \
pyenv global 3.13 && \
pyenv rehash
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
make -j$(nproc) && \
make install && \
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
ldconfig && \
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Update PATH to include Python installation
ENV PATH="/usr/local/bin:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
+19 -15
View File
@@ -7,8 +7,10 @@
FROM fedora:42
# Set build argument for fpm version (can be overridden at build time)
# Set build arguments
ARG FPM_VERSION=1.16.0
ARG PYTHON_VERSION=3.14.3
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
# Install system dependencies including tk8-devel for Python tkinter support
# Using tk8 (version 8.6) instead of tk (version 9.0) for Python 3.12 compatibility
@@ -42,24 +44,26 @@ RUN dnf -y update && \
libXrender-devel && \
dnf clean all
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
# Download Python source and verify SHA256 checksum against python.org
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
# Install Python 3.13 via pyenv with tkinter support
# The tk8-devel package must be installed before this step for _tkinter to be compiled
RUN eval "$(pyenv init -)" && \
# Build and install Python from source
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
cd Python-${PYTHON_VERSION} && \
LDFLAGS="-L/usr/lib64" \
CPPFLAGS="-I/usr/include" \
PYTHON_CONFIGURE_OPTS="--enable-shared" \
pyenv install 3.13 && \
pyenv global 3.13 && \
pyenv rehash
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
make -j$(nproc) && \
make install && \
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
ldconfig && \
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Update PATH to include Python installation
ENV PATH="/usr/local/bin:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
+29 -20
View File
@@ -1,52 +1,57 @@
# Docker Build Environment
# Container Build Environment
This directory contains Dockerfiles for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
This directory contains OCI-compatible Containerfiles (Dockerfiles) for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
The project uses **Podman** as the container runtime. All commands below use Podman; if you have Docker installed, the Dockerfiles are OCI-compatible and will work with Docker as well.
## Quick Start
### Using Docker Compose (Recommended)
### Using Podman Compose (Recommended)
Build for all distributions:
```bash
docker-compose up --build
podman-compose up --build
```
Build for a specific distribution:
```bash
docker-compose up --build debian
docker-compose up --build arch
docker-compose up --build rhel
podman-compose up --build debian
podman-compose up --build arch
podman-compose up --build rhel
```
Build all distributions in parallel:
### Using Prefect + Dagger (CI Pipeline Locally)
The CI/CD pipeline uses Prefect and Dagger to orchestrate builds. You can run it locally:
```bash
docker-compose up --build --parallel
poetry install --with ci
poetry run python -m ci.prefect_flow build-linux
```
### Manual Docker Build
### Manual Podman Build
Build the image:
```bash
# Debian
docker build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
podman build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
# Arch
docker build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
podman build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
# RHEL/Fedora
docker build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
podman build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
```
Run the build:
```bash
# Debian
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-debian-builder
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-debian-builder
# Arch
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-arch-builder
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-arch-builder
# RHEL/Fedora
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-rhel-builder
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-rhel-builder
```
## Output
@@ -81,9 +86,13 @@ After building, you'll find:
### Virtualenv Conflicts
The Docker Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`.
The Podman Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`.
If you encounter virtualenv-related errors, ensure you're using the latest docker-compose.yml configuration.
If you encounter virtualenv-related errors, ensure you're using the latest podman-compose.yml configuration.
### SELinux (Fedora/RHEL hosts)
Volume mounts use the `:Z` suffix to apply the correct SELinux labels automatically. If you encounter permission errors, ensure the `:Z` suffix is present on volume mounts.
## Cleaning Up
@@ -92,9 +101,9 @@ Remove build artifacts:
rm -rf dist pkg_dist_* dist_*
```
Remove Docker volumes and containers:
Remove Podman containers and volumes:
```bash
docker compose down -v
podman-compose down -v
```
## Customization
+1 -1
View File
@@ -19,7 +19,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
fi
# Ensure installed binary is executable
if [ -f /usr/local/bin/android-file-handler ]; then
if [ -f /usr/bin/android-file-handler ]; then
chmod 0755 /usr/bin/android-file-handler || true
fi
@@ -1,10 +1,13 @@
import os
block_cipher = None
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
a = Analysis(
['../../src/main.py'],
pathex=['src'],
[os.path.join(SRC_DIR, 'main.py')],
pathex=[SRC_DIR],
binaries=[],
datas=[
('../../src/gui', 'gui'),
(os.path.join(SRC_DIR, 'gui'), 'gui'),
],
hiddenimports=[
# GUI modules
@@ -33,7 +36,8 @@ a = Analysis(
'managers.transfer_manager',
# Utility modules
'utils',
'utils.file_deduplication'
'utils.file_deduplication',
'utils.security_utils'
],
hookspath=[],
hooksconfig={},
@@ -1,12 +1,15 @@
# -*- mode: python ; coding: utf-8 -*-
import os
block_cipher = None
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
a = Analysis(
['../../src/main.py'],
pathex=['src'],
[os.path.join(SRC_DIR, 'main.py')],
pathex=[SRC_DIR],
binaries=[],
datas=[
('../../src/gui', 'gui'),
('../../scripts/debian_postinst.sh', 'scripts'),
(os.path.join(SRC_DIR, 'gui'), 'gui'),
(os.path.join(SPEC_DIR, '..', 'debian_postinst.sh'), 'scripts'),
],
hiddenimports=[
# GUI modules
@@ -35,7 +38,8 @@ a = Analysis(
'managers.transfer_manager',
# Utility modules
'utils',
'utils.file_deduplication'
'utils.file_deduplication',
'utils.security_utils'
],
hookspath=[],
hooksconfig={},
@@ -1,11 +1,14 @@
import os
block_cipher = None
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
a = Analysis(
['../../src/main.py'],
pathex=['src'],
[os.path.join(SRC_DIR, 'main.py')],
pathex=[SRC_DIR],
binaries=[],
datas=[
('../../src/gui', 'gui'),
('../../scripts/rhel_postinst.sh', 'scripts'),
(os.path.join(SRC_DIR, 'gui'), 'gui'),
(os.path.join(SPEC_DIR, '..', 'rhel_postinst.sh'), 'scripts'),
],
hiddenimports=[
# GUI modules
@@ -34,7 +37,8 @@ a = Analysis(
'managers.transfer_manager',
# Utility modules
'utils',
'utils.file_deduplication'
'utils.file_deduplication',
'utils.security_utils'
],
hookspath=[],
hooksconfig={},
@@ -1,12 +1,14 @@
# -*- mode: python ; coding: utf-8 -*-
import os
block_cipher = None
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
a = Analysis(
['../../src/main.py'],
pathex=['../..'],
[os.path.join(SRC_DIR, 'main.py')],
pathex=[SRC_DIR],
binaries=[],
datas=[
('../../src/gui', 'gui'),
('../windows/first_run_install.ps1', 'scripts/windows')
(os.path.join(SRC_DIR, 'gui'), 'gui'),
],
hiddenimports=[
# GUI modules
@@ -35,7 +37,8 @@ a = Analysis(
'managers.transfer_manager',
# Utility modules
'utils',
'utils.file_deduplication'
'utils.file_deduplication',
'utils.security_utils'
],
hookspath=[],
hooksconfig={},
@@ -67,5 +70,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='../../icon_media/robot_files_256.ico',
icon=os.path.join(SPEC_DIR, '..', '..', 'icon_media', 'robot_files_256.ico'),
)
@@ -0,0 +1,61 @@
; Inno Setup script for Android File Handler
; Compiles a Windows installer from the PyInstaller one-file executable.
;
; Usage (CI):
; iscc scripts\windows\android-file-handler-setup.iss /DMyAppVersion=1.2.3
;
; Usage (local, from repo root):
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss
#ifndef MyAppVersion
#define MyAppVersion "0.1.1"
#endif
#define MyAppName "Android File Handler"
#define MyAppPublisher "Jason Ross"
#define MyAppURL "https://github.com/JMR-dev/android-file-handler"
#define MyAppExeName "android-file-handler-windows.exe"
[Setup]
AppId={{8F2B3A7E-4D1C-4E8F-9A2B-6C7D8E9F0A1B}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
; AppVerName controls the registry DisplayName value. Without it Inno Setup
; defaults to "AppName version AppVersion" which breaks exact-name lookups.
; Windows convention: DisplayName = product name, DisplayVersion = version.
AppVerName={#MyAppName}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
LicenseFile=..\..\LICENSE.txt
OutputDir=..\..\dist
OutputBaseFilename=android-file-handler-setup
SetupIconFile=..\..\icon_media\robot_files_256.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
Compression=lzma2/normal
LZMADictionarySize=16384
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin
MinVersion=10.0
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
-51
View File
@@ -1,51 +0,0 @@
param(
[string]$ExePath = "$PSScriptRoot\..\..\dist\android-file-handler.exe",
[string]$IconPath = "$PSScriptRoot\..\..\assets\icons\android-file-handler.ico",
[string]$AppName = "Android File Handler"
)
function Ensure-Elevated {
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
# Relaunch the script with elevation
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "powershell.exe"
$psi.Arguments = "-ExecutionPolicy Bypass -File `"$PSCommandPath`""
$psi.Verb = "runas"
try {
[System.Diagnostics.Process]::Start($psi) | Out-Null
Exit 0
} catch {
Write-Error "Elevation required to install to Program Files."
Exit 1
}
}
}
Ensure-Elevated
$destDir = Join-Path ${env:ProgramFiles} $AppName
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir | Out-Null }
$resolvedExe = Resolve-Path -Path $ExePath -ErrorAction SilentlyContinue
if (-not $resolvedExe) {
Write-Error "Application executable not found at $ExePath"
Exit 1
}
Copy-Item -Path $resolvedExe -Destination (Join-Path $destDir (Split-Path $resolvedExe -Leaf)) -Force
# Create Start Menu shortcut
$programs = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
$appFolder = Join-Path $programs $AppName
if (-not (Test-Path $appFolder)) { New-Item -ItemType Directory -Path $appFolder | Out-Null }
$shortcutPath = Join-Path $appFolder "$AppName.lnk"
$wsh = New-Object -ComObject WScript.Shell
$sc = $wsh.CreateShortcut($shortcutPath)
$sc.TargetPath = (Join-Path $destDir (Split-Path $resolvedExe -Leaf))
$sc.WorkingDirectory = $destDir
if (Test-Path $IconPath) { $sc.IconLocation = Resolve-Path $IconPath }
$sc.Save()
Write-Output "Installed $AppName to $destDir and created Start Menu shortcut."
Exit 0
+334
View File
@@ -0,0 +1,334 @@
# run_vagrant_tests.ps1 — Host-side orchestrator for Vagrant Windows 11 test cycle.
#
# Workflow:
# 1. Build the installer locally (PyInstaller + Inno Setup)
# 2. Ensure the Vagrant VM is running and has a "clean" snapshot
# 3. Restore to the clean snapshot
# 4. Transfer build assets to the VM and compile the installer (Inno Setup)
# 5. Run the test script inside the VM via PSSession
# 6. Retrieve screenshots and results to the host
# 7. Print results summary to CLI
# 8. Revert VM back to clean snapshot
#
# Usage (from repo root):
# .\scripts\windows\run_vagrant_tests.ps1
# .\scripts\windows\run_vagrant_tests.ps1 -SkipBuild
# .\scripts\windows\run_vagrant_tests.ps1 -SkipRevert
#
# Prerequisites:
# - Vagrant and Hyper-V enabled on the host
# - Poetry environment set up
# - The VM created and "clean" snapshot saved:
# cd vagrant && vagrant up --provider=hyperv && vagrant snapshot save clean
# Note: Inno Setup 6.7.1 is installed inside the VM by provision.ps1 — no host install needed.
[CmdletBinding()]
param(
[switch]$SkipBuild,
[switch]$SkipRevert,
[string]$SnapshotName = "clean",
[string]$LocalResultsDir = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$vmSession = $null # PSSession — closed in finally block
$VagrantDir = Join-Path $RepoRoot "vagrant"
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
if (-not $LocalResultsDir) {
$LocalResultsDir = Join-Path $RepoRoot "vagrant_test_results\$Timestamp"
}
function Write-Step {
param([string]$Message)
Write-Host "`n>>> $Message" -ForegroundColor Cyan
}
# ── Ensure we're in the vagrant directory ────────────────────────────────────
Push-Location $VagrantDir
try {
# ── Step 1: Build the Windows executable (host, PyInstaller only) ────────
# Inno Setup runs inside the VM — no host install of Inno Setup required.
$pyinstallerExe = Join-Path $RepoRoot "dist\android-file-handler-windows.exe"
$AppVersion = ""
if ($SkipBuild) {
Write-Step "Skipping PyInstaller build (-SkipBuild specified)"
if (-not (Test-Path $pyinstallerExe)) {
Write-Host "ERROR: -SkipBuild was specified but exe not found at $pyinstallerExe" -ForegroundColor Red
exit 1
}
}
else {
Write-Step "Building Windows executable (PyInstaller)"
Push-Location $RepoRoot
try {
Write-Host " Running PyInstaller..." -ForegroundColor Yellow
poetry run pyinstaller scripts\spec_scripts\android-file-handler-windows.spec --noconfirm
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: PyInstaller failed with exit code $LASTEXITCODE" -ForegroundColor Red
exit 1
}
if (-not (Test-Path $pyinstallerExe)) {
Write-Host "ERROR: PyInstaller output not found at $pyinstallerExe" -ForegroundColor Red
exit 1
}
Write-Host " PyInstaller output: $pyinstallerExe" -ForegroundColor Green
}
finally {
Pop-Location
}
}
# Capture version to pass to Inno Setup inside the VM
Push-Location $RepoRoot
try {
$AppVersion = (poetry version -s).Trim()
Write-Host " App version: $AppVersion" -ForegroundColor DarkGray
}
finally {
Pop-Location
}
# ── Step 2: Verify VM and snapshot exist ─────────────────────────────────
Write-Step "Checking Vagrant VM status"
$status = vagrant status --machine-readable 2>&1 |
Select-String "state,running" -Quiet
if (-not $status) {
Write-Host "VM is not running. Starting it..." -ForegroundColor Yellow
vagrant up --provider=hyperv
}
Write-Step "Verifying '$SnapshotName' snapshot exists"
$snapshots = vagrant snapshot list 2>&1
if ($snapshots -notmatch [regex]::Escape($SnapshotName)) {
Write-Host "Snapshot '$SnapshotName' not found. Creating it now..." -ForegroundColor Yellow
vagrant snapshot save $SnapshotName
}
# ── Step 3: Restore to clean snapshot ────────────────────────────────────
Write-Step "Restoring VM to '$SnapshotName' snapshot"
vagrant snapshot restore $SnapshotName
# ── Establish direct PSSession ────────────────────────────────────────────
# vagrant winrm uses a subprocess whose args are subject to the Windows
# command-line length limit (~32 KB). Large file transfers exceed that limit.
# A direct PSSession has no such restriction and supports
# Copy-Item -ToSession / -FromSession for binary files of any size.
Write-Step "Establishing PSSession to VM"
# Ensure host WinRM client is configured to allow Basic authentication
# This is required for the connection to succeed when using -Authentication Basic
if ((Get-Item WSMan:\localhost\Client\Auth\Basic).Value -ne "true") {
Write-Host " Enabling WinRM Client Basic authentication on host..." -ForegroundColor Yellow
Set-Item WSMan:\localhost\Client\Auth\Basic -Value $true -Force
}
$remoteBuildRoot = "C:\vagrant_build"
$remoteInstallerPath = "$remoteBuildRoot\dist\android-file-handler-setup.exe"
$remoteResultsDir = "C:\vagrant_test_results"
# vagrant winrm-config reports the plaintext port (5985) used by Vagrant itself.
# We extract the host and port, then derive the HTTPS port (typically +1).
# If the direct IP fails, we fallback to the forwarded port on localhost.
$winrmConfig = & vagrant winrm-config 2>$null | Out-String
$vmHost = if ($winrmConfig -match 'HostName\s+(\S+)') { $Matches[1] } else { '127.0.0.1' }
$vmPort = if ($winrmConfig -match 'Port\s+(\d+)') { [int]$Matches[1] + 1 } else { 5986 }
# If we have a direct IP but it's not reachable, fallback to the forwarded port
if ($vmHost -ne '127.0.0.1' -and -not (Test-NetConnection -ComputerName $vmHost -Port $vmPort -InformationLevel Quiet)) {
Write-Host " Direct IP $vmHost`:$vmPort unreachable. Falling back to localhost..." -ForegroundColor Yellow
$vmHost = '127.0.0.1'
$vmPort = 55986 # The host-side forwarded port for 5986
}
# Verify connectivity before trying PSSession
if (-not (Test-NetConnection -ComputerName $vmHost -Port $vmPort -InformationLevel Quiet)) {
Write-Host "ERROR: Could not reach VM at $vmHost`:$vmPort. Ensure 'vagrant up' and provisioning completed successfully." -ForegroundColor Red
exit 1
}
$vagrantCred = New-Object PSCredential 'vagrant',
(ConvertTo-SecureString 'vagrant' -AsPlainText -Force)
# SkipCACheck / SkipCNCheck accept the self-signed cert created by provision.ps1.
$sessionOpts = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$vmSession = New-PSSession -ComputerName $vmHost -Port $vmPort `
-Credential $vagrantCred -Authentication Basic `
-UseSSL -SessionOption $sessionOpts
Write-Host " Connected to VM at $vmHost`:$vmPort" -ForegroundColor DarkGray
# ── Step 4: Transfer build assets to VM and compile installer ────────────
Write-Step "Transferring build assets to VM"
Invoke-Command -Session $vmSession -ScriptBlock {
param($root)
@("$root\dist", "$root\scripts\windows", "$root\icon_media") |
ForEach-Object { New-Item -ItemType Directory -Force -Path $_ | Out-Null }
} -ArgumentList $remoteBuildRoot
$transfers = @(
@{ L = Join-Path $RepoRoot "dist\android-file-handler-windows.exe"; R = "$remoteBuildRoot\dist\android-file-handler-windows.exe" },
@{ L = Join-Path $RepoRoot "scripts\windows\android-file-handler-setup.iss"; R = "$remoteBuildRoot\scripts\windows\android-file-handler-setup.iss" },
@{ L = Join-Path $RepoRoot "LICENSE.txt"; R = "$remoteBuildRoot\LICENSE.txt" },
@{ L = Join-Path $RepoRoot "icon_media\robot_files_256.ico"; R = "$remoteBuildRoot\icon_media\robot_files_256.ico" },
@{ L = Join-Path $RepoRoot "vagrant\test_installer.ps1"; R = "$remoteBuildRoot\test_installer.ps1" }
)
foreach ($t in $transfers) {
Copy-Item -Path $t.L -Destination $t.R -ToSession $vmSession -Force
$sizeMB = [math]::Round((Get-Item $t.L).Length / 1MB, 1)
Write-Host " Sent: $(Split-Path -Leaf $t.L) (${sizeMB} MB)" -ForegroundColor DarkGray
}
# Unblock transferred files — Copy-Item -ToSession can mark files with a
# Zone.Identifier ADS that execution policy may block even under -Scope Process Bypass.
Invoke-Command -Session $vmSession -ScriptBlock {
param($root)
Get-ChildItem -Path $root -Recurse -File |
ForEach-Object { Unblock-File -Path $_.FullName -ErrorAction SilentlyContinue }
} -ArgumentList $remoteBuildRoot
Write-Host " Files unblocked on VM." -ForegroundColor DarkGray
Write-Step "Building installer inside the VM (Inno Setup 6.7.1)"
Invoke-Command -Session $vmSession -ScriptBlock {
param($buildRoot, $version)
& 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' `
"$buildRoot\scripts\windows\android-file-handler-setup.iss" `
"/DMyAppVersion=$version"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} -ArgumentList $remoteBuildRoot, $AppVersion
Write-Host " Installer built: $remoteInstallerPath" -ForegroundColor Green
# ── Step 5: Run tests inside the VM ──────────────────────────────────────
Write-Step "Running test suite inside the VM"
$testExitCode = 0
try {
Invoke-Command -Session $vmSession -ScriptBlock {
param($buildRoot, $installer)
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force
& "$buildRoot\test_installer.ps1" -InstallerPath $installer
# Propagate the test script's exit code as output so the host can inspect it.
$LASTEXITCODE
} -ArgumentList $remoteBuildRoot, $remoteInstallerPath |
ForEach-Object {
if ($_ -is [int]) { $testExitCode = $_ }
}
}
catch {
$testExitCode = 1
Write-Host "Test script encountered an error:" -ForegroundColor Yellow
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
Write-Host " Category : $($_.CategoryInfo.Category)" -ForegroundColor Red
Write-Host " FullError: $_" -ForegroundColor Red
}
# Re-establish the PSSession if the test run left it in a non-Opened state.
# An unhandled exception inside Invoke-Command can terminate the remote runspace.
if ($vmSession.State -ne 'Opened') {
Write-Host " PSSession is '$($vmSession.State)' — re-establishing for results retrieval..." -ForegroundColor Yellow
Remove-PSSession $vmSession -ErrorAction SilentlyContinue
$vmSession = New-PSSession -ComputerName $vmHost -Port $vmPort `
-Credential $vagrantCred -Authentication Basic `
-UseSSL -SessionOption $sessionOpts
Write-Host " Session re-established." -ForegroundColor DarkGray
}
# ── Step 6: Retrieve results and screenshots ─────────────────────────────
Write-Step "Retrieving test results and screenshots"
if (-not (Test-Path $LocalResultsDir)) {
New-Item -ItemType Directory -Path $LocalResultsDir -Force | Out-Null
}
try {
$hasResults = Invoke-Command -Session $vmSession -ScriptBlock {
param($dir) Test-Path $dir
} -ArgumentList $remoteResultsDir
if ($hasResults) {
Copy-Item -Path "$remoteResultsDir\*" -Destination $LocalResultsDir `
-FromSession $vmSession -Recurse -Force
Write-Host " Retrieved results from $remoteResultsDir" -ForegroundColor DarkGray
}
else {
Write-Host " No results directory found on VM." -ForegroundColor Yellow
}
}
catch {
Write-Host " Warning: Could not retrieve some result files: $_" -ForegroundColor Yellow
}
# ── Step 7: Print local summary ──────────────────────────────────────────
Write-Step "Test Results"
$csvPath = Join-Path $LocalResultsDir "test_results.csv"
if (Test-Path $csvPath) {
$results = Import-Csv $csvPath
$results | Format-Table -AutoSize
$passCount = @($results | Where-Object { $_.Status -eq "PASS" }).Count
$failCount = @($results | Where-Object { $_.Status -eq "FAIL" }).Count
Write-Host " Passed: $passCount | Failed: $failCount | Total: $($passCount + $failCount)" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" })
}
else {
Write-Host " No results CSV found. Check VM output above." -ForegroundColor Yellow
}
Write-Host "`n Results directory: $LocalResultsDir" -ForegroundColor White
# List screenshots
$screenshots = Get-ChildItem $LocalResultsDir -Filter "*.png" -ErrorAction SilentlyContinue
if ($screenshots) {
Write-Host " Screenshots:" -ForegroundColor White
foreach ($shot in $screenshots) {
Write-Host " - $($shot.FullName)" -ForegroundColor DarkGray
}
}
# ── Step 8: Revert to clean snapshot ─────────────────────────────────────
if (-not $SkipRevert) {
Write-Step "Reverting VM to '$SnapshotName' snapshot"
vagrant snapshot restore $SnapshotName
Write-Host "VM reverted to clean state." -ForegroundColor Green
}
else {
Write-Host "`n -SkipRevert specified, VM left in current state." -ForegroundColor Yellow
}
# ── Exit ─────────────────────────────────────────────────────────────────
Write-Host ""
if ($testExitCode -ne 0) {
Write-Host "One or more tests FAILED." -ForegroundColor Red
exit 1
}
else {
Write-Host "All tests PASSED." -ForegroundColor Green
exit 0
}
}
finally {
if ($vmSession) { Remove-PSSession $vmSession -ErrorAction SilentlyContinue }
Pop-Location
}
-45
View File
@@ -9,9 +9,6 @@ import tkinter as tk
from tkinter import messagebox, scrolledtext
import tempfile
import stat
import subprocess
import sys
import os
def get_license_file_path() -> str:
@@ -107,48 +104,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE."""
def resource_path(relative_path: str) -> str:
"""Return absolute path to resource for dev and frozen runs."""
try:
if getattr(sys, "frozen", False):
base = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
else:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.normpath(os.path.join(base, relative_path))
except Exception:
return os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)
)
def run_windows_first_run_if_needed() -> None:
"""If running on Windows and license not agreed, launch first-run installer script.
Uses `resource_path` to locate the bundled PowerShell script in both dev and frozen modes.
"""
try:
if not sys.platform.startswith("win"):
return
if check_license_agreement():
return
script_rel = os.path.join("scripts", "windows", "first_run_install.ps1")
script_path = resource_path(script_rel)
if not os.path.exists(script_path):
return
try:
subprocess.Popen(
["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
except Exception:
pass
class LicenseAgreementFrame(tk.Frame):
"""License agreement UI frame that can be embedded in the main window."""
-3
View File
@@ -6,12 +6,9 @@ Simple entry point to launch the Android file transfer application.
try:
from gui.main_window import main
from gui.dialogs.license_agreement import run_windows_first_run_if_needed
except ImportError:
from .gui.main_window import main
from .gui.dialogs.license_agreement import run_windows_first_run_if_needed
if __name__ == "__main__":
run_windows_first_run_if_needed()
main()
+89
View File
@@ -0,0 +1,89 @@
# Vagrant Windows 11 Test Environment
Automated testing of the Android File Handler installer on a clean Windows 11 VM using Hyper-V.
## Prerequisites
- [Vagrant](https://www.vagrantup.com/) >= 2.4
- Hyper-V enabled (`Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All`)
- [Inno Setup 6](https://jrsoftware.org/isdl.php) installed (default path or ISCC.exe in PATH)
- Poetry environment set up (`poetry install`)
## Setup (one-time)
```powershell
cd vagrant
vagrant up --provider=hyperv
vagrant snapshot save clean
```
> **Note:** Vagrant will prompt for your Windows credentials to set up the SMB synced folder.
> You can set `VAGRANT_SMB_USERNAME` and `VAGRANT_SMB_PASSWORD` environment variables to skip the prompt.
## Running Tests
From the repo root:
```powershell
.\scripts\windows\run_vagrant_tests.ps1
```
This will:
1. Build the installer locally (PyInstaller + Inno Setup)
2. Restore the VM to the `clean` snapshot
3. Push the installer to the VM via WinRM
4. Run the installer silently inside the VM
5. Verify installation (directory, exe, shortcuts, registry)
6. Launch the app, find the window, take a GUI screenshot
7. Run silent uninstall and verify cleanup
8. Retrieve results CSV + screenshots to `vagrant_test_results\<timestamp>\`
9. Print a summary table to the CLI
10. Revert the VM back to the `clean` snapshot
### Options
| Flag | Description |
|---|---|
| `-SkipBuild` | Skip the local build step (use an existing installer in `dist\`) |
| `-SkipRevert` | Leave the VM in post-test state (useful for debugging) |
| `-SnapshotName <name>` | Use a different snapshot name (default: `clean`) |
| `-LocalResultsDir <path>` | Override where results are saved |
## What Gets Tested
| # | Test | Description |
|---|---|---|
| 1 | Installer exists | Checks the built `.exe` is present |
| 2 | Silent install | Runs the Inno Setup installer with `/VERYSILENT` |
| 3 | Install directory | Verifies `C:\Program Files\Android File Handler` exists |
| 4 | Executable present | Checks the app `.exe` is in the install directory |
| 5 | Start Menu shortcut | Verifies the Start Menu entry was created |
| 6 | Registry entry | Checks Add/Remove Programs registration |
| 7 | App launches | Starts the app and confirms it stays running |
| 8 | Window found | Finds the main window by its title |
| 9 | Silent uninstall | Runs the uninstaller and verifies cleanup |
## File Structure
```
vagrant/
├── Vagrantfile # VM definition (Windows 11 Enterprise, Hyper-V)
├── provision.ps1 # VM provisioning (nircmd for screenshots)
├── test_installer.ps1 # Test script that runs inside the VM
└── README.md # This file
scripts/windows/
└── run_vagrant_tests.ps1 # Host-side orchestrator (builds, pushes, tests, reverts)
```
## Troubleshooting
**VM won't start**: Ensure Hyper-V is enabled and you are running the terminal as Administrator.
**WinRM connection fails**: The box uses plaintext WinRM on port 55985. Ensure no firewall blocks it.
**SMB share prompt**: Set `VAGRANT_SMB_USERNAME` and `VAGRANT_SMB_PASSWORD` environment variables to avoid interactive credential prompts.
**Inno Setup not found**: Install Inno Setup 6 to the default path or add `ISCC.exe` to your PATH.
**Want to inspect the VM manually**: Use `vagrant rdp` or pass `-SkipRevert`.
+54
View File
@@ -0,0 +1,54 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
#
# Vagrant Windows 11 test machine for Android File Handler installer testing.
#
# Prerequisites:
# - Vagrant >= 2.4
# - Hyper-V enabled (Windows host)
#
# Usage:
# cd vagrant
# vagrant up --provider=hyperv
# vagrant snapshot save clean
# ..\scripts\windows\run_vagrant_tests.ps1
#
Vagrant.configure("2") do |config|
config.vm.box = "gusztavvargadr/windows-11-24h2-enterprise"
config.vm.hostname = "afh-test"
config.vm.communicator = "winrm"
config.winrm.username = "vagrant"
config.winrm.password = "vagrant"
config.winrm.transport = :plaintext # Vagrant's own comms (boot check, provision)
config.winrm.basic_auth_only = true
config.vm.guest = :windows
config.vm.boot_timeout = 900
config.vm.graceful_halt_timeout = 120
# Automatically select the Default Switch for Hyper-V to avoid interactive prompt
config.vm.network "public_network", bridge: "Default Switch"
config.vm.network "forwarded_port", guest: 5985, host: 55985 # Vagrant plaintext WinRM
config.vm.network "forwarded_port", guest: 5986, host: 55986 # PSSession HTTPS WinRM
config.vm.provider "hyperv" do |hv|
hv.vmname = "afh-win11-test"
hv.memory = 8192
hv.maxmemory = 8192
hv.cpus = 4
hv.enable_enhanced_session_mode = true
hv.linked_clone = true
end
# Disable default Vagrant synced folder to prevent SMB credential prompts.
# Files are transferred directly via PSSession by run_vagrant_tests.ps1.
config.vm.synced_folder ".", "/vagrant", disabled: true
config.vm.provision "shell",
path: "provision.ps1",
privileged: true
end
+96
View File
@@ -0,0 +1,96 @@
# provision.ps1 — Vagrant provisioning script for Windows 11 test VM.
# Installs prerequisites needed to test the Android File Handler installer.
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Write-Host "=== Provisioning Android File Handler test VM ===" -ForegroundColor Cyan
# --- Enable auto-logon so GUI tests work after reboot ---
$RegPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty -Path $RegPath -Name AutoAdminLogon -Value "1"
Set-ItemProperty -Path $RegPath -Name DefaultUserName -Value "vagrant"
Set-ItemProperty -Path $RegPath -Name DefaultPassword -Value "vagrant"
# --- Allow script execution (required for test suite) ---
# Write the LocalMachine execution policy directly via the registry instead of using
# Set-ExecutionPolicy. Vagrant runs provision.ps1 under "powershell -ExecutionPolicy Bypass",
# which sets a Process-scope policy. When a Process-scope policy is active,
# Set-ExecutionPolicy -Scope LocalMachine throws a terminating SecurityException
# ("overridden by a policy defined at a more specific scope") that cannot be suppressed
# with -ErrorAction. Writing the registry key bypasses that cmdlet restriction entirely.
# The value takes effect in all subsequent WinRM PSSessions (which have no Process-scope
# override) — exactly the sessions that run the test suite.
$psMachinePolicyPath = 'HKLM:\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell'
Set-ItemProperty -Path $psMachinePolicyPath -Name 'ExecutionPolicy' -Value 'Unrestricted' -Force
Write-Host " Execution policy set to Unrestricted (LocalMachine)." -ForegroundColor DarkGray
# --- Install Chocolatey (package manager) ---
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host "Installing Chocolatey..." -ForegroundColor Yellow
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
# --- Configure HTTPS WinRM listener ---
# HTTP WinRM (plaintext) causes PowerShell PSSession to reject unencrypted
# traffic. A self-signed cert on port 5986 satisfies WinRM's security policy
# without needing a domain CA. The 10-year expiry covers long-lived test boxes.
Write-Host "Configuring HTTPS WinRM listener..." -ForegroundColor Yellow
# Ensure network is Private so WinRM HTTPS works correctly
Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private
# Allow local accounts to perform administrative tasks via WinRM
# (Required for the 'vagrant' user to have full admin rights over PSSession)
$policyPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
Set-ItemProperty -Path $policyPath -Name LocalAccountTokenFilterPolicy -Value 1 -Force
# Create certificate for HTTPS listener
$cert = New-SelfSignedCertificate `
-DnsName "afh-test", "localhost" `
-CertStoreLocation "Cert:\LocalMachine\My" `
-KeyAlgorithm RSA `
-KeyLength 2048 `
-NotAfter (Get-Date).AddYears(10)
# Check if an HTTPS listener already exists
$listeners = winrm enumerate winrm/config/listener
$hasHttps = $listeners -match "Transport = HTTPS"
if ($hasHttps) {
# If the thumbprint matches, we're already good
if ($listeners -match $cert.Thumbprint) {
Write-Host " HTTPS listener already correctly configured." -ForegroundColor Green
} else {
Write-Host " Updating existing HTTPS listener with new certificate..." -ForegroundColor Gray
winrm delete winrm/config/Listener?Address=*+Transport=HTTPS
winrm create winrm/config/Listener?Address=*+Transport=HTTPS `
"@{Hostname=`"afh-test`";CertificateThumbprint=`"$($cert.Thumbprint)`"}"
}
} else {
Write-Host " Creating new HTTPS listener..." -ForegroundColor Gray
winrm create winrm/config/Listener?Address=*+Transport=HTTPS `
"@{Hostname=`"afh-test`";CertificateThumbprint=`"$($cert.Thumbprint)`"}"
}
winrm set winrm/config/service/Auth '@{Basic="true"}'
netsh advfirewall firewall add rule `
name="WinRM HTTPS" protocol=TCP dir=in localport=5986 action=allow profile=any | Out-Null
Write-Host " HTTPS WinRM listener ready on port 5986" -ForegroundColor Green
# --- Install nircmd for screenshot capture ---
Write-Host "Installing nircmd for screenshot capture..." -ForegroundColor Yellow
choco install nircmd -y --no-progress
# --- Install Inno Setup 6.7.1 (pinned) ---
Write-Host "Installing Inno Setup 6.7.1..." -ForegroundColor Yellow
choco install innosetup --version 6.7.1 -y --no-progress
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
Write-Host "=== Provisioning complete ===" -ForegroundColor Green
+332
View File
@@ -0,0 +1,332 @@
# test_installer.ps1 — Runs INSIDE the Vagrant VM via WinRM.
# Tests installation and basic function of Android File Handler.
#
# Exit codes: 0 = all passed, 1 = one or more failures.
param(
[string]$InstallerPath = "C:\vagrant_build\dist\android-file-handler-setup.exe",
[string]$ScreenshotDir = "C:\vagrant_test_results",
[string]$AppVersion = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ── Helpers ──────────────────────────────────────────────────────────────────
$Script:Passed = 0
$Script:Failed = 0
$Script:Results = @()
function Write-TestResult {
param([string]$Name, [bool]$Pass, [string]$Detail = "")
$status = if ($Pass) { "PASS" } else { "FAIL" }
$color = if ($Pass) { "Green" } else { "Red" }
$msg = "[$status] $Name"
if ($Detail) { $msg += " - $Detail" }
Write-Host $msg -ForegroundColor $color
if ($Pass) { $Script:Passed++ } else { $Script:Failed++ }
$Script:Results += [PSCustomObject]@{ Test = $Name; Status = $status; Detail = $Detail }
}
function Take-Screenshot {
param([string]$Name)
if (-not (Test-Path $ScreenshotDir)) {
New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null
}
$outFile = Join-Path $ScreenshotDir "$Name.png"
$nircmd = Get-Command nircmd -ErrorAction SilentlyContinue
if ($nircmd) {
Start-Process -FilePath $nircmd.Source -ArgumentList "savescreenshot", $outFile -NoNewWindow -Wait
return $outFile
}
# Fallback: .NET screenshot
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
$bitmap.Save($outFile, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
return $outFile
}
# ── Setup ────────────────────────────────────────────────────────────────────
$AppName = "Android File Handler"
$ExeName = "android-file-handler-windows.exe"
$DefaultInstDir = Join-Path $env:ProgramFiles $AppName
Write-Host ""
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " Android File Handler - Vagrant Test Suite " -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host ""
if (-not (Test-Path $ScreenshotDir)) {
New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null
}
try {
# ── Build installer if not already present ──────────────────────────────────
# When run directly inside the VM (not via run_vagrant_tests.ps1), the build
# dir may not exist yet. Since the SMB mount is disabled by default, this
# fallback requires manual transfer of assets to C:\vagrant_project.
if (-not (Test-Path $InstallerPath)) {
$projectRoot = "C:\vagrant_project"
$sourceExe = "$projectRoot\dist\android-file-handler-windows.exe"
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
$buildRoot = "C:\vagrant_build"
if (-not (Test-Path $sourceExe)) {
Write-Host "`nFATAL: Installer not found and cannot build - PyInstaller exe missing." -ForegroundColor Red
Write-Host " Run PyInstaller on the host first, then use the orchestrator:" -ForegroundColor Yellow
Write-Host " ..\scripts\windows\run_vagrant_tests.ps1"
Write-Host " (Or manually transfer files to C:\vagrant_project inside the VM)."
exit 1
}
if (-not (Test-Path $iscc)) {
Write-Host "`nFATAL: Inno Setup not found at $iscc." -ForegroundColor Red
Write-Host " Re-provision the VM: vagrant provision" -ForegroundColor Yellow
exit 1
}
Write-Host "`n--- Building installer inside the VM (Inno Setup 6.7.1) ---" -ForegroundColor Yellow
@("$buildRoot\dist", "$buildRoot\scripts\windows", "$buildRoot\icon_media") |
ForEach-Object { New-Item -ItemType Directory -Force -Path $_ | Out-Null }
Copy-Item $sourceExe "$buildRoot\dist\" -Force
Copy-Item "$projectRoot\scripts\windows\android-file-handler-setup.iss" "$buildRoot\scripts\windows\" -Force
Copy-Item "$projectRoot\LICENSE.txt" "$buildRoot\" -Force
Copy-Item "$projectRoot\icon_media\robot_files_256.ico" "$buildRoot\icon_media\" -Force
$isccArgs = @("$buildRoot\scripts\windows\android-file-handler-setup.iss")
if ($AppVersion) { $isccArgs += "/DMyAppVersion=$AppVersion" }
& $iscc @isccArgs
if ($LASTEXITCODE -ne 0) {
Write-Host "FATAL: Inno Setup failed (exit $LASTEXITCODE)." -ForegroundColor Red
exit 1
}
Write-Host " Installer built: $InstallerPath" -ForegroundColor Green
}
# ── Test 1: Installer exists ────────────────────────────────────────────────
$installerExists = Test-Path $InstallerPath
Write-TestResult -Name "Installer file exists" -Pass $installerExists -Detail $InstallerPath
if (-not $installerExists) {
throw "Installer not found at $InstallerPath."
}
# ── Test 2: Silent install ──────────────────────────────────────────────────
Write-Host "`n--- Running silent install ---" -ForegroundColor Yellow
$installProc = Start-Process -FilePath $InstallerPath `
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-" `
-Wait -PassThru
$installOk = $installProc.ExitCode -eq 0
Write-TestResult -Name "Silent install completed" -Pass $installOk -Detail "Exit code: $($installProc.ExitCode)"
# ── Test 3: Install directory created ────────────────────────────────────────
$dirExists = Test-Path $DefaultInstDir
Write-TestResult -Name "Install directory exists" -Pass $dirExists -Detail $DefaultInstDir
# ── Test 4: Executable present ──────────────────────────────────────────────
$exePath = Join-Path $DefaultInstDir $ExeName
$exeExists = Test-Path $exePath
Write-TestResult -Name "Executable exists" -Pass $exeExists -Detail $exePath
# ── Test 5: Start Menu shortcut ─────────────────────────────────────────────
$startMenuDir = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\$AppName"
$shortcutPath = Join-Path $startMenuDir "$AppName.lnk"
$shortcutExists = Test-Path $shortcutPath
Write-TestResult -Name "Start Menu shortcut exists" -Pass $shortcutExists -Detail $shortcutPath
# ── Test 6: Registry uninstall entry ────────────────────────────────────────
# Check both the native (64-bit) and WoW6432Node (32-bit) uninstall hives.
# WinRM sessions may run 32-bit PowerShell, which redirects HKLM:\SOFTWARE to
# WoW6432Node, missing the 64-bit installer's entry if we only check one path.
# try/catch in the Where-Object handles the StrictMode "property not found" throw
# that neither -ErrorAction nor PSObject.Properties can fully avoid.
$uninstallPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
)
$registryEntry = $uninstallPaths | ForEach-Object {
Get-ChildItem $_ -ErrorAction SilentlyContinue
} | Where-Object {
# Match exact name OR "AppName version X.Y.Z" (Inno Setup default when
# AppVerName is not set). The .iss now sets AppVerName={#MyAppName} so
# exact match is expected; the -like guard covers older installer builds.
try {
$dn = (Get-ItemProperty $_.PSPath -ErrorAction Stop).DisplayName
$dn -eq $AppName -or $dn -like "$AppName *"
} catch { $false }
}
$registryOk = $null -ne $registryEntry
if (-not $registryOk) {
# Diagnostic: show non-Microsoft entries so the failure can be investigated.
Write-Host " [Diag] Entries found in Uninstall hives:" -ForegroundColor DarkGray
$uninstallPaths | ForEach-Object {
Get-ChildItem $_ -ErrorAction SilentlyContinue
} | ForEach-Object {
$dn = try { (Get-ItemProperty $_.PSPath -ErrorAction Stop).DisplayName } catch { $null }
if ($dn -and $dn -notmatch '^(Microsoft|Windows|KB\d)') {
Write-Host " '$dn'" -ForegroundColor DarkGray
}
}
}
Write-TestResult -Name "Registry uninstall entry" -Pass $registryOk
# ── Test 7: Launch application & capture GUI ─────────────────────────────────
if ($exeExists) {
Write-Host "`n--- Launching application ---" -ForegroundColor Yellow
$appProc = Start-Process -FilePath $exePath -PassThru
Start-Sleep -Seconds 8
# Check if process is still running (GUI app should stay alive)
$appRunning = -not $appProc.HasExited
Write-TestResult -Name "Application launches and stays running" -Pass $appRunning
# Look for the window by title.
# WinRM sessions run in a non-interactive window station; the GUI process
# may have no accessible HWND from this session even when fully running.
# Try .NET MainWindowTitle first, then Win32 FindWindow as a fallback.
# If both come up empty but the process is still alive, count it as a pass
# with a note — the "stays running" check above already validates the install.
$windowFound = $false
$windowNote = ""
$hwnd = [IntPtr]::Zero
$appProc.Refresh()
if ($appProc.MainWindowTitle -eq $AppName) {
$windowFound = $true
$windowNote = "via .NET MainWindowTitle"
} else {
try {
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32 {
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
}
"@ -ErrorAction SilentlyContinue
$hwnd = [Win32]::FindWindow($null, $AppName)
if ($hwnd -ne [IntPtr]::Zero) {
$windowFound = $true
$windowNote = "HWND: $hwnd"
}
} catch {
Write-Host " Warning: Could not check for window title via Win32 API." -ForegroundColor Yellow
}
if (-not $windowFound -and $appRunning) {
# Process is alive; window just not reachable from the WinRM session.
$windowFound = $true
$windowNote = "process alive - window not accessible from WinRM session"
}
}
Write-TestResult -Name "Main window found by title" -Pass $windowFound -Detail $windowNote
# Take screenshot of the running application
try {
Start-Sleep -Seconds 2
$screenshotFile = Take-Screenshot -Name "app_running"
Write-Host " Screenshot saved: $screenshotFile" -ForegroundColor DarkGray
} catch {
Write-Host " Warning: Could not capture screenshot: $_" -ForegroundColor Yellow
}
# Gracefully close, then ensure the entire process tree is dead before
# the uninstaller runs. PyInstaller onefile spawns a child process that
# continues after the stub exits; taskkill /F /T kills both. The extra
# sleep lets the OS release executable file handles so the uninstaller
# can delete the .exe.
if (-not $appProc.HasExited) {
$appProc.CloseMainWindow() | Out-Null
Start-Sleep -Seconds 3
}
$procName = [System.IO.Path]::GetFileNameWithoutExtension($ExeName)
taskkill /F /T /IM "$procName.exe" 2>&1 | Out-Null
Start-Sleep -Seconds 3 # Wait for OS to release file handles
}
else {
Write-TestResult -Name "Application launches and stays running" -Pass $false -Detail "Skipped - exe not found"
Write-TestResult -Name "Main window found by title" -Pass $false -Detail "Skipped - exe not found"
}
# ── Test 8: Silent uninstall ────────────────────────────────────────────────
Write-Host "`n--- Running silent uninstall ---" -ForegroundColor Yellow
$uninstallerPath = Join-Path $DefaultInstDir "unins000.exe"
if (Test-Path $uninstallerPath) {
$uninstProc = Start-Process -FilePath $uninstallerPath `
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" `
-Wait -PassThru
$uninstallOk = $uninstProc.ExitCode -eq 0
Write-TestResult -Name "Silent uninstall completed" -Pass $uninstallOk -Detail "Exit code: $($uninstProc.ExitCode)"
Start-Sleep -Seconds 2
# Assert the executable is gone — not the directory. The app may have
# created platform-tools/, a license file, or other runtime files inside
# the install dir during Test 7; Inno Setup only tracks files it installed
# and will not remove a non-empty directory.
$exeGone = -not (Test-Path $exePath)
Write-TestResult -Name "Executable removed after uninstall" -Pass $exeGone -Detail $exePath
}
else {
Write-TestResult -Name "Silent uninstall completed" -Pass $false -Detail "Uninstaller not found"
Write-TestResult -Name "Install directory removed after uninstall" -Pass $false -Detail "Skipped"
}
} catch {
# Catch unexpected terminating exceptions so the finally block can still save
# results and so the PSSession is not broken by an unhandled remote exception.
$errMsg = "$_"
Write-Host "`nFATAL: Unhandled exception in test suite: $errMsg" -ForegroundColor Red
Write-TestResult -Name "Script execution (fatal error)" -Pass $false -Detail $errMsg
}
finally {
# ── Summary ──────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " Test Summary" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " Passed : $Script:Passed" -ForegroundColor Green
Write-Host " Failed : $Script:Failed" -ForegroundColor $(if ($Script:Failed -gt 0) { "Red" } else { "Green" })
Write-Host " Total : $($Script:Passed + $Script:Failed)" -ForegroundColor White
Write-Host ""
$Script:Results | Format-Table -AutoSize
# Export results as CSV for retrieval
if ($Script:Results.Count -gt 0) {
$csvPath = Join-Path $ScreenshotDir "test_results.csv"
$Script:Results | Export-Csv -Path $csvPath -NoTypeInformation
Write-Host "Results saved to $csvPath" -ForegroundColor DarkGray
}
if ($error.Count -gt 0) {
Write-Host "`nScript encountered errors during execution:" -ForegroundColor Red
$error[0] | Format-List -Force
}
}
exit $(if ($Script:Failed -gt 0) { 1 } else { 0 })