Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8020bede3a |
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"WebFetch(domain:community.chocolatey.org)",
|
|
||||||
"WebFetch(domain:github.com)",
|
|
||||||
"WebFetch(domain:jrsoftware.org)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
# 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,15 +68,6 @@ jobs:
|
|||||||
# Use the Windows spec file so packaging is consistent and reproducible
|
# Use the Windows spec file so packaging is consistent and reproducible
|
||||||
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
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
|
- name: Import GPG key
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -84,37 +75,23 @@ jobs:
|
|||||||
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||||
gpg --list-secret-keys
|
gpg --list-secret-keys
|
||||||
|
|
||||||
- name: Sign and hash Windows artifacts
|
- name: Sign and hash Windows executable
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
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
|
# Create temporary file for passphrase
|
||||||
$passphraseFile = New-TemporaryFile
|
$passphraseFile = New-TemporaryFile
|
||||||
try {
|
try {
|
||||||
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
|
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
|
||||||
|
|
||||||
# Sign and hash the standalone executable
|
# Sign with GPG using passphrase file
|
||||||
$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"
|
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 {
|
finally {
|
||||||
# Clean up passphrase file
|
# Clean up passphrase file
|
||||||
@@ -123,17 +100,22 @@ 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
|
- name: Upload Windows artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-binary
|
name: windows-binary
|
||||||
path: |
|
path: |
|
||||||
dist/android-file-handler-windows.exe
|
dist/**/android-file-handler*.exe
|
||||||
dist/android-file-handler-windows.exe.asc
|
dist/**/android-file-handler*.exe.asc
|
||||||
|
dist/android-file-handler.exe
|
||||||
|
dist/android-file-handler.exe.asc
|
||||||
dist/android-file-handler-windows.sha256
|
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:
|
build-debian:
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
+1
-3
@@ -15,6 +15,4 @@ pkg*
|
|||||||
# Python cache files
|
# Python cache files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
.vagrant/
|
|
||||||
vagrant_test_results/
|
|
||||||
@@ -58,39 +58,21 @@ poetry run mypy src/
|
|||||||
poetry run python scripts/build_package_linux.py
|
poetry run python scripts/build_package_linux.py
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Prefect + Dagger Build (CI Pipeline Locally)
|
#### Docker Compose Build (Recommended for Linux)
|
||||||
```sh
|
```sh
|
||||||
# Install CI dependencies
|
# Build all distributions (Debian, Arch, RHEL)
|
||||||
poetry install --with ci
|
docker compose up --build
|
||||||
|
|
||||||
# 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
|
# Build specific distribution
|
||||||
podman-compose up --build debian
|
docker compose up --build debian
|
||||||
podman-compose up --build arch
|
docker compose up --build arch
|
||||||
podman-compose up --build rhel
|
docker compose up --build rhel
|
||||||
|
|
||||||
|
# Build all in parallel
|
||||||
|
docker compose up --build --parallel
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
podman-compose down -v && rm -rf dist pkg_dist_* dist_*
|
docker compose down -v && rm -rf dist pkg_dist_* dist_*
|
||||||
```
|
```
|
||||||
|
|
||||||
See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker build documentation.
|
See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker build documentation.
|
||||||
@@ -100,10 +82,6 @@ See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker bui
|
|||||||
# Windows executable (PyInstaller)
|
# Windows executable (PyInstaller)
|
||||||
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
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:
|
# Linux packages use distro-specific spec files:
|
||||||
# - android-file-handler-debian.spec
|
# - android-file-handler-debian.spec
|
||||||
# - android-file-handler-arch.spec
|
# - android-file-handler-arch.spec
|
||||||
@@ -138,13 +116,6 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
|||||||
- **scripts/**: Build and packaging scripts
|
- **scripts/**: Build and packaging scripts
|
||||||
- `build_package_linux.py`: Unified Linux packaging script (uses DISTRO_TYPE env var)
|
- `build_package_linux.py`: Unified Linux packaging script (uses DISTRO_TYPE env var)
|
||||||
- `spec_scripts/`: PyInstaller spec files for each platform
|
- `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
|
- **tests/**: Test suite mirroring src/ structure
|
||||||
|
|
||||||
@@ -164,26 +135,12 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
|||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
The project uses a **Prefect + Dagger** pipeline wrapped by GitHub Actions (`.github/workflows/release-prefect-dagger.yml`):
|
The project uses GitHub Actions for multi-platform builds (`.github/workflows/release.yml`):
|
||||||
|
- Runs tests on Linux and Windows
|
||||||
- **Dagger** runs containerized Linux builds (Debian, Arch, RHEL) using pre-built builder images
|
- Builds binaries for Windows, Debian, Arch, and RHEL
|
||||||
- **Prefect** orchestrates the pipeline: build → sign → release → R2 upload
|
- Packages using PyInstaller + fpm
|
||||||
- **GitHub Actions** provides the runner infrastructure and Windows build (cannot containerize)
|
- Supports manual workflow dispatch with configurable jobs
|
||||||
- **Podman** is the container runtime (Dagger connects via Podman socket)
|
- Optional GitHub release creation and S3 upload
|
||||||
|
|
||||||
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
|
## Coding Standards
|
||||||
|
|
||||||
@@ -201,6 +158,6 @@ The CI pipeline modules live in `ci/`:
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **ADB Binaries**: Stored in `src/platform-tools/` - do not modify or delete unless explictly instructed to
|
- **ADB Binaries**: Stored in `src/platform-tools/` - do not modify or delete unless explictly instructed to
|
||||||
- **Python Version**: Requires Python 3.13 (< 3.14)
|
- **Python Version**: Requires Python 3.13 or 3.14 (>=3.13, <3.15)
|
||||||
- **Package Mode**: Poetry is configured with `package-mode = false`
|
- **Package Mode**: Poetry is configured with `package-mode = false`
|
||||||
- **License**: First-run license agreement required on Windows
|
- **License**: First-run license agreement required on Windows
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""CI/CD pipeline orchestration using Prefect and Dagger."""
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""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",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
"""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
@@ -1,120 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"""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
|
|
||||||
+4
-13
@@ -1,18 +1,9 @@
|
|||||||
# DEPRECATED: This file is kept for backward compatibility.
|
# Docker Compose configuration for local multi-platform builds
|
||||||
# Use podman-compose.yml instead:
|
# Matches the exact images and configurations from .github/workflows/release.yml
|
||||||
# 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:
|
# Usage:
|
||||||
# Build all distributions: podman-compose up --build
|
# Build all distributions: docker-compose up --build
|
||||||
# Build specific distro: podman-compose up --build debian
|
# Build specific distro: docker-compose up --build debian
|
||||||
#
|
#
|
||||||
# Each service builds a distribution package and outputs to:
|
# Each service builds a distribution package and outputs to:
|
||||||
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
|
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
# 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
+15
-4006
File diff suppressed because it is too large
Load Diff
+1
-10
@@ -39,18 +39,9 @@ pytest-cov = "^7.0.0"
|
|||||||
[tool.poetry.group.build.dependencies]
|
[tool.poetry.group.build.dependencies]
|
||||||
pyinstaller = "^6.1.0"
|
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]
|
[tool.black]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
target-version = ['py313']
|
target-version = ['py313', 'py314']
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.13"
|
python_version = "3.13"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import sys
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, TypedDict
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
class DistroType(Enum):
|
class DistroType(Enum):
|
||||||
@@ -16,19 +16,7 @@ class DistroType(Enum):
|
|||||||
RHEL = "rhel"
|
RHEL = "rhel"
|
||||||
|
|
||||||
|
|
||||||
class DistroConfigDict(TypedDict):
|
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess:
|
||||||
"""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."""
|
"""Run command and handle errors."""
|
||||||
print(f"Running: {' '.join(cmd)}")
|
print(f"Running: {' '.join(cmd)}")
|
||||||
try:
|
try:
|
||||||
@@ -41,9 +29,9 @@ def run_command(cmd: list[str], check: bool = True, working_dir: str | None = No
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def get_distro_config(distro_type: DistroType) -> DistroConfigDict:
|
def get_distro_config(distro_type: DistroType) -> dict:
|
||||||
"""Get configuration for specific distro type."""
|
"""Get configuration for specific distro type."""
|
||||||
configs: dict[DistroType, DistroConfigDict] = {
|
configs = {
|
||||||
DistroType.DEBIAN: {
|
DistroType.DEBIAN: {
|
||||||
"name": "Debian",
|
"name": "Debian",
|
||||||
"bin_path": "usr/local/bin",
|
"bin_path": "usr/local/bin",
|
||||||
@@ -143,11 +131,10 @@ def package_with_fpm(distro_type: DistroType, version: str, project_root: Path)
|
|||||||
# Add distro-specific options
|
# Add distro-specific options
|
||||||
if distro_type == DistroType.DEBIAN:
|
if distro_type == DistroType.DEBIAN:
|
||||||
output_file = dist_dir / f"android-file-handler_{version}_{config['architecture']}.deb"
|
output_file = dist_dir / f"android-file-handler_{version}_{config['architecture']}.deb"
|
||||||
postinstall = config["postinstall"]
|
|
||||||
fpm_cmd.extend([
|
fpm_cmd.extend([
|
||||||
"--deb-user", "root",
|
"--deb-user", "root",
|
||||||
"--deb-group", "root",
|
"--deb-group", "root",
|
||||||
*(["--after-install", postinstall] if postinstall else []),
|
"--after-install", config["postinstall"],
|
||||||
"-p", str(output_file)
|
"-p", str(output_file)
|
||||||
])
|
])
|
||||||
elif distro_type == DistroType.ARCH:
|
elif distro_type == DistroType.ARCH:
|
||||||
@@ -157,9 +144,9 @@ def package_with_fpm(distro_type: DistroType, version: str, project_root: Path)
|
|||||||
])
|
])
|
||||||
elif distro_type == DistroType.RHEL:
|
elif distro_type == DistroType.RHEL:
|
||||||
output_file = dist_dir / f"android-file-handler-{version}.{config['architecture']}.rpm"
|
output_file = dist_dir / f"android-file-handler-{version}.{config['architecture']}.rpm"
|
||||||
postinstall = config["postinstall"]
|
|
||||||
fpm_cmd.extend([
|
fpm_cmd.extend([
|
||||||
*(["--after-install", postinstall] if postinstall else []),
|
"--prefix", "/usr/bin",
|
||||||
|
"--after-install", config["postinstall"],
|
||||||
"-p", str(output_file)
|
"-p", str(output_file)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
# 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,10 +9,8 @@
|
|||||||
# For reproducibility, pin to a specific date tag like: archlinux:base-20251016
|
# For reproducibility, pin to a specific date tag like: archlinux:base-20251016
|
||||||
FROM archlinux:latest
|
FROM archlinux:latest
|
||||||
|
|
||||||
# Set build arguments
|
# Set build argument for fpm version (can be overridden at build time)
|
||||||
ARG FPM_VERSION=1.16.0
|
ARG FPM_VERSION=1.16.0
|
||||||
ARG PYTHON_VERSION=3.14.3
|
|
||||||
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
|
|
||||||
|
|
||||||
# Install system dependencies (Arch) including Python build dependencies
|
# Install system dependencies (Arch) including Python build dependencies
|
||||||
RUN pacman -Syu --noconfirm \
|
RUN pacman -Syu --noconfirm \
|
||||||
@@ -58,26 +56,24 @@ RUN gem install --no-document -v "${FPM_VERSION}" fpm && \
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Download Python source and verify SHA256 checksum against python.org
|
# Install pyenv
|
||||||
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
|
ENV PYENV_ROOT="/root/.pyenv"
|
||||||
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
|
ENV PATH="$PYENV_ROOT/bin:$PATH"
|
||||||
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
|
|
||||||
|
|
||||||
# Build and install Python from source
|
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
|
||||||
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
|
|
||||||
cd Python-${PYTHON_VERSION} && \
|
# 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 -)" && \
|
||||||
LDFLAGS="-L/usr/lib" \
|
LDFLAGS="-L/usr/lib" \
|
||||||
CPPFLAGS="-I/usr/include" \
|
CPPFLAGS="-I/usr/include" \
|
||||||
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
|
PYTHON_CONFIGURE_OPTS="--enable-shared" \
|
||||||
make -j$(nproc) && \
|
pyenv install 3.13 && \
|
||||||
make install && \
|
pyenv global 3.13 && \
|
||||||
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
|
pyenv rehash
|
||||||
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 Python installation
|
# Update PATH to include pyenv shims
|
||||||
ENV PATH="/usr/local/bin:$PATH"
|
ENV PATH="/root/.pyenv/shims:$PATH"
|
||||||
|
|
||||||
# Verify Python has tkinter support
|
# Verify Python has tkinter support
|
||||||
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
||||||
|
|||||||
@@ -8,10 +8,8 @@
|
|||||||
# Use Debian 13 "Trixie" (latest stable release)
|
# Use Debian 13 "Trixie" (latest stable release)
|
||||||
FROM debian:13
|
FROM debian:13
|
||||||
|
|
||||||
# Set build arguments
|
# Set build argument for fpm version (can be overridden at build time)
|
||||||
ARG FPM_VERSION=1.16.0
|
ARG FPM_VERSION=1.16.0
|
||||||
ARG PYTHON_VERSION=3.14.3
|
|
||||||
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
|
|
||||||
|
|
||||||
# Install system dependencies including Python build dependencies
|
# Install system dependencies including Python build dependencies
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
@@ -43,26 +41,24 @@ RUN apt-get update && \
|
|||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Download Python source and verify SHA256 checksum against python.org
|
# Install pyenv
|
||||||
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
|
ENV PYENV_ROOT="/root/.pyenv"
|
||||||
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
|
ENV PATH="$PYENV_ROOT/bin:$PATH"
|
||||||
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
|
|
||||||
|
|
||||||
# Build and install Python from source
|
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
|
||||||
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
|
|
||||||
cd Python-${PYTHON_VERSION} && \
|
# 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 -)" && \
|
||||||
LDFLAGS="-L/usr/lib/x86_64-linux-gnu" \
|
LDFLAGS="-L/usr/lib/x86_64-linux-gnu" \
|
||||||
CPPFLAGS="-I/usr/include/tcl8.6" \
|
CPPFLAGS="-I/usr/include/tcl8.6" \
|
||||||
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
|
PYTHON_CONFIGURE_OPTS="--enable-shared" \
|
||||||
make -j$(nproc) && \
|
pyenv install 3.13 && \
|
||||||
make install && \
|
pyenv global 3.13 && \
|
||||||
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
|
pyenv rehash
|
||||||
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 Python installation
|
# Update PATH to include pyenv shims
|
||||||
ENV PATH="/usr/local/bin:$PATH"
|
ENV PATH="/root/.pyenv/shims:$PATH"
|
||||||
|
|
||||||
# Verify Python has tkinter support
|
# Verify Python has tkinter support
|
||||||
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
||||||
|
|||||||
@@ -7,10 +7,8 @@
|
|||||||
|
|
||||||
FROM fedora:42
|
FROM fedora:42
|
||||||
|
|
||||||
# Set build arguments
|
# Set build argument for fpm version (can be overridden at build time)
|
||||||
ARG FPM_VERSION=1.16.0
|
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
|
# 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
|
# Using tk8 (version 8.6) instead of tk (version 9.0) for Python 3.12 compatibility
|
||||||
@@ -44,26 +42,24 @@ RUN dnf -y update && \
|
|||||||
libXrender-devel && \
|
libXrender-devel && \
|
||||||
dnf clean all
|
dnf clean all
|
||||||
|
|
||||||
# Download Python source and verify SHA256 checksum against python.org
|
# Install pyenv
|
||||||
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
|
ENV PYENV_ROOT="/root/.pyenv"
|
||||||
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
|
ENV PATH="$PYENV_ROOT/bin:$PATH"
|
||||||
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
|
|
||||||
|
|
||||||
# Build and install Python from source
|
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
|
||||||
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
|
|
||||||
cd Python-${PYTHON_VERSION} && \
|
# 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 -)" && \
|
||||||
LDFLAGS="-L/usr/lib64" \
|
LDFLAGS="-L/usr/lib64" \
|
||||||
CPPFLAGS="-I/usr/include" \
|
CPPFLAGS="-I/usr/include" \
|
||||||
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
|
PYTHON_CONFIGURE_OPTS="--enable-shared" \
|
||||||
make -j$(nproc) && \
|
pyenv install 3.13 && \
|
||||||
make install && \
|
pyenv global 3.13 && \
|
||||||
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
|
pyenv rehash
|
||||||
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 Python installation
|
# Update PATH to include pyenv shims
|
||||||
ENV PATH="/usr/local/bin:$PATH"
|
ENV PATH="/root/.pyenv/shims:$PATH"
|
||||||
|
|
||||||
# Verify Python has tkinter support
|
# Verify Python has tkinter support
|
||||||
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
||||||
|
|||||||
+20
-29
@@ -1,57 +1,52 @@
|
|||||||
# Container Build Environment
|
# Docker Build Environment
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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
|
## Quick Start
|
||||||
|
|
||||||
### Using Podman Compose (Recommended)
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
Build for all distributions:
|
Build for all distributions:
|
||||||
```bash
|
```bash
|
||||||
podman-compose up --build
|
docker-compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Build for a specific distribution:
|
Build for a specific distribution:
|
||||||
```bash
|
```bash
|
||||||
podman-compose up --build debian
|
docker-compose up --build debian
|
||||||
podman-compose up --build arch
|
docker-compose up --build arch
|
||||||
podman-compose up --build rhel
|
docker-compose up --build rhel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Prefect + Dagger (CI Pipeline Locally)
|
Build all distributions in parallel:
|
||||||
|
|
||||||
The CI/CD pipeline uses Prefect and Dagger to orchestrate builds. You can run it locally:
|
|
||||||
```bash
|
```bash
|
||||||
poetry install --with ci
|
docker-compose up --build --parallel
|
||||||
poetry run python -m ci.prefect_flow build-linux
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Podman Build
|
### Manual Docker Build
|
||||||
|
|
||||||
Build the image:
|
Build the image:
|
||||||
```bash
|
```bash
|
||||||
# Debian
|
# Debian
|
||||||
podman build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
|
docker build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
|
||||||
|
|
||||||
# Arch
|
# Arch
|
||||||
podman build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
|
docker build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
|
||||||
|
|
||||||
# RHEL/Fedora
|
# RHEL/Fedora
|
||||||
podman build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
|
docker build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the build:
|
Run the build:
|
||||||
```bash
|
```bash
|
||||||
# Debian
|
# Debian
|
||||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-debian-builder
|
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-debian-builder
|
||||||
|
|
||||||
# Arch
|
# Arch
|
||||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-arch-builder
|
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-arch-builder
|
||||||
|
|
||||||
# RHEL/Fedora
|
# RHEL/Fedora
|
||||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-rhel-builder
|
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-rhel-builder
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
@@ -86,13 +81,9 @@ After building, you'll find:
|
|||||||
|
|
||||||
### Virtualenv Conflicts
|
### Virtualenv Conflicts
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
If you encounter virtualenv-related errors, ensure you're using the latest podman-compose.yml configuration.
|
If you encounter virtualenv-related errors, ensure you're using the latest docker-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
|
## Cleaning Up
|
||||||
|
|
||||||
@@ -101,9 +92,9 @@ Remove build artifacts:
|
|||||||
rm -rf dist pkg_dist_* dist_*
|
rm -rf dist pkg_dist_* dist_*
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove Podman containers and volumes:
|
Remove Docker volumes and containers:
|
||||||
```bash
|
```bash
|
||||||
podman-compose down -v
|
docker compose down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure installed binary is executable
|
# Ensure installed binary is executable
|
||||||
if [ -f /usr/bin/android-file-handler ]; then
|
if [ -f /usr/local/bin/android-file-handler ]; then
|
||||||
chmod 0755 /usr/bin/android-file-handler || true
|
chmod 0755 /usr/bin/android-file-handler || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import os
|
|
||||||
block_cipher = None
|
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(
|
a = Analysis(
|
||||||
[os.path.join(SRC_DIR, 'main.py')],
|
['../../src/main.py'],
|
||||||
pathex=[SRC_DIR],
|
pathex=['src'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[
|
datas=[
|
||||||
(os.path.join(SRC_DIR, 'gui'), 'gui'),
|
('../../src/gui', 'gui'),
|
||||||
],
|
],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
# GUI modules
|
# GUI modules
|
||||||
@@ -36,8 +33,7 @@ a = Analysis(
|
|||||||
'managers.transfer_manager',
|
'managers.transfer_manager',
|
||||||
# Utility modules
|
# Utility modules
|
||||||
'utils',
|
'utils',
|
||||||
'utils.file_deduplication',
|
'utils.file_deduplication'
|
||||||
'utils.security_utils'
|
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
import os
|
|
||||||
block_cipher = None
|
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(
|
a = Analysis(
|
||||||
[os.path.join(SRC_DIR, 'main.py')],
|
['../../src/main.py'],
|
||||||
pathex=[SRC_DIR],
|
pathex=['src'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[
|
datas=[
|
||||||
(os.path.join(SRC_DIR, 'gui'), 'gui'),
|
('../../src/gui', 'gui'),
|
||||||
(os.path.join(SPEC_DIR, '..', 'debian_postinst.sh'), 'scripts'),
|
('../../scripts/debian_postinst.sh', 'scripts'),
|
||||||
],
|
],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
# GUI modules
|
# GUI modules
|
||||||
@@ -38,8 +35,7 @@ a = Analysis(
|
|||||||
'managers.transfer_manager',
|
'managers.transfer_manager',
|
||||||
# Utility modules
|
# Utility modules
|
||||||
'utils',
|
'utils',
|
||||||
'utils.file_deduplication',
|
'utils.file_deduplication'
|
||||||
'utils.security_utils'
|
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import os
|
|
||||||
block_cipher = None
|
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(
|
a = Analysis(
|
||||||
[os.path.join(SRC_DIR, 'main.py')],
|
['../../src/main.py'],
|
||||||
pathex=[SRC_DIR],
|
pathex=['src'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[
|
datas=[
|
||||||
(os.path.join(SRC_DIR, 'gui'), 'gui'),
|
('../../src/gui', 'gui'),
|
||||||
(os.path.join(SPEC_DIR, '..', 'rhel_postinst.sh'), 'scripts'),
|
('../../scripts/rhel_postinst.sh', 'scripts'),
|
||||||
],
|
],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
# GUI modules
|
# GUI modules
|
||||||
@@ -37,8 +34,7 @@ a = Analysis(
|
|||||||
'managers.transfer_manager',
|
'managers.transfer_manager',
|
||||||
# Utility modules
|
# Utility modules
|
||||||
'utils',
|
'utils',
|
||||||
'utils.file_deduplication',
|
'utils.file_deduplication'
|
||||||
'utils.security_utils'
|
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
import os
|
|
||||||
block_cipher = None
|
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(
|
a = Analysis(
|
||||||
[os.path.join(SRC_DIR, 'main.py')],
|
['../../src/main.py'],
|
||||||
pathex=[SRC_DIR],
|
pathex=['../..'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[
|
datas=[
|
||||||
(os.path.join(SRC_DIR, 'gui'), 'gui'),
|
('../../src/gui', 'gui'),
|
||||||
|
('../windows/first_run_install.ps1', 'scripts/windows')
|
||||||
],
|
],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
# GUI modules
|
# GUI modules
|
||||||
@@ -37,8 +35,7 @@ a = Analysis(
|
|||||||
'managers.transfer_manager',
|
'managers.transfer_manager',
|
||||||
# Utility modules
|
# Utility modules
|
||||||
'utils',
|
'utils',
|
||||||
'utils.file_deduplication',
|
'utils.file_deduplication'
|
||||||
'utils.security_utils'
|
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@@ -70,5 +67,5 @@ exe = EXE(
|
|||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon=os.path.join(SPEC_DIR, '..', '..', 'icon_media', 'robot_files_256.ico'),
|
icon='../../icon_media/robot_files_256.ico',
|
||||||
)
|
)
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
; 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
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
# 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,58 +211,6 @@ class DialogManager:
|
|||||||
dialog.protocol("WM_DELETE_WINDOW", on_close)
|
dialog.protocol("WM_DELETE_WINDOW", on_close)
|
||||||
ok_button.config(command=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:
|
def show_error(self, title: str, message: str) -> None:
|
||||||
"""Show an error dialog.
|
"""Show an error dialog.
|
||||||
|
|
||||||
@@ -386,7 +334,7 @@ class DialogManager:
|
|||||||
side="right"
|
side="right"
|
||||||
)
|
)
|
||||||
|
|
||||||
def show_transfer_stats(self, stats: dict, operation: str):
|
def show_transfer_stats(self, stats: dict, operation: str) -> None:
|
||||||
"""Show transfer statistics in a dialog.
|
"""Show transfer statistics in a dialog.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -437,7 +385,7 @@ class DialogManager:
|
|||||||
# Close button
|
# Close button
|
||||||
tk.Button(main_frame, text="Close", command=stats_window.destroy).pack()
|
tk.Button(main_frame, text="Close", command=stats_window.destroy).pack()
|
||||||
|
|
||||||
def show_disable_debugging_reminder(self):
|
def show_disable_debugging_reminder(self) -> None:
|
||||||
"""Show reminder about disabling USB debugging after transfer."""
|
"""Show reminder about disabling USB debugging after transfer."""
|
||||||
messagebox.showinfo(
|
messagebox.showinfo(
|
||||||
"Security Reminder",
|
"Security Reminder",
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import tkinter as tk
|
|||||||
from tkinter import messagebox, scrolledtext
|
from tkinter import messagebox, scrolledtext
|
||||||
import tempfile
|
import tempfile
|
||||||
import stat
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
def get_license_file_path() -> str:
|
def get_license_file_path() -> str:
|
||||||
@@ -104,6 +107,48 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||||||
SOFTWARE."""
|
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):
|
class LicenseAgreementFrame(tk.Frame):
|
||||||
"""License agreement UI frame that can be embedded in the main window."""
|
"""License agreement UI frame that can be embedded in the main window."""
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ Simple entry point to launch the Android file transfer application.
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from gui.main_window import main
|
from gui.main_window import main
|
||||||
|
from gui.dialogs.license_agreement import run_windows_first_run_if_needed
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from .gui.main_window import main
|
from .gui.main_window import main
|
||||||
|
from .gui.dialogs.license_agreement import run_windows_first_run_if_needed
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
run_windows_first_run_if_needed()
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ class TransferManager:
|
|||||||
lambda: self.dialog_manager.show_transfer_stats(
|
lambda: self.dialog_manager.show_transfer_stats(
|
||||||
stats,
|
stats,
|
||||||
direction.capitalize(),
|
direction.capitalize(),
|
||||||
self.device_manager.adb_manager.deduplicator,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
# 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
@@ -1,54 +0,0 @@
|
|||||||
# -*- 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
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
# 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