From 02b341bae3873dfddc769657b75cdc92dc9b8f77 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Thu, 23 Jul 2026 23:05:05 -0700 Subject: [PATCH] feat(packaging): add self-contained AppImage build (x86_64 + aarch64) (#573) * feat(packaging): add self-contained AppImage build Bundles the wheel plus PyGObject/GTK3/AppIndicator/IBus typelibs into a relocatable x86_64 AppImage via linuxdeploy + linuxdeploy-plugin-gtk, so users get a zero-install, no-sandbox-negotiation alternative alongside AUR/PyPI. Wired into nightly and tagged releases, plus a smoke-test job in the PR pipeline since AppImage/ELF bundling can't be verified on a macOS dev machine. * feat(packaging): add aarch64 AppImage builds Parameterizes build.sh on uname -m instead of hardcoding x86_64 (both linuxdeploy and appimagetool publish aarch64 continuous builds, and pywhispercpp ships manylinux_aarch64 wheels, so no from-source compile needed). Adds an arch matrix to the PR smoke-test job, and a second job in nightly.yml/release.yml that builds on GitHub's free ubuntu-24.04-arm hosted runner and attaches the aarch64 AppImage to the same release the x86_64 job already created. --- .github/workflows/nightly.yml | 89 ++++++++++++++++++++++- .github/workflows/release.yml | 89 +++++++++++++++++++++++ .github/workflows/unified-pipeline.yml | 63 +++++++++++++++++ packaging/appimage/build.sh | 98 ++++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 3 deletions(-) create mode 100755 packaging/appimage/build.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4bd77c4..ad87f93 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -12,6 +12,9 @@ jobs: nightly: name: Build and Publish Nightly runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + release_tag: ${{ steps.create_release.outputs.tag }} steps: - name: Checkout code @@ -33,7 +36,13 @@ jobs: portaudio19-dev \ python3-dev \ libgirepository1.0-dev \ - pkg-config + libgirepository-2.0-dev \ + pkg-config \ + libcairo2-dev \ + gobject-introspection \ + gir1.2-gtk-3.0 \ + gir1.2-appindicator3-0.1 \ + gir1.2-ibus-1.0 - name: Install build dependencies run: | @@ -80,6 +89,11 @@ jobs: done ls -la dist/ + - name: Build AppImage (x86_64) + run: | + bash packaging/appimage/build.sh dist/*.whl "${{ steps.version.outputs.version }}" dist + ls -la dist/*.AppImage + - name: Cleanup old nightly releases env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -166,6 +180,14 @@ jobs: curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh | bash \`\`\` + #### AppImage (no install, no root) + Download the \`.AppImage\` matching your CPU (\`x86_64\` or \`aarch64\`) below: + \`\`\`bash + chmod +x Vocalinux-${{ steps.version.outputs.version }}-x86_64.AppImage + ./Vocalinux-${{ steps.version.outputs.version }}-x86_64.AppImage + \`\`\` + Requires \`xdotool\`/\`wtype\`/\`ydotool\` on the host for text injection, same as the PyPI install path. + --- ### Build Information @@ -184,6 +206,8 @@ jobs: - **\`vocalinux-${{ steps.version.outputs.version }}-py3-none-any.whl\`** - Python wheel (recommended) - **\`vocalinux-${{ steps.version.outputs.version }}.tar.gz\`** - Source distribution + - **\`Vocalinux-${{ steps.version.outputs.version }}-x86_64.AppImage\`** - Self-contained, no install (Intel/AMD) + - **\`Vocalinux-${{ steps.version.outputs.version }}-aarch64.AppImage\`** - Self-contained, no install (ARM64, added shortly after this release is published) --- @@ -202,26 +226,85 @@ jobs: echo "notes_file=/tmp/release_notes.md" >> $GITHUB_OUTPUT - name: Create nightly release + id: create_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Create release with dated tag: nightly-2025-02-11 RELEASE_TAG="nightly-$(date +%Y-%m-%d)" - + # If a release for today already exists, delete it first gh release delete "$RELEASE_TAG" --yes --repo ${{ github.repository }} 2>/dev/null || true git push origin :refs/tags/"$RELEASE_TAG" 2>/dev/null || true git tag -d "$RELEASE_TAG" 2>/dev/null || true - + gh release create "$RELEASE_TAG" \ --title "Nightly Build - ${{ steps.version.outputs.version }}" \ --notes-file ${{ steps.release_notes.outputs.notes_file }} \ --prerelease \ dist/* + echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + - name: Upload artifacts (backup) uses: actions/upload-artifact@v4 with: name: vocalinux-nightly-${{ steps.version.outputs.version }} path: dist/ retention-days: 7 + + nightly-appimage-arm64: + name: Build and Attach Nightly AppImage (aarch64) + runs-on: ubuntu-24.04-arm + needs: nightly + if: needs.nightly.result == 'success' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + portaudio19-dev \ + python3-dev \ + libgirepository1.0-dev \ + libgirepository-2.0-dev \ + pkg-config \ + libcairo2-dev \ + gobject-introspection \ + gir1.2-gtk-3.0 \ + gir1.2-appindicator3-0.1 \ + gir1.2-ibus-1.0 + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip setuptools wheel build + + - name: Build package + run: | + python -m build + ls -la dist/ + + - name: Build AppImage (aarch64) + run: | + bash packaging/appimage/build.sh dist/*.whl "${{ needs.nightly.outputs.version }}" dist + ls -la dist/*.AppImage + + - name: Attach to nightly release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ needs.nightly.outputs.release_tag }}" \ + dist/*.AppImage \ + --repo ${{ github.repository }} \ + --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ee2bcf..30c152e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,21 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + portaudio19-dev \ + python3-dev \ + libgirepository1.0-dev \ + libgirepository-2.0-dev \ + pkg-config \ + libcairo2-dev \ + gobject-introspection \ + gir1.2-gtk-3.0 \ + gir1.2-appindicator3-0.1 \ + gir1.2-ibus-1.0 + - name: Install build dependencies run: | python -m pip install --upgrade pip @@ -59,6 +74,11 @@ jobs: echo "Built packages:" ls -la dist/ + - name: Build AppImage (x86_64) + run: | + bash packaging/appimage/build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist + ls -la dist/*.AppImage + - name: Create Release id: create_release uses: softprops/action-gh-release@v2 @@ -66,6 +86,7 @@ jobs: files: | dist/*.whl dist/*.tar.gz + dist/*.AppImage generate_release_notes: true draft: false prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }} @@ -103,9 +124,77 @@ jobs: yay -S vocalinux ``` + ### AppImage (no install, no root) + Download the `.AppImage` matching your CPU (`x86_64` or `aarch64`, the latter attached + shortly after this release is published) below: + ```bash + chmod +x Vocalinux-${{ steps.get_version.outputs.VERSION }}-x86_64.AppImage + ./Vocalinux-${{ steps.get_version.outputs.VERSION }}-x86_64.AppImage + ``` + Still requires `xdotool`/`wtype`/`ydotool` on the host for text injection, same as the PyPI path. + --- See [CHANGELOG](https://github.com/jatinkrmalik/vocalinux/releases) for detailed changes. + build-appimage-arm64: + name: Build and Attach AppImage (aarch64) + needs: build-and-release + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + portaudio19-dev \ + python3-dev \ + libgirepository1.0-dev \ + libgirepository-2.0-dev \ + pkg-config \ + libcairo2-dev \ + gobject-introspection \ + gir1.2-gtk-3.0 \ + gir1.2-appindicator3-0.1 \ + gir1.2-ibus-1.0 + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build wheel setuptools + + - name: Extract version from tag + id: get_version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Build package + run: | + python -m build + ls -la dist/ + + - name: Build AppImage (aarch64) + run: | + bash packaging/appimage/build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist + ls -la dist/*.AppImage + + - name: Attach to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ github.ref_name }}" \ + dist/*.AppImage \ + --repo ${{ github.repository }} \ + --clobber + publish-pypi: name: Publish to PyPI needs: build-and-release diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index a422bf8..5c1a194 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -170,6 +170,69 @@ jobs: files: coverage.xml fail_ci_if_error: false + appimage-smoke-test: + name: 📦 Build AppImage (smoke test, ${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + needs: changes + if: needs.changes.outputs.python == 'true' + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-latest + - arch: aarch64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + xdotool \ + gir1.2-appindicator3-0.1 \ + gir1.2-ibus-1.0 \ + libcairo2-dev \ + pkg-config \ + python3-dev \ + libgirepository1.0-dev \ + gobject-introspection \ + libcairo-gobject2 \ + gir1.2-gtk-3.0 \ + portaudio19-dev + + - name: Install GObject introspection dependencies + run: | + sudo apt-get update + sudo apt-get install -y gir1.2-glib-2.0 libgirepository-2.0-dev pkg-config + + - name: Build wheel + run: | + python -m pip install --upgrade pip build + python -m build + ls -la dist/ + + - name: Build AppImage + run: | + VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py) + WHEEL=$(ls dist/*.whl) + bash packaging/appimage/build.sh "$WHEEL" "$VERSION" dist + ls -la dist/*.AppImage + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: vocalinux-appimage-${{ matrix.arch }} + path: dist/*.AppImage + retention-days: 7 + web-build: name: 🌐 Build Website runs-on: ubuntu-latest diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh new file mode 100755 index 0000000..61e5066 --- /dev/null +++ b/packaging/appimage/build.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Build a relocatable AppImage for Vocalinux, natively for whichever +# architecture this script runs on (x86_64 or aarch64) - no cross-compiling. +# +# Usage: build.sh [output-dir] +# +# Bundles a full copy of the active Python interpreter (relocated via +# PYTHONHOME, not a venv, since venvs hard-code absolute paths) plus +# PyGObject/GTK3/AppIndicator/IBus GObject-Introspection typelibs, since +# those are needed by `gi.repository` at runtime and linuxdeploy-plugin-gtk +# does not bundle them (it targets native C GTK apps, which don't need +# introspection data). +# +# ponytail: text-injection CLI tools (xdotool/wtype/ydotool) are not +# bundled, same runtime prerequisite as the PyPI install path documented +# in docs/INSTALL.md. Add bundling if users hit missing-binary complaints. +set -euo pipefail + +WHEEL="$1" +VERSION="$2" +OUTDIR="${3:-dist}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ARCH="$(uname -m)" + +case "$ARCH" in + x86_64|aarch64) ;; + *) echo "Unsupported architecture: $ARCH (need x86_64 or aarch64)" >&2; exit 1 ;; +esac + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT +APPDIR="$WORKDIR/AppDir" +TOOLDIR="$WORKDIR/tools" +mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$TOOLDIR" "$OUTDIR" + +echo "== Fetching AppImage tooling ($ARCH) ==" +curl -fsSL -o "$TOOLDIR/linuxdeploy" \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage" +curl -fsSL -o "$TOOLDIR/linuxdeploy-plugin-gtk.sh" \ + https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh +curl -fsSL -o "$TOOLDIR/appimagetool" \ + "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" +chmod +x "$TOOLDIR/linuxdeploy" "$TOOLDIR/linuxdeploy-plugin-gtk.sh" "$TOOLDIR/appimagetool" + +echo "== Bundling Python runtime ==" +PY_PREFIX="$(python3 -c 'import sys; print(sys.prefix)')" +PY_VER="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')" +cp -L "$(command -v python3)" "$APPDIR/usr/bin/python3" +cp -r "$PY_PREFIX/lib/python${PY_VER}" "$APPDIR/usr/lib/python${PY_VER}" +rm -rf "$APPDIR/usr/lib/python${PY_VER}/site-packages" + +echo "== Installing Vocalinux + PyGObject into the bundle ==" +python3 -m pip install --no-cache-dir --prefix "$APPDIR/usr" "$WHEEL" PyGObject pycairo + +echo "== Adding desktop entry + icon ==" +install -Dm644 "$REPO_ROOT/vocalinux.desktop" "$APPDIR/usr/share/applications/vocalinux.desktop" +install -Dm644 "$REPO_ROOT/resources/icons/scalable/vocalinux.svg" \ + "$APPDIR/usr/share/icons/hicolor/scalable/apps/vocalinux.svg" + +echo "== Copying GObject-Introspection typelibs (not handled by linuxdeploy-plugin-gtk) ==" +mkdir -p "$APPDIR/usr/lib/girepository-1.0" +for typelib in Gtk-3.0 Gdk-3.0 GdkX11-3.0 GdkPixbuf-2.0 GLib-2.0 GObject-2.0 Gio-2.0 \ + Pango-1.0 PangoCairo-1.0 cairo-1.0 HarfBuzz-0.0 Atk-1.0 freetype2-2.0 \ + AppIndicator3-0.1 AyatanaAppIndicator3-0.1 Notify-0.7 IBus-1.0; do + found="$(find /usr/lib -name "${typelib}.typelib" 2>/dev/null | head -1)" + if [ -n "$found" ]; then + cp "$found" "$APPDIR/usr/lib/girepository-1.0/" + fi +done + +echo "== Writing AppRun ==" +cat > "$APPDIR/AppRun" << 'APPRUN' +#!/usr/bin/env bash +HERE="$(dirname "$(readlink -f "${0}")")" +export PYTHONHOME="$HERE/usr" +export PYTHONPATH="$HERE/usr/lib/python3:$HERE/usr/lib/python3/site-packages" +export GI_TYPELIB_PATH="$HERE/usr/lib/girepository-1.0" +export LD_LIBRARY_PATH="$HERE/usr/lib:${LD_LIBRARY_PATH:-}" +export XDG_DATA_DIRS="$HERE/usr/share:${XDG_DATA_DIRS:-/usr/share}" +exec "$HERE/usr/bin/python3" -m vocalinux.main "$@" +APPRUN +# PYTHONPATH above uses a version-agnostic symlink so AppRun doesn't need +# to know the exact interpreter minor version at runtime. +ln -s "python${PY_VER}" "$APPDIR/usr/lib/python3" +chmod +x "$APPDIR/AppRun" + +echo "== Running linuxdeploy (resolves the shared-library closure + GTK theming) ==" +export DEPLOY_GTK_VERSION=3 +"$TOOLDIR/linuxdeploy" --appdir "$APPDIR" \ + --plugin gtk \ + -e "$APPDIR/usr/bin/python3" \ + -d "$APPDIR/usr/share/applications/vocalinux.desktop" \ + -i "$APPDIR/usr/share/icons/hicolor/scalable/apps/vocalinux.svg" + +echo "== Packaging AppImage ==" +OUTPUT="$OUTDIR/Vocalinux-${VERSION}-${ARCH}.AppImage" +ARCH="$ARCH" "$TOOLDIR/appimagetool" "$APPDIR" "$OUTPUT" +echo "Built $OUTPUT"