diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml new file mode 100644 index 0000000..35bc9fc --- /dev/null +++ b/.github/workflows/linux-packages.yml @@ -0,0 +1,339 @@ +name: Linux packages + +on: + pull_request: + paths: + - ".github/workflows/linux-packages.yml" + - "packaging/linux/**" + - "assets/linux/**" + - "desktopApp/**" + - "shared/**" + - "crates/vnidrop/**" + - "Cargo.toml" + - "Cargo.lock" + - "LICENSE" + - "build.gradle.kts" + - "settings.gradle.kts" + - "gradle.properties" + - "gradle/**" + - "gradlew" + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: Release version in MAJOR.MINOR.PATCH form + required: true + default: "1.0.0" + type: string + +permissions: + contents: read + +concurrency: + group: linux-packages-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + shell: bash + +jobs: + build-deb: + name: Build Debian package (x64) + runs-on: ubuntu-22.04 + timeout-minutes: 90 + env: + CARGO_TERM_COLOR: always + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install packaging tools + run: | + sudo apt-get update + sudo apt-get install --yes fakeroot unzip + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: "21.0.11+10.0.LTS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Set up Rust 1.91 + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1 + with: + toolchain: "1.91.0" + + - name: Cache Cargo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: linux-deb-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }} + restore-keys: | + linux-deb-x64-cargo-1.91.0- + + - name: Resolve version + id: version + env: + REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }} + run: | + version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") + echo "app=$version" >> "$GITHUB_OUTPUT" + + - name: Test and build Debian package + run: | + ./gradlew \ + :shared:jvmTest \ + :desktopApp:packageReleaseDeb \ + -Pvnidrop.version=${{ steps.version.outputs.app }} \ + -Pvnidrop.desktop.rustVariant=release \ + -Pvnidrop.diagnostics.included=false \ + --no-daemon \ + --no-configuration-cache \ + --stacktrace + + - name: Validate Debian package + id: package + env: + VERSION: ${{ steps.version.outputs.app }} + run: | + mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/deb -maxdepth 1 -type f -name '*.deb') + if (( ${#packages[@]} != 1 )); then + echo "Expected exactly one Debian package, found ${#packages[@]}" >&2 + exit 1 + fi + + output_directory=build/release/linux/deb + output_name="vnidrop_${VERSION}-1_amd64.deb" + mkdir -p "$output_directory" + cp "${packages[0]}" "$output_directory/$output_name" + packaging/linux/verify-package.sh deb "$VERSION" "$output_directory/$output_name" + ( + cd "$output_directory" + sha256sum "$output_name" > "$output_name.sha256" + ) + + - name: Upload Debian artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vnidrop-${{ steps.version.outputs.app }}-linux-deb-x64 + path: build/release/linux/deb/ + if-no-files-found: error + retention-days: 14 + compression-level: 0 + + - name: Summarize Debian package + run: | + echo "### Debian package" >> "$GITHUB_STEP_SUMMARY" + echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY" + echo "- Architecture: amd64" >> "$GITHUB_STEP_SUMMARY" + echo "- Build baseline: Ubuntu 22.04" >> "$GITHUB_STEP_SUMMARY" + + build-rpm: + name: Build RPM package (x64) + runs-on: ubuntu-24.04 + container: registry.fedoraproject.org/fedora:43 + timeout-minutes: 90 + env: + CARGO_TERM_COLOR: always + + steps: + - name: Install build and packaging tools + run: | + dnf install --assumeyes \ + alsa-lib \ + cpio \ + curl \ + cups-libs \ + desktop-file-utils \ + findutils \ + fontconfig \ + freetype \ + gcc \ + gcc-c++ \ + git \ + gzip \ + gtk3 \ + libX11 \ + libXext \ + libXi \ + libXrandr \ + libXrender \ + libXtst \ + make \ + mesa-libGL \ + rpm-build \ + tar \ + unzip \ + which \ + xz \ + zstd + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: "21.0.11+10.0.LTS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Set up Rust 1.91 + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1 + with: + toolchain: "1.91.0" + + - name: Cache Cargo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: linux-rpm-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }} + restore-keys: | + linux-rpm-x64-cargo-1.91.0- + + - name: Resolve version + id: version + env: + REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }} + run: | + version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") + echo "app=$version" >> "$GITHUB_OUTPUT" + + - name: Build RPM package + run: | + ./gradlew \ + :desktopApp:packageReleaseRpm \ + -Pvnidrop.version=${{ steps.version.outputs.app }} \ + -Pvnidrop.desktop.rustVariant=release \ + -Pvnidrop.diagnostics.included=false \ + --no-daemon \ + --no-configuration-cache \ + --stacktrace + + - name: Validate RPM package + env: + VERSION: ${{ steps.version.outputs.app }} + run: | + mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/rpm -maxdepth 1 -type f -name '*.rpm') + if (( ${#packages[@]} != 1 )); then + echo "Expected exactly one RPM package, found ${#packages[@]}" >&2 + exit 1 + fi + + output_directory=build/release/linux/rpm + output_name="vnidrop-${VERSION}-1.x86_64.rpm" + mkdir -p "$output_directory" + cp "${packages[0]}" "$output_directory/$output_name" + packaging/linux/verify-package.sh rpm "$VERSION" "$output_directory/$output_name" + ( + cd "$output_directory" + sha256sum "$output_name" > "$output_name.sha256" + ) + + - name: Upload RPM artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vnidrop-${{ steps.version.outputs.app }}-linux-rpm-x64 + path: build/release/linux/rpm/ + if-no-files-found: error + retention-days: 14 + compression-level: 0 + + - name: Summarize RPM package + run: | + echo "### RPM package" >> "$GITHUB_STEP_SUMMARY" + echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY" + echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY" + echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY" + + publish-release: + name: Publish GitHub Release assets + if: github.event_name == 'push' && github.ref_type == 'tag' + needs: + - build-deb + - build-rpm + runs-on: ubuntu-22.04 + timeout-minutes: 15 + permissions: + contents: write + + steps: + - name: Checkout release history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify tag is on master + run: | + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then + echo "Release tags must point to a commit on master" >&2 + exit 1 + fi + + - name: Download Linux artifacts + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + pattern: vnidrop-*-linux-*-x64 + path: build/release/linux + merge-multiple: true + + - name: Verify artifacts and checksums + run: | + cd build/release/linux + shopt -s nullglob + deb_packages=(*.deb) + rpm_packages=(*.rpm) + checksum_files=(*.sha256) + if (( ${#deb_packages[@]} != 1 || ${#rpm_packages[@]} != 1 || ${#checksum_files[@]} != 2 )); then + echo "Expected one DEB, one RPM, and two checksum sidecars" >&2 + exit 1 + fi + version=${GITHUB_REF_NAME#v} + if [[ ${deb_packages[0]} != "vnidrop_${version}-1_amd64.deb" || ${rpm_packages[0]} != "vnidrop-${version}-1.x86_64.rpm" ]]; then + echo "Downloaded package names do not match tag $GITHUB_REF_NAME" >&2 + exit 1 + fi + sha256sum --check "${checksum_files[@]}" + sha256sum "${deb_packages[@]}" "${rpm_packages[@]}" > SHA256SUMS + rm -- "${checksum_files[@]}" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + tag=${GITHUB_REF_NAME} + version=${tag#v} + if gh release view "$tag" >/dev/null 2>&1; then + echo "GitHub Release $tag already exists; refusing to replace its assets" >&2 + exit 1 + fi + gh release create "$tag" \ + build/release/linux/*.deb \ + build/release/linux/*.rpm \ + build/release/linux/SHA256SUMS \ + --verify-tag \ + --title "VniDrop $version" \ + --generate-notes diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index d1b896a..75fa1f5 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -37,11 +37,12 @@ compose.desktop { buildTypes.release.proguard.isEnabled.set(false) nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Deb) + targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Rpm) packageName = "VniDrop" packageVersion = appVersion description = "Send files directly across your devices" vendor = "Sudosy Labs" + licenseFile.set(project.file("../LICENSE")) macOS { bundleID = "com.vnidrop.app" iconFile.set(project.file("../assets/macos/app-icon.icns")) @@ -52,6 +53,9 @@ compose.desktop { linux { packageName = "vnidrop" iconFile.set(project.file("../assets/linux/app-icon.png")) + debMaintainer = "support@sudosy.fr" + appRelease = "1" + rpmLicenseType = "Apache-2.0" } fileAssociation( mimeType = "application/vnd.vnidrop.transfer", diff --git a/packaging/linux/README.md b/packaging/linux/README.md new file mode 100644 index 0000000..53a3030 --- /dev/null +++ b/packaging/linux/README.md @@ -0,0 +1,84 @@ +# Linux packaging + +VniDrop ships native x64 packages for the two common Linux package families: + +- Debian/Ubuntu: `.deb` +- Current Fedora systems: `.rpm` + +Both packages contain the application, its release Rust library, and a private +Java runtime. Users do not need to install Java separately. The packages are +currently distributed as direct GitHub Release downloads, so they use SHA-256 +checksums rather than a Linux repository signing key. A future APT or RPM +repository should add repository metadata signing and its own update channel. + +## GitHub Actions + +The Linux packages workflow runs for relevant pull requests, release tags +matching `vMAJOR.MINOR.PATCH`, and manual dispatches. Each native package is +built and validated on its matching distribution family: + +- `.deb` on Ubuntu 22.04 for a conservative glibc baseline +- `.rpm` inside Fedora 43 so `jpackage` can discover normal RPM dependencies + +The shared JVM suite runs inside the Debian build job. Package construction and +payload validation happen in both build jobs, so there is no separate test +runner. Pull requests build and verify both packages but do not retain +artifacts. Manual runs retain build artifacts for 14 days. A pushed version tag +whose commit is on `master` creates the matching GitHub Release with the `.deb`, +`.rpm`, and a combined `SHA256SUMS`. + +The existing `v1.0.0` tag predates this workflow and will not run it +retroactively. Use the next version tag after this configuration reaches +`master`. + +## Install a downloaded package + +Verify downloads from the directory containing all three release files: + +```bash +sha256sum -c SHA256SUMS +``` + +On Debian or Ubuntu: + +```bash +sudo apt install ./vnidrop_VERSION-1_amd64.deb +``` + +On Fedora: + +```bash +sudo dnf install ./vnidrop-VERSION-1.x86_64.rpm +``` + +The package manager installs declared system-library dependencies and creates +the VniDrop desktop launcher. Uninstall with `sudo apt remove vnidrop` or +`sudo dnf remove vnidrop`. + +## Manual native builds + +Use JDK 21 and Rust 1.91. Build DEB packages on Debian/Ubuntu with `dpkg` and +`fakeroot`; build RPM packages on Fedora with `rpm-build`. Building an RPM on +Ubuntu prevents `jpackage` from discovering normal RPM dependencies. + +From the repository root on the matching Linux family, run one of: + +```bash +./gradlew :shared:jvmTest :desktopApp:packageReleaseDeb \ + -Pvnidrop.version=1.0.0 \ + -Pvnidrop.desktop.rustVariant=release \ + -Pvnidrop.diagnostics.included=false \ + --no-daemon --no-configuration-cache --stacktrace + +./gradlew :shared:jvmTest :desktopApp:packageReleaseRpm \ + -Pvnidrop.version=1.0.0 \ + -Pvnidrop.desktop.rustVariant=release \ + -Pvnidrop.diagnostics.included=false \ + --no-daemon --no-configuration-cache --stacktrace +``` + +Compose writes the packages under +`desktopApp/build/compose/binaries/main-release/deb/` and +`desktopApp/build/compose/binaries/main-release/rpm/`. The workflow then +validates package identity, version, architecture, dependencies, bundled JVM, +and release Rust payload before publishing anything. diff --git a/packaging/linux/resolve-version.sh b/packaging/linux/resolve-version.sh new file mode 100755 index 0000000..da3488c --- /dev/null +++ b/packaging/linux/resolve-version.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +version=${1:-1.0.0} + +if [[ ${GITHUB_REF_TYPE:-} == "tag" ]]; then + if [[ ! ${GITHUB_REF_NAME:-} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Linux release tags must use vMAJOR.MINOR.PATCH" >&2 + exit 1 + fi + version=${GITHUB_REF_NAME#v} +fi + +if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version must use MAJOR.MINOR.PATCH" >&2 + exit 1 +fi + +IFS=. read -r major minor patch <<< "$version" +parts=("$major" "$minor" "$patch") + +for index in "${!parts[@]}"; do + part=${parts[$index]} + if [[ $part != "0" && $part == 0* ]]; then + echo "Version components must be canonical integers without leading zeroes" >&2 + exit 1 + fi + if (( ${#part} > 5 )) || (( 10#$part > 65535 )); then + echo "Version components must be between 0 and 65535" >&2 + exit 1 + fi + if (( index == 0 && 10#$part == 0 )); then + echo "The major version must be non-zero" >&2 + exit 1 + fi +done + +printf '%s\n' "$version" diff --git a/packaging/linux/verify-package.sh b/packaging/linux/verify-package.sh new file mode 100755 index 0000000..1c55cbe --- /dev/null +++ b/packaging/linux/verify-package.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if (( $# != 3 )); then + echo "Usage: $0 " >&2 + exit 2 +fi + +format=$1 +version=$2 +package_path=$3 + +fail() { + echo "Package verification failed: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' is not installed" +} + +[[ -f $package_path ]] || fail "package not found: $package_path" +require_command find +require_command unzip + +extract_root=$(mktemp -d) +trap 'rm -rf "$extract_root"' EXIT + +case $format in + deb) + require_command dpkg-deb + [[ $(dpkg-deb -f "$package_path" Package) == "vnidrop" ]] || fail "unexpected Debian package name" + [[ $(dpkg-deb -f "$package_path" Version) == "$version-1" ]] || fail "unexpected Debian package version" + [[ $(dpkg-deb -f "$package_path" Architecture) == "amd64" ]] || fail "unexpected Debian architecture" + [[ $(dpkg-deb -f "$package_path" Maintainer) == *"support@sudosy.fr"* ]] || fail "unexpected Debian maintainer" + deb_dependencies=$(dpkg-deb -f "$package_path" Depends) + deb_libc_pattern='(^|,[[:space:]])libc6([[:space:](]|,|$)' + deb_xdg_pattern='(^|,[[:space:]])xdg-utils([[:space:](]|,|$)' + [[ $deb_dependencies =~ $deb_libc_pattern ]] || fail "Debian package does not declare libc6" + [[ $deb_dependencies =~ $deb_xdg_pattern ]] || fail "Debian package does not declare xdg-utils" + dpkg-deb -x "$package_path" "$extract_root" + ;; + rpm) + require_command rpm + require_command rpm2cpio + require_command cpio + metadata=$(rpm -qp --queryformat '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n%{LICENSE}\n' "$package_path") + mapfile -t fields <<< "$metadata" + [[ ${fields[0]:-} == "vnidrop" ]] || fail "unexpected RPM package name" + [[ ${fields[1]:-} == "$version" ]] || fail "unexpected RPM package version" + [[ ${fields[2]:-} == "1" ]] || fail "unexpected RPM release" + [[ ${fields[3]:-} == "x86_64" ]] || fail "unexpected RPM architecture" + [[ ${fields[4]:-} == "Apache-2.0" ]] || fail "unexpected RPM license" + mapfile -t runtime_requirements < <(rpm -qpR "$package_path" | grep -Ev '^(rpmlib\(|/bin/sh$)' || true) + printf '%s\n' "${runtime_requirements[@]}" | grep -Eq '^(glibc($|[[:space:]])|libc\.so\.6)' || fail "RPM package does not declare glibc" + printf '%s\n' "${runtime_requirements[@]}" | grep -Fxq 'xdg-utils' || fail "RPM package does not declare xdg-utils" + rpm2cpio "$package_path" | ( + cd "$extract_root" + cpio -idm --quiet + ) + ;; + *) + fail "unsupported package format: $format" + ;; +esac + +mapfile -d '' -t launchers < <(find "$extract_root" -type f -iname 'vnidrop' -perm /111 -print0) +(( ${#launchers[@]} == 1 )) || fail "expected exactly one executable VniDrop launcher" + +mapfile -d '' -t desktop_entries < <(find "$extract_root" -type f -name '*.desktop' -print0) +(( ${#desktop_entries[@]} == 1 )) || fail "expected exactly one desktop entry" +grep -Eiq '^Exec=.*/VniDrop([[:space:]]|$)' "${desktop_entries[0]}" || fail "desktop entry does not launch VniDrop" +grep -Eq '^MimeType=.*application/vnd\.vnidrop\.transfer(;|$)' "${desktop_entries[0]}" || fail "desktop entry does not register VniDrop invitations" + +mapfile -d '' -t bundled_jvms < <(find "$extract_root" -type f -path '*/lib/runtime/lib/server/libjvm.so' -print0) +(( ${#bundled_jvms[@]} == 1 )) || fail "expected exactly one bundled JVM" + +mapfile -d '' -t debug_rust_jars < <(find "$extract_root" -type f -name 'shared-linux-x86-64-debug-*.jar' -print0) +(( ${#debug_rust_jars[@]} == 0 )) || fail "package contains a debug Rust runtime JAR" + +mapfile -d '' -t rust_jars < <( + find "$extract_root" -type f -name 'shared-linux-x86-64-*.jar' ! -name 'shared-linux-x86-64-debug-*.jar' -print0 +) +(( ${#rust_jars[@]} == 1 )) || fail "expected exactly one release Rust runtime JAR" + +native_entry_size=$(unzip -p "${rust_jars[0]}" 'linux-x86-64/libvnidrop.so' | wc -c) +[[ $native_entry_size =~ ^[0-9]+$ ]] || fail "release Rust runtime JAR does not contain libvnidrop.so" +(( native_entry_size > 0 )) || fail "release Rust runtime JAR contains an empty libvnidrop.so" + +mapfile -d '' -t shared_jars < <(find "$extract_root" -type f -name 'shared-jvm-*.jar' -print0) +(( ${#shared_jars[@]} == 1 )) || fail "expected exactly one shared JVM JAR" +unzip -p "${shared_jars[0]}" META-INF/MANIFEST.MF | tr -d '\r' | + grep -Fxq "Implementation-Version: $version" || fail "packaged app version does not match $version" + +printf 'Verified %s package: %s\n' "$format" "$package_path"