initial refactor of ci-cd pipeline
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""CI/CD pipeline orchestration using Prefect and Dagger."""
|
||||
@@ -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",
|
||||
],
|
||||
})
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
@@ -1,9 +1,18 @@
|
||||
# Docker Compose configuration for local multi-platform builds
|
||||
# Matches the exact images and configurations from .github/workflows/release.yml
|
||||
# DEPRECATED: This file is kept for backward compatibility.
|
||||
# Use podman-compose.yml instead:
|
||||
# podman-compose -f podman-compose.yml up --build
|
||||
#
|
||||
# Docker Compose will also read podman-compose.yml if you symlink:
|
||||
# ln -sf podman-compose.yml docker-compose.yml
|
||||
#
|
||||
# --- Original configuration follows (mirrors podman-compose.yml) ---
|
||||
|
||||
# Podman Compose / Docker Compose configuration for local multi-platform builds
|
||||
# Matches the exact images and configurations from .github/workflows/release-prefect-dagger.yml
|
||||
#
|
||||
# Usage:
|
||||
# Build all distributions: docker-compose up --build
|
||||
# Build specific distro: docker-compose up --build debian
|
||||
# Build all distributions: podman-compose up --build
|
||||
# Build specific distro: podman-compose up --build debian
|
||||
#
|
||||
# Each service builds a distribution package and outputs to:
|
||||
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Podman Compose configuration for local multi-platform builds
|
||||
# Compatible with podman-compose and docker-compose (via podman socket)
|
||||
#
|
||||
# Usage:
|
||||
# Build all distributions: podman-compose up --build
|
||||
# Build specific distro: podman-compose up --build debian
|
||||
#
|
||||
# Each service builds a distribution package and outputs to:
|
||||
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
|
||||
# - pkg_dist_{distro}/ - Staging directory for package contents
|
||||
# - dist_{distro}/ - PyInstaller build output
|
||||
|
||||
services:
|
||||
debian:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.debian
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace:Z
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=debian
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
|
||||
arch:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.arch
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace:Z
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=arch
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
|
||||
rhel:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.rhel
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace:Z
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=rhel
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
@@ -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']
|
||||
|
||||
@@ -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
@@ -1,52 +1,57 @@
|
||||
# Docker Build Environment
|
||||
# Container Build Environment
|
||||
|
||||
This directory contains Dockerfiles for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
|
||||
This directory contains OCI-compatible Containerfiles (Dockerfiles) for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
|
||||
|
||||
The project uses **Podman** as the container runtime. All commands below use Podman; if you have Docker installed, the Dockerfiles are OCI-compatible and will work with Docker as well.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
### Using Podman Compose (Recommended)
|
||||
|
||||
Build for all distributions:
|
||||
```bash
|
||||
docker-compose up --build
|
||||
podman-compose up --build
|
||||
```
|
||||
|
||||
Build for a specific distribution:
|
||||
```bash
|
||||
docker-compose up --build debian
|
||||
docker-compose up --build arch
|
||||
docker-compose up --build rhel
|
||||
podman-compose up --build debian
|
||||
podman-compose up --build arch
|
||||
podman-compose up --build rhel
|
||||
```
|
||||
|
||||
Build all distributions in parallel:
|
||||
### Using Prefect + Dagger (CI Pipeline Locally)
|
||||
|
||||
The CI/CD pipeline uses Prefect and Dagger to orchestrate builds. You can run it locally:
|
||||
```bash
|
||||
docker-compose up --build --parallel
|
||||
poetry install --with ci
|
||||
poetry run python -m ci.prefect_flow build-linux
|
||||
```
|
||||
|
||||
### Manual Docker Build
|
||||
### Manual Podman Build
|
||||
|
||||
Build the image:
|
||||
```bash
|
||||
# Debian
|
||||
docker build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
|
||||
podman build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
|
||||
|
||||
# Arch
|
||||
docker build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
|
||||
podman build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
|
||||
|
||||
# RHEL/Fedora
|
||||
docker build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
|
||||
podman build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
|
||||
```
|
||||
|
||||
Run the build:
|
||||
```bash
|
||||
# Debian
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-debian-builder
|
||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-debian-builder
|
||||
|
||||
# Arch
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-arch-builder
|
||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-arch-builder
|
||||
|
||||
# RHEL/Fedora
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-rhel-builder
|
||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-rhel-builder
|
||||
```
|
||||
|
||||
## Output
|
||||
@@ -81,9 +86,13 @@ After building, you'll find:
|
||||
|
||||
### Virtualenv Conflicts
|
||||
|
||||
The Docker Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`.
|
||||
The Podman Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`.
|
||||
|
||||
If you encounter virtualenv-related errors, ensure you're using the latest docker-compose.yml configuration.
|
||||
If you encounter virtualenv-related errors, ensure you're using the latest podman-compose.yml configuration.
|
||||
|
||||
### SELinux (Fedora/RHEL hosts)
|
||||
|
||||
Volume mounts use the `:Z` suffix to apply the correct SELinux labels automatically. If you encounter permission errors, ensure the `:Z` suffix is present on volume mounts.
|
||||
|
||||
## Cleaning Up
|
||||
|
||||
@@ -92,9 +101,9 @@ Remove build artifacts:
|
||||
rm -rf dist pkg_dist_* dist_*
|
||||
```
|
||||
|
||||
Remove Docker volumes and containers:
|
||||
Remove Podman containers and volumes:
|
||||
```bash
|
||||
docker compose down -v
|
||||
podman-compose down -v
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
@@ -19,7 +19,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# Ensure installed binary is executable
|
||||
if [ -f /usr/local/bin/android-file-handler ]; then
|
||||
if [ -f /usr/bin/android-file-handler ]; then
|
||||
chmod 0755 /usr/bin/android-file-handler || true
|
||||
fi
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -9,9 +9,6 @@ import tkinter as tk
|
||||
from tkinter import messagebox, scrolledtext
|
||||
import tempfile
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
def get_license_file_path() -> str:
|
||||
@@ -107,48 +104,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE."""
|
||||
|
||||
|
||||
def resource_path(relative_path: str) -> str:
|
||||
"""Return absolute path to resource for dev and frozen runs."""
|
||||
try:
|
||||
if getattr(sys, "frozen", False):
|
||||
base = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
|
||||
else:
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.normpath(os.path.join(base, relative_path))
|
||||
except Exception:
|
||||
return os.path.normpath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)
|
||||
)
|
||||
|
||||
|
||||
def run_windows_first_run_if_needed() -> None:
|
||||
"""If running on Windows and license not agreed, launch first-run installer script.
|
||||
|
||||
Uses `resource_path` to locate the bundled PowerShell script in both dev and frozen modes.
|
||||
"""
|
||||
try:
|
||||
if not sys.platform.startswith("win"):
|
||||
return
|
||||
|
||||
if check_license_agreement():
|
||||
return
|
||||
|
||||
script_rel = os.path.join("scripts", "windows", "first_run_install.ps1")
|
||||
script_path = resource_path(script_rel)
|
||||
if not os.path.exists(script_path):
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class LicenseAgreementFrame(tk.Frame):
|
||||
"""License agreement UI frame that can be embedded in the main window."""
|
||||
|
||||
|
||||
@@ -6,12 +6,9 @@ Simple entry point to launch the Android file transfer application.
|
||||
|
||||
try:
|
||||
from gui.main_window import main
|
||||
from gui.dialogs.license_agreement import run_windows_first_run_if_needed
|
||||
except ImportError:
|
||||
from .gui.main_window import main
|
||||
from .gui.dialogs.license_agreement import run_windows_first_run_if_needed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_windows_first_run_if_needed()
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user