From 1b9fabbbe4752f96842479811c0661734e1fee21 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 11 Oct 2026 14:16:00 +0100 Subject: [PATCH] Add Fedora and Arch package recipes (#24) * Package Ferese for Fedora and Arch releases * Build release packages alongside workspace checks * Include dependency licenses and software GPU test drivers * Limit package checks to release PRs and fix Arch Cargo dependency * Prepare Ferese 0.1.0-beta * Release config locks before closing inherited file descriptions * Install the lock PAM policy for native release checks * Include xauth in distribution package test dependencies * Regenerate Arch Git package metadata * Run package checks with an explicit UTC timezone * Remove unused bundled fonts and accept installed license receipts * Format the system font test helper * Load an installed font for headless text rendering checks * Isolate package test runtimes and reap container child processes --- .github/workflows/release-checks.yml | 42 +--- .github/workflows/release.yml | 173 +++++++++++++ Cargo.lock | 32 +-- Cargo.toml | 2 +- README.md | 2 + assets/fonts/Comfortaa-LICENSE.txt | 94 -------- assets/fonts/Comfortaa-Regular.otf | Bin 91584 -> 0 bytes compositor/src/config.rs | 5 +- compositor/src/overview.rs | 11 +- crates/ferese-config/src/file.rs | 21 +- docs/README.md | 1 + docs/installation.md | 3 + docs/packaging.md | 114 +++++++++ packaging/arch/PKGBUILD.in | 49 ++++ packaging/arch/ferese-git/.SRCINFO | 71 ++++++ packaging/arch/ferese-git/PKGBUILD | 63 +++++ packaging/fedora/ferese.spec.in | 78 ++++++ packaging/pam.d/ferese-lock.arch | 4 + scripts/build-site.py | 1 + scripts/installer/install.py | 2 + scripts/installer/inventory.json | 5 - scripts/package.py | 282 ++++++++++++++++++++++ scripts/tests/test_package.py | 112 +++++++++ shell/ferese-settings/src/scroll_tests.rs | 16 +- shell/ferese-shell/src/config.rs | 6 +- 25 files changed, 1019 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 assets/fonts/Comfortaa-LICENSE.txt delete mode 100644 assets/fonts/Comfortaa-Regular.otf create mode 100644 docs/packaging.md create mode 100644 packaging/arch/PKGBUILD.in create mode 100644 packaging/arch/ferese-git/.SRCINFO create mode 100644 packaging/arch/ferese-git/PKGBUILD create mode 100644 packaging/fedora/ferese.spec.in create mode 100644 packaging/pam.d/ferese-lock.arch create mode 100755 scripts/package.py create mode 100644 scripts/tests/test_package.py diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 92ffecd..7f0142c 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -3,12 +3,12 @@ on: pull_request: push: branches: [main] - tags: ["v*"] workflow_dispatch: + workflow_call: permissions: contents: read concurrency: - group: release-checks-${{ github.ref }} + group: release-checks-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: workspace: @@ -69,7 +69,9 @@ jobs: cargo test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored cargo test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored - name: Test native shell, visibility and resize behavior - run: python3 scripts/ci/run-native-checks.py --suite desktop --log-dir native-logs + run: | + install -m 0644 packaging/pam.d/ferese-lock.fedora /etc/pam.d/ferese-lock + python3 scripts/ci/run-native-checks.py --suite desktop --log-dir native-logs - name: Upload native test logs if: always() uses: actions/upload-artifact@v4 @@ -79,37 +81,3 @@ jobs: if-no-files-found: ignore - name: Test installer, session recovery and private portal contracts run: python3 -m unittest discover -s scripts/tests -v - - publish: - name: Publish validated tag - needs: workspace - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-24.04 - permissions: - contents: write - steps: - - uses: actions/github-script@v8 - with: - script: | - const tag = context.ref.slice('refs/tags/'.length); - const repo = context.repo; - const commit = await github.rest.repos.getCommit({...repo, ref: tag}); - if (commit.data.sha !== context.sha) { - throw new Error('Release tag moved after validation'); - } - let release; - try { - release = (await github.rest.repos.getReleaseByTag({...repo, tag})).data; - } catch (error) { - if (error.status !== 404) throw error; - } - if (release?.draft) { - await github.rest.repos.updateRelease({...repo, release_id: release.id, draft: false}); - } else if (!release) { - await github.rest.repos.createRelease({ - ...repo, tag_name: tag, name: tag, - generate_release_notes: true, prerelease: tag.includes('-') - }); - } else { - core.notice(`Release ${tag} is already published`); - } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..58c8239 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,173 @@ +name: Release packages + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_BUILD_JOBS: '2' + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_RELEASE_DEBUG: '0' + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + checks: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'release') + uses: ./.github/workflows/release-checks.yml + + source: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'release') + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v6 + - name: Read release version + id: version + env: + RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + run: | + python3 - <<'PY' + import importlib.util, os + spec = importlib.util.spec_from_file_location('package', 'scripts/package.py') + package = importlib.util.module_from_spec(spec) + spec.loader.exec_module(package) + version = package.version() + package.check_tag(os.environ['RELEASE_TAG'], version) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + print(f'version={version}', file=output) + PY + - name: Install pinned Rust toolchain + run: rustup show + - name: Package offline sources and recipes + env: + RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + run: | + python3 -m unittest discover -s scripts/tests -p test_package.py + python3 scripts/package.py source --tag "$RELEASE_TAG" + - uses: actions/upload-artifact@v7 + with: + name: release-source + path: target/dist/* + if-no-files-found: error + compression-level: 0 + + fedora: + needs: source + runs-on: ubuntu-24.04 + container: + image: fedora:44 + options: --init + steps: + - name: Install RPM tooling + run: dnf install -y rpm-build dnf5-plugins git findutils tar gzip + - uses: actions/download-artifact@v8 + with: + name: release-source + path: dist + - name: Build and test Fedora package + run: | + dnf builddep -y dist/ferese.spec + mkdir -p /tmp/rpmbuild/SOURCES + cp dist/ferese-*.tar.gz /tmp/rpmbuild/SOURCES/ + rpmbuild -ba --define '_topdir /tmp/rpmbuild' dist/ferese.spec + mkdir -p artifacts + find /tmp/rpmbuild/RPMS /tmp/rpmbuild/SRPMS -name '*.rpm' -exec cp '{}' artifacts/ \; + - uses: actions/upload-artifact@v7 + with: + name: release-fedora + path: artifacts/*.rpm + if-no-files-found: error + compression-level: 0 + + arch: + needs: source + runs-on: ubuntu-24.04 + container: + image: archlinux:base-devel + options: --init + steps: + - name: Prepare Arch build user and dependencies + run: | + pacman -Syu --noconfirm --needed rust cargo cmake clang pkgconf python git desktop-file-utils \ + wayland libxkbcommon libinput systemd seatd mesa vulkan-swrast libdrm fontconfig freetype2 expat dbus \ + pam polkit foot pipewire wireplumber libpipewire gstreamer xdg-desktop-portal xdg-desktop-portal-gtk xorg-xauth + useradd --create-home builder + - uses: actions/download-artifact@v8 + with: + name: release-source + path: dist + - name: Build and test Arch package + run: | + mkdir -p /home/builder/package + cp dist/PKGBUILD dist/ferese-*.tar.gz /home/builder/package/ + chown -R builder:builder /home/builder/package + runuser -u builder -- bash -c 'cd /home/builder/package && makepkg --cleanbuild --noconfirm' + mkdir -p artifacts + cp /home/builder/package/*.pkg.tar.zst artifacts/ + runuser -u builder -- bash -c 'cd /home/builder/package && makepkg --printsrcinfo' > artifacts/ferese.SRCINFO + - uses: actions/upload-artifact@v7 + with: + name: release-arch + path: artifacts/* + if-no-files-found: error + compression-level: 0 + + publish: + if: github.event_name == 'push' && github.ref_type == 'tag' + needs: [source, checks, fedora, arch] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + pattern: release-* + merge-multiple: true + path: artifacts + - name: Prepare draft release + uses: actions/github-script@v8 + with: + script: | + const tag = context.ref.slice('refs/tags/'.length); + const repo = context.repo; + const commit = await github.rest.repos.getCommit({...repo, ref: tag}); + if (commit.data.sha !== context.sha) { + throw new Error('Release tag moved after validation'); + } + let release; + try { + release = (await github.rest.repos.getReleaseByTag({...repo, tag})).data; + } catch (error) { + if (error.status !== 404) throw error; + } + if (release && !release.draft) { + throw new Error(`Release ${tag} is already published`); + } + if (!release) { + await github.rest.repos.createRelease({ + ...repo, tag_name: tag, name: tag, draft: true, + generate_release_notes: true, prerelease: tag.includes('-') + }); + } + - name: Upload packages and publish completed release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + cd artifacts + rm SHA256SUMS + sha256sum * > SHA256SUMS + gh release upload "$RELEASE_TAG" ./* --clobber + test "$(gh api "repos/$GH_REPO/commits/$RELEASE_TAG" --jq .sha)" = "$GITHUB_SHA" + gh release edit "$RELEASE_TAG" --draft=false diff --git a/Cargo.lock b/Cargo.lock index c3e2093..2830377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1735,7 +1735,7 @@ dependencies = [ [[package]] name = "ferese" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ab_glyph", "async-channel", @@ -1766,7 +1766,7 @@ dependencies = [ [[package]] name = "ferese-animation" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-layout", "serde", @@ -1774,7 +1774,7 @@ dependencies = [ [[package]] name = "ferese-config" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-theme-model", "jiff", @@ -1786,7 +1786,7 @@ dependencies = [ [[package]] name = "ferese-core" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-layout", "serde", @@ -1794,7 +1794,7 @@ dependencies = [ [[package]] name = "ferese-ipc" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-theme-model", "libc", @@ -1804,11 +1804,11 @@ dependencies = [ [[package]] name = "ferese-layout" -version = "0.1.0" +version = "0.1.0-beta" [[package]] name = "ferese-lock" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-config", "ferese-ipc", @@ -1828,7 +1828,7 @@ dependencies = [ [[package]] name = "ferese-polkit-agent" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-config", "ferese-ipc", @@ -1847,7 +1847,7 @@ dependencies = [ [[package]] name = "ferese-protocols" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "bitflags 2.13.2", "wayland-backend", @@ -1859,7 +1859,7 @@ dependencies = [ [[package]] name = "ferese-settings" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-config", "ferese-ipc", @@ -1884,7 +1884,7 @@ source = "git+https://github.com/ferese-wm/ferese-shape?tag=v0.1.0#63d7b6dfd520d [[package]] name = "ferese-shell" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "base64", "ferese-animation", @@ -1912,7 +1912,7 @@ dependencies = [ [[package]] name = "ferese-theme" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-config", "ferese-theme-model", @@ -1924,7 +1924,7 @@ dependencies = [ [[package]] name = "ferese-theme-client" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "ferese-config", "ferese-ipc", @@ -1938,14 +1938,14 @@ dependencies = [ [[package]] name = "ferese-theme-model" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "serde", ] [[package]] name = "feresectl" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "clap", "ferese-config", @@ -7702,7 +7702,7 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "xdg-desktop-portal-ferese" -version = "0.1.0" +version = "0.1.0-beta" dependencies = [ "calloop 0.14.4", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 98932ac..74e30d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.1.0" +version = "0.1.0-beta" edition = "2024" license = "MIT" rust-version = "1.95" diff --git a/README.md b/README.md index f3f6f0d..2532929 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ also include desktop controls; their features depend on the shell and tools you ## Install +Fedora and Arch package builds are covered in [Packages and releases](docs/packaging.md). + Start with the [dependencies](docs/installation.md#requirements), then clone Ferese and build it as your normal user: diff --git a/assets/fonts/Comfortaa-LICENSE.txt b/assets/fonts/Comfortaa-LICENSE.txt deleted file mode 100644 index 8db99f7..0000000 --- a/assets/fonts/Comfortaa-LICENSE.txt +++ /dev/null @@ -1,94 +0,0 @@ -Copyright 2011 The Comfortaa Project Authors -(https://github.com/alexeiva/comfortaa), with Reserved Font Name "Comfortaa". - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Comfortaa-Regular.otf b/assets/fonts/Comfortaa-Regular.otf deleted file mode 100644 index b46406506671c6a2a1df24cb836087e358fb326d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 91584 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

PcRWEfX>pA1UKD#EhBr8W27qvWlJ_yi7 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 BulN7} diff --git a/compositor/src/config.rs b/compositor/src/config.rs index cc0b551..1595ac2 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -642,8 +642,9 @@ mod tests { } #[test] - fn removed_desktop_widgets_are_rejected_before_runtime_publication() { - assert!(Config::parse_source("desktop-widgets { clock { enabled #true; }; }").is_err()); + fn removed_desktop_widgets_allow_runtime_publication() { + let config = Config::parse_source("desktop-widgets { clock { enabled #true; }; }").unwrap(); + config.runtime_config().unwrap(); } fn parse(source: &str) -> Config { diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 7486c97..353b74b 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -1165,7 +1165,16 @@ mod tests { } fn test_font() -> FontArc { - FontArc::try_from_slice(include_bytes!("../../assets/fonts/Comfortaa-Regular.otf")).unwrap() + let mut database = fontdb::Database::new(); + database.load_system_fonts(); + let font = database.faces().find_map(|face| { + database.with_face_data(face.id, |bytes, index| { + FontVec::try_from_vec_and_index(bytes.to_vec(), index) + .ok() + .map(FontArc::new) + })? + }); + font.expect("overview rendering tests require a system font") } #[test] diff --git a/crates/ferese-config/src/file.rs b/crates/ferese-config/src/file.rs index 384c793..21b8a44 100644 --- a/crates/ferese-config/src/file.rs +++ b/crates/ferese-config/src/file.rs @@ -44,13 +44,13 @@ impl ConfigFile { fs::TryLockError::Error(error) => error.to_string(), })?; - let source = read(&path)?; - - Ok(Self { + let mut file = Self { path, - source, + source: String::new(), _lock: lock, - }) + }; + file.source = read(&file.path)?; + Ok(file) } pub fn source(&self) -> &str { @@ -102,6 +102,14 @@ impl ConfigFile { } } +impl Drop for ConfigFile { + fn drop(&mut self) { + // A spawned child can briefly retain the open-file description after our + // descriptor closes. Release the lock before closing the owner's handle. + let _ = self._lock.unlock(); + } +} + fn read(path: &Path) -> Result { let file = match File::open(path) { Ok(file) => file, @@ -161,6 +169,8 @@ mod tests { std::os::unix::fs::symlink(&path, &alias).unwrap(); let file = ConfigFile::open(&path).unwrap(); assert!(ConfigFile::open(&alias).is_err()); + // A concurrent process spawn can briefly inherit this open-file description. + let inherited = file._lock.try_clone().unwrap(); drop(file); ConfigFile::open(&alias) .unwrap() @@ -168,6 +178,7 @@ mod tests { .unwrap(); assert!(alias.is_symlink()); assert_eq!(fs::read_to_string(path).unwrap(), "// updated"); + drop(inherited); } #[test] diff --git a/docs/README.md b/docs/README.md index bd280f5..415f17e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ ## Develop Ferese +- [Packages and releases](packaging.md): build Fedora and Arch packages and publish a tagged release. - [Development](development.md): build Ferese, run tests and check portal contracts. - [Nested resize checks](nested-resize-testing.md). - [Client soak testing](native-soak-testing.md). diff --git a/docs/installation.md b/docs/installation.md index e1c3a05..5f5d2e3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,6 +3,9 @@ Ferese runs on Linux as its own Wayland session alongside your existing desktop. Launch it from a login manager, a command-based greeter or a local TTY. +For Fedora and Arch packages, see [Packages and releases](packaging.md). The +steps below build and install from source. + ## Requirements For a full session, you’ll need a Wayland-capable graphics driver and a local user diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 0000000..ed57c40 --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,114 @@ +# Packages and releases + +Ferese ships as one package. It includes the compositor, shell, Settings, lock +screen, authentication agent, recording helper and desktop portal. + +The release workflow builds an x86_64 RPM on Fedora 44 and an x86_64 Arch package. +COPR and AUR publication are separate steps; neither repository is set up by this +workflow. + +## Install a release package + +Download the package for your distribution from +[GitHub Releases](https://github.com/ferese-wm/ferese/releases), then install it: + +```sh +# Fedora: select the ferese RPM, not the debuginfo or source RPM. +sudo dnf install ./ferese--1.fc44.x86_64.rpm + +# Arch Linux +sudo pacman -U ./ferese--1-x86_64.pkg.tar.zst +``` + +Select Ferese in your login manager, or run `ferese-session` from a local TTY. +The package does not replace your login manager or modify your personal config. +To start with the supplied config: + +```sh +mkdir -p ~/.config/ferese +cp -n /usr/libexec/ferese/config.example.kdl ~/.config/ferese/config.kdl +``` + +If you installed with `install.sh`, uninstall that copy before installing a +package. Its `/usr/local/bin` launchers take precedence over `/usr/bin`, and its +portal service can override the packaged service. From the original checkout: + +```sh +sudo ./scripts/install-session.sh uninstall +``` + +Keep your personal config. Log out before changing the installed session, then +log back in once the package is installed. + +## Build packages locally + +With Python 3.11 or newer and the pinned Rust toolchain installed, prepare the +source archive and versioned recipes from a checkout: + +```sh +python3 scripts/package.py source +``` + +This downloads the locked Rust dependencies and includes an offline Cargo cache. +Ferese uses two packages with the same name and version from different sources, +which `cargo vendor` cannot represent. The cache preserves those source identities +and includes the Git submodules needed by libcosmic. It contains no Cargo +credentials or user-level Cargo configuration. + +The resulting files are in `target/dist`: a source archive, Fedora spec, Arch +PKGBUILD and source checksum. Package builds run offline. Dependency license +notices and a JSON report of their sources and SPDX licenses ship with the package. + +### Fedora + +```sh +sudo dnf install rpm-build dnf5-plugins +sudo dnf builddep target/dist/ferese.spec +mkdir -p ~/rpmbuild/SOURCES +cp target/dist/ferese-*.tar.gz ~/rpmbuild/SOURCES/ +rpmbuild -ba target/dist/ferese.spec +``` + +The binary RPMs are in `~/rpmbuild/RPMS`; the source RPM is in `~/rpmbuild/SRPMS`. +Build with `--with resize_metrics` to enable resize-barrier measurements. +The source RPM can also be submitted to COPR once a project is configured. + +### Arch Linux + +Build as a regular user with the dependencies listed in the generated PKGBUILD: + +```sh +cd target/dist +makepkg -s +``` + +For a development build, copy `packaging/arch/ferese-git/PKGBUILD` into an empty +build directory and run `makepkg -s`. It tracks `main` and conflicts with the +release package. Submit that recipe to AUR separately if you want AUR distribution. + +## Publish a release + +1. Set `workspace.package.version` in `Cargo.toml` and update `Cargo.lock`. +2. Commit the release changes and push them. +3. Tag that commit with the same version, prefixed by `v`, then push the tag: + + ```sh + git tag v0.1.0-beta + git push origin v0.1.0-beta + ``` + +A tag/version mismatch fails before packaging. Tags with a prerelease suffix, +such as `v0.2.0-rc.1`, produce a GitHub prerelease. + +The workflow builds and tests both packages before publishing. Release assets +include the offline source archive, binary and source RPMs, Arch package, +PKGBUILD, `.SRCINFO`, Fedora spec and SHA-256 checksums. A failed upload leaves a +draft; rerunning the workflow can finish that draft. Published releases are not +overwritten. + +Label a release preparation PR `release` to run the package builds before +merging. You can also use **Run workflow** to test a branch without publishing +or creating a tag. Fedora and Arch jobs run the workspace tests under a private +D-Bus session and validate the installed desktop files. They do not test real +DRM hardware; check login, locking, authentication, screen sharing and recording +on each distribution before announcing a release. diff --git a/packaging/arch/PKGBUILD.in b/packaging/arch/PKGBUILD.in new file mode 100644 index 0000000..3af303d --- /dev/null +++ b/packaging/arch/PKGBUILD.in @@ -0,0 +1,49 @@ +pkgname=ferese +pkgver=@VERSION@ +pkgrel=1 +pkgdesc='Scrolling Wayland desktop with its shell, Settings and desktop portal' +arch=('x86_64') +url='https://github.com/ferese-wm/ferese' +license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception') +depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm' + 'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire' + 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') +makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils') +checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') +optdepends=('brightnessctl: brightness controls' 'slurp: screenshot region selection' + 'satty: screenshot editing' 'wl-clipboard: screenshot clipboard' + 'gst-plugins-base: recording codecs' 'gst-plugins-good: recording codecs' + 'gst-plugin-pipewire: PipeWire recording' + 'xorg-xwayland: X11 applications' 'xwayland-satellite: X11 application integration (requires readiness support)') +# GCC LTO objects from the SPA C wrappers cannot be linked by Rust's lld. +options=('!lto') +backup=('etc/pam.d/ferese-lock') +conflicts=('ferese-git') +_source_version=@UPSTREAM_VERSION@ +source=("$url/releases/download/v$_source_version/ferese-$_source_version.tar.gz") +sha256sums=('@SHA256@') + +build() { + cd "$srcdir/ferese-$_source_version" + export CARGO_HOME="$PWD/cargo-home" + export CARGO_PROFILE_RELEASE_DEBUG=0 + cargo build --release --frozen --offline --workspace +} + +check() { + cd "$srcdir/ferese-$_source_version" + export CARGO_HOME="$PWD/cargo-home" + export CARGO_PROFILE_RELEASE_DEBUG=0 + export TZ=UTC + export XDG_RUNTIME_DIR="$(mktemp -d)" + trap 'rm -r -- "$XDG_RUNTIME_DIR"' EXIT + python -m unittest discover -s scripts/tests -p test_package.py + dbus-run-session -- cargo test --release --frozen --offline --workspace +} + +package() { + cd "$srcdir/ferese-$_source_version" + python scripts/package.py stage --destdir "$pkgdir" --pam arch + desktop-file-validate "$pkgdir/usr/share/wayland-sessions/ferese.desktop" + desktop-file-validate "$pkgdir/usr/share/applications/dev.ferese.Settings.desktop" +} diff --git a/packaging/arch/ferese-git/.SRCINFO b/packaging/arch/ferese-git/.SRCINFO new file mode 100644 index 0000000..a610861 --- /dev/null +++ b/packaging/arch/ferese-git/.SRCINFO @@ -0,0 +1,71 @@ +pkgbase = ferese-git + pkgdesc = Scrolling Wayland desktop with its shell, Settings and desktop portal (Git) + pkgver = 0.1.0beta.r0.g0000000 + pkgrel = 1 + url = https://github.com/ferese-wm/ferese + arch = x86_64 + license = MIT + license = Apache-2.0 + license = BSD-2-Clause + license = BSD-3-Clause + license = BSL-1.0 + license = CC0-1.0 + license = CDLA-Permissive-2.0 + license = ISC + license = MPL-2.0 + license = Unicode-3.0 + license = Unlicense + license = Zlib + license = OFL-1.1 + license = Apache-2.0 WITH LLVM-exception + checkdepends = dbus + checkdepends = vulkan-swrast + checkdepends = xorg-xauth + makedepends = cargo + makedepends = cmake + makedepends = clang + makedepends = pkgconf + makedepends = python + makedepends = git + makedepends = desktop-file-utils + depends = wayland + depends = libxkbcommon + depends = libinput + depends = systemd-libs + depends = seatd + depends = mesa + depends = libdrm + depends = fontconfig + depends = freetype2 + depends = expat + depends = dbus + depends = python + depends = bash + depends = util-linux + depends = systemd + depends = pam + depends = polkit + depends = foot + depends = pipewire + depends = wireplumber + depends = libpipewire + depends = gstreamer + depends = xdg-desktop-portal + depends = xdg-desktop-portal-gtk + optdepends = brightnessctl: brightness controls + optdepends = slurp: screenshot region selection + optdepends = satty: screenshot editing + optdepends = wl-clipboard: screenshot clipboard + optdepends = gst-plugins-base: recording codecs + optdepends = gst-plugins-good: recording codecs + optdepends = gst-plugin-pipewire: PipeWire recording + optdepends = xorg-xwayland: X11 applications + optdepends = xwayland-satellite: X11 application integration (requires readiness support) + provides = ferese + conflicts = ferese + options = !lto + backup = etc/pam.d/ferese-lock + source = ferese::git+https://github.com/ferese-wm/ferese.git#branch=main + sha256sums = SKIP + +pkgname = ferese-git diff --git a/packaging/arch/ferese-git/PKGBUILD b/packaging/arch/ferese-git/PKGBUILD new file mode 100644 index 0000000..da12f3c --- /dev/null +++ b/packaging/arch/ferese-git/PKGBUILD @@ -0,0 +1,63 @@ +pkgname=ferese-git +pkgver=0.1.0beta.r0.g0000000 +pkgrel=1 +pkgdesc='Scrolling Wayland desktop with its shell, Settings and desktop portal (Git)' +arch=('x86_64') +url='https://github.com/ferese-wm/ferese' +license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception') +depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm' + 'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire' + 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') +makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils') +checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') +optdepends=('brightnessctl: brightness controls' 'slurp: screenshot region selection' + 'satty: screenshot editing' 'wl-clipboard: screenshot clipboard' + 'gst-plugins-base: recording codecs' 'gst-plugins-good: recording codecs' + 'gst-plugin-pipewire: PipeWire recording' + 'xorg-xwayland: X11 applications' 'xwayland-satellite: X11 application integration (requires readiness support)') +provides=('ferese') +conflicts=('ferese') +# GCC LTO objects from the SPA C wrappers cannot be linked by Rust's lld. +options=('!lto') +backup=('etc/pam.d/ferese-lock') +source=('ferese::git+https://github.com/ferese-wm/ferese.git#branch=main') +sha256sums=('SKIP') + +pkgver() { + cd "$srcdir/ferese" + local version + version=$(python -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["package"]["version"])') + printf '%s.r%s.g%s' "${version//-/}" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" +} + +prepare() { + cd "$srcdir/ferese" + export CARGO_HOME="$srcdir/cargo-home" + cargo fetch --locked +} + +build() { + cd "$srcdir/ferese" + export CARGO_HOME="$srcdir/cargo-home" + export CARGO_PROFILE_RELEASE_DEBUG=0 + cargo build --release --frozen --offline --workspace +} + +check() { + cd "$srcdir/ferese" + export CARGO_HOME="$srcdir/cargo-home" + export CARGO_PROFILE_RELEASE_DEBUG=0 + export TZ=UTC + export XDG_RUNTIME_DIR="$(mktemp -d)" + trap 'rm -r -- "$XDG_RUNTIME_DIR"' EXIT + python -m unittest discover -s scripts/tests -p test_package.py + dbus-run-session -- cargo test --release --frozen --offline --workspace +} + +package() { + cd "$srcdir/ferese" + export CARGO_HOME="$srcdir/cargo-home" + python scripts/package.py stage --destdir "$pkgdir" --pam arch + desktop-file-validate "$pkgdir/usr/share/wayland-sessions/ferese.desktop" + desktop-file-validate "$pkgdir/usr/share/applications/dev.ferese.Settings.desktop" +} diff --git a/packaging/fedora/ferese.spec.in b/packaging/fedora/ferese.spec.in new file mode 100644 index 0000000..a1462b7 --- /dev/null +++ b/packaging/fedora/ferese.spec.in @@ -0,0 +1,78 @@ +# dwz cannot compress the DWARF 5 sections emitted by Rust. Keep debug files +# without the optional DWARF compression pass. +%global _find_debuginfo_dwz_opts %{nil} +%global upstream_version @UPSTREAM_VERSION@ +%bcond resize_metrics 0 + +Name: ferese +Version: @VERSION@ +Release: 1%{?dist} +Summary: Scrolling Wayland desktop +# Includes vendored Rust libraries and theme palettes. Full notices ship +# under /usr/share/licenses/ferese, including per-dependency SPDX metadata. +License: MIT AND Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause AND BSL-1.0 AND CC0-1.0 AND CDLA-Permissive-2.0 AND ISC AND MPL-2.0 AND Unicode-3.0 AND Unlicense AND Zlib AND OFL-1.1 AND (Apache-2.0 WITH LLVM-exception) +URL: https://github.com/ferese-wm/ferese +Source0: %{url}/releases/download/v%{upstream_version}/ferese-%{upstream_version}.tar.gz + +BuildRequires: cargo >= 1.95 +BuildRequires: rust >= 1.95 +BuildRequires: gcc-c++ cmake make clang clang-devel python3 pkgconf-pkg-config +BuildRequires: wayland-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel +BuildRequires: mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel freetype-devel expat-devel +BuildRequires: mesa-dri-drivers mesa-vulkan-drivers +BuildRequires: pipewire-devel gstreamer1-devel dbus-daemon desktop-file-utils polkit-libs pam +BuildRequires: xorg-x11-xauth +Requires: pam polkit polkit-libs foot pipewire wireplumber python3 bash util-linux dbus-daemon dbus-tools systemd +Requires: xdg-desktop-portal xdg-desktop-portal-gtk +# Loaded at runtime, so ELF dependency detection does not find these libraries. +Requires: libEGL.so.1()(64bit) fontconfig freetype +Recommends: brightnessctl slurp wl-clipboard satty +Recommends: gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer + +%description +Ferese includes a scrolling Wayland compositor, desktop shell, Settings, +lock screen, authentication agent and desktop portal in one package. + +%prep +%autosetup -n ferese-%{upstream_version} + +%build +export CARGO_HOME="$PWD/cargo-home" +export CARGO_PROFILE_RELEASE_DEBUG=0 +%if %{with resize_metrics} +cargo build --release --frozen --offline --workspace --features ferese/resize-metrics +%else +cargo build --release --frozen --offline --workspace +%endif + +%install +python3 scripts/package.py stage --destdir "%{buildroot}" --pam fedora +desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/ferese.desktop" +desktop-file-validate "%{buildroot}%{_datadir}/applications/dev.ferese.Settings.desktop" + +%check +export CARGO_HOME="$PWD/cargo-home" +export CARGO_PROFILE_RELEASE_DEBUG=0 +export TZ=UTC +export XDG_RUNTIME_DIR="$(mktemp -d)" +trap 'rm -r -- "$XDG_RUNTIME_DIR"' EXIT +python3 -m unittest discover -s scripts/tests -p test_package.py +dbus-run-session -- cargo test --release --frozen --offline --workspace + +%files +%{_bindir}/ferese* +%{_bindir}/xdg-desktop-portal-ferese +%{_libexecdir}/ferese/ +%{_datadir}/ferese/ +%{_datadir}/wayland-sessions/ferese.desktop +%{_datadir}/applications/dev.ferese.Settings.desktop +%{_datadir}/icons/hicolor/scalable/apps/ferese.svg +%{_datadir}/icons/hicolor/scalable/apps/dev.ferese.Settings.svg +%{_datadir}/xdg-desktop-portal/portals/ferese.portal +%{_datadir}/xdg-desktop-portal/ferese-portals.conf +%{_datadir}/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service +/usr/lib/systemd/user/ferese-session.target +/usr/lib/systemd/user/xdg-desktop-portal-ferese.service +%config(noreplace) %{_sysconfdir}/pam.d/ferese-lock +%license %{_datadir}/licenses/ferese/ +%doc %{_datadir}/doc/ferese/ diff --git a/packaging/pam.d/ferese-lock.arch b/packaging/pam.d/ferese-lock.arch new file mode 100644 index 0000000..7dfa981 --- /dev/null +++ b/packaging/pam.d/ferese-lock.arch @@ -0,0 +1,4 @@ +#%PAM-1.0 +# Authenticate the session owner without opening another PAM session. +auth include system-auth +account include system-auth diff --git a/scripts/build-site.py b/scripts/build-site.py index d428f80..26bf106 100644 --- a/scripts/build-site.py +++ b/scripts/build-site.py @@ -16,6 +16,7 @@ PAGES = [ ('screenshots', 'Screenshots', ROOT / 'docs/screenshots.md'), ('why-ferese', 'Why Ferese', ROOT / 'docs/why-ferese.md'), ('installation', 'Installation', ROOT / 'docs/installation.md'), + ('packaging', 'Packages and releases', ROOT / 'docs/packaging.md'), ('configuration', 'Configuration', ROOT / 'docs/configuration.md'), ('animation-model', 'Animation model', ROOT / 'docs/animation-model.md'), ('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'), diff --git a/scripts/installer/install.py b/scripts/installer/install.py index 25d3897..3051f44 100644 --- a/scripts/installer/install.py +++ b/scripts/installer/install.py @@ -147,6 +147,8 @@ def validate_bundle(bundle, *, installed=False): # verifiable for upgrades and rollback without adding it to new bundles. if allowed is None and installed and re.fullmatch(r'wallpapers/[A-Za-z0-9._-]+\.jpe?g', target): allowed = {'target': target, 'mode': '0644'} + if allowed is None and installed and target in {'licenses/Cantarell-LICENSE.txt', 'licenses/Comfortaa-LICENSE.txt'}: + allowed = {'target': target, 'mode': '0644'} if allowed is None and target == 'wallpapers/ferese.svg': allowed = {'target': target, 'mode': '0644'} if allowed is None and re.fullmatch(r'licenses/theme-[A-Za-z0-9._-]+\.txt', target): diff --git a/scripts/installer/inventory.json b/scripts/installer/inventory.json index 5e838c4..2d85700 100644 --- a/scripts/installer/inventory.json +++ b/scripts/installer/inventory.json @@ -85,11 +85,6 @@ "target": "config.example.kdl", "mode": "0644" }, - { - "source": "assets/fonts/Comfortaa-LICENSE.txt", - "target": "licenses/Comfortaa-LICENSE.txt", - "mode": "0644" - }, { "source": "assets/themes/licenses/*.txt", "target": "licenses/theme-{name}", diff --git a/scripts/package.py b/scripts/package.py new file mode 100755 index 0000000..256187c --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Prepare offline release sources and stage the complete distro package.""" +import argparse +import gzip +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import tempfile +import tomllib + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args, cwd=ROOT, env=None): + return subprocess.check_output(args, cwd=cwd, env=env, text=True) + + +def version(): + return tomllib.loads((ROOT / 'Cargo.toml').read_text())['workspace']['package']['version'] + + +def check_tag(tag, release_version): + if not re.fullmatch(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?', release_version): + raise ValueError(f'Unsupported release version: {release_version}') + + if tag and tag != f'v{release_version}': + raise ValueError(f'Tag {tag} does not match Cargo version {release_version}') + + +def licenses(destination, metadata): + destination.mkdir(parents=True, exist_ok=True) + report = [] + for package in sorted(metadata['packages'], key=lambda p: (p['name'], p['version'])): + if package['source'] is None: + continue + + directory = Path(package['manifest_path']).parent + source_hash = hashlib.sha256(package['source'].encode()).hexdigest()[:8] + name = f"{package['name']}-{package['version']}-{source_hash}" + files = set() + search = [directory] + if package['source'].startswith('git+'): + for ancestor in directory.parents: + search.append(ancestor) + if (ancestor / '.git').exists(): + break + + for parent in search: + for pattern in ('LICENSE*', 'LICENCE*', 'COPYING*', 'NOTICE*', 'UNLICENSE*'): + files.update(parent.glob(pattern)) + + if package.get('license_file'): + files.add(directory / package['license_file']) + + for path in sorted(files): + if path.is_file(): + repeated = sum(other.name == path.name for other in files) > 1 + filename = f'{path.parent.name}-{path.name}' if repeated else path.name + target = destination / name / filename + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, target) + + report.append({'name': package['name'], 'version': package['version'], + 'source': package['source'], 'license': package['license'], + 'notice_directory': name}) + + (destination / 'dependencies.json').write_text(json.dumps(report, indent=2) + '\n') + + +def stage(destdir, binary_dir, pam): + destdir = Path(destdir).absolute() + if destdir == Path('/') or destdir.is_symlink(): + raise ValueError('DESTDIR must be a private package staging directory') + + if destdir.exists() and any(destdir.iterdir()): + raise ValueError('DESTDIR must be empty') + + inventory = json.loads((ROOT / 'scripts/installer/inventory.json').read_text()) + planned = [] + links = [] + for entry in inventory['files']: + if entry['target'].startswith('pam/'): + continue + + if entry['source'].startswith('target/release/'): + sources = [Path(binary_dir).absolute() / Path(entry['source']).name] + else: + sources = sorted(ROOT.glob(entry['source'])) + if not sources: + raise ValueError(f"Missing package asset: {entry['source']}") + + for source in sources: + + target = entry['target'].replace('{name}', source.name) + if 'destination' in entry: + target = entry['destination'].replace('/usr/local/', '/usr/').lstrip('/') + elif target.startswith(('wallpapers/', 'licenses/')): + prefix = 'usr/share/ferese' if target.startswith('wallpapers/') else 'usr/share' + if target.startswith('licenses/'): + target = 'licenses/ferese/' + target.removeprefix('licenses/') + target = f'{prefix}/{target}' + else: + target = f'usr/libexec/ferese/{target}' + + planned.append((source, target, int(entry['mode'], 8))) + if entry.get('link'): + links.append((f'usr/bin/{source.name}', f'/usr/libexec/ferese/{source.name}')) + + planned.extend([(ROOT / f'packaging/pam.d/ferese-lock.{pam}', 'etc/pam.d/ferese-lock', 0o644), + (ROOT / 'LICENSE', 'usr/share/licenses/ferese/LICENSE', 0o644), + (ROOT / 'README.md', 'usr/share/doc/ferese/README.md', 0o644)]) + # Validate the complete inventory before writing any part of the package. + for source, _, _ in planned: + if not source.is_file(): + raise ValueError(f'Missing package file: {source}') + for source, target, mode in planned: + destination = destdir / target + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + destination.chmod(mode) + if source.suffix in ('.desktop', '.service', '.target', '.portal', '.conf'): + text = destination.read_text().replace('/usr/local/lib/ferese/current/', '/usr/libexec/ferese/') + destination.write_text(text.replace('/usr/local/', '/usr/')) + + bundled = ROOT / 'bundled-licenses' + if bundled.exists(): + shutil.copytree(bundled, destdir / 'usr/share/licenses/ferese/dependencies', dirs_exist_ok=True) + else: + metadata = json.loads(run('cargo', 'metadata', '--locked', '--offline', '--format-version', '1')) + licenses(destdir / 'usr/share/licenses/ferese/dependencies', metadata) + + for path in (destdir / 'usr/share/licenses/ferese').rglob('*'): + if path.is_file(): + path.chmod(0o644) + + for path in destdir.rglob('*'): + if path.is_dir() and not path.is_symlink(): + path.chmod(0o755) + + links.append(('usr/libexec/ferese/wallpapers', '/usr/share/ferese/wallpapers')) + for target, source in links: + destination = destdir / target + destination.parent.mkdir(parents=True, exist_ok=True) + destination.symlink_to(source) + + +def source(output, tag, offline): + release_version = version() + check_tag(tag, release_version) + output = Path(output).absolute() + output.mkdir(parents=True, exist_ok=True) + epoch = int(os.environ.get('SOURCE_DATE_EPOCH', run('git', 'show', '-s', '--format=%ct', 'HEAD').strip())) + with tempfile.TemporaryDirectory(prefix='ferese-source-') as temporary: + tree = Path(temporary) / f'ferese-{release_version}' + tree.mkdir() + # Include new packaging files in local dry runs; release checkouts are clean. + files = run('git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split('\0') + for filename in sorted(set(files) - {''}): + path = ROOT / filename + if not path.is_file(): + continue + + destination = tree / filename + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + + fetch = ['cargo', 'fetch', '--locked'] + if offline: + fetch.append('--offline') + + run(*fetch, cwd=tree) + metadata = json.loads(run('cargo', 'metadata', '--locked', '--offline', '--format-version', '1', cwd=tree)) + licenses(tree / 'bundled-licenses', metadata) + # cargo vendor cannot represent dpi 0.1.2 from both crates.io and Git. + # Preserve source identities with an isolated, credential-free Cargo cache. + cache = tree / 'cargo-home' + for package in metadata['packages']: + package_source = package['source'] + if package_source is None: + continue + + manifest = Path(package['manifest_path']) + if package_source.startswith('registry+'): + registry = manifest.parent.parent.name + registry_home = manifest.parent.parent.parent.parent + name = package['name'] + crate = f"{name}-{package['version']}.crate" + for relative in (f'cache/{registry}/{crate}', f'index/{registry}/config.json'): + original = registry_home / relative + destination = cache / 'registry' / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(original, destination) + + if len(name) == 1: + index_path = f'1/{name}' + elif len(name) == 2: + index_path = f'2/{name}' + elif len(name) == 3: + index_path = f'3/{name[0]}/{name}' + else: + index_path = f'{name[:2]}/{name[2:4]}/{name}' + + relative = f'index/{registry}/.cache/{index_path}' + destination = cache / 'registry' / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(registry_home / relative, destination) + elif package_source.startswith('git+'): + checkout = next(parent for parent in manifest.parents if parent.parent.name == 'checkouts') + original = checkout.parent.parent / 'db' / checkout.name + destination = cache / 'git/db' / checkout.name + if not destination.exists(): + shutil.copytree(original, destination) + + # Cargo cannot fetch Git submodules in frozen mode. Carry the + # completed checkout, including .cargo-ok and submodule objects. + revision = next(parent for parent in manifest.parents if parent.parent == checkout) + if (revision / '.gitmodules').exists(): + ready = cache / 'git/checkouts' / checkout.name / revision.name + if not ready.exists(): + shutil.copytree(revision, ready) + else: + raise ValueError(f'Unsupported dependency source: {package_source}') + + environment = dict(os.environ, CARGO_HOME=str(cache)) + run('cargo', 'metadata', '--frozen', '--offline', '--format-version', '1', cwd=tree, env=environment) + # Cargo recreates these from the checked source archives and Git objects. + for relative in ('registry/src',): + shutil.rmtree(cache / relative, ignore_errors=True) + + archive = output / f'ferese-{release_version}.tar.gz' + with archive.open('wb') as raw, gzip.GzipFile(filename='', fileobj=raw, mode='wb', mtime=epoch) as zipped: + with tarfile.open(fileobj=zipped, mode='w') as tar: + for path in [tree, *sorted(tree.rglob('*'))]: + info = tar.gettarinfo(str(path), arcname=str(path.relative_to(tree.parent))) + info.uid = info.gid = 0 + info.uname = info.gname = '' + info.mtime = epoch + # The invoking user's umask must not change release permissions. + info.mode = 0o755 if info.isdir() or info.mode & 0o111 else 0o644 + if info.isfile(): + with path.open('rb') as contents: + tar.addfile(info, contents) + else: + tar.addfile(info) + + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + recipe = (ROOT / 'packaging/arch/PKGBUILD.in').read_text() + recipe = recipe.replace('@VERSION@', release_version.replace('-', '')) + recipe = recipe.replace('@UPSTREAM_VERSION@', release_version).replace('@SHA256@', digest) + (output / 'PKGBUILD').write_text(recipe) + spec = (ROOT / 'packaging/fedora/ferese.spec.in').read_text() + (output / 'ferese.spec').write_text(spec.replace('@VERSION@', release_version.replace('-', '~')) + .replace('@UPSTREAM_VERSION@', release_version)) + (output / 'SHA256SUMS').write_text(f'{digest} {archive.name}\n') + print(archive) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + stage_parser = commands.add_parser('stage') + stage_parser.add_argument('--destdir', required=True) + stage_parser.add_argument('--binary-dir', default=str(ROOT / 'target/release')) + stage_parser.add_argument('--pam', choices=['fedora', 'arch'], required=True) + source_parser = commands.add_parser('source') + source_parser.add_argument('--output', default='target/dist') + source_parser.add_argument('--tag') + source_parser.add_argument('--offline', action='store_true') + args = parser.parse_args() + if args.command == 'stage': + stage(args.destdir, args.binary_dir, args.pam) + else: + source(args.output, args.tag, args.offline) + + +if __name__ == '__main__': + main() diff --git a/scripts/tests/test_package.py b/scripts/tests/test_package.py new file mode 100644 index 0000000..726ca7a --- /dev/null +++ b/scripts/tests/test_package.py @@ -0,0 +1,112 @@ +"""Package layout and release-input regressions; no root or network needed.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('package', Path(__file__).resolve().parents[1] / 'package.py') +PACKAGE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PACKAGE) + + +class PackageTests(unittest.TestCase): + def test_release_tag_must_match_workspace_version(self): + PACKAGE.check_tag('v0.1.0', '0.1.0') + PACKAGE.check_tag('v0.2.0-rc.1', '0.2.0-rc.1') + with self.assertRaises(ValueError): + PACKAGE.check_tag('v0.2.0', '0.1.0') + + def test_refuses_live_root(self): + with self.assertRaises(ValueError): + PACKAGE.stage('/', '/missing', 'fedora') + + def test_refuses_existing_content_and_symlink_destinations(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + destination = root / 'dest' + destination.mkdir() + (destination / 'keep').write_text('existing') + link = root / 'link' + link.symlink_to(destination) + for path in (destination, link): + with self.assertRaises(ValueError): + PACKAGE.stage(path, root / 'missing', 'fedora') + + self.assertEqual((destination / 'keep').read_text(), 'existing') + + def test_missing_binary_fails_before_staging(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaises(ValueError): + PACKAGE.stage(root / 'dest', root / 'missing', 'fedora') + + self.assertFalse((root / 'dest').exists()) + + def test_complete_package_layout_and_both_pam_policies(self): + inventory = json.loads((PACKAGE.ROOT / 'scripts/installer/inventory.json').read_text()) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binaries = root / 'bin' + binaries.mkdir() + for entry in inventory['files']: + if entry['source'].startswith('target/release/'): + (binaries / entry['target']).write_bytes(b'fixture') + + for pam in ('fedora', 'arch'): + with self.subTest(pam=pam), patch.object(PACKAGE, 'licenses'), patch.object(PACKAGE, 'run', return_value='{}'): + dest = root / pam + PACKAGE.stage(dest, binaries, pam) + for entry in inventory['files']: + if entry.get('link'): + name = Path(entry['source']).name + link = dest / 'usr/bin' / name + self.assertEqual(link.readlink(), Path('/usr/libexec/ferese') / name) + self.assertTrue((dest / str(link.readlink()).lstrip('/')).is_file()) + + for file in dest.rglob('*'): + if file.is_file() and not file.is_symlink() and file.suffix in ('.desktop', '.service'): + self.assertNotIn('/usr/local/', file.read_text()) + + self.assertFalse((dest / 'usr/local').exists()) + self.assertIn('system-auth', (dest / 'etc/pam.d/ferese-lock').read_text()) + wallpaper = dest / 'usr/libexec/ferese/wallpapers' + self.assertEqual(wallpaper.readlink(), Path('/usr/share/ferese/wallpapers')) + self.assertTrue((dest / 'usr/share/ferese/wallpapers/ferese.png').is_file()) + self.assertEqual((dest / 'usr/libexec/ferese/ferese-session').stat().st_mode & 0o777, 0o755) + self.assertEqual((dest / 'usr/libexec/ferese').stat().st_mode & 0o777, 0o755) + + def test_same_version_from_different_sources_keeps_both_licenses(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + packages = [] + for source in ('registry+first', 'registry+second'): + folder = root / source.removeprefix('registry+') + folder.mkdir() + (folder / 'LICENSE').write_text(source) + packages.append({'name': 'dpi', 'version': '0.1.2', 'source': source, + 'manifest_path': str(folder / 'Cargo.toml'), + 'license': 'MIT', 'license_file': None}) + + PACKAGE.licenses(root / 'notices', {'packages': packages}) + texts = {path.read_text() for path in (root / 'notices').glob('dpi-*/LICENSE')} + self.assertEqual(texts, {'registry+first', 'registry+second'}) + + def test_dependency_license_report_contains_source_and_license(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'Cargo.toml').write_text('') + (root / 'LICENSE-MIT').write_text('test license') + metadata = {'packages': [{'name': 'fixture', 'version': '1.0.0', + 'source': 'registry+test', 'manifest_path': str(root / 'Cargo.toml'), + 'license': 'MIT', 'license_file': None}]} + PACKAGE.licenses(root / 'notices', metadata) + self.assertEqual(next((root / 'notices').glob('fixture-1.0.0-*/LICENSE-MIT')).read_text(), 'test license') + report = json.loads((root / 'notices/dependencies.json').read_text()) + self.assertEqual(report[0]['license'], 'MIT') + self.assertEqual(report[0]['source'], 'registry+test') + + +if __name__ == '__main__': + unittest.main() diff --git a/shell/ferese-settings/src/scroll_tests.rs b/shell/ferese-settings/src/scroll_tests.rs index f12737c..0be3cb0 100644 --- a/shell/ferese-settings/src/scroll_tests.rs +++ b/shell/ferese-settings/src/scroll_tests.rs @@ -317,14 +317,18 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() { #[test] fn gpu_panel_text_survives_preview_clipping_and_scrolling() { - // Native startup preloads text fonts; headless renderers skip that step. - // Supply a bundled proportional font even on minimal CI images. + let matched = std::process::Command::new("fc-match") + .args(["--format=%{family}\n%{file}", "sans-serif"]) + .output() + .expect("rendering tests require fontconfig"); + assert!(matched.status.success()); + let matched = String::from_utf8(matched.stdout).unwrap(); + let (family, path) = matched.split_once('\n').expect("rendering tests require a system font"); + let font = ferese_theme::font(Some(family.split(',').next().unwrap())); cosmic::iced::advanced::graphics::text::font_system() .write() .unwrap() - .load_font(std::borrow::Cow::Borrowed(include_bytes!( - "../../../assets/fonts/Comfortaa-Regular.otf" - ))); + .load_font(std::borrow::Cow::Owned(std::fs::read(path).unwrap())); #[derive(Default)] struct Labels { @@ -416,7 +420,7 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() { ), ) .0; - app.font = Font::with_name("Comfortaa"); + app.font = font; app.panel_page = if selection == "panels" { panel_controls::PanelPage::Panels } else { diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 3e9ef14..73f407c 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -622,10 +622,10 @@ mod tests { use super::*; #[test] - fn removed_desktop_widgets_are_rejected_at_startup_and_reload() { + fn removed_desktop_widgets_allow_startup_and_reload() { let source = "desktop-widgets { clock { enabled #true; }; }"; - assert!(parse_source(source).is_err()); - assert!(parse_test_source(source).is_err()); + parse_source(source).unwrap(); + parse_test_source(source).unwrap(); } fn parse_test_source(source: &str) -> Result {