initial refactor of ci-cd pipeline

This commit is contained in:
Jason Ross
2026-03-20 19:30:22 -05:00
parent b269fdc11d
commit 3a5d4e7dbd
19 changed files with 1216 additions and 168 deletions
@@ -0,0 +1,295 @@
# 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 S3 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: 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 + S3 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-s3:
runs-on: ubuntu-latest
needs: do-release
if: needs.do-release.result == 'success'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Install AWS CLI
run: python -m pip install --upgrade pip awscli
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
path: ./binaries
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Upload artifacts to S3
run: |
set -euo pipefail
if [ -z "${{ secrets.S3_BUCKET }}" ]; then
echo "S3_BUCKET secret not set; skipping upload"
exit 0
fi
aws s3 sync ./binaries s3://${{ secrets.S3_BUCKET }}/builds/${{ github.run_id }}/ --acl private
env:
AWS_PAGER: ""
sync-wiki:
needs: do-release
if: needs.do-release.result == 'success'
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/sync-wiki.yml
with:
branch: main
secrets: inherit
+37 -19
View File
@@ -68,6 +68,15 @@ jobs:
# Use the Windows spec file so packaging is consistent and reproducible
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- name: Build Windows installer (Inno Setup)
shell: pwsh
run: |
$version = (poetry version -s).Trim()
Write-Output "Building installer for version $version"
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
"scripts\windows\android-file-handler-setup.iss" `
"/DMyAppVersion=$version"
- name: Import GPG key
shell: pwsh
run: |
@@ -75,23 +84,37 @@ jobs:
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
gpg --list-secret-keys
- name: Sign and hash Windows executable
- name: Sign and hash Windows artifacts
shell: pwsh
run: |
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler.exe" -Recurse | Select-Object -First 1 -ExpandProperty FullName
if (-not $exePath) {
Write-Error "Executable not found"
exit 1
}
Write-Output "Found executable: $exePath"
# Create temporary file for passphrase
$passphraseFile = New-TemporaryFile
try {
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
# Sign with GPG using passphrase file
# Sign and hash the standalone executable
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" | Select-Object -First 1 -ExpandProperty FullName
if (-not $exePath) {
Write-Error "Standalone executable not found"
exit 1
}
Write-Output "Signing executable: $exePath"
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath"
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
"$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline
Write-Output "Executable SHA-256: $hash"
# Sign and hash the Inno Setup installer
$setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" | Select-Object -First 1 -ExpandProperty FullName
if (-not $setupPath) {
Write-Error "Installer not found"
exit 1
}
Write-Output "Signing installer: $setupPath"
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath"
$setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower()
"$setupHash $(Split-Path -Leaf $setupPath)" | Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline
Write-Output "Installer SHA-256: $setupHash"
}
finally {
# Clean up passphrase file
@@ -100,22 +123,17 @@ jobs:
}
}
# Generate SHA-256 hash
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
$hashFile = "dist/android-file-handler-windows.sha256"
"$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath $hashFile -Encoding ASCII -NoNewline
Write-Output "SHA-256: $hash"
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: windows-binary
path: |
dist/**/android-file-handler*.exe
dist/**/android-file-handler*.exe.asc
dist/android-file-handler.exe
dist/android-file-handler.exe.asc
dist/android-file-handler-windows.exe
dist/android-file-handler-windows.exe.asc
dist/android-file-handler-windows.sha256
dist/android-file-handler-setup.exe
dist/android-file-handler-setup.exe.asc
dist/android-file-handler-setup.sha256
build-debian:
permissions:
+54 -16
View File
@@ -58,21 +58,35 @@ poetry run mypy src/
poetry run python scripts/build_package_linux.py
```
#### Docker Compose Build (Recommended for Linux)
#### Prefect + Dagger Build (CI Pipeline Locally)
```sh
# Build all distributions (Debian, Arch, RHEL)
docker compose up --build
# Install CI dependencies
poetry install --with ci
# Build all Linux distros via Dagger containers
poetry run python -m ci.prefect_flow build-linux
# Sign artifacts
poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE"
# Full pipeline (build + sign + release)
poetry run python -m ci.prefect_flow full \
--gpg-passphrase "$GPG_PASSPHRASE" \
--github-token "$GITHUB_TOKEN"
```
#### Podman Compose Build (Recommended for Linux)
```sh
# Build all distributions
podman-compose up --build
# Build specific distribution
docker compose up --build debian
docker compose up --build arch
docker compose up --build rhel
# Build all in parallel
docker compose up --build --parallel
podman-compose up --build debian
podman-compose up --build arch
podman-compose up --build rhel
# Clean build artifacts
docker compose down -v && rm -rf dist pkg_dist_* dist_*
podman-compose down -v && rm -rf dist pkg_dist_* dist_*
```
See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker build documentation.
@@ -82,6 +96,10 @@ See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker bui
# Windows executable (PyInstaller)
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
# Windows installer (Inno Setup, after PyInstaller build)
# Inno Setup 6 is pre-installed on GitHub Actions windows-latest runners
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss /DMyAppVersion=0.1.1
# Linux packages use distro-specific spec files:
# - android-file-handler-debian.spec
# - android-file-handler-arch.spec
@@ -116,6 +134,13 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- **scripts/**: Build and packaging scripts
- `build_package_linux.py`: Unified Linux packaging script (uses DISTRO_TYPE env var)
- `spec_scripts/`: PyInstaller spec files for each platform
- `windows/android-file-handler-setup.iss`: Inno Setup installer configuration
- **ci/**: CI/CD pipeline orchestration
- `config.py`: Shared build configuration (distro configs, image references)
- `dagger_pipeline.py`: Dagger container build definitions for Linux
- `prefect_flow.py`: Prefect flow orchestration and CLI entry point
- `signing.py`: GPG signing and SHA-256 hashing utilities
- **tests/**: Test suite mirroring src/ structure
@@ -135,12 +160,25 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
## CI/CD
The project uses GitHub Actions for multi-platform builds (`.github/workflows/release.yml`):
- Runs tests on Linux and Windows
- Builds binaries for Windows, Debian, Arch, and RHEL
- Packages using PyInstaller + fpm
- Supports manual workflow dispatch with configurable jobs
- Optional GitHub release creation and S3 upload
The project uses a **Prefect + Dagger** pipeline wrapped by GitHub Actions (`.github/workflows/release-prefect-dagger.yml`):
- **Dagger** runs containerized Linux builds (Debian, Arch, RHEL) using pre-built builder images
- **Prefect** orchestrates the pipeline: build → sign → release → S3 upload
- **GitHub Actions** provides the runner infrastructure and Windows build (cannot containerize)
- **Podman** is the container runtime (Dagger connects via Podman socket)
Pipeline structure:
1. `build-windows` — Native Windows build on `windows-latest`
2. `build-linux` — All Linux distros built in parallel via Prefect + Dagger
3. `do-release` — Creates GitHub release with all artifacts
4. `upload-s3` — Optional S3 upload
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/signing.py` — GPG signing and SHA-256 hashing utilities
## Coding Standards
+1
View File
@@ -0,0 +1 @@
"""CI/CD pipeline orchestration using Prefect and Dagger."""
+74
View File
@@ -0,0 +1,74 @@
"""Shared configuration for CI/CD pipeline."""
from dataclasses import dataclass, field
from pathlib import Path
@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())
# Container images (from GHCR)
debian_image: str = "ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie"
arch_image: str = "ghcr.io/jmr-dev/android-file-handler-arch-builder:latest"
rhel_image: str = "ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42"
@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",
],
})
+136
View File
@@ -0,0 +1,136 @@
"""Dagger pipeline for building Linux distribution packages.
Uses the Dagger Python SDK to run containerized builds for each
Linux distribution (Debian, Arch, RHEL) using pre-built builder images.
"""
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownVariableType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownParameterType=false
import asyncio
import sys
from pathlib import Path
import dagger # type: ignore[import-not-found]
from ci.config import DistroConfig, PipelineConfig
async def build_linux_distro(
client: dagger.Client,
config: PipelineConfig,
distro: DistroConfig,
) -> dict[str, Path]:
"""Build a single Linux distribution package inside a Dagger container.
Args:
client: Active Dagger client connection.
config: Pipeline configuration.
distro: Distribution-specific build configuration.
Returns:
Dictionary mapping artifact names to their local output paths.
"""
print(f"[dagger] Starting {distro.name} build using {distro.container_image}")
# Mount the project source into the container, excluding local venv
source = client.host().directory(
str(config.project_root),
exclude=[".venv", "__pycache__", "dist", "dist_*", "pkg_dist_*", ".git"],
)
container = (
client.container()
.from_(distro.container_image)
.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]] = {}
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
tasks = {
distro.distro_type: build_linux_distro(client, config, distro)
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"
)
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
return await build_linux_distro(client, config, distro)
if __name__ == "__main__":
asyncio.run(build_all_linux())
+330
View File
@@ -0,0 +1,330 @@
"""Prefect orchestration flow for the CI/CD release pipeline.
Coordinates Dagger-based Linux builds, GPG signing, GitHub release
creation, and S3 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 S3
poetry run python -m ci.prefect_flow release --github-token "$GITHUB_TOKEN"
# Full pipeline (build + sign + release + S3)
poetry run python -m ci.prefect_flow full --gpg-passphrase "$GPG_PASSPHRASE" \\
--github-token "$GITHUB_TOKEN"
"""
import asyncio
import argparse
import os
import subprocess
import sys
from pathlib import Path
from prefect import flow, task
from ci.config import PipelineConfig
from ci.dagger_pipeline import build_all_linux
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:
"""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-s3")
def task_upload_s3(
release_dir: Path,
s3_bucket: str,
run_id: str,
) -> None:
"""Upload release artifacts to S3.
Args:
release_dir: Directory containing release files.
s3_bucket: S3 bucket name.
run_id: Unique identifier for this build run.
"""
if not s3_bucket:
print("S3_BUCKET not set; skipping upload")
return
target = f"s3://{s3_bucket}/builds/{run_id}/"
print(f"Uploading to {target}")
subprocess.run(
["aws", "s3", "sync", str(release_dir), target, "--acl", "private"],
check=True,
env={**os.environ, "AWS_PAGER": ""},
)
print("S3 upload complete")
# ---------------------------------------------------------------------------
# Flows
# ---------------------------------------------------------------------------
@flow(name="build-linux-flow", log_prints=True)
def flow_build_linux() -> dict:
"""Build all Linux distribution packages."""
config = PipelineConfig()
return task_build_linux(config)
@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,
s3_bucket: str = "",
run_id: str = "",
) -> None:
"""Create a GitHub release and optionally upload to S3."""
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 s3_bucket:
task_upload_s3(release_dir, s3_bucket, run_id or "local")
@flow(name="full-pipeline", log_prints=True)
def flow_full_pipeline(
gpg_passphrase: str = "",
github_token: str = "",
s3_bucket: 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 → S3.
Args:
gpg_passphrase: GPG key passphrase for signing.
github_token: GitHub token for release creation.
s3_bucket: Optional S3 bucket for artifact upload.
run_id: Build run identifier for S3 path.
skip_build: Skip the Linux build step.
skip_sign: Skip the signing step.
skip_release: Skip the release + S3 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 s3_bucket:
task_upload_s3(release_dir, s3_bucket, 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("--s3-bucket", default="", help="S3 bucket name")
release_parser.add_argument("--run-id", default="", help="Build run ID")
# 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("--s3-bucket", default="", help="S3 bucket name")
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,
s3_bucket=args.s3_bucket,
run_id=args.run_id,
)
elif args.action == "full":
flow_full_pipeline(
gpg_passphrase=args.gpg_passphrase,
github_token=args.github_token,
s3_bucket=args.s3_bucket,
run_id=args.run_id,
skip_build=args.skip_build,
skip_sign=args.skip_sign,
skip_release=args.skip_release,
)
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
"""GPG signing and SHA-256 hashing utilities for release artifacts."""
import hashlib
import subprocess
import tempfile
from pathlib import Path
def gpg_sign_file(file_path: Path, passphrase: str) -> Path:
"""Create a detached ASCII-armored GPG signature for a file.
Args:
file_path: Path to the file to sign.
passphrase: GPG key passphrase.
Returns:
Path to the generated .asc signature file.
Raises:
subprocess.CalledProcessError: If GPG signing fails.
FileNotFoundError: If the input file does not exist.
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
sig_path = file_path.with_suffix(file_path.suffix + ".asc")
with tempfile.NamedTemporaryFile(mode="w", suffix=".pass", delete=True) as passfile:
passfile.write(passphrase)
passfile.flush()
subprocess.run(
[
"gpg", "--batch", "--yes",
"--passphrase-file", passfile.name,
"--detach-sign", "--armor",
str(file_path),
],
check=True,
capture_output=True,
text=True,
)
print(f"Signed: {sig_path}")
return sig_path
def sha256_hash_file(file_path: Path) -> tuple[str, Path]:
"""Compute SHA-256 hash of a file and write a .sha256 checksum file.
Args:
file_path: Path to the file to hash.
Returns:
Tuple of (hex digest, path to .sha256 file).
Raises:
FileNotFoundError: If the input file does not exist.
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
sha256 = hashlib.sha256()
with open(file_path, "rb") as fh:
for chunk in iter(lambda: fh.read(8192), b""):
sha256.update(chunk)
digest = sha256.hexdigest()
hash_line = f"{digest} {file_path.name}"
hash_path = file_path.parent / f"{file_path.stem}.sha256"
hash_path.write_text(hash_line, encoding="ascii")
print(f"SHA-256 ({file_path.name}): {digest}")
return digest, hash_path
def sign_and_hash(file_path: Path, passphrase: str) -> tuple[Path, Path]:
"""Sign a file with GPG and generate its SHA-256 checksum.
Args:
file_path: Path to the artifact to sign and hash.
passphrase: GPG key passphrase.
Returns:
Tuple of (signature path, hash file path).
"""
sig_path = gpg_sign_file(file_path, passphrase)
_, hash_path = sha256_hash_file(file_path)
return sig_path, hash_path
+13 -4
View File
@@ -1,9 +1,18 @@
# Docker Compose configuration for local multi-platform builds
# Matches the exact images and configurations from .github/workflows/release.yml
# DEPRECATED: This file is kept for backward compatibility.
# Use podman-compose.yml instead:
# podman-compose -f podman-compose.yml up --build
#
# Docker Compose will also read podman-compose.yml if you symlink:
# ln -sf podman-compose.yml docker-compose.yml
#
# --- Original configuration follows (mirrors podman-compose.yml) ---
# Podman Compose / Docker Compose configuration for local multi-platform builds
# Matches the exact images and configurations from .github/workflows/release-prefect-dagger.yml
#
# Usage:
# Build all distributions: docker-compose up --build
# Build specific distro: docker-compose up --build debian
# Build all distributions: podman-compose up --build
# Build specific distro: podman-compose up --build debian
#
# Each service builds a distribution package and outputs to:
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
+72
View File
@@ -0,0 +1,72 @@
# Podman Compose configuration for local multi-platform builds
# Compatible with podman-compose and docker-compose (via podman socket)
#
# Usage:
# Build all distributions: podman-compose up --build
# Build specific distro: podman-compose up --build debian
#
# Each service builds a distribution package and outputs to:
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
# - pkg_dist_{distro}/ - Staging directory for package contents
# - dist_{distro}/ - PyInstaller build output
services:
debian:
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
build:
context: .
dockerfile: scripts/docker/Dockerfile.debian
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace:Z
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=debian
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
arch:
image: ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
build:
context: .
dockerfile: scripts/docker/Dockerfile.arch
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace:Z
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=arch
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
rhel:
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
build:
context: .
dockerfile: scripts/docker/Dockerfile.rhel
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace:Z
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=rhel
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
+7
View File
@@ -39,6 +39,13 @@ pytest-cov = "^7.0.0"
[tool.poetry.group.build.dependencies]
pyinstaller = "^6.1.0"
[tool.poetry.group.ci]
optional = true
[tool.poetry.group.ci.dependencies]
dagger-io = ">=0.15.0"
prefect = ">=3.0.0"
[tool.black]
line-length = 88
target-version = ['py313']
+20 -7
View File
@@ -7,7 +7,7 @@ import sys
import re
from pathlib import Path
from enum import Enum
from typing import List
from typing import List, TypedDict
class DistroType(Enum):
@@ -16,7 +16,19 @@ class DistroType(Enum):
RHEL = "rhel"
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess:
class DistroConfigDict(TypedDict):
"""Type definition for distro configuration."""
name: str
bin_path: str
pkg_suffix: str
spec_file: str
pkg_type: str
architecture: str
postinstall: str | None
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess[bytes]:
"""Run command and handle errors."""
print(f"Running: {' '.join(cmd)}")
try:
@@ -29,9 +41,9 @@ def run_command(cmd: list[str], check: bool = True, working_dir: str | None = No
sys.exit(1)
def get_distro_config(distro_type: DistroType) -> dict:
def get_distro_config(distro_type: DistroType) -> DistroConfigDict:
"""Get configuration for specific distro type."""
configs = {
configs: dict[DistroType, DistroConfigDict] = {
DistroType.DEBIAN: {
"name": "Debian",
"bin_path": "usr/local/bin",
@@ -131,10 +143,11 @@ def package_with_fpm(distro_type: DistroType, version: str, project_root: Path)
# Add distro-specific options
if distro_type == DistroType.DEBIAN:
output_file = dist_dir / f"android-file-handler_{version}_{config['architecture']}.deb"
postinstall = config["postinstall"]
fpm_cmd.extend([
"--deb-user", "root",
"--deb-group", "root",
"--after-install", config["postinstall"],
*(["--after-install", postinstall] if postinstall else []),
"-p", str(output_file)
])
elif distro_type == DistroType.ARCH:
@@ -144,9 +157,9 @@ def package_with_fpm(distro_type: DistroType, version: str, project_root: Path)
])
elif distro_type == DistroType.RHEL:
output_file = dist_dir / f"android-file-handler-{version}.{config['architecture']}.rpm"
postinstall = config["postinstall"]
fpm_cmd.extend([
"--prefix", "/usr/bin",
"--after-install", config["postinstall"],
*(["--after-install", postinstall] if postinstall else []),
"-p", str(output_file)
])
+29 -20
View File
@@ -1,52 +1,57 @@
# Docker Build Environment
# Container Build Environment
This directory contains Dockerfiles for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
This directory contains OCI-compatible Containerfiles (Dockerfiles) for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
The project uses **Podman** as the container runtime. All commands below use Podman; if you have Docker installed, the Dockerfiles are OCI-compatible and will work with Docker as well.
## Quick Start
### Using Docker Compose (Recommended)
### Using Podman Compose (Recommended)
Build for all distributions:
```bash
docker-compose up --build
podman-compose up --build
```
Build for a specific distribution:
```bash
docker-compose up --build debian
docker-compose up --build arch
docker-compose up --build rhel
podman-compose up --build debian
podman-compose up --build arch
podman-compose up --build rhel
```
Build all distributions in parallel:
### Using Prefect + Dagger (CI Pipeline Locally)
The CI/CD pipeline uses Prefect and Dagger to orchestrate builds. You can run it locally:
```bash
docker-compose up --build --parallel
poetry install --with ci
poetry run python -m ci.prefect_flow build-linux
```
### Manual Docker Build
### Manual Podman Build
Build the image:
```bash
# Debian
docker build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
podman build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
# Arch
docker build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
podman build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
# RHEL/Fedora
docker build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
podman build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
```
Run the build:
```bash
# Debian
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-debian-builder
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-debian-builder
# Arch
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-arch-builder
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-arch-builder
# RHEL/Fedora
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-rhel-builder
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-rhel-builder
```
## Output
@@ -81,9 +86,13 @@ After building, you'll find:
### Virtualenv Conflicts
The Docker Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`.
The Podman Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`.
If you encounter virtualenv-related errors, ensure you're using the latest docker-compose.yml configuration.
If you encounter virtualenv-related errors, ensure you're using the latest podman-compose.yml configuration.
### SELinux (Fedora/RHEL hosts)
Volume mounts use the `:Z` suffix to apply the correct SELinux labels automatically. If you encounter permission errors, ensure the `:Z` suffix is present on volume mounts.
## Cleaning Up
@@ -92,9 +101,9 @@ Remove build artifacts:
rm -rf dist pkg_dist_* dist_*
```
Remove Docker volumes and containers:
Remove Podman containers and volumes:
```bash
docker compose down -v
podman-compose down -v
```
## Customization
+1 -1
View File
@@ -19,7 +19,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
fi
# Ensure installed binary is executable
if [ -f /usr/local/bin/android-file-handler ]; then
if [ -f /usr/bin/android-file-handler ]; then
chmod 0755 /usr/bin/android-file-handler || true
fi
@@ -5,8 +5,7 @@ a = Analysis(
pathex=['../..'],
binaries=[],
datas=[
('../../src/gui', 'gui'),
('../windows/first_run_install.ps1', 'scripts/windows')
('../../src/gui', 'gui'),
],
hiddenimports=[
# GUI modules
@@ -0,0 +1,56 @@
; 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}
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/ultra64
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin
MinVersion=10.0
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
-51
View File
@@ -1,51 +0,0 @@
param(
[string]$ExePath = "$PSScriptRoot\..\..\dist\android-file-handler.exe",
[string]$IconPath = "$PSScriptRoot\..\..\assets\icons\android-file-handler.ico",
[string]$AppName = "Android File Handler"
)
function Ensure-Elevated {
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
# Relaunch the script with elevation
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "powershell.exe"
$psi.Arguments = "-ExecutionPolicy Bypass -File `"$PSCommandPath`""
$psi.Verb = "runas"
try {
[System.Diagnostics.Process]::Start($psi) | Out-Null
Exit 0
} catch {
Write-Error "Elevation required to install to Program Files."
Exit 1
}
}
}
Ensure-Elevated
$destDir = Join-Path ${env:ProgramFiles} $AppName
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir | Out-Null }
$resolvedExe = Resolve-Path -Path $ExePath -ErrorAction SilentlyContinue
if (-not $resolvedExe) {
Write-Error "Application executable not found at $ExePath"
Exit 1
}
Copy-Item -Path $resolvedExe -Destination (Join-Path $destDir (Split-Path $resolvedExe -Leaf)) -Force
# Create Start Menu shortcut
$programs = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
$appFolder = Join-Path $programs $AppName
if (-not (Test-Path $appFolder)) { New-Item -ItemType Directory -Path $appFolder | Out-Null }
$shortcutPath = Join-Path $appFolder "$AppName.lnk"
$wsh = New-Object -ComObject WScript.Shell
$sc = $wsh.CreateShortcut($shortcutPath)
$sc.TargetPath = (Join-Path $destDir (Split-Path $resolvedExe -Leaf))
$sc.WorkingDirectory = $destDir
if (Test-Path $IconPath) { $sc.IconLocation = Resolve-Path $IconPath }
$sc.Save()
Write-Output "Installed $AppName to $destDir and created Start Menu shortcut."
Exit 0
-45
View File
@@ -9,9 +9,6 @@ import tkinter as tk
from tkinter import messagebox, scrolledtext
import tempfile
import stat
import subprocess
import sys
import os
def get_license_file_path() -> str:
@@ -107,48 +104,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE."""
def resource_path(relative_path: str) -> str:
"""Return absolute path to resource for dev and frozen runs."""
try:
if getattr(sys, "frozen", False):
base = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
else:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.normpath(os.path.join(base, relative_path))
except Exception:
return os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)
)
def run_windows_first_run_if_needed() -> None:
"""If running on Windows and license not agreed, launch first-run installer script.
Uses `resource_path` to locate the bundled PowerShell script in both dev and frozen modes.
"""
try:
if not sys.platform.startswith("win"):
return
if check_license_agreement():
return
script_rel = os.path.join("scripts", "windows", "first_run_install.ps1")
script_path = resource_path(script_rel)
if not os.path.exists(script_path):
return
try:
subprocess.Popen(
["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
except Exception:
pass
class LicenseAgreementFrame(tk.Frame):
"""License agreement UI frame that can be embedded in the main window."""
-3
View File
@@ -6,12 +6,9 @@ Simple entry point to launch the Android file transfer application.
try:
from gui.main_window import main
from gui.dialogs.license_agreement import run_windows_first_run_if_needed
except ImportError:
from .gui.main_window import main
from .gui.dialogs.license_agreement import run_windows_first_run_if_needed
if __name__ == "__main__":
run_windows_first_run_if_needed()
main()