diff --git a/.github/workflows/appimage-build.yml b/.github/workflows/appimage-build.yml index 86bfff30..43fb5c54 100644 --- a/.github/workflows/appimage-build.yml +++ b/.github/workflows/appimage-build.yml @@ -1,6 +1,9 @@ name: AppImage Build on: + workflow_dispatch: + schedule: + - cron: '23 6 * * 1' push: branches: [master, 'release/**'] pull_request: @@ -29,16 +32,29 @@ jobs: build-essential pkg-config meson ninja-build cmake \ gettext \ libcanberra-dev libglib2.0-dev \ - libarchive-dev \ + libarchive-dev libsecret-1-dev \ libgtk-3-dev libfontconfig1-dev libpango1.0-dev xvfb xauth dbus-daemon at-spi2-core \ libwayland-client0 libwayland-cursor0 libwayland-egl1 \ libxkbcommon0 \ libgtk-3-bin libglib2.0-bin glib-networking shared-mime-info gsettings-desktop-schemas \ liblua5.4-dev libpci-dev libperl-dev libssl-dev libayatana-appindicator3-dev \ - perl python3 python3-minimal python3-dev python3-cffi python3-sphinx mono-devel desktop-file-utils \ + perl python3 python3-sphinx mono-devel desktop-file-utils \ + libbz2-dev libffi-dev libgdbm-dev liblzma-dev libncurses-dev \ + libreadline-dev libsqlite3-dev libzstd-dev tk-dev uuid-dev zlib1g-dev \ + openssl ca-certificates \ breeze-gtk-theme \ patchelf file curl + - name: Build private Python runtime + run: | + set -eux + python_prefix="$RUNNER_TEMP/zoitechat-python" + bash packaging/appimage/build-python.sh "$python_prefix" + echo "$python_prefix/bin" >> "$GITHUB_PATH" + echo "ZOITECHAT_PYTHON_PREFIX=$python_prefix" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$python_prefix/lib" >> "$GITHUB_ENV" + echo "PKG_CONFIG_PATH=$python_prefix/lib/pkgconfig" >> "$GITHUB_ENV" + - name: Configure run: | set -eux @@ -46,6 +62,7 @@ jobs: meson setup build \ --prefix=/usr \ -Dinstall-docs=true \ + -Ddocs-python=/usr/bin/python3 \ -Dtext-frontend=true \ -Dwith-perl=perl \ -Dwith-python=python3 \ @@ -70,25 +87,8 @@ jobs: run: | set -eux - python3_version="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - - install -Dm755 "$(command -v python3)" "AppDir/usr/bin/python3" - if [ -x "/usr/bin/perl" ]; then - install -Dm755 /usr/bin/perl AppDir/usr/bin/perl - fi - - install -d AppDir/usr/lib - cp -a "/usr/lib/python${python3_version}" "AppDir/usr/lib/" - - if [ -d "/usr/lib/python3/dist-packages" ]; then - install -d AppDir/usr/lib/python3 - cp -a /usr/lib/python3/dist-packages AppDir/usr/lib/python3/ - fi - - if [ -d "/usr/lib/x86_64-linux-gnu/python3/dist-packages" ]; then - install -d AppDir/usr/lib/x86_64-linux-gnu/python3 - cp -a /usr/lib/x86_64-linux-gnu/python3/dist-packages AppDir/usr/lib/x86_64-linux-gnu/python3/ - fi + "$ZOITECHAT_PYTHON_PREFIX/bin/python3" packaging/appimage/bundle-python.py AppDir + install -Dm755 /usr/bin/perl AppDir/usr/bin/perl if [ -d "/usr/lib/x86_64-linux-gnu/perl-base" ]; then install -d AppDir/usr/lib/x86_64-linux-gnu @@ -118,11 +118,6 @@ jobs: perl -MFile::Spec -e 'print "Build host File::Spec: $INC{\"File/Spec.pm\"}\n"' find AppDir/usr -path '*/File/Spec.pm' -print -quit | grep -q . - if compgen -G '/usr/lib/x86_64-linux-gnu/libpython3*.so*' > /dev/null; then - install -d AppDir/usr/lib/x86_64-linux-gnu - cp -a /usr/lib/x86_64-linux-gnu/libpython3*.so* AppDir/usr/lib/x86_64-linux-gnu/ - fi - # Every package ships the pinned font, including portable builds. cmp data/fonts/NotoColorEmoji.ttf AppDir/usr/share/fonts/zoitechat/NotoColorEmoji.ttf @@ -174,126 +169,20 @@ jobs: chmod +x linuxdeploy-x86_64.AppImage curl -fL --retry 3 -o linuxdeploy-plugin-gtk \ - https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh + https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/7a3fbc31a9e5075073ff8790f26effbac5f84453/linuxdeploy-plugin-gtk.sh + # The generated GTK hook otherwise overrides both Wayland selection + # and the user's explicit GDK_BACKEND before our AppRun is reached. + grep -Fx 'export GDK_BACKEND=x11 # Crash with Wayland backend on Wayland' linuxdeploy-plugin-gtk + sed -i '/^export GDK_BACKEND=x11 # Crash with Wayland backend on Wayland$/d' linuxdeploy-plugin-gtk chmod +x linuxdeploy-plugin-gtk export PATH="${PWD}:${PATH}" install -Dm644 /etc/ssl/certs/ca-certificates.crt \ AppDir/etc/ssl/certs/ca-certificates.crt - cat > AppRun <<'EOF' - #!/bin/sh - set -eu + install -m755 packaging/appimage/AppRun AppRun - APPDIR="${APPDIR:-$(dirname "$(readlink -f "$0")")}" - - export PATH="$APPDIR/usr/bin:${PATH:-/usr/bin:/bin}" - export LD_LIBRARY_PATH="$APPDIR/usr/lib:$APPDIR/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" - export XDG_DATA_DIRS="$APPDIR/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" - export GTK_EXE_PREFIX="$APPDIR/usr" - export GTK_DATA_PREFIX="$APPDIR/usr" - gtk_path_entries="" - if [ -d "$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0" ]; then - gtk_path_entries="$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0" - fi - if [ -d "$APPDIR/usr/lib/gtk-3.0" ]; then - gtk_path_entries="${gtk_path_entries:+$gtk_path_entries:}$APPDIR/usr/lib/gtk-3.0" - fi - if [ -n "$gtk_path_entries" ]; then - export GTK_PATH="$gtk_path_entries${GTK_PATH:+:$GTK_PATH}" - fi - - if [ -d "$APPDIR/usr/lib/x86_64-linux-gnu/zoitechat/plugins" ]; then - export ZOITECHAT_LIBDIR="$APPDIR/usr/lib/x86_64-linux-gnu/zoitechat/plugins" - elif [ -d "$APPDIR/usr/lib/zoitechat/plugins" ]; then - export ZOITECHAT_LIBDIR="$APPDIR/usr/lib/zoitechat/plugins" - fi - - if [ -d "$APPDIR/usr/share/glib-2.0/schemas" ]; then - export GSETTINGS_SCHEMA_DIR="$APPDIR/usr/share/glib-2.0/schemas${GSETTINGS_SCHEMA_DIR:+:$GSETTINGS_SCHEMA_DIR}" - fi - - if [ -d "$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules" ]; then - gio_module_dir="$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules" - elif [ -d "$APPDIR/usr/lib/gio/modules" ]; then - gio_module_dir="$APPDIR/usr/lib/gio/modules" - else - gio_module_dir="" - fi - - if [ -n "$gio_module_dir" ]; then - export GIO_MODULE_DIR="$gio_module_dir" - export GIO_EXTRA_MODULES="$gio_module_dir" - fi - - unset GTK_MODULES - - perl5lib_entries="" - for dir in \ - "$APPDIR/usr/lib/x86_64-linux-gnu/perl-base" \ - "$APPDIR/usr/lib/x86_64-linux-gnu/perl"/* \ - "$APPDIR/usr/lib/x86_64-linux-gnu/perl5"/* \ - "$APPDIR/usr/share/perl"/* \ - "$APPDIR/usr/share/perl5" - do - if [ -d "$dir" ]; then - perl5lib_entries="${perl5lib_entries:+$perl5lib_entries:}$dir" - fi - done - - if [ -n "$perl5lib_entries" ]; then - export PERL5LIB="$perl5lib_entries${PERL5LIB:+:$PERL5LIB}" - fi - - export PYTHONHOME="$APPDIR/usr" - python_stdlib_dir="$(find "$APPDIR/usr/lib" -maxdepth 1 -type d -name 'python3.*' | head -n 1 || true)" - pythonpath_entries="" - if [ -n "$python_stdlib_dir" ] && [ -d "$python_stdlib_dir/dist-packages" ]; then - pythonpath_entries="$python_stdlib_dir/dist-packages" - fi - if [ -d "$APPDIR/usr/lib/python3/dist-packages" ]; then - pythonpath_entries="${pythonpath_entries:+$pythonpath_entries:}$APPDIR/usr/lib/python3/dist-packages" - fi - if [ -d "$APPDIR/usr/lib/x86_64-linux-gnu/python3/dist-packages" ]; then - pythonpath_entries="${pythonpath_entries:+$pythonpath_entries:}$APPDIR/usr/lib/x86_64-linux-gnu/python3/dist-packages" - fi - if [ -n "$pythonpath_entries" ]; then - export PYTHONPATH="$pythonpath_entries${PYTHONPATH:+:$PYTHONPATH}" - fi - - # Register the private font without hiding the host's normal fonts. - export ZOITECHAT_EMOJI_FONT="${ZOITECHAT_EMOJI_FONT:-$APPDIR/usr/share/fonts/zoitechat/NotoColorEmoji.ttf}" - - # OpenSSL trust store override - export SSL_CERT_FILE="${SSL_CERT_FILE:-$APPDIR/etc/ssl/certs/ca-certificates.crt}" - export SSL_CERT_DIR="${SSL_CERT_DIR:-$APPDIR/etc/ssl/certs}" - export CURL_CA_BUNDLE="${CURL_CA_BUNDLE:-$SSL_CERT_FILE}" - - # Prefer Wayland if the session provides it, but keep X11 fallback. - # Don't override if the user already set GDK_BACKEND explicitly. - if [ -z "${GDK_BACKEND:-}" ]; then - if [ -n "${WAYLAND_DISPLAY:-}" ] || [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then - export GDK_BACKEND="wayland,x11" - else - export GDK_BACKEND="x11" - fi - fi - - for hook_dir in "$APPDIR/apprun-hooks" "$APPDIR/usr/apprun-hooks"; do - if [ -d "$hook_dir" ]; then - for hook in "$hook_dir"/*.sh; do - if [ -f "$hook" ]; then - . "$hook" - fi - done - fi - done - - exec "$APPDIR/usr/bin/zoitechat" "$@" - EOF - chmod +x AppRun - - VERSION="$(git describe --tags --always)" + export VERSION="$(cat VERSION)" ./linuxdeploy-x86_64.AppImage \ --appdir AppDir \ @@ -304,7 +193,39 @@ jobs: --output appimage appimage_path="$(ls -1 *.AppImage | grep -v linuxdeploy | head -n 1)" - mv "$appimage_path" "Zoitechat-${VERSION}-x86_64.AppImage" + output_name="Zoitechat-${VERSION}-x86_64.AppImage" + if [ "$appimage_path" != "$output_name" ]; then + mv -- "$appimage_path" "$output_name" + fi + + - name: Test packaged runtime and plugins + run: | + set -eux + appimage_path="$(find . -maxdepth 1 -name 'Zoitechat-*-x86_64.AppImage' -print -quit)" + test -n "$appimage_path" + mkdir -p "$RUNNER_TEMP/appimage check" + (cd "$RUNNER_TEMP/appimage check" && "$GITHUB_WORKSPACE/$appimage_path" --appimage-extract) + # Hide the build interpreter so it cannot mask broken relocation. + mv "$ZOITECHAT_PYTHON_PREFIX" "$ZOITECHAT_PYTHON_PREFIX.hidden" + trap 'mv "$ZOITECHAT_PYTHON_PREFIX.hidden" "$ZOITECHAT_PYTHON_PREFIX"' EXIT + env -u LD_LIBRARY_PATH -u PYTHONHOME -u PYTHONPATH \ + /usr/bin/python3 packaging/appimage/check-runtime.py "$RUNNER_TEMP/appimage check/squashfs-root" + + - name: Record dependency versions and checksum + run: | + set -eux + mkdir -p appimage-audit + dpkg-query -W -f='${binary:Package}\t${Version}\n' > appimage-audit/build-packages.tsv + cp "$RUNNER_TEMP/appimage check/runtime.json" appimage-audit/runtime.json + sha256sum linuxdeploy-x86_64.AppImage linuxdeploy-plugin-gtk > appimage-audit/build-tools.sha256 + sha256sum Zoitechat-*-x86_64.AppImage > appimage-audit/SHA256SUMS + + - name: Upload dependency audit + uses: actions/upload-artifact@v7 + with: + name: zoitechat-appimage-audit + path: appimage-audit/ + if-no-files-found: error - name: Attest AppImage (Build Provenance) if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} @@ -317,3 +238,4 @@ jobs: with: name: zoitechat-appimage path: Zoitechat-*-x86_64.AppImage + if-no-files-found: error diff --git a/meson.build b/meson.build index dc314f73..996e3a64 100644 --- a/meson.build +++ b/meson.build @@ -184,7 +184,7 @@ endif add_project_link_arguments(global_ldflags, language: 'c') if get_option('install-docs') - docs_python = find_program('python3', 'python') + docs_python = find_program(get_option('docs-python'), 'python') docs_sphinx = run_command(docs_python, '-c', 'import sphinx', check: false) if docs_sphinx.returncode() != 0 error('Sphinx is required to build the local HTML documentation. Install Sphinx or configure with -Dinstall-docs=false.') diff --git a/meson_options.txt b/meson_options.txt index 03d525ea..65530a06 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -43,6 +43,9 @@ option('install-emoji-font', type: 'boolean', value: true, option('install-docs', type: 'boolean', value: false, description: 'Build and install the local HTML documentation' ) +option('docs-python', type: 'string', value: 'python3', + description: 'Python interpreter with Sphinx for building documentation' +) # Plugins option('with-checksum', type: 'boolean', diff --git a/packaging/appimage/AppRun b/packaging/appimage/AppRun new file mode 100755 index 00000000..8b899cff --- /dev/null +++ b/packaging/appimage/AppRun @@ -0,0 +1,89 @@ +#!/bin/sh +set -eu + +APPDIR="${APPDIR:-$(dirname "$(readlink -f "$0")")}" + +export PATH="$APPDIR/usr/bin:${PATH:-/usr/bin:/bin}" +export LD_LIBRARY_PATH="$APPDIR/usr/lib:$APPDIR/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +export XDG_DATA_DIRS="$APPDIR/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +export GTK_EXE_PREFIX="$APPDIR/usr" +export GTK_DATA_PREFIX="$APPDIR/usr" +gtk_path_entries="" +if [ -d "$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0" ]; then + gtk_path_entries="$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0" +fi +if [ -d "$APPDIR/usr/lib/gtk-3.0" ]; then + gtk_path_entries="${gtk_path_entries:+$gtk_path_entries:}$APPDIR/usr/lib/gtk-3.0" +fi +if [ -n "$gtk_path_entries" ]; then + export GTK_PATH="$gtk_path_entries${GTK_PATH:+:$GTK_PATH}" +fi + +if [ -d "$APPDIR/usr/lib/x86_64-linux-gnu/zoitechat/plugins" ]; then + export ZOITECHAT_LIBDIR="$APPDIR/usr/lib/x86_64-linux-gnu/zoitechat/plugins" +elif [ -d "$APPDIR/usr/lib/zoitechat/plugins" ]; then + export ZOITECHAT_LIBDIR="$APPDIR/usr/lib/zoitechat/plugins" +fi + +if [ -d "$APPDIR/usr/share/glib-2.0/schemas" ]; then + export GSETTINGS_SCHEMA_DIR="$APPDIR/usr/share/glib-2.0/schemas" +fi + +if [ -d "$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules" ]; then + gio_module_dir="$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules" +elif [ -d "$APPDIR/usr/lib/gio/modules" ]; then + gio_module_dir="$APPDIR/usr/lib/gio/modules" +else + gio_module_dir="" +fi + +if [ -n "$gio_module_dir" ]; then + export GIO_MODULE_DIR="$gio_module_dir" + export GIO_EXTRA_MODULES="$gio_module_dir" +fi + +unset GTK_MODULES + +perl5lib_entries="" +for dir in \ + "$APPDIR/usr/lib/x86_64-linux-gnu/perl-base" \ + "$APPDIR/usr/lib/x86_64-linux-gnu/perl"/* \ + "$APPDIR/usr/lib/x86_64-linux-gnu/perl5"/* \ + "$APPDIR/usr/share/perl"/* \ + "$APPDIR/usr/share/perl5" +do + if [ -d "$dir" ]; then + perl5lib_entries="${perl5lib_entries:+$perl5lib_entries:}$dir" + fi +done + +if [ -n "$perl5lib_entries" ]; then + export PERL5LIB="$perl5lib_entries${PERL5LIB:+:$PERL5LIB}" +fi + +export PYTHONHOME="$APPDIR/usr" +# CPython finds the private versioned site-packages directory itself. +export TCL_LIBRARY="${TCL_LIBRARY:-$APPDIR/usr/share/tcltk/tcl8.6}" +export TK_LIBRARY="${TK_LIBRARY:-$APPDIR/usr/share/tcltk/tk8.6}" + +# Register the private font without hiding the host's normal fonts. +export ZOITECHAT_EMOJI_FONT="${ZOITECHAT_EMOJI_FONT:-$APPDIR/usr/share/fonts/zoitechat/NotoColorEmoji.ttf}" + +# OpenSSL trust store override +export SSL_CERT_FILE="${SSL_CERT_FILE:-$APPDIR/etc/ssl/certs/ca-certificates.crt}" +export SSL_CERT_DIR="${SSL_CERT_DIR:-$APPDIR/etc/ssl/certs}" +export CURL_CA_BUNDLE="${CURL_CA_BUNDLE:-$SSL_CERT_FILE}" + +# Prefer Wayland if the session provides it, but keep X11 fallback. +# Don't override if the user already set GDK_BACKEND explicitly. +if [ -z "${GDK_BACKEND:-}" ]; then + if [ -n "${WAYLAND_DISPLAY:-}" ] || [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then + export GDK_BACKEND="wayland,x11" + else + export GDK_BACKEND="x11" + fi +fi + +# linuxdeploy's outer AppRun has already sourced its generated hooks. + +exec "$APPDIR/usr/bin/zoitechat" "$@" diff --git a/packaging/appimage/README.md b/packaging/appimage/README.md new file mode 100644 index 00000000..39b443ee --- /dev/null +++ b/packaging/appimage/README.md @@ -0,0 +1,82 @@ +# AppImage build + +The workflow stays on Ubuntu 22.04 so updating Python does not also raise the +AppImage's glibc requirement. Python is built separately from a checked source +archive. `python.env` holds its version and SHA-256. CFFI and pycparser are pinned +in `python-requirements.txt`, including wheel hashes. + +The Python interpreter used to build the documentation is separate from the +one used by the scripting plugin. The `docs-python` Meson option selects the +interpreter with Sphinx; it defaults to `python3` for normal builds. + +## What was in the published build + +Checked on October 27, 2026, using the 2.20.0-dev release asset: + +`Zoitechat-2.20.0-dev-x86_64.AppImage` + +SHA-256: `062eff9ba37556d3eca6c17388db45451353732a0cdf15c2bf3d6a3eae620989` + +| Component | Published AppImage | This build | +| --- | --- | --- | +| Python | 3.10.12, built August 31, 2026 | 3.14.10 | +| CFFI | 1.15.0 | 2.1.1 | +| pycparser | 2.21 | 3.12 | +| Perl | 5.34.0 | Ubuntu 22.04 package, with available updates | +| OpenSSL reported by Python | 3.0.2 | Ubuntu 22.04 package, with available updates | + +The old build copied the runner's entire Python `dist-packages` tree. That +included cloud-init, the Azure agent, apt bindings, Sphinx and other build-host +packages. The new bundle includes the standard library and the two declared +Python plugin dependencies. It leaves out CPython regression tests, bytecode +caches, static libraries and build tools. Tcl/Tk's runtime scripts are included +for the standard library's tkinter module. + +Python 3.10 reached upstream end of life in October 2026. That does not mean +every Ubuntu Python 3.10 package is unpatched: Ubuntu backports security fixes. +The same applies to the old-looking OpenSSL and Perl version strings. The old +AppImage did not record the Ubuntu package revisions, so those strings alone +cannot establish its vulnerability status. + +Python 3.14 is a deliberate choice. It is maintained upstream and supported by +CFFI. Moving a scripting runtime can still affect scripts using removed Python +APIs or native extensions built for Python 3.10. Third-party modules required +by user scripts need to be supplied for the new interpreter; incidental runner +packages are no longer treated as part of the scripting API. + +## Checks and rebuilds + +The workflow can be run manually and rebuilds weekly to pick up available +Ubuntu package and CA certificate updates. It creates an artifact; releasing a +new download is still a separate step. Already downloaded AppImages do not +receive those updates. + +After packaging, the workflow extracts the AppImage into a path containing a +space and hides the Python build prefix. `check-runtime.py` checks the bundled +interpreter, native standard-library extensions, CFFI, CA loading and GIO TLS. +It starts the actual application under Xvfb and runs Python, Perl and Lua +scripts through their plugins. A missing script marker, process error or timeout +fails the build. Existing emoji tests remain in the workflow. + +The `zoitechat-appimage-audit` artifact contains runtime versions, the build +host's exact Ubuntu package revisions, hashes of the deployment helpers and +the final AppImage checksum. The package list describes the build host, not an +SBOM of the AppImage. linuxdeploy's `continuous` download and its output plugin +remain upstream-managed; their versions should be reviewed when packaging +changes. + +The GTK deployment helper is pinned to a commit. Its unconditional X11 +override is removed so AppRun can honour `GDK_BACKEND` and choose Wayland with +X11 fallback. Hooks run once through linuxdeploy's generated outer launcher. +The Xvfb test covers X11; Wayland still needs testing in a Wayland session. + +When updating Python, change its version and checksum together, refresh the +two wheel pins and hashes, and run the workflow. Review the audit artifact and +test the result on both Ubuntu 22.04 and a current distribution before release. + +References: + +- [Python release status](https://devguide.python.org/versions/) +- [Python 3.14.10](https://www.python.org/downloads/release/python-31410/) +- [CFFI release notes](https://cffi.readthedocs.io/en/latest/whatsnew.html) +- [Ubuntu security updates](https://documentation.ubuntu.com/security/security-updates/) diff --git a/packaging/appimage/build-python.sh b/packaging/appimage/build-python.sh new file mode 100755 index 00000000..e5884ccd --- /dev/null +++ b/packaging/appimage/build-python.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck source=python.env +source "$script_dir/python.env" +prefix="${1:?Usage: build-python.sh ABSOLUTE_INSTALL_PREFIX}" +[[ "$prefix" = /* ]] +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +curl -fL --retry 3 -o "$work_dir/python.tar.xz" \ + "https://www.python.org/ftp/python/$PYTHON_VERSION/Python-$PYTHON_VERSION.tar.xz" +printf '%s %s\n' "$PYTHON_SHA256" "$work_dir/python.tar.xz" | sha256sum --check --strict +tar -xJf "$work_dir/python.tar.xz" -C "$work_dir" +cd "$work_dir/Python-$PYTHON_VERSION" + +# Build on the AppImage's oldest supported distribution. A newer runner +# would raise the glibc requirement for the interpreter and its extensions. +./configure --prefix="$prefix" --enable-shared --without-static-libpython --with-ensurepip=install +make -j"$(nproc)" +LD_LIBRARY_PATH="$PWD${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" make install +export LD_LIBRARY_PATH="$prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +"$prefix/bin/python3" -m pip install --disable-pip-version-check \ + --only-binary=:all: --require-hashes -r "$script_dir/python-requirements.txt" + +# Fail here if configure silently omitted a standard-library extension. +"$prefix/bin/python3" -I -c \ + 'import bz2, ctypes, curses, dbm.gnu, lzma, readline, sqlite3, ssl, tkinter, zlib, cffi, _cffi_backend; print(ssl.OPENSSL_VERSION)' diff --git a/packaging/appimage/bundle-python.py b/packaging/appimage/bundle-python.py new file mode 100755 index 00000000..f4ba1114 --- /dev/null +++ b/packaging/appimage/bundle-python.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Copy the private interpreter, stdlib and declared plugin dependencies.""" + +import importlib.metadata +from pathlib import Path +import shutil +import sys +import sysconfig + + +def main(): + destination = Path(sys.argv[1]).resolve() / "usr" + version = f"{sys.version_info.major}.{sys.version_info.minor}" + stdlib = Path(sysconfig.get_path("stdlib")) + target_stdlib = destination / "lib" / f"python{version}" + target_stdlib.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree( + stdlib, target_stdlib, + ignore=shutil.ignore_patterns("site-packages", "__pycache__", "*.a", "test", "tests"), + ) + (destination / "bin").mkdir(parents=True, exist_ok=True) + shutil.copy2(sys.executable, destination / "bin" / "python3") + libdir = Path(sysconfig.get_config_var("LIBDIR")) + library = sysconfig.get_config_var("LDLIBRARY") + for source in libdir.glob(f"{library}*"): + # Dereference the build prefix's symlinks so none point outside AppDir. + shutil.copy2(source, destination / "lib" / source.name) + + site = target_stdlib / "site-packages" + site.mkdir() + for name in ("cffi", "pycparser"): + distribution = importlib.metadata.distribution(name) + for entry in distribution.files: + # Wheel metadata and the native backend belong to the runtime; + # scripts installed outside site-packages belong to the build. + if ".." in entry.parts or entry.suffix == ".pyc": + continue + source = Path(distribution.locate_file(entry)) + target = site / entry + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + # _tkinter also needs Tcl/Tk's scripts, not just its ELF dependencies. + for name in ("tcl8.6", "tk8.6"): + shutil.copytree(Path("/usr/share/tcltk") / name, + destination / "share/tcltk" / name) + + +if __name__ == "__main__": + main() diff --git a/packaging/appimage/check-runtime.py b/packaging/appimage/check-runtime.py new file mode 100755 index 00000000..f24ddc95 --- /dev/null +++ b/packaging/appimage/check-runtime.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Exercise an extracted AppImage without borrowing the build interpreter.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + + +PROBE = r''' +import bz2, ctypes, curses, dbm.gnu, json, lzma, pathlib, readline, sqlite3, ssl, sys, tkinter, zlib +import cffi, _cffi_backend, pycparser +root = pathlib.Path(sys.argv[1]).resolve() +assert pathlib.Path(sys.base_prefix).resolve() == root / 'usr', sys.base_prefix +for module in (cffi, _cffi_backend, pycparser, ssl, sqlite3): + assert pathlib.Path(module.__file__).resolve().is_relative_to(root), module.__file__ +assert cffi.FFI().sizeof('int') > 0 +assert tkinter.Tcl().eval('info patchlevel').startswith('8.6.') +assert ssl.create_default_context().get_ca_certs(), 'No trusted CA certificates' +gio = ctypes.CDLL('libgio-2.0.so.0') +gio.g_tls_backend_get_default.restype = ctypes.c_void_p +gio.g_tls_backend_supports_tls.argtypes = [ctypes.c_void_p] +assert gio.g_tls_backend_supports_tls(gio.g_tls_backend_get_default()), 'GIO TLS backend unavailable' +print(json.dumps({'python': sys.version, 'openssl': ssl.OPENSSL_VERSION, + 'cffi': cffi.__version__, 'pycparser': pycparser.__version__})) +''' + +PYTHON_PLUGIN = r''' +import os +from pathlib import Path +import sys +import zoitechat, hexchat, xchat +__module_name__ = 'AppImage Python check' +__module_version__ = '1' +root = Path(os.environ['ZOITECHAT_SMOKE']) +(root / 'python.ok').write_text(sys.version) +def finish(userdata): + if all((root / (name + '.ok')).is_file() for name in ('python', 'perl', 'lua')): + # Return from Python before shutdown unloads the native plugin. + zoitechat.command('TIMER 0.1 KILLALL') + return False + return True +zoitechat.hook_timer(100, finish) +''' + +PERL_PLUGIN = r''' +use strict; +use warnings; +use File::Spec; +use Encode; +ZoiteChat::register('AppImage Perl check', '1', 'Runtime check'); +open my $marker, '>', File::Spec->catfile($ENV{ZOITECHAT_SMOKE}, 'perl.ok') or die $!; +print {$marker} $^V; +close $marker or die $!; +''' + +LUA_PLUGIN = r''' +zoitechat.register('AppImage Lua check', '1', 'Runtime check') +local marker = assert(io.open(os.getenv('ZOITECHAT_SMOKE') .. '/lua.ok', 'w')) +marker:write(_VERSION) +marker:close() +''' + + +def run(command, env): + try: + result = subprocess.run(command, env=env, text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=90) + except subprocess.TimeoutExpired as error: + raise RuntimeError(f"{command[0]} timed out:\n{error.stdout}\n{error.stderr}") from error + if result.returncode: + raise RuntimeError(f"{command[0]} failed:\n{result.stdout}\n{result.stderr}") + return result.stdout.strip() + + +def main(): + appdir = Path(sys.argv[1]).resolve() + env = os.environ.copy() + for key in ("PYTHONHOME", "PYTHONPATH", "PERL5LIB", "LD_LIBRARY_PATH", + "GIO_MODULE_DIR", "GIO_EXTRA_MODULES"): + env.pop(key, None) + env.update({ + "APPDIR": str(appdir), + "PYTHONHOME": str(appdir / "usr"), + "PYTHONNOUSERSITE": "1", + "LD_LIBRARY_PATH": f"{appdir}/usr/lib:{appdir}/usr/lib/x86_64-linux-gnu", + "SSL_CERT_FILE": str(appdir / "etc/ssl/certs/ca-certificates.crt"), + "SSL_CERT_DIR": str(appdir / "etc/ssl/certs"), + "GDK_BACKEND": "x11", + "TCL_LIBRARY": str(appdir / "usr/share/tcltk/tcl8.6"), + "TK_LIBRARY": str(appdir / "usr/share/tcltk/tk8.6"), + }) + for relative in ("usr/lib/x86_64-linux-gnu/gio/modules", "usr/lib/gio/modules"): + if (appdir / relative).is_dir(): + env["GIO_MODULE_DIR"] = str(appdir / relative) + env["GIO_EXTRA_MODULES"] = str(appdir / relative) + break + perl_dirs = [appdir / "usr/lib/x86_64-linux-gnu/perl-base"] + for relative in ("usr/lib/x86_64-linux-gnu/perl", "usr/lib/x86_64-linux-gnu/perl5", "usr/share/perl"): + perl_dirs.extend(sorted((appdir / relative).glob("*"))) + perl_dirs.append(appdir / "usr/share/perl5") + env["PERL5LIB"] = ":".join(str(path) for path in perl_dirs if path.is_dir()) + runtime = json.loads(run([str(appdir / "usr/bin/python3"), "-s", "-c", PROBE, str(appdir)], env)) + expected = dict(line.split("=", 1) for line in + Path(__file__).with_name("python.env").read_text().splitlines() + if line and not line.startswith("#"))["PYTHON_VERSION"] + assert runtime["python"].split()[0] == expected, runtime + runtime["perl"] = run([str(appdir / "usr/bin/perl"), "-MFile::Spec", "-MEncode", "-MPOSIX", + "-MJSON::PP", "-e", "print $^V"], env) + + with tempfile.TemporaryDirectory(prefix="zoitechat-smoke-") as temporary: + config = Path(temporary) + (config / "zoitechat.conf").write_text("gui_slist_skip = 1\n") + autoload = config / "addons" + autoload.mkdir() + for filename, content in (("smoke.py", PYTHON_PLUGIN), ("smoke.pl", PERL_PLUGIN), ("smoke.lua", LUA_PLUGIN)): + (autoload / filename).write_text(content) + env["HOME"] = temporary + env["XDG_CONFIG_HOME"] = temporary + env["ZOITECHAT_SMOKE"] = temporary + output = run(["xvfb-run", "-a", "dbus-run-session", "--", str(appdir / "AppRun"), + "--run-as-root", "--no-auto", "--cfgdir", temporary], env) + for name in ("python", "perl", "lua"): + assert (config / f"{name}.ok").is_file(), f"{name} plugin failed:\n{output}" + runtime["lua"] = (config / "lua.ok").read_text() + + (appdir.parent / "runtime.json").write_text(json.dumps(runtime, indent=2) + "\n") + print(json.dumps(runtime, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/packaging/appimage/python-requirements.txt b/packaging/appimage/python-requirements.txt new file mode 100644 index 00000000..af46bd93 --- /dev/null +++ b/packaging/appimage/python-requirements.txt @@ -0,0 +1,5 @@ +# CPython 3.14, Linux x86_64. Hashes are for the PyPI wheels. +cffi==2.1.1 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 +pycparser==3.12 \ + --hash=sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e diff --git a/packaging/appimage/python.env b/packaging/appimage/python.env new file mode 100644 index 00000000..77164641 --- /dev/null +++ b/packaging/appimage/python.env @@ -0,0 +1,4 @@ +# Update the version and upstream source checksum together. +# https://www.python.org/downloads/release/python-31410/ +PYTHON_VERSION=3.14.10 +PYTHON_SHA256=de414a57119abef52784d8993e341d53aed635fc2b05c2607d9f98a2c5b72032