# Build installable HyprCosmic packages for Fedora, Arch and Debian. # # WHY THIS IS A SEPARATE WORKFLOW FROM hyprcosmic.yml # -------------------------------------------------- # hyprcosmic.yml beside it answers "does the fork still build and do the assets # still install where they claim to", on every push, in a few minutes. This one # compiles 27 Rust components three times over and takes hours. Sharing a file # would mean either running the slow thing on every push or never running the # fast thing on a tag, and a `if:` guard threaded through a shared matrix to # avoid that is harder to read than two files. # # WHY IT DOES NOT RUN ON EVERY PUSH # --------------------------------- # Three full desktop builds per commit is hours of runner time to produce # artifacts nobody downloads. Tags get packages because that is when a package # means something; workflow_dispatch covers wanting one at any other time. # # WHY THE WHOLE JOB RUNS IN A CONTAINER # ------------------------------------- # Nothing here is statically linked, so a package is only valid on the # distribution that built it -- an RPM built on Ubuntu's runner would name # Ubuntu's sonames and refuse to install on Fedora. `container:` puts the # compile, the staging and the package build all inside an image of the target, # so the sonames recorded are the ones that will exist on the machine installing # it. This is also why no step here runs on a developer's workstation: the # distribution being targeted is rarely the one being typed at. name: Packages on: push: tags: ['v*'] workflow_dispatch: inputs: version: description: 'Version to stamp on the packages' required: false default: '0.1.0' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 jobs: package: name: ${{ matrix.distro }} runs-on: ubuntu-latest container: ${{ matrix.image }} # GitHub defaults `run:` to `sh -e {0}` inside a container, and on Debian # that is dash. Several steps below use brace expansion and process # substitution, which dash does not have and which bash-invoked-as-sh # disables. Naming bash once here is better than writing POSIX around a # constraint no target actually imposes -- all three images ship bash. defaults: run: shell: bash strategy: # One distribution failing on a package name is worth seeing on its own, # and the other two artifacts are still worth having. fail-fast: false matrix: include: - distro: fedora image: fedora:44 - distro: arch image: archlinux:base-devel # trixie rather than the bookworm the other workflows use. bookworm has # no `just` package -- it arrived in trixie -- and its rustc is 1.63, # so `just` would have to be compiled by a toolchain installed before # the thing that installs toolchains. CI pays that to prove the crate # builds on the oldest supported Debian; a package has no such point # to make. - distro: debian image: debian:trixie steps: # Before checkout, deliberately: actions/checkout needs git in the image # and these are bare. # # The library lists are the union of Build-Depends across all 27 upstream # components' debian/control files, translated per distribution rather # than trimmed. Derived mechanically rather than assembled by hand, after # a hand-assembled list built for eighteen minutes and then failed on # dav1d -- a dependency of cosmic-bg that nothing in the obvious set names. # # The Fedora and Arch translations were checked against dnf repoquery and # archlinux.org's package API rather than guessed, because a name that # does not exist fails the whole step: it is libdav1d-devel on Fedora, not # dav1d-devel, and ttf-opensans on Arch, not otf-opensans. # # The Fedora list names a set of basic tools the other two get for free. # Fedora's container image is deliberately minimal: no `which` (deprecated # there in favour of `command -v`), no make, no findutils, no tar. Arch's # base-devel and Debian's build-essential plus its essential set include # all of them, which is why this only ever broke on one of the three. # # These are not guesses. The top-level justfile opens with # `make := \`which make\``, and the submodules' own recipes shell out to # find, xargs, tar and sed -- 114 tar invocations and 18 find between # them. Several steps in this workflow use find as well. - name: Install build dependencies (fedora) if: matrix.distro == 'fedora' run: | dnf -y install --setopt=install_weak_deps=False \ git curl ca-certificates just \ gcc gcc-c++ make which findutils tar gzip sed diffutils \ cmake pkgconf-pkg-config nasm lld mold \ clang-devel llvm-devel \ desktop-file-utils rpm-build intltool ImageMagick open-sans-fonts \ dbus-devel expat-devel fontconfig-devel freetype-devel \ libinput-devel libseat-devel libxkbcommon-devel \ mesa-libgbm-devel mesa-libEGL-devel libglvnd-devel \ wayland-devel libdisplay-info-devel libdav1d-devel \ pixman-devel cairo-devel pango-devel glib2-devel gtk3-devel gtk4-devel \ pipewire-devel pulseaudio-libs-devel \ gstreamer1-devel gstreamer1-plugins-base-devel \ flatpak-devel systemd-devel libgudev-devel \ openssl-devel pam-devel libxml2-devel xkeyboard-config-devel # Shorter than the others, and not by omission: Arch ships headers in the # main package rather than splitting a -devel, so `wayland` here is # `wayland-devel` on Fedora. - name: Install build dependencies (arch) if: matrix.distro == 'arch' run: | pacman -Syu --noconfirm --needed \ git curl just cmake pkgconf nasm lld mold clang llvm \ desktop-file-utils sudo intltool imagemagick ttf-opensans \ dbus expat fontconfig freetype2 \ libinput seatd libxkbcommon \ mesa libglvnd wayland libdisplay-info dav1d \ pixman cairo pango glib2 gtk3 gtk4 \ pipewire libpipewire libpulse gst-plugins-base-libs \ flatpak systemd-libs libgudev \ openssl pam libxml2 xkeyboard-config - name: Install build dependencies (debian) if: matrix.distro == 'debian' run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ git curl ca-certificates just \ build-essential cmake pkg-config nasm lld mold \ clang libclang-dev llvm-dev \ desktop-file-utils dpkg-dev fakeroot file \ intltool imagemagick fonts-open-sans \ libdbus-1-dev libexpat1-dev libfontconfig-dev libfreetype-dev \ libinput-dev libseat-dev libxkbcommon-dev \ libgbm-dev libegl-dev libegl1-mesa-dev libgles-dev \ libwayland-dev libdisplay-info-dev libdav1d-dev \ libpixman-1-dev libcairo2-dev libpango1.0-dev libglib2.0-dev \ libgtk-3-dev libgtk-4-dev \ libpipewire-0.3-dev libspa-0.2-dev libpulse-dev \ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ libflatpak-dev libsystemd-dev libudev-dev libgudev-1.0-dev \ libssl-dev libpam0g-dev libxml2-dev xkb-data libxcb1-dev # submodules: recursive is the whole point -- this repository is 27 # components plus the two forks, and a checkout without them builds # nothing. - uses: actions/checkout@v4 with: submodules: recursive # stable as the default, not `none`. # # The two fork workflows use --default-toolchain none and let # rust-toolchain.toml decide, which is right there because the checkout # root is the crate and the pin sits in it. Here it does not: the pin is # cosmic-comp/rust-toolchain.toml, one level down, and this job's other 27 # components have no pin at all. With `none` there is no default to fall # back to and the first cargo invocation at the repository root fails # before anything is built. # # Naming stable does not weaken the pin. rustup applies a directory-local # rust-toolchain.toml whenever it enters that directory and installs it on # demand, so cosmic-comp still compiles with the 1.93 it asks for while # everything else uses stable. - name: Install Rust run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --default-toolchain stable --profile minimal echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Show toolchain run: | rustup show cargo --version just --version # No Swatinem/rust-cache here, unlike the two fork workflows. # # It was tried and it fails on this repository specifically: the action # runs `cargo metadata` at the workspace root to work out what to cache, # and this root is a meta-repository with no Cargo.toml -- it is 29 # submodules, each its own crate with its own target/. The action reported # `could not find Cargo.toml in /__w/hyprcosmic/hyprcosmic` and cached # nothing. # # Listing all 29 workspaces would fix the error and create a worse # problem: their target directories come to roughly 17 GB, against a 10 GB # per-repository cache limit, so the jobs would evict each other's entries # every run and pay upload time for the privilege. A cold build is the # honest cost of a workflow that only runs on tags and on demand. - name: Determine version id: ver run: | set -eux # A tag is authoritative; a manual run uses its input; anything else # falls back so the job is still testable from a branch. if [ "${GITHUB_REF_TYPE}" = "tag" ]; then v="${GITHUB_REF_NAME#v}" else v="${{ inputs.version || '0.1.0' }}" fi echo "version=$v" >> "$GITHUB_OUTPUT" - name: Build run: just build # prefix=/usr, not the justfile's /usr/local default. Both .desktop files # name an absolute Exec under /usr/bin -- a desktop entry cannot # interpolate a prefix -- so any other prefix stages entries pointing at # paths this step did not write. - name: Stage the install run: just install "$PWD/stage" /usr # The three binaries this fork actually changes, under their own names. # # Done on every distribution, because start-hyprcosmic and the session # entry name these paths and there is no reason for those to differ per # distribution. cosmic-session takes the compositor as argv[1], so the # pair is arranged in start-hyprcosmic and needs no source change. - name: Give this fork's binaries their own names run: | set -eux mv stage/usr/bin/cosmic-comp stage/usr/bin/hyprcosmic-comp mv stage/usr/bin/cosmic-session stage/usr/bin/hyprcosmic-session mv stage/usr/bin/cosmic-conf stage/usr/bin/hyprcosmic-conf # The stock session entry goes with it, on Debian only. # # start-cosmic execs /usr/bin/cosmic-session, which the rename above just # took away, so cosmic.desktop would sit on the greeter's menu and die # silently when chosen. Everywhere else the reduction below removes both # and the distribution's own cosmic-session package supplies a working # pair; on Debian there is no such package, so they are removed here and # the greeter offers the HyprCosmic entry alone. - name: Drop the stock session entry it can no longer start if: matrix.distro == 'debian' run: | set -eux rm -f stage/usr/bin/start-cosmic rm -f stage/usr/share/wayland-sessions/cosmic.desktop # HyprCosmic installs beside COSMIC rather than over it, and this is the # step that makes that true. # # `just install` stages the whole desktop, because it builds the whole # tree. Nearly all of it is byte-identical to what the distribution # already ships, and the parts that are not are file conflicts that stop # the install outright -- 62 of them on a stock Fedora COSMIC, across 25 # packages, which rpm reports only in the transaction check, long after # dnf's dependency solving has said the transaction is fine. # # The alternative to reducing the tree is to claim all 25 packages with # Conflicts, which means erasing them to install this, which on Fedora # includes cosmic-greeter -- the display manager. A fork you can only try # by removing the desktop you would fall back to is a fork with no way # back. So everything the distribution already owns is dropped, and the # package depends on the distribution's COSMIC for it. # # Not on Debian, which has no COSMIC to depend on: neither cosmic-session # nor cosmic-comp is packaged there, in any suite. Reducing the tree there # would produce a package whose dependency can never be satisfied, so the # Debian build keeps the whole desktop it just compiled and stands alone. # Revisit when Debian packages COSMIC. - name: Reduce the staged tree to this fork's own files if: matrix.distro != 'debian' run: | set -eux # /usr/share/cosmic goes too, all of it. Those are the defaults the # compositor reads at first run, and this fork carries upstream's # copies unmodified -- every one of them is byte-identical to a file a # distribution package already owns. rpm permits two packages to own # an identical file, so keeping them would install today and then # collide the first time the distribution changed one. They arrive # with the cosmic-comp this package depends on. ( cd stage && find . \( -type f -o -type l \) -printf '%P\n' ) | while read -r p; do case $p in usr/bin/hyprcosmic-*|usr/bin/start-hyprcosmic) continue ;; usr/share/hyprcosmic/*) continue ;; usr/share/wayland-sessions/hyprcosmic.desktop) continue ;; esac rm -f "stage/$p" done find stage -type d -empty -delete # Worth failing here rather than shipping a package that is missing the # compositor. The negative assertions are the ones that would rot quietly: # nothing may return to the private libexec layout this fork used to # install into, and no cosmic-* name may come back, because either one is # a file conflict that only shows up on a machine that has COSMIC # installed -- which is every machine this is meant for. - name: Assert the staged tree is this fork and nothing else if: matrix.distro != 'debian' run: | set -eux test -x stage/usr/bin/hyprcosmic-comp test -x stage/usr/bin/hyprcosmic-session test -x stage/usr/bin/hyprcosmic-conf test -x stage/usr/bin/start-hyprcosmic test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop test -d stage/usr/share/hyprcosmic test ! -e stage/usr/libexec/hyprcosmic test ! -e stage/usr/bin/cosmic-comp test ! -e stage/usr/bin/cosmic-session test ! -e stage/usr/share/wayland-sessions/cosmic.desktop test ! -e stage/usr/share/cosmic test -z "$(find stage/usr/bin -mindepth 1 ! -name 'hyprcosmic-*' ! -name 'start-hyprcosmic')" echo "staged files: $(find stage -type f | wc -l)" # Debian is not reduced, so the assertion is the opposite one: the package # stands alone there and has to carry a desktop that starts. The renamed # three must be present under their new names, and the components the # session launches must still be in the tree rather than assumed to arrive # from a distribution package that does not exist. - name: Assert the staged tree is a complete desktop if: matrix.distro == 'debian' run: | set -eux test -x stage/usr/bin/hyprcosmic-comp test -x stage/usr/bin/hyprcosmic-session test -x stage/usr/bin/hyprcosmic-conf test -x stage/usr/bin/start-hyprcosmic test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop test -d stage/usr/share/hyprcosmic test -d stage/usr/share/cosmic test ! -e stage/usr/libexec/hyprcosmic test ! -e stage/usr/bin/cosmic-comp test ! -e stage/usr/bin/cosmic-session test ! -e stage/usr/bin/start-cosmic test ! -e stage/usr/share/wayland-sessions/cosmic.desktop for c in cosmic-settings cosmic-settings-daemon cosmic-osd cosmic-notifications; do test -x "stage/usr/bin/$c" || { echo "missing $c" >&2; exit 1; } done echo "staged files: $(find stage -type f | wc -l)" # Checked here, once, rather than in each of the three packaging recipes, # and not with desktop-file-validate. # # desktop-file-validate rejects DesktopNames -- "keys extending the format # should start with X-" -- because the Desktop Entry Specification # registers keys for application launchers, and these are session files. # DesktopNames is what a display manager reads to set XDG_CURRENT_DESKTOP, # so the session needs it. cosmic.desktop is upstream cosmic-session's # file, unchanged here apart from the Exec path, and the copy Fedora ships # as cosmic-session-1.5.0-1.fc44 fails the identical check: the validator # has no entry for the key, and every distribution ships the file anyway. # # What the validator would not have caught is the failure that actually # matters: an Exec naming a binary this package does not install puts an # entry on the greeter's menu that dies silently when chosen. So that is # what is checked, against the tree about to be packaged. - name: Check the session entries run: | set -eu for f in stage/usr/share/wayland-sessions/*.desktop; do echo "== $f" cat "$f" test "$(sed -n 1p "$f")" = '[Desktop Entry]' || { echo "$f: first line is not [Desktop Entry]" >&2; exit 1; } for key in Name Type Exec DesktopNames; do grep -q "^${key}=" "$f" || { echo "$f: no $key=" >&2; exit 1; } done grep -qx 'Type=Application' "$f" || { echo "$f: Type is not Application; a greeter will ignore it" >&2; exit 1; } exec_path=$(sed -n '0,/^Exec=/s/^Exec=//p' "$f" | cut -d' ' -f1) case $exec_path in /*) ;; *) echo "$f: Exec=$exec_path is not absolute" >&2; exit 1 ;; esac test -x "stage${exec_path}" || { echo "$f: Exec=$exec_path is not an executable this package installs" >&2 exit 1 } echo " Exec -> stage${exec_path} ok" done # ---- Fedora ------------------------------------------------------- # # The file list is generated rather than written into the spec. Across 27 # components a hand-maintained %files would be stale within a week, and # stale in the direction that omits files nobody misses until a login # fails. # # Directories need care. A %dir line for every staged directory would # have the package claim /usr, /usr/bin and /usr/share, which the # `filesystem` package owns -- the RPM would build fine and then refuse to # install, or worse, take those directories with it on uninstall. So a # directory is only claimed if no package on the build system already owns # it, which leaves exactly the ones this fork creates # (/usr/share/hyprcosmic and friends). - name: Build the RPM if: matrix.distro == 'fedora' run: | set -eux mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS} : > files.list find stage -mindepth 1 -type d -printf '%P\n' | while read -r d; do rpm -qf --quiet "/$d" || printf '%%%%dir "/%s"\n' "$d" >> files.list done find stage -mindepth 1 \! -type d -printf '"/%P"\n' >> files.list wc -l files.list rpmbuild -bb packaging/fedora/hyprcosmic.spec \ --define "_topdir $PWD/rpmbuild" \ --define "stagedir $PWD/stage" \ --define "filelist $PWD/files.list" \ --define "ver ${{ steps.ver.outputs.version }}" mkdir -p dist find rpmbuild/RPMS -name '*.rpm' -exec cp -v {} dist/ \; # A package that installs is the claim being made, so it is tested rather # than assumed. --setopt=tsflags=test does the whole resolution and # conflict check without writing to the container. - name: Verify the RPM if: matrix.distro == 'fedora' run: | set -eux rpm -qpi dist/*.rpm rpm -qp --requires dist/*.rpm dnf -y install --setopt=tsflags=test dist/*.rpm # ---- Arch --------------------------------------------------------- # # makepkg refuses to run as root, and a container is root by default, so # the build runs as an unprivileged user that owns the tree it reads. # --nodeps because the depends array names a running system's runtime # libraries, which this image has no reason to hold, and nothing is being # compiled at this point anyway. - name: Build the Arch package if: matrix.distro == 'arch' run: | set -eux useradd -m builder mkdir -p dist cp packaging/arch/PKGBUILD . chown -R builder:builder . sudo -u builder \ HYPRCOSMIC_STAGEDIR="$PWD/stage" \ HYPRCOSMIC_VERSION="${{ steps.ver.outputs.version }}" \ PKGDEST="$PWD/dist" \ makepkg --nodeps --noconfirm - name: Verify the Arch package if: matrix.distro == 'arch' run: | set -eux pacman -Qip dist/*.pkg.tar.zst # Contents rather than an install: pacman has no dry run that resolves # dependencies without touching the filesystem, and this image is not # a desktop, so a real install would fail on runtime libraries that # say nothing about whether the package is well formed. # To a file and then head: `| head` would kill pacman with SIGPIPE # once head has its 20 lines, and these steps run with pipefail. pacman -Qlp dist/*.pkg.tar.zst > contents.txt echo "entries: $(wc -l < contents.txt)" head -20 contents.txt # ---- Debian ------------------------------------------------------- # # dpkg-deb --build over a staged tree, rather than a full source package. # The Depends line is computed by dpkg-shlibdeps from the binaries # themselves rather than written by hand -- 27 components link against # more libraries than anyone will keep an accurate list of, and a hand # list is wrong in the direction that installs and then fails to start. # # dpkg-shlibdeps insists on a debian/control in the working directory even # when invoked outside a source package, hence the stub. - name: Build the Debian package if: matrix.distro == 'debian' run: | set -eux mkdir -p debian printf 'Source: hyprcosmic\n\nPackage: hyprcosmic\nArchitecture: amd64\n' > debian/control binaries=$(find stage -type f -perm -100 -exec sh -c 'file -b "$1" | grep -q ELF && echo "$1"' _ {} \;) dpkg-shlibdeps -O --ignore-missing-info $binaries > shlibdeps.txt deps=$(sed 's/^shlibs:Depends=//' shlibdeps.txt) size=$(du -sk stage | cut -f1) mkdir -p stage/DEBIAN sed -e "s|@VERSION@|${{ steps.ver.outputs.version }}|" \ -e "s|@INSTALLED_SIZE@|$size|" \ -e "s|@SHLIB_DEPENDS@|$deps|" \ packaging/debian/control.in > stage/DEBIAN/control cat stage/DEBIAN/control mkdir -p dist dpkg-deb --build --root-owner-group stage \ "dist/hyprcosmic_${{ steps.ver.outputs.version }}_amd64.deb" - name: Verify the Debian package if: matrix.distro == 'debian' run: | set -eux dpkg-deb --info dist/*.deb # To a file and then head, not `| head`. Actions runs these steps with # pipefail, and head closing the pipe after 20 lines kills dpkg-deb # with SIGPIPE, which pipefail reports as a failed step -- a green # 217 MB package failed here on nothing but that. dpkg-deb --contents dist/*.deb > contents.txt echo "entries: $(wc -l < contents.txt)" head -20 contents.txt # lintian is not installed and would fail this package on a dozen # policy points that do not apply to a desktop fork shipped outside # the archive. What matters here is that dpkg can read it back. dpkg-deb --fsys-tarfile dist/*.deb | tar -tf - >/dev/null - uses: actions/upload-artifact@v4 with: name: hyprcosmic-${{ matrix.distro }} path: dist/ retention-days: 30 # Only on a tag. A dispatch run is for getting artifacts to try, and turning # one into a public release would make every experiment look like a shipped # version. release: needs: package if: github.ref_type == 'tag' runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - name: Attach the packages to the release uses: softprops/action-gh-release@v2 with: files: dist/* # Draft, deliberately. These install beside the distribution's COSMIC # rather than over it, so a bad one costs a logout rather than a # desktop -- but a tag push is still not a decision to publish. The # generated notes are written by a machine reading commit subjects, # and the Debian package differs from the other two in what it # carries; both are worth a human reading before anyone downloads. draft: true generate_release_notes: true