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 b464065..0000000 Binary files a/assets/fonts/Comfortaa-Regular.otf and /dev/null differ 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 {