Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d33ea69eab | ||
|
|
18ede809e2 | ||
|
|
ee6d5e0960 | ||
|
|
298819fc91 | ||
|
|
3a5d4e7dbd |
@@ -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
|
||||
@@ -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
@@ -15,4 +15,6 @@ pkg*
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*$py.class
|
||||
.vagrant/
|
||||
vagrant_test_results/
|
||||
@@ -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
|
||||
|
||||
@@ -158,6 +201,6 @@ The project uses GitHub Actions for multi-platform builds (`.github/workflows/re
|
||||
## Notes
|
||||
|
||||
- **ADB Binaries**: Stored in `src/platform-tools/` - do not modify or delete unless explictly instructed to
|
||||
- **Python Version**: Requires Python 3.13 or 3.14 (>=3.13, <3.15)
|
||||
- **Python Version**: Requires Python 3.13 (< 3.14)
|
||||
- **Package Mode**: Poetry is configured with `package-mode = false`
|
||||
- **License**: First-run license agreement required on Windows
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""CI/CD pipeline orchestration using Prefect and Dagger."""
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -39,9 +39,18 @@ 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', 'py314']
|
||||
target-version = ['py313']
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.13"
|
||||
|
||||
@@ -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)
|
||||
])
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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')" || \
|
||||
|
||||
@@ -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')" || \
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -211,6 +211,58 @@ class DialogManager:
|
||||
dialog.protocol("WM_DELETE_WINDOW", on_close)
|
||||
ok_button.config(command=on_close)
|
||||
|
||||
def show_disable_debugging_reminder(self) -> None:
|
||||
"""Show reminder to disable USB debugging after transfer."""
|
||||
msg = (
|
||||
"Transfer completed!\n\n"
|
||||
"For security, please disable USB debugging when done:\n"
|
||||
"Settings → Developer Options → disable 'USB debugging'."
|
||||
)
|
||||
messagebox.showinfo("Disable USB Debugging", msg)
|
||||
|
||||
def show_transfer_stats(
|
||||
self, stats: dict, operation: str, deduplicator=None
|
||||
) -> None:
|
||||
"""Show transfer statistics dialog.
|
||||
|
||||
Args:
|
||||
stats: Dictionary containing transfer statistics
|
||||
operation: Type of operation ("Pull" or "Push")
|
||||
deduplicator: Optional deduplicator instance for byte formatting
|
||||
"""
|
||||
# Format bytes saved
|
||||
bytes_saved_str = ""
|
||||
if stats["bytes_saved"] > 0:
|
||||
if deduplicator and hasattr(deduplicator, "format_bytes"):
|
||||
bytes_saved_str = (
|
||||
f" ({deduplicator.format_bytes(stats['bytes_saved'])} saved)"
|
||||
)
|
||||
else:
|
||||
bytes_saved_str = f" ({stats['bytes_saved']} bytes saved)"
|
||||
|
||||
# Build message
|
||||
title = f"{operation} Transfer Complete"
|
||||
|
||||
if stats["total_files"] == 0:
|
||||
message = "No files were found to transfer."
|
||||
else:
|
||||
message_parts = [
|
||||
f"Transfer completed successfully!\n",
|
||||
f"Files found: {stats['total_files']}",
|
||||
f"Files transferred: {stats['transferred']}",
|
||||
f"Duplicate files skipped: {stats['skipped']}{bytes_saved_str}",
|
||||
]
|
||||
|
||||
if stats["skipped"] > 0:
|
||||
message_parts.append(
|
||||
f"\nDuplicate detection helped avoid unnecessary transfers!"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
# Show dialog
|
||||
messagebox.showinfo(title, message)
|
||||
|
||||
def show_error(self, title: str, message: str) -> None:
|
||||
"""Show an error dialog.
|
||||
|
||||
@@ -334,7 +386,7 @@ class DialogManager:
|
||||
side="right"
|
||||
)
|
||||
|
||||
def show_transfer_stats(self, stats: dict, operation: str) -> None:
|
||||
def show_transfer_stats(self, stats: dict, operation: str):
|
||||
"""Show transfer statistics in a dialog.
|
||||
|
||||
Args:
|
||||
@@ -385,7 +437,7 @@ class DialogManager:
|
||||
# Close button
|
||||
tk.Button(main_frame, text="Close", command=stats_window.destroy).pack()
|
||||
|
||||
def show_disable_debugging_reminder(self) -> None:
|
||||
def show_disable_debugging_reminder(self):
|
||||
"""Show reminder about disabling USB debugging after transfer."""
|
||||
messagebox.showinfo(
|
||||
"Security Reminder",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -255,6 +255,7 @@ class TransferManager:
|
||||
lambda: self.dialog_manager.show_transfer_stats(
|
||||
stats,
|
||||
direction.capitalize(),
|
||||
self.device_manager.adb_manager.deduplicator,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
Vendored
+54
@@ -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
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
Reference in New Issue
Block a user