name: AppImage on: push: tags: - "v*.*.*" workflow_dispatch: inputs: tag: description: "Optional release tag to build from (e.g. v0.5.8). Leave empty to build from the selected ref (usually main) -- useful for verifying the recipe before cutting a tag." required: false default: "" permissions: contents: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: # workflow_dispatch input: explicit tag; tag push: ref already there. ref: ${{ github.event.inputs.tag || github.ref }} - uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install build prerequisites run: | python -m pip install --upgrade pip python -m pip install --upgrade build - name: Pre-install appimagetool (extract-and-wrap, FUSE-free) # GitHub Actions ubuntu-24.04 runners cannot mount AppImages # via FUSE in the sandboxed environment. Download appimagetool # ourselves, extract its squashfs payload, and put a thin # wrapper on PATH at /usr/local/bin/appimagetool that exec's # into the extracted AppRun. run: | cd /tmp wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool.AppImage chmod +x appimagetool.AppImage ./appimagetool.AppImage --appimage-extract >/dev/null sudo mv squashfs-root /opt/appimagetool sudo tee /usr/local/bin/appimagetool >/dev/null <<'WRAPPER' #!/bin/sh exec /opt/appimagetool/AppRun "$@" WRAPPER sudo chmod +x /usr/local/bin/appimagetool appimagetool --version || true - name: Build winpodx wheel run: python -m build --wheel --outdir dist - name: Stage AppDir with portable Python + winpodx # Bypass python-appimage entirely -- it does its own # appimagetool download that ignores our PATH wrapper and # silently fails on CI. Instead: drop a python-build-standalone # portable Python 3.11 into AppDir, install winpodx into its # site-packages, write AppRun + desktop + icon, then hand off # to bundle-system-bins.sh for the FreeRDP overlay. # # Thin AppImage (0.6.0 item A): we no longer pip-install # podman-compose into the bundled interpreter, and the dnf step # below no longer installs the podman stack. Rootless podman # cannot be bundled (host systemd / subuid integration); the # AppImage now requires the user to install podman / docker / # libvirt from the host package manager. See #357 / #363 for the # root cause this redesign removes. run: | set -euo pipefail mkdir -p AppDir/opt AppDir/usr/bin AppDir/usr/lib # Portable Python 3.11 with linked OpenSSL, gnu-linux x86_64. # Pinned to a known-good astral-sh python-build-standalone release. # Bump the tag + version pair together when refreshing. PYBUILD_URL="https://github.com/astral-sh/python-build-standalone/releases/download/20240909/cpython-3.11.10+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz" curl -fsSL "${PYBUILD_URL}" -o /tmp/python-standalone.tar.gz # Supply-chain: verify against the upstream .sha256 sidecar # (astral-sh publishes one per asset). The portable interpreter # executes winpodx's code -- a tampered asset would run with the # user's RDP password + agent token, so fail closed on mismatch. curl -fsSL "${PYBUILD_URL}.sha256" -o /tmp/python-standalone.sha256 echo "$(cat /tmp/python-standalone.sha256) /tmp/python-standalone.tar.gz" | sha256sum -c - tar -xzf /tmp/python-standalone.tar.gz -C AppDir/opt/ # Tarball extracts to AppDir/opt/python/ on the install_only build. AppDir/opt/python/bin/python3 -m pip install --upgrade pip WHEEL="$(ls -t dist/winpodx-*.whl | head -n1)" AppDir/opt/python/bin/python3 -m pip install --no-cache-dir "${WHEEL}[gui,reverse-open]" # AppRun cat > AppDir/AppRun <<'APPRUN' #!/bin/sh APPDIR="$(dirname "$(readlink -f "$0")")" export PATH="$APPDIR/usr/bin:$APPDIR/opt/python/bin:$PATH" export LD_LIBRARY_PATH="$APPDIR/usr/lib:$APPDIR/opt/python/lib:$LD_LIBRARY_PATH" export PYTHONPATH="$APPDIR/opt/python/lib/python3.11/site-packages:$PYTHONPATH" # Invoke via `python3 -m winpodx`, NOT the pip-generated # bin/winpodx console script. pip bakes an absolute build-time # shebang (#!/home/runner/work/.../AppDir/opt/python/bin/python3) # into bin/winpodx that doesn't exist at AppImage-extract time # -> "bad interpreter". The -m form runs the module through the # bundled interpreter directly and ignores the broken shebang. exec "$APPDIR/opt/python/bin/python3" -m winpodx "$@" APPRUN chmod +x AppDir/AppRun # Desktop + icon -- appimagetool expects them in AppDir root. cp packaging/appimage/recipe/winpodx.desktop AppDir/ cp packaging/appimage/recipe/winpodx.png AppDir/ # Symlink common icon location too (some launchers look here) mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps cp packaging/appimage/recipe/winpodx.png AppDir/usr/share/icons/hicolor/256x256/apps/winpodx.png # License texts (LGPL/GPL/Apache/PSF redistribution obligation -- # the fat AppImage redistributes third-party binaries, so their # license + NOTICE texts must travel with it). winpodx's own MIT # LICENSE + the aggregate THIRD_PARTY_LICENSES.md go at the doc # root; the Fedora-bundled binaries' texts are added by the # docker step below under third-party/. PyPI deps (PySide6/Qt, # Pillow, cairosvg, pyxdg) already carry their license in their # site-packages *.dist-info inside AppDir/opt/python. mkdir -p AppDir/usr/share/doc/winpodx/third-party cp LICENSE THIRD_PARTY_LICENSES.md AppDir/usr/share/doc/winpodx/ # python-build-standalone ships its license files; surface them. find AppDir/opt/python -maxdepth 3 -iname "LICENSE*" -path "*python*" \ -exec cp {} AppDir/usr/share/doc/winpodx/third-party/python-LICENSE.txt \; 2>/dev/null || true # LGPL compliance (legal gate): the fat AppImage REDISTRIBUTES the # PySide6/Qt6 binaries (LGPL-3.0), so their license text must travel # with it. The PySide6 wheels do NOT ship the LGPL/GPL texts (verified: # neither the dist-info nor the PySide6/ tree carries them), so we # vendor the verbatim texts + a NOTICE in-repo under # packaging/appimage/licenses/pyside6/ and copy those in. HARD-FAIL if # the vendored texts are missing, so the compliance artifact can't be # dropped silently. mkdir -p AppDir/usr/share/doc/winpodx/third-party/pyside6 cp packaging/appimage/licenses/pyside6/*.txt \ AppDir/usr/share/doc/winpodx/third-party/pyside6/ if ! ls AppDir/usr/share/doc/winpodx/third-party/pyside6/ 2>/dev/null \ | grep -qiE "lgpl"; then echo "::error::LGPL compliance: no PySide6/Qt LGPL text bundled in the AppImage; refusing to publish a non-compliant build." >&2 exit 1 fi du -sh AppDir - name: Slim PySide6 — drop unused Qt6 modules # PySide6 wheels bundle the entire Qt6 stack (QtWebEngine ~100 MB+, # QtQuick/QtQml, Qt3D, QtCharts, QtMultimedia + FFmpeg, QtPdf, …). # winpodx links only QtCore/QtGui/QtWidgets/QtSvg/QtDBus, so the rest # is dead weight — this was the real reason the "Thin" AppImage was # still ~270 MB (dropping the podman stack only saved ~20 MB). The # prune removes ~500 MB uncompressed (the bulk of the AppImage). run: | bash packaging/appimage/slim-pyside6.sh \ AppDir/opt/python/lib/python3.11/site-packages/PySide6 du -sh AppDir - name: Bundle FreeRDP via Fedora 41 # Thin-bundle stage: pulls only the FreeRDP 3 client binaries + # libwinpr out of a Fedora 41 container and traverses ldd to bring # in transitive lib deps. Host-critical libs (glibc, libX11, # libwayland, libGL, ...) stay on the host -- they cannot be # bundled without breaking desktop integration or hard-crashing # on glibc mismatch. # # The container stack (podman / podman-compose / conmon / crun / # netavark / aardvark-dns / slirp4netns / passt / pasta) is # intentionally NOT installed here -- 0.6.0 item A drops it. # Rootless podman needs host systemd / subuid integration that # an AppImage can't carry; bundling it caused #357 (shadowed # host podman-compose) and #363 (LD_LIBRARY_PATH-poisoned # systemd-run / aardvark-dns). The AppImage now requires the # user to install a container runtime from the host package # manager (same model as install.sh). run: | docker run --rm \ -v "$PWD/AppDir:/AppDir" \ -v "$PWD/packaging/appimage:/scripts" \ fedora:41 bash -c ' set -euo pipefail dnf install -y --setopt=install_weak_deps=False \ freerdp \ freerdp-libs \ libwinpr \ file echo "[provenance] freerdp binaries provided:" rpm -ql freerdp | grep -E "^(/usr/bin|/usr/libexec)/.*freerdp.*" || true bash /scripts/bundle-system-bins.sh /AppDir # License + NOTICE texts for the bundled FreeRDP stack # (Apache-2.0). VENDORED in-repo at # packaging/appimage/licenses/ (mounted at /scripts/licenses), # harvested verbatim from the Fedora rpm payloads -- see # that dir`s README for provenance + refresh steps. We do # NOT read them off the installed filesystem: the fedora # image installs with `tsflags=nodocs`, which strips # /usr/share/licenses, and every attempt to defeat that on # the CI runner silently produced nothing. # # Thin AppImage: only the FreeRDP stack`s license texts # ride along now; the container-stack license dirs (podman # / podman-compose / conmon / crun / netavark / passt / # slirp4netns) stay vendored in-repo for provenance but are # no longer copied into the AppImage because their binaries # are no longer bundled. dst=/AppDir/usr/share/doc/winpodx/third-party mkdir -p "$dst" src=/scripts/licenses # REQUIRED FreeRDP-stack license texts -- fail the build if # missing. Apache-2.0 redistribution obligation: freerdp-libs # + libwinpr ship the combined FreeRDP license. for pkg in freerdp-libs libwinpr; do if [ ! -d "$src/$pkg" ]; then echo "FATAL: vendored license dir $src/$pkg missing -- cannot ship a" >&2 echo "license-compliant AppImage. Re-run packaging/appimage/licenses refresh." >&2 exit 1 fi cp -rL "$src/$pkg" "$dst/$pkg" done echo "[provenance] bundled license dirs:" ls -1 "$dst" || true ' - name: Repack as thin AppImage env: ARCH: x86_64 run: | OUT="winpodx-x86_64.AppImage" appimagetool AppDir "${OUT}" chmod +x "${OUT}" ls -lh "${OUT}" - name: Locate built AppImage id: locate run: | APPIMAGE="$(ls -t winpodx-x86_64.AppImage | head -n1)" if [ -z "${APPIMAGE}" ]; then echo "No AppImage produced." >&2 exit 1 fi echo "appimage_path=${APPIMAGE}" >> "$GITHUB_OUTPUT" ls -lh "${APPIMAGE}" - name: Upload as workflow artefact (always) uses: actions/upload-artifact@v4 with: name: winpodx-appimage-x86_64 path: ${{ steps.locate.outputs.appimage_path }} if-no-files-found: error # Single-writer rule: release.yml (REL- marker tag) is the SOLE creator # of the GitHub Release and the only writer of its title/notes. This # workflow only attaches the AppImage to an already-created release — # it never creates or edits the notes. (softprops/action-gh-release # would create-or-update the release even with only `files:` set, which # contributed to the "edited N times" churn; `gh release upload` to an # existing release does not.) Wait up to 12 min for release.yml — which # runs the full test suite first — to create the release; skip # gracefully if it never appears (the AppImage stays a build artifact). - name: Wait for release (release.yml owns creation) if: github.event_name == 'push' id: wait env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | tag="${{ github.ref_name }}" for _ in $(seq 1 48); do if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then echo "Release $tag exists — attaching AppImage." echo "exists=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "Release $tag not created yet (release.yml owns creation) — waiting…" sleep 15 done echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping AppImage upload; it remains as a build artifact." echo "exists=false" >> "$GITHUB_OUTPUT" - name: Attach AppImage to GitHub Release (tag push only) if: github.event_name == 'push' && steps.wait.outputs.exists == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release upload "${{ github.ref_name }}" \ "${{ steps.locate.outputs.appimage_path }}" --clobber \ --repo "${{ github.repository }}"