Build Linux Release #53
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Linux Release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Version name for the release (e.g., 1.0.0)' | |
| required: true | |
| type: string | |
| workflow_call: | |
| inputs: | |
| version: | |
| description: 'Version name for the release (e.g., 1.0.0)' | |
| required: true | |
| type: string | |
| permissions: | |
| contents: write | |
| env: | |
| PYTHON_VERSION: '3.13' | |
| jobs: | |
| build-linux: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Get version | |
| id: get_version | |
| shell: bash | |
| run: | | |
| version="${{ inputs.version || github.event.inputs.version }}" | |
| echo "Extracted version: $version" | |
| echo "VERSION=$version" >> "$GITHUB_OUTPUT" | |
| - name: Setup Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| - name: Cache Python dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| venv | |
| ~/.cache/pip | |
| key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pip- | |
| - name: Install system dependencies | |
| shell: bash | |
| run: | | |
| sudo apt-get update -y | |
| # Tools commonly required by cx_Freeze on Linux packaging | |
| sudo apt-get install -y --no-install-recommends \ | |
| rpm alien fakeroot patchelf desktop-file-utils file xz-utils zsync | |
| - name: Create virtual environment and install dependencies | |
| shell: bash | |
| run: | | |
| python -m venv venv | |
| source venv/bin/activate | |
| python -m pip install --upgrade pip | |
| pip install --no-cache-dir . | |
| pip install --no-cache-dir cx_Freeze | |
| - name: Prepare build variables | |
| shell: bash | |
| run: | | |
| version="${{ steps.get_version.outputs.VERSION }}" | |
| echo "VERSION=$version" >> "$GITHUB_ENV" | |
| arch="$(uname -m)" # x86_64 or aarch64 | |
| echo "ARCH=$arch" >> "$GITHUB_ENV" | |
| echo "Prepared build variables for version: $version on $(uname -a)" | |
| - name: Create cx_Freeze setup script (Linux) | |
| shell: bash | |
| run: | | |
| # Create entry point script | |
| cat > ytsage_entry.py <<'ENTRY' | |
| from ytsage.main import main | |
| if __name__ == "__main__": | |
| main() | |
| ENTRY | |
| cat > setup_cxfreeze.py <<'PY' | |
| import os | |
| import sys | |
| from pathlib import Path | |
| from cx_Freeze import setup, Executable | |
| version = os.environ.get("VERSION", "0.0.0") | |
| # Prepare include_files list | |
| include_files_list = [ | |
| ("ytsage/assets/Icon", "lib/assets/Icon"), | |
| ("ytsage/assets/sound", "lib/assets/sound"), | |
| ("ytsage/languages", "lib/languages"), | |
| ("branding/icons", "lib/assets/branding/icons"), | |
| ("ytsage.desktop", "share/applications/ytsage.desktop"), | |
| ("branding/icons/icon.png", "share/pixmaps/ytsage.png"), | |
| ] | |
| build_exe_options = dict( | |
| optimize=2, | |
| silent=True, | |
| packages=[ | |
| "ytsage", | |
| "PySide6.QtCore", | |
| "PySide6.QtGui", | |
| "PySide6.QtWidgets", | |
| "PySide6.QtMultimedia", | |
| "PySide6.QtNetwork", | |
| "PySide6.QtDBus", | |
| "PySide6.QtSvg", | |
| "requests", | |
| "PIL", | |
| "packaging", | |
| "markdown", | |
| "loguru", | |
| "setuptools", | |
| ], | |
| zip_include_packages=[ | |
| "PySide6", | |
| "shiboken6", | |
| "requests", | |
| "PIL", | |
| "packaging", | |
| ], | |
| excludes=[ | |
| "PySide6.QtBluetooth", | |
| "PySide6.QtOpenGL", | |
| "PySide6.QtPrintSupport", | |
| "PySide6.QtTest", | |
| "PySide6.QtXml", | |
| "PySide6.QtSql", | |
| "PySide6.QtHelp", | |
| "PySide6.QtQml", | |
| "PySide6.QtQuick", | |
| "PySide6.QtWebEngineCore", | |
| "PIL.ImageDraw", | |
| "PIL.ImageFont", | |
| "numpy", | |
| "scipy", | |
| "wx", | |
| "pandas", | |
| "tkinter", | |
| "yt_dlp", | |
| "unittest", | |
| "test", | |
| "tests", | |
| "pydoc", | |
| "doctest", | |
| "email", | |
| ], | |
| include_files=include_files_list, | |
| # Bundle all dependencies - avoid system library references | |
| bin_includes=[], | |
| bin_excludes=[], | |
| # Important: include system libs to avoid external dependencies | |
| replace_paths=[("*", "")], | |
| ) | |
| executables = [ | |
| Executable( | |
| script="ytsage_entry.py", | |
| target_name="ytsage", | |
| icon="branding/icons/icon.png", | |
| ) | |
| ] | |
| setup( | |
| name="ytsage", | |
| version=version, | |
| description="YTSage", | |
| options={ | |
| "build_exe": build_exe_options, | |
| # AppImage packaging | |
| "bdist_appimage": { | |
| # If ends with .AppImage, use verbatim file name | |
| "target_name": f"ytsage-v{version}.AppImage", | |
| }, | |
| }, | |
| executables=executables, | |
| ) | |
| PY | |
| - name: Create desktop entry | |
| shell: bash | |
| run: | | |
| cat > ytsage.desktop <<'DESKTOP' | |
| [Desktop Entry] | |
| Type=Application | |
| Name=YTSage | |
| Comment=YouTube downloader | |
| Exec=/usr/bin/ytsage %U | |
| Icon=ytsage | |
| Terminal=false | |
| Categories=AudioVideo;Network;Utility; | |
| StartupWMClass=YTSage | |
| DESKTOP | |
| - name: Build AppImage and RPM | |
| shell: bash | |
| run: | | |
| set -e | |
| source venv/bin/activate | |
| # Ensure a clean build and build_exe first with -OO | |
| python -OO setup_cxfreeze.py build_exe | |
| # Clean unnecessary files from build directory before packaging | |
| build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1 || true) | |
| if [ -n "$build_dir" ]; then | |
| echo "Trimming files in $build_dir..." | |
| lib_dir="$build_dir/lib" | |
| if [ -d "$lib_dir" ]; then | |
| # 1. Remove screenshots | |
| if [ -d "$lib_dir/assets/branding/screenshots" ]; then | |
| rm -rf "$lib_dir/assets/branding/screenshots" | |
| echo "Removed screenshots folder" | |
| fi | |
| # 2. Remove unused Qt translations | |
| if [ -d "$lib_dir/PySide6/translations" ]; then | |
| rm -rf "$lib_dir/PySide6/translations" | |
| echo "Removed Qt translations" | |
| fi | |
| # 3. Remove unused Qt plugins | |
| plugins_dir="$lib_dir/PySide6/plugins" | |
| if [ -d "$plugins_dir" ]; then | |
| for plugin in designer pdf sql help qml quick webengine bluetooth opengl printsupport test xml; do | |
| if [ -d "$plugins_dir/$plugin" ]; then | |
| rm -rf "$plugins_dir/$plugin" | |
| echo "Removed plugin: $plugin" | |
| fi | |
| done | |
| # Specific cleanups (imageformats) | |
| rm -f "$plugins_dir/imageformats/libqpdf.so" | |
| fi | |
| # 4. Remove bloat shared libraries (Recursively finds in lib/ and lib/PySide6/) | |
| # Matches libQt6Qml.so.6, Qt6Qml.abi3.so, etc. | |
| echo "Removing bloat Qt libraries..." | |
| find "$lib_dir" -name "*Qt6Web*" -delete | |
| find "$lib_dir" -name "*Qt6Pdf*" -delete | |
| find "$lib_dir" -name "*Qt6Qml*" -delete | |
| find "$lib_dir" -name "*Qt6Quick*" -delete | |
| find "$lib_dir" -name "*Qt6VirtualKeyboard*" -delete | |
| find "$lib_dir" -name "*Qt6OpenGL*" -delete | |
| # 5. Remove build tools and unused python modules | |
| for tool in setuptools wheel pkg_resources _distutils_hack curses _pyrepl; do | |
| rm -rf "$lib_dir/$tool" | |
| echo "Removed build tool/module: $tool" | |
| done | |
| else | |
| echo "Warning: lib directory not found inside build_dir" | |
| fi | |
| else | |
| echo "Warning: Build directory not found, cannot trim files" | |
| fi | |
| # Build AppImage manually to avoid cx_Freeze rebuilding (and untrimming) the artifacts | |
| echo "Creating AppImage manually..." | |
| mkdir -p dist | |
| # Download appimagetool | |
| wget -q -O appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage | |
| chmod +x appimagetool | |
| # Setup AppDir structure (Root layout matches cx_Freeze build output exactly) | |
| rm -rf AppDir | |
| mkdir -p AppDir | |
| # Copy trimmed build content DIRECTLY to AppDir root | |
| # This keeps 'lib' adjacent to 'ytsage', preventing 'ModuleNotFoundError: encodings' | |
| cp -r "$build_dir"/* AppDir/ | |
| # Setup metadata | |
| cp ytsage.desktop AppDir/ | |
| # Fix Exec path for AppImage context (binary is in root, not /usr/bin) | |
| sed -i 's|Exec=/usr/bin/ytsage|Exec=ytsage|g' AppDir/ytsage.desktop | |
| cp branding/icons/icon.png AppDir/ytsage.png | |
| # .DirIcon for file managers | |
| cp branding/icons/icon.png AppDir/.DirIcon | |
| # Create AppRun as a script to correctly set environment | |
| cat > AppDir/AppRun <<'APPRUN' | |
| #!/bin/sh | |
| SELF=$(readlink -f "$0") | |
| HERE=$(dirname "$SELF") | |
| export LD_LIBRARY_PATH="$HERE/lib:$LD_LIBRARY_PATH" | |
| exec "$HERE/ytsage" "$@" | |
| APPRUN | |
| chmod +x AppDir/AppRun | |
| # Build AppImage (using --appimage-extract-and-run to avoid FUSE issues in CI) | |
| # We explicitly set ARCH for appimagetool | |
| ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir "dist/ytsage-v${version}.AppImage" | |
| # Build RPM manually with proper spec file | |
| version="${{ steps.get_version.outputs.VERSION }}" | |
| arch_uname="${ARCH}" | |
| workspace_root="$(pwd)" | |
| build_dir_abs="${workspace_root}/${build_dir}" | |
| # Create custom RPM spec file that doesn't auto-detect dependencies | |
| mkdir -p build/rpm/{BUILD,RPMS,SOURCES,SPECS,SRPMS} | |
| cat > build/rpm/SPECS/ytsage.spec <<SPEC | |
| %global __requires_exclude_from ^/opt/ytsage/.*$ | |
| %global __provides_exclude_from ^/opt/ytsage/.*$ | |
| %define _build_id_links none | |
| %undefine _missing_build_ids_terminate_build | |
| Name: ytsage | |
| Version: ${version//-/.} | |
| Release: 1%{?dist} | |
| Summary: YTSage - A modern YouTube downloader | |
| License: MIT | |
| URL: https://github.com/oop7/YTSage | |
| AutoReqProv: no | |
| %description | |
| YTSage is a modern YouTube downloader with a PySide6 interface. | |
| Download videos, extract audio, fetch subtitles, and more. | |
| All dependencies are bundled within the package. | |
| %install | |
| rm -rf %{buildroot} | |
| mkdir -p %{buildroot}/opt/ytsage | |
| mkdir -p %{buildroot}/usr/bin | |
| mkdir -p %{buildroot}/usr/share/applications | |
| mkdir -p %{buildroot}/usr/share/pixmaps | |
| # Copy application files | |
| cp -a ${build_dir_abs}/* %{buildroot}/opt/ytsage/ | |
| # Create wrapper script | |
| cat > %{buildroot}/usr/bin/ytsage <<'WRAPPER' | |
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| APPDIR="/opt/ytsage" | |
| cd "\$APPDIR" | |
| exec "\$APPDIR/ytsage" "\$@" | |
| WRAPPER | |
| chmod 0755 %{buildroot}/usr/bin/ytsage | |
| # Install desktop file and icon | |
| install -m 0644 ${workspace_root}/ytsage.desktop %{buildroot}/usr/share/applications/ytsage.desktop | |
| install -m 0644 ${workspace_root}/branding/icons/icon.png %{buildroot}/usr/share/pixmaps/ytsage.png | |
| %files | |
| /opt/ytsage | |
| /usr/bin/ytsage | |
| /usr/share/applications/ytsage.desktop | |
| /usr/share/pixmaps/ytsage.png | |
| %post | |
| if command -v update-desktop-database >/dev/null 2>&1; then | |
| update-desktop-database -q /usr/share/applications || true | |
| fi | |
| %postun | |
| if command -v update-desktop-database >/dev/null 2>&1; then | |
| update-desktop-database -q /usr/share/applications || true | |
| fi | |
| %changelog | |
| * $(date +'%a %b %d %Y') YTSage Maintainers <noreply@example.com> - ${version//-/.}-1 | |
| - Release ${version} | |
| SPEC | |
| # Build the RPM | |
| rpmbuild -bb build/rpm/SPECS/ytsage.spec \ | |
| --define "_topdir $(pwd)/build/rpm" \ | |
| --buildroot "$(pwd)/build/rpm/BUILDROOT" | |
| # Copy RPM to dist | |
| mkdir -p dist | |
| find build/rpm/RPMS -name "*.rpm" -exec cp {} dist/ \; | |
| echo "Post-build directory listing:" | |
| echo "-- dist --"; ls -lah dist || true | |
| echo "-- build --"; ls -lah build || true | |
| - name: Build native DEB package | |
| shell: bash | |
| run: | | |
| set -e | |
| version="${{ steps.get_version.outputs.VERSION }}" | |
| arch_uname="${ARCH}" | |
| # Map uname -m to Debian arch names | |
| case "$arch_uname" in | |
| x86_64) deb_arch=amd64 ;; | |
| aarch64) deb_arch=arm64 ;; | |
| *) deb_arch="$arch_uname" ;; | |
| esac | |
| # Locate build output | |
| build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1) | |
| if [ -z "$build_dir" ]; then | |
| echo "Error: build/exe.* directory not found" >&2 | |
| exit 1 | |
| fi | |
| # Staging root | |
| pkgroot=deb_pkg | |
| rm -rf "$pkgroot" | |
| mkdir -p "$pkgroot/DEBIAN" \ | |
| "$pkgroot/usr/bin" \ | |
| "$pkgroot/usr/share/applications" \ | |
| "$pkgroot/usr/share/pixmaps" \ | |
| "$pkgroot/opt/ytsage" | |
| # Install application payload under /opt/ytsage | |
| cp -a "$build_dir"/* "$pkgroot/opt/ytsage/" | |
| # Wrapper to ensure correct working directory | |
| cat > "$pkgroot/usr/bin/ytsage" <<'WRAP' | |
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| APPDIR="/opt/ytsage" | |
| cd "$APPDIR" | |
| exec "$APPDIR/ytsage" "$@" | |
| WRAP | |
| chmod 0755 "$pkgroot/usr/bin/ytsage" | |
| # Desktop file and icon | |
| install -m 0644 ytsage.desktop "$pkgroot/usr/share/applications/ytsage.desktop" | |
| install -m 0644 branding/icons/icon.png "$pkgroot/usr/share/pixmaps/ytsage.png" | |
| # Control file | |
| cat > "$pkgroot/DEBIAN/control" <<CONTROL | |
| Package: ytsage | |
| Version: ${version} | |
| Section: utils | |
| Priority: optional | |
| Architecture: ${deb_arch} | |
| Maintainer: YTSage Maintainers <noreply@example.com> | |
| Homepage: https://github.com/oop7/YTSage | |
| Description: YTSage - A modern YouTube downloader with a PySide6 interface | |
| Download videos, extract audio, fetch subtitles, and more. | |
| CONTROL | |
| # Post-install script to refresh desktop/menu caches (best-effort) | |
| cat > "$pkgroot/DEBIAN/postinst" <<'POSTINST' | |
| #!/bin/sh | |
| set -e | |
| if command -v update-desktop-database >/dev/null 2>&1; then | |
| update-desktop-database -q || true | |
| fi | |
| if command -v gtk-update-icon-cache >/dev/null 2>&1; then | |
| gtk-update-icon-cache -q /usr/share/icons/hicolor || true | |
| fi | |
| exit 0 | |
| POSTINST | |
| chmod 0755 "$pkgroot/DEBIAN/postinst" | |
| # Triggers so Debian updates caches | |
| cat > "$pkgroot/DEBIAN/triggers" <<'TRIGGERS' | |
| interest-noawait /usr/share/applications | |
| interest-noawait /usr/share/icons/hicolor | |
| TRIGGERS | |
| # Build the deb | |
| deb_out="YTSage-v${version}-${deb_arch}.deb" | |
| dpkg-deb --build "$pkgroot" "$deb_out" | |
| mkdir -p artifacts | |
| mv "$deb_out" artifacts/ | |
| echo "Built native DEB: artifacts/$deb_out" | |
| - name: Build Flatpak Bundle | |
| shell: bash | |
| run: | | |
| version="${{ steps.get_version.outputs.VERSION }}" | |
| # Install flatpak-builder | |
| sudo apt-get install -y flatpak-builder | |
| flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo | |
| # Define manifest ID | |
| APP_ID="io.github.oop7.YTSage" | |
| # Create a specific desktop file for Flatpak | |
| # - Exec must be 'ytsage' (in path), not /usr/bin/ytsage | |
| # - Icon must match the App ID (io.github.oop7.YTSage) | |
| cat > flatpak.desktop <<EOF | |
| [Desktop Entry] | |
| Type=Application | |
| Name=YTSage | |
| Comment=YouTube downloader | |
| Exec=ytsage | |
| Icon=$APP_ID | |
| Terminal=false | |
| Categories=AudioVideo;Network;Utility; | |
| StartupWMClass=YTSage | |
| EOF | |
| # Create manifest manually | |
| cat > ytsage_flatpak.json <<EOF | |
| { | |
| "app-id": "$APP_ID", | |
| "runtime": "org.kde.Platform", | |
| "runtime-version": "6.10", | |
| "sdk": "org.kde.Sdk", | |
| "command": "ytsage", | |
| "finish-args": [ | |
| "--share=ipc", | |
| "--socket=x11", | |
| "--socket=wayland", | |
| "--socket=pulseaudio", | |
| "--device=dri", | |
| "--share=network", | |
| "--filesystem=host", | |
| "--env=YTDLP_APP_BIN_PATH=/var/data/yt-dlp", | |
| "--env=DENO_APP_BIN_PATH=/var/data/deno" | |
| ], | |
| "modules": [ | |
| { | |
| "name": "ytsage", | |
| "buildsystem": "simple", | |
| "build-commands": [ | |
| "mkdir -p /app/bin /app/share/ytsage", | |
| "cp -r dist/ytsage-v${version}-*/* /app/share/ytsage/", | |
| "ln -s /app/share/ytsage/ytsage /app/bin/ytsage", | |
| "install -D flatpak.desktop /app/share/applications/$APP_ID.desktop", | |
| "install -D branding/icons/icon.png /app/share/icons/hicolor/128x128/apps/$APP_ID.png" | |
| ], | |
| "sources": [ | |
| { | |
| "type": "dir", | |
| "path": "." | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| EOF | |
| # Build the Flatpak | |
| # Note: In a real environment, building from source is preferred. | |
| # Here we are "bundling" the already built binaries from the previous step (cx_Freeze) for simplicity in CI. | |
| # This requires the 'dist/' folder to be populated by the previous 'Create cx_Freeze setup script' + build steps. | |
| # Wait, the previous steps built into 'build/exe.linux-...' not 'dist/'. Let's find it. | |
| build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1) | |
| if [ -z "$build_dir" ]; then | |
| echo "Error: build/exe.* directory not found for Flatpak build" >&2 | |
| exit 1 | |
| fi | |
| # Move build dir to a fixed location for the manifest source to catch | |
| mkdir -p dist/ytsage-v${version}-flatpak | |
| cp -r "$build_dir"/* "dist/ytsage-v${version}-flatpak/" | |
| # Install runtime/sdk | |
| flatpak install -y --user flathub org.kde.Platform//6.10 org.kde.Sdk//6.10 | |
| # Build | |
| flatpak-builder --user --install-deps-from=flathub --repo=repo --force-clean build-flatpak ytsage_flatpak.json | |
| # Bundle | |
| flatpak build-bundle repo artifacts/YTSage-v${version}-x86_64.flatpak $APP_ID | |
| echo "Built Flatpak: artifacts/YTSage-v${version}-x86_64.flatpak" | |
| - name: Package and prepare release artifacts (.AppImage, .rpm, .deb) | |
| shell: bash | |
| run: | | |
| version="${{ steps.get_version.outputs.VERSION }}" | |
| mkdir -p artifacts | |
| # Copy AppImage | |
| appimage_src=$(ls dist/ytsage-v*.AppImage 2>/dev/null | head -n1 || true) | |
| if [ -n "$appimage_src" ]; then | |
| cp "$appimage_src" "artifacts/YTSage-v${version}-${ARCH}.AppImage" | |
| echo "Copied AppImage: $appimage_src -> artifacts/YTSage-v${version}-${ARCH}.AppImage" | |
| else | |
| echo "Warning: No .AppImage found in dist/" | |
| fi | |
| # Copy RPM (normalize name) | |
| rpm_src=$(ls dist/ytsage-*.rpm 2>/dev/null | head -n1 || true) | |
| if [ -n "$rpm_src" ]; then | |
| cp "$rpm_src" "artifacts/YTSage-v${version}-${ARCH}.rpm" | |
| echo "Copied RPM: $rpm_src -> artifacts/YTSage-v${version}-${ARCH}.rpm" | |
| else | |
| echo "Warning: No .rpm found in dist/" | |
| fi | |
| # DEB is already staged in artifacts by the native packaging step | |
| ls -1 artifacts/*.deb 2>/dev/null || echo "Warning: No .deb found in artifacts/" | |
| # Check for Flatpak | |
| ls -1 artifacts/*.flatpak 2>/dev/null || echo "Warning: No .flatpak found in artifacts/" | |
| echo "Final artifacts:" | |
| ls -lh artifacts || true | |
| - name: Create/Update draft release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ steps.get_version.outputs.VERSION }} | |
| name: YTSage v${{ steps.get_version.outputs.VERSION }} | |
| draft: true | |
| prerelease: false | |
| append_body: true | |
| fail_on_unmatched_files: false | |
| body: | | |
| # YTSage v${{ steps.get_version.outputs.VERSION }} | |
| **Release Date**: ${{ github.event.head_commit.timestamp }} | |
| files: | | |
| artifacts/* | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |