Migrate repository automation to C#

This commit is contained in:
devmobasa committed 2026-09-10 18:51:27 +02:00
1 parent d3a346ae12
commit 6d3dccfab8
54 files changed
+7960 -456

No files matched your search

+65 -236
View File
@@ -18,40 +18,28 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up repository tool SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Cache repository tool packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', 'tools/**/*.cs') }}
- name: Build repository tool once
run: dotnet build tools/wayscriber.cs
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libwayland-dev \
libxkbcommon-dev \
libcairo2-dev \
libpango1.0-dev \
libgtk-3-dev \
libgtk-4-dev \
libadwaita-1-dev \
meson \
ninja-build \
wayland-protocols \
libssl-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxcb-render0-dev \
libxcb1-dev \
libx11-dev \
rpm
run: dotnet run tools/wayscriber.cs --no-build -- ci install-dependencies package
- name: Set up Rust
uses: dtolnay/rust-toolchain@1.98.1
- name: Install nfpm
run: |
NFPM_VERSION=2.43.4
curl -sSfL "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" \
| tar -xz nfpm
sudo mv nfpm /usr/local/bin/
nfpm --version
run: dotnet run tools/wayscriber.cs --no-build -- ci install-nfpm
- name: Cache cargo
uses: Swatinem/rust-cache@v2
@@ -61,20 +49,13 @@ jobs:
- name: Read release version
id: meta
run: |
if [[ "${GITHUB_REF_NAME:-}" == v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
fi
if [[ -z "${VERSION:-}" ]]; then
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name=="wayscriber") | .version')
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
run: dotnet run tools/wayscriber.cs --no-build -- release resolve-version
- name: Check version metadata
run: bash tools/check-version-consistency.sh --release-version "${{ steps.meta.outputs.version }}"
run: dotnet run tools/wayscriber.cs --no-build -- version check --release-version "${{ steps.meta.outputs.version }}"
- name: Check nixpkgs recipe dependencies
run: ./tools/check-nixpkgs-recipe.py
run: dotnet run tools/wayscriber.cs --no-build -- check nixpkgs-recipe
- name: Install Nix
uses: cachix/install-nix-action@v31
@@ -84,96 +65,25 @@ jobs:
experimental-features = nix-command flakes
- name: Check Nix flake package versions
run: |
cargo_version="$(awk -F'"' '/^version = / { print $2; exit }' Cargo.toml)"
wayscriber_version="$(nix eval .#packages.x86_64-linux.wayscriber.version --raw)"
configurator_version="$(nix eval .#packages.x86_64-linux.wayscriber-configurator.version --raw)"
run: dotnet run tools/wayscriber.cs --no-build -- check nix-versions
test "$wayscriber_version" = "$cargo_version"
test "$configurator_version" = "$cargo_version"
- name: Check Nix flake package instantiation
run: nix build .#wayscriber .#wayscriber-configurator --no-link --dry-run
- name: Check Nix flake package and development shell instantiation
run: dotnet run tools/wayscriber.cs --no-build -- check nix-instantiation
- name: Pre-fetch Cargo dependencies (locked)
env:
CARGO_TARGET_DIR: target
run: |
cargo fetch --locked --target x86_64-unknown-linux-gnu
if [ -f configurator/Cargo.toml ]; then
cargo fetch --locked --manifest-path configurator/Cargo.toml --target x86_64-unknown-linux-gnu
fi
run: dotnet run tools/wayscriber.cs --no-build -- dev fetch --target x86_64-unknown-linux-gnu
- name: Build & package (tar/deb/rpm)
env:
VERSION: ${{ steps.meta.outputs.version }}
FORMATS: tar,deb,rpm
ARTIFACT_ROOT: dist
CARGO_TARGET_DIR: target
run: |
./tools/package.sh
- name: Build and package (tar/deb/rpm)
run: dotnet run tools/wayscriber.cs --no-build -- package build --version "${{ steps.meta.outputs.version }}" --formats tar,deb,rpm --artifact-root dist
- name: Check direct Arch installer compatibility
run: |
curl --disable --fail --silent --show-error --location \
--proto '=https' --proto-redir '=https' \
--retry 3 --connect-timeout 15 --max-time 60 --max-filesize 1048576 \
--output "$RUNNER_TEMP/arch-install.sh" \
https://wayscriber.com/arch-install.sh
./tools/check-arch-installer-manifest.sh \
--installer "$RUNNER_TEMP/arch-install.sh" \
--archive "dist/wayscriber-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz"
run: dotnet run tools/wayscriber.cs --no-build -- package check-live-arch-installer --archive "dist/wayscriber-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz"
- name: Verify packaged metadata and retained licenses
run: |
# Keep these floors aligned with MAX_GLIBC_VERSION in tools/package.sh.
expected_package_version="${{ steps.meta.outputs.version }}-1"
test "$(dpkg-deb -f dist/wayscriber-amd64.deb Version)" = "$expected_package_version"
test "$(dpkg-deb -f dist/wayscriber-configurator-amd64.deb Version)" = "$expected_package_version"
deb_depends="$(dpkg-deb -f dist/wayscriber-amd64.deb Depends)"
deb_files="$(dpkg-deb -c dist/wayscriber-amd64.deb)"
grep -Fq 'libc6 (>= 2.39)' <<< "$deb_depends"
grep -Fq 'libgtk-4-1 (>= 4.12)' <<< "$deb_depends"
! grep -Fq 'libgtk4-layer-shell' <<< "$deb_depends"
grep -Fq '/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell' <<< "$deb_files"
configurator_deb_depends="$(dpkg-deb -f dist/wayscriber-configurator-amd64.deb Depends)"
grep -Fq 'libc6 (>= 2.39)' <<< "$configurator_deb_depends"
grep -Fq 'libadwaita-1-0 (>= 1.4)' <<< "$configurator_deb_depends"
mkdir -p "$RUNNER_TEMP/rpmdb"
test "$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qp --qf '%{VERSION}-%{RELEASE}\n' dist/wayscriber-x86_64.rpm)" = "$expected_package_version"
test "$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qp --qf '%{VERSION}-%{RELEASE}\n' dist/wayscriber-configurator-x86_64.rpm)" = "$expected_package_version"
rpm_requires="$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qp --requires dist/wayscriber-x86_64.rpm)"
rpm_files="$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qlp dist/wayscriber-x86_64.rpm)"
grep -Fxq 'glibc >= 2.39' <<< "$rpm_requires"
grep -Fxq 'gtk4 >= 4.12' <<< "$rpm_requires"
! grep -Fq 'gtk4-layer-shell' <<< "$rpm_requires"
grep -Fxq '/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell' <<< "$rpm_files"
configurator_rpm_requires="$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qp --requires dist/wayscriber-configurator-x86_64.rpm)"
grep -Fxq 'glibc >= 2.39' <<< "$configurator_rpm_requires"
grep -Fxq 'libadwaita >= 1.4' <<< "$configurator_rpm_requires"
tar_files="$(tar -tzf "dist/wayscriber-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz")"
grep -Eq '/usr/bin/wayscriber$' <<< "$tar_files"
grep -Eq '/usr/share/licenses/wayscriber/LICENSE\.gtk4-layer-shell$' <<< "$tar_files"
configurator_tar_files="$(tar -tzf "dist/wayscriber-configurator-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz")"
grep -Eq '/usr/bin/wayscriber-configurator$' <<< "$configurator_tar_files"
run: dotnet run tools/wayscriber.cs --no-build -- package verify-artifacts --artifact-root dist --version "${{ steps.meta.outputs.version }}"
- name: Clean Ubuntu 24.04 install smoke
run: |
docker run --rm \
-v "$PWD/dist:/dist:ro" \
ubuntu:24.04 \
bash -euxo pipefail -c '
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
/dist/wayscriber-amd64.deb \
/dist/wayscriber-configurator-amd64.deb
wayscriber --version
# Installing proves the configurator runtime dependencies resolve
# on the supported LTS. Do not launch this foreground GUI here.
test -x /usr/bin/wayscriber-configurator
'
run: dotnet run tools/wayscriber.cs --no-build -- package smoke-ubuntu --artifact-root dist
- name: Upload tarball
uses: actions/upload-artifact@v4
@@ -239,16 +149,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Install repo tooling
run: |
sudo apt-get update
sudo apt-get install -y \
apt-utils \
dpkg-dev \
rpm \
createrepo-c \
rsync \
gnupg
- name: Set up repository tool SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Build repository tool once
run: dotnet build tools/wayscriber.cs
- name: Install repository tooling
run: dotnet run tools/wayscriber.cs --no-build -- ci install-dependencies repositories
- name: Download packaged artifacts
uses: actions/download-artifact@v4
@@ -256,7 +166,7 @@ jobs:
path: dist
merge-multiple: true
- name: Build apt + rpm repositories
- name: Build apt and rpm repositories
env:
ARTIFACT_ROOT: dist
OUTPUT_ROOT: repo-out
@@ -267,9 +177,7 @@ jobs:
REPO_ORIGIN: Wayscriber
REPO_LABEL: Wayscriber
SIGN_RPMS: 1
run: |
chmod +x tools/build-package-repos.sh
tools/build-package-repos.sh
run: dotnet run tools/wayscriber.cs --no-build -- package build-repositories
- name: Upload repo bundle
uses: actions/upload-artifact@v4
@@ -286,22 +194,7 @@ jobs:
DEPLOY_HOST: ${{ env.PACKAGE_REPO_REMOTE_HOST }}
DEPLOY_PATH: ${{ env.PACKAGE_REPO_REMOTE_PATH }}
DEPLOY_USER: ${{ env.PACKAGE_REPO_REMOTE_USER }}
run: |
mkdir -p ~/.ssh
echo "$PACKAGE_REPO_SSH_KEY" > ~/.ssh/repo_key
chmod 600 ~/.ssh/repo_key
if [ -n "$PACKAGE_REPO_SSH_KNOWN_HOSTS" ]; then
echo "$PACKAGE_REPO_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
else
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
fi
DEPLOY_USER="${DEPLOY_USER:-root}"
ssh -i ~/.ssh/repo_key -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p '${DEPLOY_PATH}/apt' '${DEPLOY_PATH}/rpm'"
rsync -rlvz --omit-dir-times --delete --no-perms --no-owner --no-group -e "ssh -i ~/.ssh/repo_key -o StrictHostKeyChecking=yes" repo-out/apt/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/apt/"
rsync -rlvz --omit-dir-times --delete --no-perms --no-owner --no-group -e "ssh -i ~/.ssh/repo_key -o StrictHostKeyChecking=yes" repo-out/rpm/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/rpm/"
# The public key is already copied into /apt/ and /rpm/ above. The
# legacy top-level alias is not an install path and is not writable
# by this deployment account.
run: dotnet run tools/wayscriber.cs --no-build -- release deploy-package-repositories
release:
needs: package
@@ -323,7 +216,7 @@ jobs:
generate_release_notes: true
aur:
needs: package
needs: [package, release]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-24.04
env:
@@ -331,72 +224,28 @@ jobs:
AUR_SSH_KNOWN_HOSTS: ${{ secrets.AUR_SSH_KNOWN_HOSTS }}
AUR_SSH_PASSPHRASE: ${{ secrets.AUR_SSH_PASSPHRASE }}
steps:
- name: Fail if AUR secret missing
if: env.AUR_SSH_PRIVATE_KEY == ''
run: |
echo "AUR secrets not set; failing AUR job."
exit 1
- name: Configure SSH for AUR (and test)
run: |
mkdir -p ~/.ssh
echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
{
echo "Host aur.archlinux.org"
echo " HostName aur.archlinux.org"
echo " User aur"
echo " IdentityFile ~/.ssh/aur"
echo " StrictHostKeyChecking yes"
} >> ~/.ssh/config
if [ -n "$AUR_SSH_KNOWN_HOSTS" ]; then
echo "$AUR_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
else
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
fi
SSH_CMD="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes"
if [ -n "$AUR_SSH_PASSPHRASE" ]; then
cat <<'EOS' > /tmp/aur-askpass.sh
#!/usr/bin/env bash
printf '%s\n' "${AUR_SSH_PASSPHRASE:-}"
EOS
chmod +x /tmp/aur-askpass.sh
export SSH_ASKPASS=/tmp/aur-askpass.sh
export SSH_ASKPASS_REQUIRE=force
export DISPLAY=:0
eval "$(ssh-agent -s)"
if ! setsid -w env SSH_ASKPASS=$SSH_ASKPASS SSH_ASKPASS_REQUIRE=$SSH_ASKPASS_REQUIRE DISPLAY=$DISPLAY ssh-add ~/.ssh/aur </dev/null; then
echo "ssh-add failed; ensure AUR_SSH_PASSPHRASE is set correctly if the key is encrypted."
exit 1
fi
SSH_CMD="ssh -o StrictHostKeyChecking=yes"
{
echo "SSH_ASKPASS=$SSH_ASKPASS"
echo "SSH_ASKPASS_REQUIRE=$SSH_ASKPASS_REQUIRE"
echo "DISPLAY=$DISPLAY"
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK"
echo "SSH_AGENT_PID=$SSH_AGENT_PID"
} >> "$GITHUB_ENV"
else
echo "No AUR_SSH_PASSPHRASE provided; using key directly with -i."
fi
echo "GIT_SSH_COMMAND=$SSH_CMD" >> "$GITHUB_ENV"
ls -l ~/.ssh
echo "ssh config:"
cat ~/.ssh/config
ssh-add -l || true
echo "Key fingerprint:"
ssh-keygen -lf ~/.ssh/aur || true
echo "Testing SSH connectivity..."
GIT_SSH_COMMAND="$SSH_CMD" git ls-remote ssh://aur@aur.archlinux.org/wayscriber.git HEAD
- name: Checkout repo
- name: Checkout
uses: actions/checkout@v4
- name: Install helpers
run: |
sudo apt-get update
sudo apt-get install -y jq
- name: Set up repository tool SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Cache repository tool packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', 'tools/**/*.cs') }}
- name: Build repository tool once
run: dotnet build tools/wayscriber.cs
- name: Require AUR credentials
run: dotnet run tools/wayscriber.cs --no-build -- ci require-environment AUR_SSH_PRIVATE_KEY
- name: Configure SSH for AUR and test it
run: dotnet run tools/wayscriber.cs --no-build -- aur prepare-ssh
- name: Download packaged artifacts
uses: actions/download-artifact@v4
@@ -408,30 +257,10 @@ jobs:
env:
AUR_GIT_USERNAME: ${{ secrets.AUR_GIT_USERNAME }}
AUR_GIT_EMAIL: ${{ secrets.AUR_GIT_EMAIL }}
run: |
if [ -z "$AUR_GIT_USERNAME" ] || [ -z "$AUR_GIT_EMAIL" ]; then
echo "AUR_GIT_USERNAME and AUR_GIT_EMAIL secrets must be set."
exit 1
fi
git config --global user.name "$AUR_GIT_USERNAME"
git config --global user.email "$AUR_GIT_EMAIL"
run: dotnet run tools/wayscriber.cs --no-build -- aur configure-git
- name: Clone AUR repos
run: |
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber.git aur-wayscriber
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber-bin.git aur-wayscriber-bin
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber-configurator.git aur-wayscriber-configurator
run: dotnet run tools/wayscriber.cs --no-build -- aur clone
- name: Update AUR from manifest
env:
PUSH: "1"
run: |
VERSION=$(jq -r '.version' dist/manifest.json)
chmod +x tools/update-aur-from-manifest.sh
./tools/update-aur-from-manifest.sh \
--version "$VERSION" \
--manifest dist/manifest.json \
--source-dir aur-wayscriber \
--bin-dir aur-wayscriber-bin \
--config-dir aur-wayscriber-configurator \
--push
run: dotnet run tools/wayscriber.cs --no-build -- aur update --manifest dist/manifest.json --source-dir aur-wayscriber --bin-dir aur-wayscriber-bin --config-dir aur-wayscriber-configurator --push
+55 -104
View File
@@ -15,48 +15,31 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up repository tool SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Cache repository tool packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', 'tools/**/*.cs') }}
- name: Build repository tool once
run: dotnet build tools/wayscriber.cs
- name: Build one-command C# installer
run: dotnet build tools/install.cs --disable-build-servers
- name: Build repository tool tests once
run: dotnet build tools/wayscriber.tests.cs
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
clang \
cmake \
libssl-dev \
libwayland-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libegl1-mesa-dev \
libgles2-mesa-dev \
libdbus-1-dev \
libinput-dev \
libudev-dev \
libgtk-3-dev \
libgtk-4-dev \
libadwaita-1-dev \
meson \
ninja-build \
wayland-protocols \
libpango1.0-dev \
libcairo2-dev \
libpixman-1-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxcb-render0-dev \
libxcb-randr0-dev \
libxcb1-dev \
libx11-dev
run: dotnet run tools/wayscriber.cs --no-build -- ci install-dependencies checks
- name: Build gtk4-layer-shell
# Build both forms privately: normal CI exercises the dynamic source
# build used by AUR/Nix, and a dedicated step checks release linkage.
run: |
GTK4_LAYER_SHELL_PREFIX="$RUNNER_TEMP/gtk4-layer-shell" \
GTK4_LAYER_SHELL_LIBRARY_MODE=both \
bash tools/install-gtk4-layer-shell.sh
echo "PKG_CONFIG_PATH=$RUNNER_TEMP/gtk4-layer-shell/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=$RUNNER_TEMP/gtk4-layer-shell/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV"
run: dotnet run tools/wayscriber.cs --no-build -- ci prepare-gtk4-layer-shell --library-mode both
- name: Set up Rust
uses: dtolnay/rust-toolchain@1.98.1
@@ -70,26 +53,18 @@ jobs:
.
- name: Check dynamic gtk4-layer-shell linkage
run: |
cargo build --locked --bin wayscriber
readelf -d target/debug/wayscriber | grep -Eq 'Shared library: \[libgtk4-layer-shell\.so'
run: dotnet run tools/wayscriber.cs --no-build -- ci build-linkage dynamic
- name: Run canonical workspace validation
run: ./tools/lint-and-test.sh
- name: Run canonical workspace and standalone release validation
run: dotnet run tools/wayscriber.cs --no-build -- ci lint-and-test
- name: Check static gtk4-layer-shell linkage
env:
SYSTEM_DEPS_GTK4_LAYER_SHELL_0_LINK: static
run: |
cargo build --locked --bin wayscriber
bash tools/verify-static-gtk4-layer-shell.sh target/debug/wayscriber
run: dotnet run tools/wayscriber.cs --no-build -- ci build-linkage static
- name: Record code health
if: always()
continue-on-error: true
run: |
./tools/code-health-report.sh > code-health-report.txt
sed -n '1,/^$/p' code-health-report.txt
run: dotnet run tools/wayscriber.cs --no-build -- report code-health --output code-health-report.txt --github-summary
- name: Upload code-health report
if: always()
@@ -107,51 +82,25 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up repository tool SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Cache repository tool packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', 'tools/**/*.cs') }}
- name: Build repository tools once
run: dotnet build tools/wayscriber.cs
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
weston \
dbus-x11 \
fonts-dejavu-core \
build-essential \
pkg-config \
clang \
cmake \
libssl-dev \
libwayland-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libegl1-mesa-dev \
libgles2-mesa-dev \
libdbus-1-dev \
libinput-dev \
libudev-dev \
libgtk-3-dev \
libgtk-4-dev \
libadwaita-1-dev \
meson \
ninja-build \
wayland-protocols \
libpango1.0-dev \
libcairo2-dev \
libpixman-1-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxcb-render0-dev \
libxcb-randr0-dev \
libxcb1-dev \
libx11-dev
run: dotnet run tools/wayscriber.cs --no-build -- ci install-dependencies widgets
- name: Build gtk4-layer-shell
# Build both forms privately: normal CI exercises the dynamic source
# build used by AUR/Nix, and a dedicated step checks release linkage.
run: |
GTK4_LAYER_SHELL_PREFIX="$RUNNER_TEMP/gtk4-layer-shell" \
GTK4_LAYER_SHELL_LIBRARY_MODE=both \
bash tools/install-gtk4-layer-shell.sh
echo "PKG_CONFIG_PATH=$RUNNER_TEMP/gtk4-layer-shell/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=$RUNNER_TEMP/gtk4-layer-shell/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV"
run: dotnet run tools/wayscriber.cs --no-build -- ci prepare-gtk4-layer-shell --library-mode both
- name: Set up Rust
uses: dtolnay/rust-toolchain@1.98.1
@@ -165,7 +114,7 @@ jobs:
.
- name: Exercise widgets on an isolated display
run: ./tools/test-gtk-widgets.sh
run: dotnet run tools/wayscriber.cs --no-build -- ci gtk-widgets
nix-flake:
name: Nix flake
@@ -175,6 +124,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up repository tool SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Build repository tools once
run: dotnet build tools/wayscriber.cs
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
@@ -183,13 +140,7 @@ jobs:
experimental-features = nix-command flakes
- name: Check flake package versions
run: |
cargo_version="$(awk -F'"' '/^version = / { print $2; exit }' Cargo.toml)"
wayscriber_version="$(nix eval .#packages.x86_64-linux.wayscriber.version --raw)"
configurator_version="$(nix eval .#packages.x86_64-linux.wayscriber-configurator.version --raw)"
run: dotnet run tools/wayscriber.cs --no-build -- check nix-versions
test "$wayscriber_version" = "$cargo_version"
test "$configurator_version" = "$cargo_version"
- name: Check flake package instantiation
run: nix build .#wayscriber .#wayscriber-configurator --no-link --dry-run
- name: Check flake package and development shell instantiation
run: dotnet run tools/wayscriber.cs --no-build -- check nix-instantiation
+1
View File
@@ -22,6 +22,7 @@ CLAUDE.md
# Test scripts
test_run.sh
run.sh
!/tools/run.sh
# Local release helper
tools/release.sh
+27 -8
View File
@@ -38,6 +38,13 @@ Git hash, and tells Cargo which Git metadata should trigger a rebuild. Cargo fea
## Development
GitHub CI uses the C# file app. To run that route locally, build it once with
`dotnet build tools/wayscriber.cs`, then run commands with
`dotnet run tools/wayscriber.cs --no-build -- ...`. The standalone scripts remain
available for development and installation on machines without .NET. Nix does
not provide .NET; `global.json` selects the required SDK when it is installed
separately.
Build both packages without launching a window:
```bash
@@ -121,10 +128,21 @@ Before submitting a broad or cross-package change, run the local CI entry point:
./tools/lint-and-test.sh
```
It checks release/package metadata, package layout, Rust source coverage, formatting, strict
all-feature Clippy, all-feature tests, and no-default-feature tests. The source-coverage gate uses
current rustc dep-info and rejects tracked or unignored `.rs` files that are outside the supported
Cargo target/feature matrix.
It checks release/package metadata, all three retained release contracts, package layout, Rust
source coverage, formatting, strict all-feature Clippy, all-feature tests, and no-default-feature
tests. When the pinned .NET SDK is installed, it also builds and runs the C# repository-tool tests;
otherwise it reports that optional local check as skipped. The source-coverage gate uses current
rustc dep-info and rejects tracked or unignored `.rs` files that are outside the supported Cargo
target/feature matrix.
The canonical gate serializes the Rust test harness because parallel rendering tests have
crashed in the native font stack through context-menu, board-picker, and region-capture paths.
This changes scheduling, not test selection; tests may still create their own threads.
The context-menu and board-picker retained-text rendering regressions each run in a separate
process under both feature configurations. Both have crashed inside Cairo/FreeType during
parallel tests. This isolation covers these observed failures; it does not prove that the
native race is fixed. Direct parallel `cargo test` runs can still hit it. See [Releasing](docs/RELEASING.md) for
the full release and website handoff.
For offline work, prefetch dependencies first:
@@ -213,10 +231,11 @@ not as a reason for mechanical splitting.
See [tools/README.md](tools/README.md) for build, install, packaging, version, and release helpers.
Local checks and the Rust CI job both run `./tools/lint-and-test.sh`. It lints,
builds binaries, and tests the whole workspace with all features and with no
default features, alongside source and packaging checks. CI additionally checks
dynamic and static gtk4-layer-shell linkage and uploads its code-health report.
Run `./tools/lint-and-test.sh` for the standalone local gate. It lints, builds
binaries, and tests the whole workspace with all features and with no default
features, alongside source, packaging, retained release-contract, and C# tool
checks when .NET is installed. CI runs the equivalent C# command and additionally checks dynamic
and static gtk4-layer-shell linkage and uploads its code-health report.
GTK widget coverage runs separately with `./tools/test-gtk-widgets.sh` (Weston and
`dbus-run-session` required). It creates a private headless display and requires
+1 -1
View File
@@ -120,7 +120,7 @@ keeps the draft; it does not attempt to cancel a durable write already in progre
See the [application map](../docs/codebase-overview.md) for shape edits and capture, and
[CONTRIBUTING](../CONTRIBUTING.md) for setup. From the repository root, use
`cargo test -p wayscriber-configurator` for focused coverage and `./tools/lint-and-test.sh`
for the canonical workspace checks used by CI. `./tools/test-gtk-widgets.sh` runs required
for the standalone workspace checks mirrored by CI. `./tools/test-gtk-widgets.sh` runs required
GTK toolbar assertions on a private headless compositor. These checks do not prove behavior
on an installed desktop or screen-reader announcements.
+1
View File
@@ -6,6 +6,7 @@
## Architecture
- `docs/CONFIG.md` is the user-facing config guide.
- `docs/SETUP.md` documents installation/setup workflows.
- For release preparation, tagging, or website publication, follow `docs/RELEASING.md`.
- `docs/codebase-overview.md` is architecture reference material for the main crate.
- `docs/temp/` is draft and planning material unless a file explicitly says otherwise.
+51
View File
@@ -0,0 +1,51 @@
# Releasing Wayscriber
## Prepare the release
Land tooling fixes, test routing, and release documentation as focused commits,
then commit the version bump separately immediately before tagging. An uncommitted
release preparation may contain these groups together; stage them separately at handoff.
1. Run `./tools/bump-version.sh X.Y.Z`. It updates both workspace versions and
package metadata without refreshing locked dependencies. Review `Cargo.lock`:
a version-only release should change only the two workspace package versions.
Prefetch dependencies first if the local Cargo cache is empty.
2. Run `./tools/lint-and-test.sh` and `./tools/test-gtk-widgets.sh`. The canonical
gate serializes the Rust test harness to avoid the observed parallel native-font
crashes. It also runs each context-menu and board-picker retained-text rendering regression
in a separate process under both feature configurations. This preserves coverage around the native
Cairo/FreeType concurrency issue; it does not establish a native-library fix.
3. Exercise the affected desktop flows on Wayland, including capture, keyboard
focus, save/reopen, and configurator edits. Automated checks do not prove
installed-binary or compositor behavior.
4. Prepare the website's `docs-src/src/release-notes.md` and run its
`build-docs.sh`. Keep the published `latest.json` on the previous release
until the new downloads and package channels are available.
5. If the tarball file layout changes, verify and publish the matching Arch
installer before tagging, as described in [the packaging guide](../tools/README.md#packaging).
## Publish and verify
After reviewing and committing the release changes, push the branch and wait for
its GitHub checks. Use `./tools/publish-release-tag.sh --version X.Y.Z` only when
ready to publish. It creates and pushes the tag; the tag starts the Release workflow.
Verify the whole Release workflow, including the GitHub assets, AUR recipes, and
apt/rpm repository deployment. AUR waits for successful GitHub asset publication.
Check that repository deployment actually ran: its configured-host step can be
skipped even when repository generation succeeds.
Then publish the prepared website notes through the website's deployment
workflow, excluding `/latest.json` from the upload so a stale checkout cannot
downgrade the live notice. Verify that exclusion before deploying. Finally, use
the private website's existing update-manifest publisher as
documented in that checkout: preview the proposed notice, publish it, and verify
`https://wayscriber.com/latest.json`. The notice must name the published version,
use its actual GitHub publication date, and link to the current release notes
and update instructions. Neither a pushed tag nor successful AUR publication
updates this notice automatically.
The website publisher updates its local manifest as well, but ordinary website
sync must still exclude the notice. Keep this final step after
all install channels and notes are ready. Four-part packaging hotfix versions
are unsupported by released update checkers; do not publish them as update notices.
+13 -11
View File
@@ -30,6 +30,18 @@
rustc = rustToolchain;
};
servicePath = pkgs.lib.makeBinPath [ pkgs.grim pkgs.slurp pkgs.wl-clipboard ];
developmentInputs = with pkgs; [
rustToolchain
util-linux
pkg-config
cairo
pango
wayland
libxkbcommon
gtk4
gtk4-layer-shell
libadwaita
];
in
# Instantiation (and CI `nix eval`) must fail before cargoBuildHook
# when the selected compiler lags Cargo.toml's rust-version.
@@ -140,17 +152,7 @@
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
rustToolchain
pkg-config
cairo
pango
wayland
libxkbcommon
gtk4
gtk4-layer-shell
libadwaita
];
buildInputs = developmentInputs;
};
});
}
+7
View File
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "11.0.100-rc.1.26425.128",
"rollForward": "disable",
"allowPrerelease": true
}
}
+1
View File
@@ -5,6 +5,7 @@
## Architecture
- Package manifests describe the wayscriber binary and configurator package outputs.
- Package YAML manifests own the desktop asset lists used by `tools/wayscriber.cs assets emit`; its implementation is in `tools/csharp/Commands/AssetsCommand.cs`.
- `PKGBUILD` and `.SRCINFO` represent Arch/AUR packaging metadata.
- `nixpkgs/package.nix` mirrors the recipe owned by nixpkgs; it is a submission draft, not a build input.
- Desktop files, icons, and `wayscriber.service` define installed desktop and daemon integration.
+6 -2
View File
@@ -56,9 +56,8 @@ package() {
# Install systemd user service
install -Dm644 packaging/wayscriber.service "$pkgdir/usr/lib/systemd/user/wayscriber.service"
# Install desktop entries and icons
# Wayscriber desktop integration
install -Dm644 packaging/wayscriber.desktop "$pkgdir/usr/share/applications/wayscriber.desktop"
install -Dm644 packaging/wayscriber-configurator.desktop "$pkgdir/usr/share/applications/wayscriber-configurator.desktop"
install -Dm644 packaging/icons/wayscriber-16.png "$pkgdir/usr/share/icons/hicolor/16x16/apps/wayscriber.png"
install -Dm644 packaging/icons/wayscriber-19.png "$pkgdir/usr/share/icons/hicolor/19x19/apps/wayscriber.png"
install -Dm644 packaging/icons/wayscriber-22.png "$pkgdir/usr/share/icons/hicolor/22x22/apps/wayscriber.png"
@@ -76,6 +75,10 @@ package() {
install -Dm644 packaging/icons/wayscriber-128.png "$pkgdir/usr/share/pixmaps/wayscriber.png"
install -Dm644 packaging/icons/wayscriber.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/wayscriber.svg"
install -Dm644 packaging/icons/wayscriber-symbolic.svg "$pkgdir/usr/share/icons/hicolor/symbolic/apps/wayscriber-symbolic.svg"
# End Wayscriber desktop integration
# Wayscriber configurator desktop integration
install -Dm644 packaging/wayscriber-configurator.desktop "$pkgdir/usr/share/applications/wayscriber-configurator.desktop"
install -Dm644 packaging/icons/wayscriber-configurator-16.png "$pkgdir/usr/share/icons/hicolor/16x16/apps/wayscriber-configurator.png"
install -Dm644 packaging/icons/wayscriber-configurator-19.png "$pkgdir/usr/share/icons/hicolor/19x19/apps/wayscriber-configurator.png"
install -Dm644 packaging/icons/wayscriber-configurator-22.png "$pkgdir/usr/share/icons/hicolor/22x22/apps/wayscriber-configurator.png"
@@ -85,6 +88,7 @@ package() {
install -Dm644 packaging/icons/wayscriber-configurator-128.png "$pkgdir/usr/share/icons/hicolor/128x128/apps/wayscriber-configurator.png"
install -Dm644 packaging/icons/wayscriber-configurator-128.png "$pkgdir/usr/share/pixmaps/wayscriber-configurator.png"
install -Dm644 packaging/icons/wayscriber-configurator.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/wayscriber-configurator.svg"
# End Wayscriber configurator desktop integration
# Install documentation and example config
install -Dm644 config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
+1
View File
@@ -19,5 +19,6 @@
- UI changes may affect `src/input/`, `src/backend/wayland/state/render/`, action metadata, keybindings, toolbar rendering, docs, and tests.
## Validation
- The retained-text context-menu and board-picker regressions are ignored by the ordinary harness and run in separate processes through `tools/lint-and-test.sh`; keep those explicit runs when renaming tests or changing test routing.
- Add focused layout/render tests for complex UI paths where possible.
- Run targeted UI/input tests for command palette, board picker, help overlay, or toolbar behavior.
+4
View File
@@ -28,7 +28,11 @@ fn pixels(
surface.data().unwrap().to_vec()
}
// A parallel-suite core dump reached Cairo/FreeType glyph loading through this
// test. tools/lint-and-test.sh runs it alone under both feature configurations;
// process isolation preserves assertions but does not fix cairo/cairo!81.
#[test]
#[ignore = "isolated by tools/lint-and-test.sh; Cairo race: https://gitlab.freedesktop.org/cairo/cairo/-/merge_requests/81"]
fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layouts() {
let engine = UiTextEngine::default();
let measurer = crate::draw::TextMeasurer::default();
+5 -7
View File
@@ -218,14 +218,12 @@ mod engine_tests {
surface.data().unwrap().to_vec()
}
// Disabled: this test has died with SIGSEGV under the parallel all-features
// run. The fault is a use-after-free inside Cairo's FreeType face cache,
// which evicts faces another thread still holds once more than ten font
// files are open in one process. Wayscriber code is not involved. Upstream
// fix, unmerged: https://gitlab.freedesktop.org/cairo/cairo/-/merge_requests/81
// Run on demand with: cargo test --all-features -- --ignored retained_context_menu_owner
// Runs separately in tools/lint-and-test.sh under both feature configurations.
// Parallel font rendering has triggered a native Cairo/FreeType crash; the
// related upstream race is tracked at cairo/cairo!81. Process isolation keeps
// these assertions covered without claiming the native race is fixed.
#[test]
#[ignore = "crashes in Cairo's FreeType face cache under parallel tests; see https://gitlab.freedesktop.org/cairo/cairo/-/merge_requests/81"]
#[ignore = "isolated by tools/lint-and-test.sh; Cairo race: https://gitlab.freedesktop.org/cairo/cairo/-/merge_requests/81"]
fn retained_context_menu_owner_preserves_layout_pixels_and_row_hits() {
let engine = UiTextEngine::default();
let mut state = crate::input::state::test_support::make_test_input_state();
+49
View File
@@ -0,0 +1,49 @@
# C# helper style: spaced parentheses and braced guards, with repository LF endings.
[*.cs]
indent_style = space
indent_size = 4
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 140
dotnet_diagnostic.CA1502.severity = error
dotnet_sort_system_directives_first = true
csharp_prefer_braces = true:warning
csharp_style_throw_expression = false:warning
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = true
csharp_space_between_method_call_parameter_list_parentheses = true
csharp_space_between_method_declaration_empty_parameter_list_parentheses = true
csharp_space_between_method_call_empty_parameter_list_parentheses = true
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_cast = true
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_between_parentheses = control_flow_statements, type_casts
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_members_in_object_initializers = true
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
# Keep explicit braced guards and their exception construction visible.
dotnet_diagnostic.CA1510.severity = none
dotnet_diagnostic.CA1513.severity = none
# Match Rider's handling of the method-parenthesis rules above.
resharper_braces_for_for = required
resharper_braces_for_foreach = required
resharper_braces_for_ifelse = required
resharper_braces_for_while = required
resharper_space_within_method_declaration_parentheses = true
resharper_space_within_method_call_parentheses = true
resharper_csharp_keep_user_linebreaks = false
resharper_csharp_align_first_arg_by_paren = false
resharper_csharp_align_multiline_argument = false
resharper_csharp_wrap_arguments_style = wrap_if_long
resharper_csharp_max_invocation_arguments_on_line = 6
resharper_csharp_keep_existing_invocation_parens_arrangement = false
+16
View File
@@ -4,12 +4,28 @@
- Applies to repository scripts under `tools/`.
## Architecture
- `wayscriber.cs` is the C# entry point used by CI and exposes parity commands for local development, installation, packaging, and releases.
- Keep its entry point small, command modules under `csharp/Commands/`, infrastructure under `csharp/Infrastructure/`, and module lists explicit in `csharp/includes.cs`.
- Build the C# file apps once, then invoke them with `dotnet run tools/<app>.cs --no-build -- ...`.
- Existing shell and Python tools remain standalone fallbacks for contributors without .NET. Production C# commands must not invoke them. The C# parity test app may execute the retained standalone release contracts so both implementations stay covered in CI.
- Scripts support build, install, lint/test, versioning, packaging, release tags, package repository generation, daemon reload, and dependency fetching.
- Scripts should resolve the repository root and work from any starting directory.
- The shell and Python fallbacks must remain usable without .NET and must not redirect to `wayscriber.cs`.
- Version-bump regressions live in `test-release-packaging.sh` and `wayscriber.tests.cs`.
- C# helper formatting follows `tools/.editorconfig`: spaced parentheses and braced guards, with LF endings required by `.gitattributes`.
- Separate logical steps in C# functions with a blank line. Keep closely related validation, setup, execution, state checks, and result mapping together, and add spacing when the purpose changes.
- Assign or deserialize a value first, then use a separate braced null guard. Do not embed `throw` in an assignment, return, or conditional expression.
- Name literals that encode a process exit, environment variable, command route, external executable, package channel, file mode, timeout, size limit, or shared repository path.
Keep cross-cutting names in `csharp/Infrastructure/ToolConstants.cs` and command-specific values beside their owning command. Leave one-use diagnostics and syntax tokens inline.
- Review changed C# code visually after formatting; mechanical formatting alone does not verify clear logical grouping.
- Keep cyclomatic complexity at or below 20 per method. `CA1502` is an error, with the threshold in `CodeMetricsConfig.txt` wired through `Directory.Build.props`.
## Invariants
- Keep C# commands and standalone script behavior aligned. Add parity fixtures to `wayscriber.tests.cs` when either implementation changes.
- Invoke external programs from C# with `ProcessStartInfo.ArgumentList`; do not invoke a shell interpreter or assemble shell command strings.
- Preserve release/version/package semantics, including packaging-only hotfix behavior.
- Keep `tools/lint-and-test.sh` aligned with CI.
- The canonical gate serializes Rust test harnesses as a native-font race workaround and runs the ignored context-menu and board-picker render regressions separately under both feature configurations. Preserve their assertions and document the workaround separately from any native-library fix.
- Keep `check-rust-source-coverage.py` aligned with the workspace's all-feature and
no-default-feature target matrix; intentional exceptions must be narrow and documented.
- Avoid platform-specific assumptions unless the script is explicitly platform-specific.
+1
View File
@@ -0,0 +1 @@
CA1502: 20
+15
View File
@@ -0,0 +1,15 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
<PublishAot>false</PublishAot>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- Threshold for the CA1502 cyclomatic-complexity analyzer. -->
<AdditionalFiles Include="$(MSBuildThisFileDirectory)CodeMetricsConfig.txt" />
</ItemGroup>
</Project>
+69
View File
@@ -2,6 +2,59 @@
Helper scripts for development, installation, packaging, and release workflows.
## C# automation
CI uses the file-based app at `tools/wayscriber.cs`. It also provides C# versions
of the local commands below, while the standalone shell and Python tools remain
available for contributors who do not have .NET installed. Production C# commands
never call those scripts.
The exact SDK is pinned by `global.json` and installed by GitHub Actions through
`actions/setup-dotnet`. Nix does not provide .NET. Local users who choose the C#
route install the pinned SDK separately. Build the file app once, then reuse that
build:
```bash
dotnet build tools/wayscriber.cs
dotnet run tools/wayscriber.cs --no-build -- --help
```
The C# regression suite uses the same pattern:
```bash
dotnet build tools/wayscriber.tests.cs
dotnet run tools/wayscriber.tests.cs --no-build
```
For a one-command C# source installation, use the executable file app. It
builds the C# entry point as needed, then builds and installs Wayscriber through
the same `install app` command:
```bash
./tools/install.cs
```
Installer options can be passed directly, for example
`./tools/install.cs --replace-other`. Use `./tools/install.cs configurator` to
install only the configurator. The standalone `./tools/install.sh` remains
available when .NET is not installed. Use `./tools/install.cs help` to show the
C# installation commands; `--help` is reserved by `dotnet run` when the file is
launched through its shebang.
The regression suite also executes the retained package-repository, release-packaging,
and AUR desktop-asset shell contracts. Production C# commands do not invoke those
fallback scripts.
`./tools/lint-and-test.sh` always runs those three retained shell contracts. When
the pinned .NET SDK is available, it also builds and runs the C# regression suite;
without .NET, it reports that optional local check as skipped and continues with
the standalone checks.
Common equivalents are `dev build`, `dev test`, `dev fetch`, `ci lint-and-test`,
`ci gtk-widgets`, `install app`, `install configurator`, `version bump`,
`package build`, `release publish-tag`, and `aur update`. Run `--help` for the
complete command list and options.
## Development
- **build.sh** - Build wayscriber release binary
@@ -59,7 +112,9 @@ Helper scripts for development, installation, packaging, and release workflows.
## Version & Release
- **bump-version.sh** - Bump version numbers
- Checks offline dependency resolution before changing version files; run `./tools/fetch-all-deps.sh` if the cache is incomplete.
- Updates Cargo.toml, configurator/Cargo.toml, the workspace Cargo.lock, PKGBUILD, and .SRCINFO
- Updates only workspace packages in the lockfile, offline; existing dependency versions stay locked
- flake.nix package version follows Cargo.toml automatically
- Auto-increments patch version if no version specified
- Use this in the same change as a user-visible overlay/settings/config toggle, or immediately
@@ -91,6 +146,9 @@ Packaging-only hotfix policy:
- Runs version consistency checks before tagging
- Usage: `./tools/publish-release-tag.sh [--version X.Y.Z[.N]] [--dry-run]`
See [Releasing](../docs/RELEASING.md) for validation, website release notes, and the
final update-manifest publication step. Pushing a tag does not update the website notice.
## Packaging
- **package.sh** - Build distribution packages
@@ -138,13 +196,24 @@ hashes, so build-level changes still need a pull request from us. See
## AUR (Arch User Repository)
- **aur-desktop-assets.sh** / **aur-desktop-assets.py** - Standalone asset recipe generator
- Reads and validates the package manifests without .NET.
- Usage: `bash tools/aur-desktop-assets.sh REPO_ROOT`.
- **wayscriber assets emit** - C# asset recipe generator used by CI
- Reads both package YAML manifests and validates asset paths and integer permissions.
- Standalone shell tools keep their own implementation and do not require .NET.
- **update-aur.sh** - Interactive AUR update
- Updates PKGBUILD, tests build locally, pushes to AUR
- Prompts for confirmation at each step
- Usage: `./tools/update-aur.sh`
- **update-aur-from-manifest.sh** - CI-friendly AUR update
- Remains a standalone shell implementation for local use and does not require .NET.
- Regenerates managed desktop asset blocks, including incomplete blocks from older updater versions.
- Updates multiple AUR packages using checksums from manifest.json
- Adds and validates desktop launchers and icons for the main source/binary packages and configurator
- Designed for CI automation after artifacts are built
- Requires the configurator AUR clone unless `--no-configurator` is passed explicitly
- Supports `--source-sha256` for offline/recovery runs
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Standalone counterpart of the C# AUR desktop-asset recipe command."""
import json
import pathlib
import re
import sys
class ManifestError(Exception):
pass
ASSET_PREFIXES = (
"/usr/share/applications/",
"/usr/share/icons/",
"/usr/share/pixmaps/",
)
def parse_contents(root: pathlib.Path, package: str, filename: str):
path = root / "packaging" / filename
lines = path.read_text(encoding="utf-8").splitlines()
content_headers = [index for index, line in enumerate(lines) if line == "contents:"]
if len(content_headers) != 1:
raise ManifestError(f"{filename}: expected one package contents sequence")
entries = []
current = None
in_contents = False
for line in lines[content_headers[0] + 1 :]:
if line and not line.startswith(" "):
break
if line.startswith(" - "):
if current is not None:
entries.append(current)
current = {}
in_contents = True
parse_field(current, line[4:], filename)
elif current is not None and line.startswith(" "):
stripped = line[4:]
if stripped == "file_info:":
current["file_info"] = True
elif line.startswith(" "):
parse_field(current, line[6:], filename)
elif stripped and not stripped.startswith("#"):
parse_field(current, stripped, filename)
elif line.strip() and in_contents:
raise ManifestError(f"{filename}: invalid content entry")
if current is not None:
entries.append(current)
if not in_contents:
raise ManifestError(f"{filename}: expected one package contents sequence")
assets = []
destinations = set()
for entry in entries:
destination = scalar(entry, "dst")
if not destination.startswith(ASSET_PREFIXES):
continue
source = scalar(entry, "src")
validate_asset(root, filename, entry, source, destination)
if destination in destinations:
raise ManifestError(f"{filename}: duplicate desktop destination {destination}")
destinations.add(destination)
assets.append((source, destination))
if (
f"/usr/share/applications/{package}.desktop" not in destinations
or not any(item.startswith("/usr/share/icons/") for item in destinations)
):
raise ManifestError(f"{filename}: launcher and icons are required")
return assets
def parse_field(entry, text, filename):
if ":" not in text:
raise ManifestError(f"{filename}: invalid content entry")
key, value = text.split(":", 1)
entry[key.strip()] = value.strip()
def scalar(entry, key):
value = entry.get(key)
if not value:
raise ManifestError(f"Expected scalar {key}")
return value
def validate_asset(root, filename, entry, source, destination):
if (
not re.fullmatch(r"packaging/[A-Za-z0-9_./-]+", source)
or not re.fullmatch(r"/usr/share/[A-Za-z0-9_./-]+", destination)
or ".." in source.split("/")
or ".." in destination.split("/")
):
raise ManifestError(f"{filename}: unsupported asset path {source} -> {destination}")
if not entry.get("file_info"):
raise ManifestError(
f"{filename}: desktop asset {destination} has no file_info mapping"
)
mode = entry.get("mode", "")
try:
if not re.fullmatch(r"(?:0o[0-7]+|0[0-7]+|[1-9][0-9]*|0)", mode):
raise ValueError
number = int(mode[2:], 8) if mode.lower().startswith("0o") else int(mode, 8 if mode.startswith("0") else 10)
except ValueError:
number = -1
if number != 0o644:
raise ManifestError(
f"{filename}: desktop asset {destination} must have mode 0644"
)
if not (root / source).is_file():
raise ManifestError(f"{filename}: missing desktop asset {source}")
def recipe(label, title, binary, package, assets, from_archive):
lines = []
destinations = []
for source, destination in assets:
input_path = f'"${{srcdir_tmp}}{destination}"' if from_archive else source
lines.append(f' install -Dm644 {input_path} "$pkgdir{destination}"')
destinations.append(destination)
return {
"label": label,
"marker": f"# {title} desktop integration",
"end_marker": f"# End {title} desktop integration",
"anchor": f' install -Dm755 "{binary}" "$pkgdir/usr/bin/{package}"',
"lines": lines,
"destinations": destinations,
}
def main():
if len(sys.argv) != 2:
raise ManifestError("Usage: aur-desktop-assets.py REPO_ROOT")
root = pathlib.Path(sys.argv[1]).resolve()
main_assets = parse_contents(root, "wayscriber", "package.wayscriber.yaml")
configurator_assets = parse_contents(
root, "wayscriber-configurator", "package.configurator.yaml"
)
result = {
"desktop_path_pattern": "/usr/share/applications/|/usr/share/icons/|/usr/share/pixmaps/",
"source": recipe(
"wayscriber source",
"Wayscriber",
"target/release/wayscriber",
"wayscriber",
main_assets,
False,
),
"bin": recipe(
"wayscriber bin",
"Wayscriber",
"${srcdir_tmp}/usr/bin/wayscriber",
"wayscriber",
main_assets,
True,
),
"configurator": recipe(
"wayscriber-configurator",
"Wayscriber configurator",
"target/release/wayscriber-configurator",
"wayscriber-configurator",
configurator_assets,
False,
),
}
output = json.dumps(result, separators=(",", ":")).replace('\\"', "\\u0022")
print(output)
try:
main()
except Exception as error:
print(f"Desktop asset manifest error: {error}", file=sys.stderr)
sys.exit(1)
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Standalone asset recipe generator for contributors who do not use .NET.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
if ! command -v python3 >/dev/null 2>&1; then
echo 'AUR packaging checks require Python 3; see CONTRIBUTING.md.' >&2
exit 1
fi
if [[ "${1:-}" == --check && "$#" -eq 1 ]]; then
exit 0
fi
if [[ "$#" -ne 1 ]]; then
echo 'Usage: bash tools/aur-desktop-assets.sh REPO_ROOT | --check' >&2
exit 1
fi
exec python3 "$SCRIPT_DIR/aur-desktop-assets.py" "$1"
+41 -8
View File
@@ -47,6 +47,7 @@ if [[ -n "${GPG_PRIVATE_KEY_B64:-}" ]]; then
need_cmd gpg
fi
if [[ "${SIGN_RPMS}" == "1" ]]; then
need_cmd rpm
need_cmd rpmsign
fi
@@ -61,8 +62,44 @@ RPM_CFG="${ARTIFACT_ROOT}/wayscriber-configurator-${RPM_ARCH}.rpm"
[[ -f "${RPM_MAIN}" ]] || die "Missing rpm package at ${RPM_MAIN}"
GNUPGHOME_TMP=""
RPM_MACRO_HOME=""
ACTIVE_KEY=""
rpm_major_version() {
local version_output
version_output="$(rpm --version)"
if [[ "${version_output}" =~ ([0-9]+)(\.[0-9]+)+ ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
return
fi
die "Cannot determine RPM version from: ${version_output}"
}
write_rpm_signing_macros() {
local rpm_major
rpm_major="$(rpm_major_version)"
RPM_MACRO_HOME="${GNUPGHOME_TMP}/rpm-home"
mkdir -p "${RPM_MACRO_HOME}"
cat > "${RPM_MACRO_HOME}/.rpmmacros" <<EOF
%_signature gpg
%_gpg_name ${ACTIVE_KEY}
%_gpg_path ${GNUPGHOME}
%__gpg $(command -v gpg)
EOF
if ((rpm_major >= 6)); then
cat >> "${RPM_MACRO_HOME}/.rpmmacros" <<EOF
%_openpgp_sign_id ${ACTIVE_KEY}
%_gpg_sign_cmd_extra_args --batch --pinentry-mode loopback --passphrase-fd 3
EOF
else
cat >> "${RPM_MACRO_HOME}/.rpmmacros" <<'EOF'
%__gpg_sign_cmd %{__gpg} --batch --pinentry-mode loopback --passphrase-fd 3 --no-armor --detach-sign --sign --local-user "%{_gpg_name}" --output %{__signature_filename} %{__plaintext_filename}
EOF
fi
}
setup_gpg() {
[[ -n "${GPG_PRIVATE_KEY_B64:-}" ]] || { warn "GPG_PRIVATE_KEY_B64 not set; repos will be unsigned"; return; }
@@ -86,13 +123,9 @@ setup_gpg() {
--export "${ACTIVE_KEY}" | gpg --dearmor > "${OUTPUT_ROOT}/WAYSCRIBER-GPG-KEY.gpg"
log "Exported public key: ${OUTPUT_ROOT}/WAYSCRIBER-GPG-KEY.asc"
cat > "${HOME}/.rpmmacros" <<EOF
%_signature gpg
%_gpg_name ${ACTIVE_KEY}
%_gpg_path ${GNUPGHOME}
%__gpg $(command -v gpg)
%__gpg_sign_cmd %{__gpg} --batch --pinentry-mode loopback --passphrase-fd 3 --no-armor --detach-sign --sign --local-user "%{_gpg_name}" --output %{__signature_filename} %{__plaintext_filename}
EOF
if [[ "${SIGN_RPMS}" == "1" ]]; then
write_rpm_signing_macros
fi
}
cleanup() {
@@ -107,7 +140,7 @@ sign_rpm() {
[[ -n "${ACTIVE_KEY}" ]] || { warn "Skipping rpm signing (no GPG key)"; return; }
[[ "${SIGN_RPMS}" == "1" ]] || { warn "Skipping rpm signing (SIGN_RPMS=0)"; return; }
if rpmsign --addsign "${rpm_path}" 3<<<"${GPG_PASSPHRASE:-}"; then
if HOME="${RPM_MACRO_HOME}" rpmsign --addsign "${rpm_path}" 3<<<"${GPG_PASSPHRASE:-}"; then
log "Signed rpm: ${rpm_path}"
else
warn "rpmsign failed for ${rpm_path}; package left unsigned"
+12 -3
View File
@@ -12,7 +12,7 @@ Usage: tools/bump-version.sh [--dry-run] [new_version]
- Updates:
* Cargo.toml (wayscriber)
* configurator/Cargo.toml
* Cargo.lock (via cargo generate-lockfile)
* Cargo.lock (workspace versions only; preserves locked dependencies)
* flake.nix package version follows Cargo.toml automatically
* packaging/PKGBUILD pkgver and template sha256sums=('SKIP')
* packaging/.SRCINFO (via makepkg --printsrcinfo)
@@ -130,14 +130,23 @@ update_version_field() {
fi
}
# Resolve against the existing manifests without writing the lockfile. An empty
# registry cache must fail before either manifest or any package metadata changes.
if [[ -f Cargo.lock ]]; then
if ! cargo update --workspace --offline --dry-run >/dev/null; then
echo "error: cannot resolve locked dependencies offline; run ./tools/fetch-all-deps.sh before bumping the version. No version files changed." >&2
exit 1
fi
fi
update_version_field "Cargo.toml"
update_version_field "configurator/Cargo.toml"
if [[ -f Cargo.lock ]]; then
if $DRY_RUN; then
echo "dry-run: would regenerate Cargo.lock"
echo "dry-run: would update workspace versions in Cargo.lock, preserving dependencies"
else
cargo generate-lockfile >/dev/null
cargo update --workspace --offline >/dev/null
fi
else
echo "warn: Cargo.lock not found, skipping lockfile update" >&2
+26 -4
View File
@@ -208,13 +208,35 @@ done < "$MANIFEST"
SERVICE_FILE="$STAGED_ROOT/usr/lib/systemd/user/wayscriber.service"
[[ -f "$SERVICE_FILE" ]] || die "archive does not contain the Wayscriber user service"
awk '
$0 == "ExecStart=/usr/bin/wayscriber --daemon" ||
$0 == "ExecStart=\"/usr/bin/wayscriber\" --daemon" {
function check_command(command) {
sub(/^[[:space:]]+/, "", command)
if (command !~ /^Exec[[:alpha:]]*=/ || index(command, "wayscriber") == 0) {
return
}
if (command == "ExecStart=/usr/bin/wayscriber --daemon" ||
command == "ExecStart=\"/usr/bin/wayscriber\" --daemon") {
matches++
} else {
unexpected = 1
}
}
{
line = $0
sub(/^[[:space:]]+/, "", line)
if (command != "" && line ~ /^[#;]/) {
next
}
index($0, "/usr/bin/wayscriber") { unexpected = 1 }
END { exit !(matches == 1 && !unexpected) }
command = command line
if (sub(/\\[[:space:]]*$/, " ", command)) {
next
}
check_command(command)
command = ""
}
END {
if (command != "") check_command(command)
exit !(matches == 1 && !unexpected)
}
' "$SERVICE_FILE" \
|| die "release user service is incompatible with the direct installer rewrite"
+22 -10
View File
@@ -12,6 +12,7 @@ Checks that release/version metadata agrees across:
* packaging/PKGBUILD
* packaging/.SRCINFO
* flake.nix
* global.json
Also keeps the configurator's supported libadwaita floor at 1.4 across its
Cargo feature, package metadata, release assertions, and AUR generation.
@@ -74,6 +75,7 @@ if [[ -z "$PYTHON" ]]; then
fi
"$PYTHON" - "$REPO_ROOT" "$release_version" <<'PY'
import json
import pathlib
import re
import sys
@@ -90,6 +92,7 @@ errors = []
version_re = re.compile(r"^\d+\.\d+\.\d+(?:\.\d+)?$")
checksum_re = re.compile(r"^[0-9a-fA-F]{64}$")
supported_libadwaita_floor = "1.4"
repository_tool_sdk = "11.0.100-rc.1.26425.128"
# A floor raise must add a reviewed runner contract instead of inheriting the
# previous Ubuntu base image by accident.
release_runner_libadwaita_floors = {
@@ -99,6 +102,24 @@ release_runner_libadwaita_floors = {
def read_text(path):
return (root / path).read_text(encoding="utf-8")
def global_json_sdk(path):
try:
document = json.loads(read_text(path))
sdk = document["sdk"]
version = sdk["version"]
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
errors.append(f"{path} SDK metadata is invalid: {error}")
return None
if not isinstance(version, str) or not version.strip():
errors.append(f"{path} SDK metadata is invalid: sdk.version must be a non-empty string")
return None
if sdk.get("rollForward") != "disable":
errors.append(f"{path} SDK rollForward must be disable")
if sdk.get("allowPrerelease") is not True:
errors.append(f"{path} SDK allowPrerelease must be true")
return version.strip()
def cargo_version(path):
text = read_text(path)
if tomllib is not None:
@@ -227,6 +248,7 @@ def require_template_sha256sums(label, values):
root_version = cargo_version("Cargo.toml")
config_version = cargo_version("configurator/Cargo.toml")
require_equal("configurator/Cargo.toml", config_version, root_version)
require_equal("global.json SDK", global_json_sdk("global.json"), repository_tool_sdk)
expected_libadwaita_feature = "v" + supported_libadwaita_floor.replace(".", "_")
libadwaita_features = cargo_libadwaita_features("configurator/Cargo.toml")
@@ -257,16 +279,6 @@ libadwaita_floors = {
"packaging/.SRCINFO",
r"^\s*depends = libadwaita>=([0-9]+\.[0-9]+)\s*$",
),
"release workflow deb libadwaita floor": single_metadata_floor(
"release workflow deb libadwaita floor",
".github/workflows/build-packages.yml",
r'''^\s*grep -Fq 'libadwaita-1-0 \(>= ([0-9]+\.[0-9]+)\)' <<< "\$configurator_deb_depends"\s*$''',
),
"release workflow rpm libadwaita floor": single_metadata_floor(
"release workflow rpm libadwaita floor",
".github/workflows/build-packages.yml",
r'''^\s*grep -Fxq 'libadwaita >= ([0-9]+\.[0-9]+)' <<< "\$configurator_rpm_requires"\s*$''',
),
"AUR updater generated libadwaita floor": single_metadata_floor(
"AUR updater generated libadwaita floor",
"tools/update-aur-from-manifest.sh",
+89
View File
@@ -0,0 +1,89 @@
using System.Text;
using Xunit;
namespace Wayscriber.Tools.Tests;
public sealed class CoreTests
{
[Theory]
[InlineData( "1.2.3", "1.2.3", false )]
[InlineData( "1.2.3.4", "1.2.3", true )]
public void ReleaseVersionParses( string value, string cargo, bool hotfix )
{
var version = ReleaseVersion.Parse( value );
Assert.Equal( cargo, version.CargoVersion );
Assert.Equal( hotfix, version.IsHotfix );
Assert.Equal( value, version.ToString( ) );
}
[Fact]
public void ReleaseVersionRejectsMalformedInput( ) =>
Assert.Throws<ToolException>( ( ) => ReleaseVersion.Parse( "1.2" ) );
[Fact]
public void ProcessArgumentsAreQuotedForDiagnostics( )
{
var value = ProcessRunner.FormatCommand( "git", ["status", "two words", "safe/path"] );
Assert.Equal( "git status 'two words' safe/path", value );
}
[Fact]
public void RustMaskPreservesLineNumbersAndRemovesCommentsAndStrings( )
{
const string source = "fn one() { /* { */ call(); }\n// call()\nlet text = \"call()\";\n";
var masked = ConfigWriterAudit.StripRustCommentsAndStrings( source );
Assert.Equal( source.Count( character => character == '\n' ), masked.Count( character => character == '\n' ) );
Assert.Single( System.Text.RegularExpressions.Regex.Matches( masked, @"\bcall\s*\(" ).Cast<System.Text.RegularExpressions.Match>( ) );
}
[Fact]
public void CfgTestRemovalHandlesAllTestPredicate( )
{
const string source = "live();\n#[cfg(all(test, feature = \"x\"))]\nfn test_only() { hidden(); }\nlive_again();";
var production = ConfigWriterAudit.RemoveCfgTestBlocks( ConfigWriterAudit.StripRustCommentsAndStrings( source ) );
Assert.Contains( "live();", production );
Assert.Contains( "live_again();", production );
Assert.DoesNotContain( "hidden", production );
}
[Fact]
public void InstallerManifestRejectsDuplicatePaths( )
{
const string source = "# ARCH_INSTALL_MANIFEST_BEGIN\nrelease_manifest() {\nprintf '%s\\n' \\\n'0755 bin/wayscriber' \\\n'0644 bin/wayscriber'\n}\n# ARCH_INSTALL_MANIFEST_END\n";
Assert.Throws<ToolException>( ( ) => PackagingCommands.ParseInstallerManifest( source ) );
}
[Fact]
public void AtomicFileSetReplacesCompleteContent( )
{
using var directory = new TemporaryDirectory( "wayscriber-test" );
var path = Path.Combine( directory.Path, "value" );
File.WriteAllText( path, "before" );
var files = new AtomicFileSet( );
files.Add( path, "after" );
files.Commit( );
Assert.Equal( "after", File.ReadAllText( path ) );
}
[Fact]
public void CurrentAssetManifestsProduceAllRecipeChannels( )
{
var root = FindRepository( );
var recipe = AssetsCommand.CreateRecipe( root );
Assert.NotNull( recipe["source"] );
Assert.NotNull( recipe["bin"] );
Assert.NotNull( recipe["configurator"] );
}
[Fact]
public void CurrentVersionMetadataIsConsistent( )
{
Assert.Empty( VersionCommands.Validate( FindRepository( ) ) );
}
private static string FindRepository( )
{
var directory = AppContext.GetData( "EntryPointFileDirectoryPath" ) as string ?? Environment.CurrentDirectory;
return Path.GetFullPath( Path.Combine( directory, ".." ) );
}
}
@@ -0,0 +1,400 @@
using System.Runtime.Versioning;
using Xunit;
namespace Wayscriber.Tools.Tests;
public sealed class ReleaseParityRegressionTests
{
private const string ArchiveRootName = "wayscriber-v1.2.3-linux-x86_64";
private const string AskpassModeVariable = EnvironmentVariables.WayscriberSshAskpass;
private const string PassphraseVariable = EnvironmentVariables.AurSshPassphrase;
private const string SyntheticPassphrase = "synthetic-passphrase";
private const string RepositoryDeployKey = "repository-deploy-key";
private const string TestDotnetSdk = "11.0.100-rc.1.26425.128";
private const string DotnetLogVariable = "WAYSCRIBER_DOTNET_LOG";
[Fact]
public async Task AurAskpassEnvironmentIsScopedToSshAdd( )
{
using var fixture = new TemporaryDirectory( "wayscriber-aur-askpass-test" );
var githubEnvironment = Path.Combine( fixture.Path, "github-environment" );
var environment = new Dictionary<string, string?>( StringComparer.Ordinal )
{
[EnvironmentVariables.Home] = fixture.Path,
[EnvironmentVariables.AurSshPrivateKey] = "private-key",
[EnvironmentVariables.AurSshKnownHosts] = "aur.archlinux.org ssh-ed25519 host-key\n",
[PassphraseVariable] = SyntheticPassphrase,
[EnvironmentVariables.GitHubEnvironment] = githubEnvironment,
};
var runner = new AskpassAurRunner( );
var context = CreateContext( runner, name => environment.GetValueOrDefault( name ) );
var command = ReleaseAurCommands.Commands.Single( item => item.Area == "aur" && item.Name == "prepare-ssh" );
Assert.Equal( ExitCodes.Success, await command.Handler( context, [] ) );
var sshAdd = Assert.Single( runner.Requests, request => request.FileName == "setsid" );
Assert.Equal( "1", sshAdd.Environment![AskpassModeVariable] );
Assert.Equal( SyntheticPassphrase, sshAdd.Environment[PassphraseVariable] );
var git = Assert.Single( runner.Requests, request => request.FileName == "git" );
Assert.False( git.Environment!.ContainsKey( AskpassModeVariable ) );
Assert.False( git.Environment.ContainsKey( PassphraseVariable ) );
Assert.Equal( AskpassAurRunner.AgentSocket, git.Environment[EnvironmentVariables.SshAuthSocket] );
Assert.Equal( AskpassAurRunner.AgentProcessId, git.Environment[EnvironmentVariables.SshAgentProcessId] );
var persisted = File.ReadAllText( githubEnvironment );
Assert.Contains( $"SSH_AUTH_SOCK={AskpassAurRunner.AgentSocket}\n", persisted, StringComparison.Ordinal );
Assert.Contains( $"SSH_AGENT_PID={AskpassAurRunner.AgentProcessId}\n", persisted, StringComparison.Ordinal );
Assert.Contains( "GIT_SSH_COMMAND=", persisted, StringComparison.Ordinal );
Assert.DoesNotContain( AskpassModeVariable, persisted, StringComparison.Ordinal );
Assert.DoesNotContain( PassphraseVariable, persisted, StringComparison.Ordinal );
Assert.DoesNotContain( SyntheticPassphrase, persisted, StringComparison.Ordinal );
}
[Fact]
public async Task ArchInstallerCheckRejectsUnexpectedTopLevelFiles( )
{
using var fixture = CreateArchiveFixture( );
File.WriteAllText( Path.Combine( fixture.Path, "unexpected.txt" ), "unexpected\n" );
var archive = await CreateArchive( fixture.Path );
var error = await RunArchInstallerCheckExpectingFailure( fixture.Path, archive );
Assert.Contains( "unexpected", error.Message, StringComparison.OrdinalIgnoreCase );
}
[Fact]
public async Task ArchInstallerCheckRejectsASymbolicLinkArchiveRoot( )
{
using var fixture = CreateArchiveFixture( );
using var externalPayload = CreateArchiveFixture( );
var archiveRoot = Path.Combine( fixture.Path, ArchiveRootName );
Directory.Delete( archiveRoot, recursive: true );
Directory.CreateSymbolicLink( archiveRoot, Path.Combine( externalPayload.Path, ArchiveRootName ) );
var archive = await CreateArchive( fixture.Path );
var error = await RunArchInstallerCheckExpectingFailure( fixture.Path, archive );
Assert.Contains( "symbolic link or special file", error.Message, StringComparison.Ordinal );
}
[Theory]
[InlineData( "hard-link" )]
[InlineData( "fifo" )]
public async Task ArchInstallerCheckRejectsUnsupportedFileTypes( string kind )
{
using var fixture = CreateArchiveFixture( );
var usr = Path.Combine( fixture.Path, ArchiveRootName, "usr" );
if ( kind == "hard-link" )
{
var source = Path.Combine( usr, "bin/wayscriber" );
var link = Path.Combine( usr, "bin/wayscriber-copy" );
await RunProcess( fixture.Path, "ln", [source, link] );
}
else
{
var specialDirectory = Path.Combine( usr, "share/wayscriber" );
Directory.CreateDirectory( specialDirectory );
await RunProcess( fixture.Path, "mkfifo", [Path.Combine( specialDirectory, "release-pipe" )] );
}
var archive = await CreateArchive( fixture.Path );
var error = await RunArchInstallerCheckExpectingFailure( fixture.Path, archive );
var expected = kind == "hard-link" ? "hard-linked file" : "symbolic link or special file";
Assert.Contains( expected, error.Message, StringComparison.Ordinal );
}
[Theory]
[InlineData( "ExecStart=/usr/bin/wayscriber --active\n" )]
[InlineData( "ExecStartPost=/usr/bin/env wayscriber --active\n" )]
[InlineData( "ExecStartPost=/usr/bin/env \\\n wayscriber --active\n" )]
[InlineData( "ExecStartPost=/usr/bin/env \\\n# ignored by systemd\n; ignored by systemd\n wayscriber --active\n" )]
public async Task ArchInstallerCheckRejectsAdditionalWayscriberServiceCommands( string command )
{
using var fixture = CreateArchiveFixture( );
var service = Path.Combine( fixture.Path, ArchiveRootName, "usr/lib/systemd/user/wayscriber.service" );
File.AppendAllText( service, command );
var archive = await CreateArchive( fixture.Path );
var error = await RunArchInstallerCheckExpectingFailure( fixture.Path, archive );
Assert.Contains( "service is incompatible", error.Message, StringComparison.Ordinal );
}
[Theory]
[InlineData( RepositoryDeployKey )]
[InlineData( RepositoryDeployKey + "\n" )]
public async Task RepositoryDeployKeyAlwaysEndsWithOnePreservedNewline( string configuredKey )
{
var environment = new Dictionary<string, string?>( StringComparer.Ordinal )
{
[EnvironmentVariables.DeployHost] = "packages.example.invalid",
[EnvironmentVariables.DeployPath] = "/srv/wayscriber",
[EnvironmentVariables.DeployUser] = "deploy",
[EnvironmentVariables.PackageRepositorySshKey] = configuredKey,
[EnvironmentVariables.PackageRepositorySshKnownHosts] = "packages.example.invalid ssh-ed25519 host-key\n",
};
var runner = new RepositoryDeployRunner( );
var context = CreateContext( runner, name => environment.GetValueOrDefault( name ) );
var command = ReleaseAurCommands.Commands.Single( item =>
item.Area == CommandAreas.Release && item.Name == CommandNames.DeployPackageRepositories );
Assert.Equal( ExitCodes.Success, await command.Handler( context, [] ) );
Assert.Equal( RepositoryDeployKey + "\n", runner.DeployKey );
}
[Fact]
public void ManagedDesktopAssetBlocksReplaceAndRejectStaleEntries( )
{
var recipe = AssetsCommand.CreateRecipe( FindRepository( ) )["source"]!.AsObject( );
var marker = recipe["marker"]!.GetValue<string>( );
var end = recipe["end_marker"]!.GetValue<string>( );
var anchor = recipe["anchor"]!.GetValue<string>( );
var expectedLines = recipe["lines"]!.AsArray( ).Select( item => item!.GetValue<string>( ) ).ToArray( );
const string staleLine = " install -Dm644 packaging/icons/wayscriber-obsolete.png \"$pkgdir/usr/share/icons/hicolor/obsolete/wayscriber.png\"";
var input = anchor + "\n\n" + marker + "\n" + string.Join( '\n', expectedLines ) + "\n" + staleLine + "\n" + end;
Assert.False( ReleaseAurCommands.HasExactDesktopAssetBlock( input, recipe ) );
var result = ReleaseAurCommands.ApplyDesktopAssets( input, recipe );
Assert.True( ReleaseAurCommands.HasExactDesktopAssetBlock( result, recipe ) );
Assert.DoesNotContain( staleLine, result, StringComparison.Ordinal );
}
[Theory]
[InlineData( PackageChannels.Source )]
[InlineData( PackageChannels.Binary )]
[InlineData( PackageChannels.Configurator )]
public void CompleteMarkerlessDesktopAssetsAreMigratedAndStaleEntriesAreRemoved( string channel )
{
var recipe = AssetsCommand.CreateRecipe( FindRepository( ) )[channel]!.AsObject( );
var anchor = recipe["anchor"]!.GetValue<string>( );
var expectedLines = recipe["lines"]!.AsArray( ).Select( item => item!.GetValue<string>( ) ).ToArray( );
var staleName = channel == PackageChannels.Configurator ? "retired-legacy-icon" : "retired-icon";
var staleLine = $" install -Dm644 packaging/icons/{staleName}.png \"$pkgdir/usr/share/icons/hicolor/obsolete/{staleName}.png\"";
var input = anchor + "\n\n" + string.Join( '\n', expectedLines ) + "\n" + staleLine;
Assert.False( ReleaseAurCommands.HasExactDesktopAssetBlock( input, recipe ) );
var result = ReleaseAurCommands.ApplyDesktopAssets( input, recipe );
Assert.True( ReleaseAurCommands.HasExactDesktopAssetBlock( result, recipe ) );
Assert.DoesNotContain( staleLine, result, StringComparison.Ordinal );
}
[Theory]
[InlineData( PackageChannels.Source )]
[InlineData( PackageChannels.Binary )]
[InlineData( PackageChannels.Configurator )]
public void MarkerlessPreviousManifestSubsetsAreMigrated( string channel )
{
var recipe = AssetsCommand.CreateRecipe( FindRepository( ) )[channel]!.AsObject( );
var anchor = recipe["anchor"]!.GetValue<string>( );
var previousManifestLines = recipe["lines"]!.AsArray( ).Select( item => item!.GetValue<string>( ) ).SkipLast( 1 );
var input = anchor + "\n\n" + string.Join( '\n', previousManifestLines );
var result = ReleaseAurCommands.ApplyDesktopAssets( input, recipe );
Assert.True( ReleaseAurCommands.HasExactDesktopAssetBlock( result, recipe ) );
}
[Fact]
public void InstallDirectoryNormalizationPreservesFilesystemRoot( )
{
Assert.Equal( Path.GetPathRoot( Environment.CurrentDirectory ), NativeDesktopCommands.NormalizeBinDirectory( "/" ) );
}
[Fact]
public async Task StandaloneGateUsesSdkResolutionAndRunsEveryCsharpBuild( )
{
if ( !OperatingSystem.IsLinux( ) )
{
return;
}
using var fixture = CreateStandaloneGateFixture( dotnetSdkAvailable: true );
var dotnetLog = Path.Combine( fixture.Path, "dotnet.log" );
var result = await RunStandaloneGate( fixture.Path, dotnetLog, new HashSet<int> { ExitCodes.Success } );
Assert.Equal( ExitCodes.Success, result.ExitCode );
var invocations = File.ReadAllText( dotnetLog );
Assert.Contains( "build tools/wayscriber.cs --disable-build-servers --verbosity quiet", invocations, StringComparison.Ordinal );
Assert.Contains( "build tools/install.cs --disable-build-servers --verbosity quiet", invocations, StringComparison.Ordinal );
Assert.Contains( "build tools/wayscriber.tests.cs --disable-build-servers --verbosity quiet", invocations, StringComparison.Ordinal );
Assert.Contains( "format style tools/wayscriber.cs --no-restore --verify-no-changes", invocations, StringComparison.Ordinal );
Assert.Contains( "format whitespace tools/install.cs --no-restore --verify-no-changes", invocations, StringComparison.Ordinal );
Assert.Contains( "format whitespace tools/wayscriber.tests.cs --no-restore --verify-no-changes", invocations, StringComparison.Ordinal );
Assert.Contains( "run tools/wayscriber.tests.cs --no-build --verbosity quiet", invocations, StringComparison.Ordinal );
}
[Fact]
public async Task StandaloneGateSkipsOnlyCsharpChecksWhenPinnedSdkIsUnavailable( )
{
if ( !OperatingSystem.IsLinux( ) )
{
return;
}
using var fixture = CreateStandaloneGateFixture( dotnetSdkAvailable: false );
var dotnetLog = Path.Combine( fixture.Path, "dotnet.log" );
var result = await RunStandaloneGate( fixture.Path, dotnetLog, new HashSet<int> { ExitCodes.Success } );
Assert.Equal( ExitCodes.Success, result.ExitCode );
Assert.Contains( "SDK selected by global.json is unavailable", result.StandardOutput, StringComparison.Ordinal );
Assert.False( File.Exists( dotnetLog ) );
}
private static ToolContext CreateContext( IProcessRunner runner, Func<string, string?>? environment = null ) =>
new( FindRepository( ), TextWriter.Null, TextWriter.Null, runner, CancellationToken.None, environment );
private static TemporaryDirectory CreateArchiveFixture( )
{
var fixture = new TemporaryDirectory( "wayscriber-archive-validation-test" );
var usr = Path.Combine( fixture.Path, ArchiveRootName, "usr" );
Directory.CreateDirectory( Path.Combine( usr, "bin" ) );
Directory.CreateDirectory( Path.Combine( usr, "lib/systemd/user" ) );
File.WriteAllText( Path.Combine( usr, "bin/wayscriber" ), "binary\n" );
File.WriteAllText( Path.Combine( usr, "lib/systemd/user/wayscriber.service" ), "ExecStart=/usr/bin/wayscriber --daemon\n" );
File.WriteAllText( Path.Combine( fixture.Path, "installer.sh" ), """
# ARCH_INSTALL_MANIFEST_BEGIN
release_manifest() {
printf '%s\n' \
'0644 bin/wayscriber' \
'0644 lib/systemd/user/wayscriber.service'
}
# ARCH_INSTALL_MANIFEST_END
""" );
return fixture;
}
private static async Task<string> CreateArchive( string directory )
{
var archive = Path.Combine( directory, "wayscriber.tar.gz" );
var entries = Directory.EnumerateFileSystemEntries( directory )
.Select( Path.GetFileName )
.Where( name => name is not "installer.sh" and not "wayscriber.tar.gz" )
.Select( name => name! )
.ToArray( );
await RunProcess( directory, "tar", ["-czf", archive, "--", .. entries] );
return archive;
}
private static async Task<ToolException> RunArchInstallerCheckExpectingFailure( string directory, string archive )
{
var context = CreateContext( new ProcessRunner( TextWriter.Null, TextWriter.Null ) );
var command = PackagingCommands.Commands.Single( item => item.Area == "package" && item.Name == "check-arch-installer" );
return await Assert.ThrowsAsync<ToolException>( ( ) => command.Handler(
context,
["--installer", Path.Combine( directory, "installer.sh" ), "--archive", archive] ) );
}
private static async Task RunProcess( string directory, string fileName, IReadOnlyList<string> arguments )
{
var runner = new ProcessRunner( TextWriter.Null, TextWriter.Null );
await runner.RunAsync( new ProcessRequest( fileName, arguments, directory, CaptureOutput: true, Trace: false ), CancellationToken.None );
}
[SupportedOSPlatform( "linux" )]
private static TemporaryDirectory CreateStandaloneGateFixture( bool dotnetSdkAvailable )
{
var fixture = new TemporaryDirectory( "wayscriber-standalone-gate-test" );
var tools = Path.Combine( fixture.Path, "tools" );
var fakeBin = Path.Combine( fixture.Path, "fake-bin" );
Directory.CreateDirectory( tools );
Directory.CreateDirectory( fakeBin );
File.Copy( Path.Combine( FindRepository( ), "tools/lint-and-test.sh" ), Path.Combine( tools, "lint-and-test.sh" ) );
File.Copy( Path.Combine( FindRepository( ), "global.json" ), Path.Combine( fixture.Path, "global.json" ) );
File.CreateSymbolicLink( Path.Combine( fakeBin, "bash" ), "/usr/bin/true" );
WriteExecutable( Path.Combine( fakeBin, "cargo" ), "#!/usr/bin/bash\nprintf 'test result: ok. 1 passed; 0 failed;\\n'\n" );
WriteExecutable( Path.Combine( fakeBin, "dotnet" ), $$"""
#!/usr/bin/bash
if [[ "$1" == "--version" ]]; then
{{(dotnetSdkAvailable ? $"printf '%s\\n' '{TestDotnetSdk}'" : "exit 1")}}
else
printf '%s\n' "$*" >> "${WAYSCRIBER_DOTNET_LOG:?}"
fi
""" );
foreach ( var check in new[] { "check-nixpkgs-recipe.py", "check-rust-source-coverage.py", "check-process-sites.py",
"check-config-writers.py", "check-shared-dependencies.py" } )
{
WriteExecutable( Path.Combine( tools, check ), "#!/usr/bin/true\n" );
}
return fixture;
}
[SupportedOSPlatform( "linux" )]
private static void WriteExecutable( string path, string content )
{
File.WriteAllText( path, content );
File.SetUnixFileMode( path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute );
}
private static Task<ProcessResult> RunStandaloneGate( string repository, string dotnetLog, IReadOnlySet<int> allowedExitCodes )
{
var environment = new Dictionary<string, string?>
{
[EnvironmentVariables.Path] = Path.Combine( repository, "fake-bin" ) + Path.PathSeparator +
Environment.GetEnvironmentVariable( EnvironmentVariables.Path ),
[DotnetLogVariable] = dotnetLog,
};
var request = new ProcessRequest( "/usr/bin/bash", [Path.Combine( repository, "tools/lint-and-test.sh" )], repository,
environment, CaptureOutput: true, Trace: false, AllowedExitCodes: allowedExitCodes );
return new ProcessRunner( TextWriter.Null, TextWriter.Null ).RunAsync( request, CancellationToken.None );
}
private static string FindRepository( )
{
var directory = AppContext.GetData( "EntryPointFileDirectoryPath" ) as string ?? Environment.CurrentDirectory;
return Path.GetFullPath( Path.Combine( directory, ".." ) );
}
private sealed class AskpassAurRunner : IProcessRunner
{
public const string AgentProcessId = "24680";
public const string AgentSocket = "/tmp/wayscriber-agent.sock";
public List<ProcessRequest> Requests { get; } = [];
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
Requests.Add( request );
if ( request.FileName == "ssh-agent" )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success,
$"SSH_AUTH_SOCK={AgentSocket}; export SSH_AUTH_SOCK;\nSSH_AGENT_PID={AgentProcessId}; export SSH_AGENT_PID;\n", string.Empty ) );
}
if ( request.FileName == "setsid" || request.FileName == "git" )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success, string.Empty, string.Empty ) );
}
throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" );
}
}
private sealed class RepositoryDeployRunner : IProcessRunner
{
public string? DeployKey { get; private set; }
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
if ( request.FileName == "ssh" )
{
var keyArgument = Array.IndexOf( request.Arguments.ToArray( ), "-i" );
DeployKey = File.ReadAllText( request.Arguments[keyArgument + 1] );
}
else if ( request.FileName != "rsync" )
{
throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" );
}
return Task.FromResult( new ProcessResult( ExitCodes.Success, string.Empty, string.Empty ) );
}
}
}
@@ -0,0 +1,471 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Xunit;
namespace Wayscriber.Tools.Tests;
public sealed class ReleaseRegressionTests
{
private const string SourceChecksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
[Fact]
public void EmptyAndWhitespaceEnvironmentValuesAreUnset( )
{
var values = new Dictionary<string, string?>( StringComparer.Ordinal )
{
["EMPTY"] = string.Empty,
["WHITESPACE"] = " \t ",
["VALUE"] = "configured",
};
var context = CreateContext( new RejectingRunner( ), name => values.GetValueOrDefault( name ) );
Assert.Null( context.Environment( "EMPTY" ) );
Assert.Null( context.Environment( "WHITESPACE" ) );
Assert.Equal( "configured", context.Environment( "VALUE" ) );
Assert.Null( context.Environment( "MISSING" ) );
}
[Fact]
public void InstallerManifestRejectsUnsupportedLinesAmongValidEntries( )
{
const string source = """
# ARCH_INSTALL_MANIFEST_BEGIN
release_manifest() {
printf '%s\n' \
'0755 bin/wayscriber' \
"0644 share/wayscriber/unsupported"
}
# ARCH_INSTALL_MANIFEST_END
""";
var error = Assert.Throws<ToolException>( ( ) => PackagingCommands.ParseInstallerManifest( source ) );
Assert.Contains( "unsupported installer manifest syntax", error.Message, StringComparison.Ordinal );
}
[Theory]
[InlineData( false )]
[InlineData( true )]
public void IncompleteDesktopAssetBlockIsNormalizedWithoutRemovingPackagePayload( bool omitLastAsset )
{
var recipe = AssetsCommand.CreateRecipe( FindRepository( ) )["configurator"]!.AsObject( );
var marker = recipe["marker"]!.GetValue<string>( );
var endMarker = recipe["end_marker"]!.GetValue<string>( );
var anchor = recipe["anchor"]!.GetValue<string>( );
var assetLines = recipe["lines"]!.AsArray( ).Select( item => item!.GetValue<string>( ) ).ToArray( );
var existingAssetLines = omitLastAsset ? assetLines[..^1] : assetLines;
var payload = " install -Dm644 README.md \"$pkgdir/usr/share/doc/wayscriber-configurator/README.md\"";
var input = anchor + "\n\n" + marker + "\n" + string.Join( '\n', existingAssetLines ) + "\n" + payload + "\n}";
var result = ReleaseAurCommands.ApplyDesktopAssets( input, recipe );
Assert.Equal( 1, result.Split( '\n' ).Count( line => line == marker ) );
Assert.Equal( 1, result.Split( '\n' ).Count( line => line == endMarker ) );
Assert.All( assetLines, line => Assert.Contains( line, result, StringComparison.Ordinal ) );
Assert.Contains( payload, result, StringComparison.Ordinal );
}
[Fact]
public void IncompleteWayscriberAssetBlockPreservesTheConfiguratorBlock( )
{
var recipes = AssetsCommand.CreateRecipe( FindRepository( ) );
var sourceRecipe = recipes[PackageChannels.Source]!.AsObject( );
var configuratorRecipe = recipes[PackageChannels.Configurator]!.AsObject( );
var sourceAnchor = sourceRecipe["anchor"]!.GetValue<string>( );
var sourceMarker = sourceRecipe["marker"]!.GetValue<string>( );
var existingSourceAsset = sourceRecipe["lines"]!.AsArray( )[0]!.GetValue<string>( );
var configuratorAnchor = configuratorRecipe["anchor"]!.GetValue<string>( );
var configuratorMarker = configuratorRecipe["marker"]!.GetValue<string>( );
var configuratorEndMarker = configuratorRecipe["end_marker"]!.GetValue<string>( );
var configuratorAssets = configuratorRecipe["lines"]!.AsArray( ).Select( item => item!.GetValue<string>( ) );
var configuratorBlock = configuratorMarker + "\n" + string.Join( '\n', configuratorAssets ) + "\n" + configuratorEndMarker;
var input = sourceAnchor + "\n\n" + sourceMarker + "\n" + existingSourceAsset + "\n\n" +
configuratorAnchor + "\n\n" + configuratorBlock;
var result = ReleaseAurCommands.ApplyDesktopAssets( input, sourceRecipe );
Assert.True( ReleaseAurCommands.HasExactDesktopAssetBlock( result, sourceRecipe ) );
Assert.True( ReleaseAurCommands.HasExactDesktopAssetBlock( result, configuratorRecipe ) );
Assert.Contains( configuratorBlock, result, StringComparison.Ordinal );
}
[Fact]
public async Task ConfiguratorUpdateUsesTheGccLibsDependencyAnchor( )
{
using var fixture = new TemporaryDirectory( "wayscriber-configurator-aur-test" );
var configurator = Path.Combine( fixture.Path, "configurator" );
CreateConfiguratorCheckout( configurator );
var manifest = WriteManifest( fixture.Path );
var context = CreateContext( new CheckoutOnlyRunner( ) );
var command = AurUpdateCommand( );
var result = await command.Handler( context,
["--manifest", manifest, "--source-dir", Path.Combine( fixture.Path, "missing-source" ), "--bin-dir", Path.Combine( fixture.Path, "missing-bin" ),
"--config-dir", configurator, "--source-sha256", SourceChecksum] );
Assert.Equal( 0, result );
var packageBuild = File.ReadAllText( Path.Combine( configurator, RepositoryNames.PackageBuildFile ) );
var sourceInfo = File.ReadAllText( Path.Combine( configurator, RepositoryNames.SourceInfoFile ) );
Assert.Contains( " 'gtk4'", packageBuild, StringComparison.Ordinal );
Assert.Contains( "\tdepends = gtk4", sourceInfo, StringComparison.Ordinal );
Assert.True( packageBuild.IndexOf( " 'gtk4'", StringComparison.Ordinal ) < packageBuild.IndexOf( " 'gcc-libs'", StringComparison.Ordinal ) );
Assert.True( sourceInfo.IndexOf( "\tdepends = gtk4", StringComparison.Ordinal ) < sourceInfo.IndexOf( "\tdepends = gcc-libs", StringComparison.Ordinal ) );
Assert.DoesNotContain( "wl-clipboard", packageBuild, StringComparison.Ordinal );
Assert.DoesNotContain( "wl-clipboard", sourceInfo, StringComparison.Ordinal );
}
[Fact]
public async Task AurPublicationOmitsAnUntrackedObsoleteHookFromTheCommitPathspec( )
{
using var fixture = new TemporaryDirectory( "wayscriber-aur-publish-test" );
var source = Path.Combine( fixture.Path, "source" );
CreateSourceCheckout( source );
var manifest = WriteManifest( fixture.Path );
var runner = new PublishingAurRunner( );
var context = CreateContext( runner );
var command = AurUpdateCommand( );
var result = await command.Handler( context,
["--manifest", manifest, "--source-dir", source, "--bin-dir", Path.Combine( fixture.Path, "missing-bin" ), "--no-configurator",
"--source-sha256", SourceChecksum, "--push"] );
Assert.Equal( 0, result );
var commit = Assert.Single( runner.Requests, request => request.FileName == "git" && request.Arguments.Contains( "commit" ) );
Assert.DoesNotContain( "wayscriber.install", commit.Arguments );
Assert.DoesNotContain( runner.Requests, request => request.FileName == "git" && request.Arguments.Contains( "rm" ) );
}
[Fact]
public async Task AurSshPreparationPreservesUserSshFiles( )
{
using var fixture = new TemporaryDirectory( "wayscriber-aur-ssh-test" );
var sshDirectory = Path.Combine( fixture.Path, ".ssh" );
Directory.CreateDirectory( sshDirectory );
var knownHostsPath = Path.Combine( sshDirectory, "known_hosts" );
var configPath = Path.Combine( sshDirectory, "config" );
File.WriteAllText( knownHostsPath, "existing.example ssh-ed25519 existing\n" );
File.WriteAllText( configPath, "Host existing.example\n User existing\n" );
var environment = new Dictionary<string, string?>( StringComparer.Ordinal )
{
[EnvironmentVariables.Home] = fixture.Path,
[EnvironmentVariables.AurSshPrivateKey] = "private-key",
[EnvironmentVariables.AurSshKnownHosts] = string.Empty,
};
var runner = new AurSshRunner( );
var context = CreateContext( runner, name => environment.GetValueOrDefault( name ) );
var command = ReleaseAurCommands.Commands.Single( item => item.Area == "aur" && item.Name == "prepare-ssh" );
Assert.Equal( ExitCodes.Success, await command.Handler( context, [] ) );
Assert.Equal( "existing.example ssh-ed25519 existing\n", File.ReadAllText( knownHostsPath ) );
Assert.Equal( "Host existing.example\n User existing\n", File.ReadAllText( configPath ) );
var dedicatedKnownHosts = Path.Combine( sshDirectory, "known_hosts.wayscriber-aur" );
Assert.Equal( AurSshRunner.ScannedHostKey, File.ReadAllText( dedicatedKnownHosts ) );
var request = Assert.Single( runner.Requests, item => item.FileName == "git" );
Assert.Contains( $"UserKnownHostsFile={dedicatedKnownHosts}", request.Environment![EnvironmentVariables.GitSshCommand], StringComparison.Ordinal );
}
[Fact]
public async Task UnstampedPrivateGtkPrefixIsNeverReused( )
{
using var fixture = new TemporaryDirectory( "wayscriber-gtk-prefix-test" );
var prefix = Path.Combine( fixture.Path, "private-prefix" );
var libraryDirectory = Path.Combine( prefix, "lib" );
Directory.CreateDirectory( libraryDirectory );
File.WriteAllText( Path.Combine( libraryDirectory, "libgtk4-layer-shell.so" ), string.Empty );
var runner = new RejectingRunner( );
var context = CreateContext( runner );
var result = await NativeDesktopCommands.GtkArtifactsExist(
context,
prefix,
"/usr",
"shared",
Path.Combine( prefix, "share/wayscriber/build-deps/gtk4-layer-shell.pin" ),
"expected-pin",
new Dictionary<string, string?>( ) );
Assert.False( result );
Assert.Empty( runner.Requests );
}
[Fact]
public async Task UnstampedNestedSystemGtkPrefixIsNeverReused( )
{
using var fixture = new TemporaryDirectory( "wayscriber-nested-gtk-prefix-test" );
var systemPrefix = Path.Combine( fixture.Path, "system" );
var prefix = Path.Combine( systemPrefix, "local" );
var libraryDirectory = Path.Combine( prefix, "lib" );
Directory.CreateDirectory( libraryDirectory );
File.WriteAllText( Path.Combine( libraryDirectory, "libgtk4-layer-shell.so" ), string.Empty );
var runner = new RejectingRunner( );
var context = CreateContext( runner );
var result = await NativeDesktopCommands.GtkArtifactsExist(
context,
prefix,
systemPrefix,
"shared",
Path.Combine( prefix, "share/wayscriber/build-deps/gtk4-layer-shell.pin" ),
"expected-pin",
new Dictionary<string, string?>( ) );
Assert.False( result );
Assert.Empty( runner.Requests );
}
[Fact]
public void NativeInstallUsesSudoOnlyForUnprivilegedCallers( )
{
const uint rootUserId = 0;
const uint unprivilegedUserId = 1_000;
Assert.False( NativeDesktopCommands.ShouldUseSudo( requiresPrivilege: false, unprivilegedUserId ) );
Assert.False( NativeDesktopCommands.ShouldUseSudo( requiresPrivilege: true, rootUserId ) );
Assert.True( NativeDesktopCommands.ShouldUseSudo( requiresPrivilege: true, unprivilegedUserId ) );
}
[Fact]
public async Task FailedBuildPreservesAnExistingInstallation( )
{
using var fixture = new TemporaryDirectory( "wayscriber-install-build-failure-test" );
var existingBinary = Path.Combine( fixture.Path, ".local/bin", RepositoryNames.MainPackage );
Directory.CreateDirectory( Path.GetDirectoryName( existingBinary )! );
File.WriteAllText( existingBinary, "known-good-installation" );
var destinationDirectory = Path.Combine( fixture.Path, "replacement", "bin" );
var environment = new Dictionary<string, string?>( StringComparer.Ordinal )
{
[EnvironmentVariables.Home] = fixture.Path,
[EnvironmentVariables.XdgConfigHome] = Path.Combine( fixture.Path, ".config" ),
};
var runner = new FailingBuildRunner( );
var context = CreateContext( runner, name => environment.GetValueOrDefault( name ) );
var command = NativeDesktopCommands.Commands.Single( item => item.Area == CommandAreas.Install && item.Name == CommandNames.App );
await Assert.ThrowsAsync<ToolException>( ( ) => command.Handler( context,
["--install-dir", destinationDirectory, "--replace-other", "--autostart", "none"] ) );
Assert.Equal( "known-good-installation", File.ReadAllText( existingBinary ) );
var request = Assert.Single( runner.Requests );
Assert.Equal( Programs.Cargo, request.FileName );
Assert.Equal( ["build", CommandLineOptions.Release, CommandLineOptions.Binaries], request.Arguments );
}
[Fact]
public async Task UnapprovedInstallConflictIsRejectedBeforeBuild( )
{
using var fixture = new TemporaryDirectory( "wayscriber-install-conflict-test" );
var existingBinary = Path.Combine( fixture.Path, ".local/bin", RepositoryNames.MainPackage );
Directory.CreateDirectory( Path.GetDirectoryName( existingBinary )! );
File.WriteAllText( existingBinary, "known-good-installation" );
var destinationDirectory = Path.Combine( fixture.Path, "replacement", "bin" );
var environment = new Dictionary<string, string?>( StringComparer.Ordinal )
{
[EnvironmentVariables.Home] = fixture.Path,
[EnvironmentVariables.XdgConfigHome] = Path.Combine( fixture.Path, ".config" ),
};
var runner = new RejectingRunner( );
var context = CreateContext( runner, name => environment.GetValueOrDefault( name ) );
var command = NativeDesktopCommands.Commands.Single( item => item.Area == CommandAreas.Install && item.Name == CommandNames.App );
var error = await Assert.ThrowsAsync<ToolException>( ( ) => command.Handler( context,
["--install-dir", destinationDirectory, "--autostart", "none"] ) );
Assert.Contains( "pass --replace-other", error.Message, StringComparison.Ordinal );
Assert.Equal( "known-good-installation", File.ReadAllText( existingBinary ) );
Assert.Empty( runner.Requests );
}
[Fact]
public void DesktopExecPathPreservesBothEscapeLayers( )
{
const string path = """/tmp/configurator % install\root"quote`tick$cash/bin/wayscriber-configurator""";
const string expected = """/tmp/configurator %% install\\\\root\\"quote\\`tick\\$cash/bin/wayscriber-configurator""";
Assert.Equal( expected, NativeDesktopCommands.EscapeDesktopExecPath( path ) );
}
[Theory]
[InlineData( "tools/test-package-repo-layout.sh" )]
[InlineData( "tools/test-release-packaging.sh" )]
[InlineData( "tools/test-aur-desktop-assets.sh" )]
public async Task StandaloneReleaseContractsPassInTheCanonicalTestApp( string relativePath )
{
var root = FindRepository( );
var runner = new ProcessRunner( TextWriter.Null, TextWriter.Null );
var result = await runner.RunAsync(
new ProcessRequest( Path.Combine( root, relativePath ), [], root, CaptureOutput: true, Trace: false ),
CancellationToken.None );
Assert.Equal( ExitCodes.Success, result.ExitCode );
}
private static ToolContext CreateContext( IProcessRunner runner, Func<string, string?>? environment = null ) =>
new( FindRepository( ), TextWriter.Null, TextWriter.Null, runner, CancellationToken.None, environment );
private static ToolCommand AurUpdateCommand( ) =>
ReleaseAurCommands.Commands.Single( item => item.Area == "aur" && item.Name == "update" );
private static string WriteManifest( string directory )
{
var path = Path.Combine( directory, "manifest.json" );
File.WriteAllText( path, """{"version":"9.9.9","artifacts":[]}""" );
return path;
}
private static void CreateSourceCheckout( string directory )
{
Directory.CreateDirectory( directory );
File.WriteAllText( Path.Combine( directory, RepositoryNames.PackageBuildFile ), """
pkgname=wayscriber
pkgver=1.0.0
pkgrel=1
depends=(
'gcc-libs'
'wl-clipboard'
)
source=("old.tar.gz::https://example.invalid/old.tar.gz")
sha256sums=('old')
package() {
cd "$pkgname"
install -Dm755 "target/release/wayscriber" "$pkgdir/usr/bin/wayscriber"
}
""" );
File.WriteAllText( Path.Combine( directory, RepositoryNames.SourceInfoFile ), """
pkgbase = wayscriber
pkgver = 1.0.0
pkgrel = 1
depends = gcc-libs
depends = wl-clipboard
source = old.tar.gz::https://example.invalid/old.tar.gz
sha256sums = old
pkgname = wayscriber
""" );
}
private static void CreateConfiguratorCheckout( string directory )
{
Directory.CreateDirectory( directory );
File.WriteAllText( Path.Combine( directory, RepositoryNames.PackageBuildFile ), """
pkgname=wayscriber-configurator
pkgver=1.0.0
pkgrel=1
pkgdesc='old'
depends=(
'gcc-libs'
)
source=("old.tar.gz::https://example.invalid/old.tar.gz")
sha256sums=('old')
package() {
cd wayscriber
install -Dm755 "target/release/wayscriber-configurator" "$pkgdir/usr/bin/wayscriber-configurator"
install -Dm644 README.md "$pkgdir/usr/share/doc/wayscriber-configurator/README.md"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/wayscriber-configurator/LICENSE"
}
""" );
File.WriteAllText( Path.Combine( directory, RepositoryNames.SourceInfoFile ), """
pkgbase = wayscriber-configurator
pkgver = 1.0.0
pkgrel = 1
pkgdesc = old
depends = gcc-libs
source = old.tar.gz::https://example.invalid/old.tar.gz
sha256sums = old
pkgname = wayscriber-configurator
""" );
}
private static string FindRepository( )
{
var directory = AppContext.GetData( "EntryPointFileDirectoryPath" ) as string ?? Environment.CurrentDirectory;
return Path.GetFullPath( Path.Combine( directory, ".." ) );
}
private sealed class RejectingRunner : IProcessRunner
{
public List<ProcessRequest> Requests { get; } = [];
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
Requests.Add( request );
throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" );
}
}
private sealed class FailingBuildRunner : IProcessRunner
{
public List<ProcessRequest> Requests { get; } = [];
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
Requests.Add( request );
throw new ToolException( "Synthetic build failure." );
}
}
private sealed class CheckoutOnlyRunner : IProcessRunner
{
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
if ( request.FileName == "git" && request.Arguments.Contains( "rev-parse" ) )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success, Path.GetFullPath( request.Arguments[1] ) + "\n", string.Empty ) );
}
throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" );
}
}
private sealed class PublishingAurRunner : IProcessRunner
{
public List<ProcessRequest> Requests { get; } = [];
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
Requests.Add( request );
if ( request.Arguments.Contains( "rev-parse" ) )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success, Path.GetFullPath( request.Arguments[1] ) + "\n", string.Empty ) );
}
if ( request.Arguments.Contains( "ls-files" ) )
{
return Task.FromResult( new ProcessResult( ExitCodes.Failure, string.Empty, string.Empty ) );
}
if ( request.Arguments.Contains( "status" ) )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success, " M PKGBUILD\n", string.Empty ) );
}
if ( request.FileName == "git" )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success, string.Empty, string.Empty ) );
}
throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" );
}
}
private sealed class AurSshRunner : IProcessRunner
{
public const string ScannedHostKey = "aur.archlinux.org ssh-ed25519 scanned-key\n";
public List<ProcessRequest> Requests { get; } = [];
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
Requests.Add( request );
if ( request.FileName == "ssh-keyscan" && request.Arguments.SequenceEqual( new[] { "-H", "aur.archlinux.org" } ) )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success, ScannedHostKey, string.Empty ) );
}
if ( request.FileName == "git" && request.Arguments.SequenceEqual( new[] { "ls-remote", "ssh://aur@aur.archlinux.org/wayscriber.git", "HEAD" } ) )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success, string.Empty, string.Empty ) );
}
throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" );
}
}
}
@@ -0,0 +1,386 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Xunit;
namespace Wayscriber.Tools.Tests;
public sealed class RepositoryContractTests
{
[Fact]
public void StandaloneInstallersRemainAvailableWithoutDotnet( )
{
var root = FindRepository( );
Assert.True( File.Exists( Path.Combine( root, "tools/install.sh" ) ) );
Assert.True( File.Exists( Path.Combine( root, "tools/install-configurator.sh" ) ) );
Assert.DoesNotContain( "dotnet", File.ReadAllText( Path.Combine( root, "tools/install.sh" ) ), StringComparison.OrdinalIgnoreCase );
Assert.DoesNotContain( "dotnet", File.ReadAllText( Path.Combine( root, "tools/install-configurator.sh" ) ), StringComparison.OrdinalIgnoreCase );
}
[Fact]
public void StandaloneDevelopmentRunnerUsesTheBuiltBinary( )
{
var source = File.ReadAllText( Path.Combine( FindRepository( ), "tools/run.sh" ) );
Assert.Contains( "target/release/wayscriber", source, StringComparison.Ordinal );
Assert.Contains( "--daemon", source, StringComparison.Ordinal );
Assert.Contains( "RUST_LOG=\"${RUST_LOG:-info}\"", source, StringComparison.Ordinal );
Assert.DoesNotContain( "dotnet", source, StringComparison.OrdinalIgnoreCase );
}
[Fact]
public void CsharpInstallShortcutRoutesToTheSharedAppInstaller( )
{
var source = File.ReadAllText( Path.Combine( FindRepository( ), "tools/install.cs" ) );
Assert.StartsWith( "#!/usr/bin/env -S dotnet run --disable-build-servers --file\n", source, StringComparison.Ordinal );
Assert.Contains( "#:include csharp/includes.cs", source, StringComparison.Ordinal );
Assert.Contains( "var command = explicitCommand ? args[0] : CommandNames.App;", source, StringComparison.Ordinal );
Assert.Contains( "ToolApplication.RunAsync( [CommandAreas.Install, command, .. commandArguments] )", source, StringComparison.Ordinal );
Assert.DoesNotContain( "install.sh", source, StringComparison.Ordinal );
}
[Fact]
public void StandaloneShellToolsDoNotRedirectToDotnet( )
{
var root = Path.Combine( FindRepository( ), "tools" );
foreach ( var path in Directory.EnumerateFiles( root, "*.sh", SearchOption.TopDirectoryOnly ) )
{
var source = File.ReadAllText( path );
Assert.DoesNotContain( "dotnet run tools/wayscriber.cs", source, StringComparison.OrdinalIgnoreCase );
Assert.DoesNotMatch( @"(?m)^\s*(?:exec\s+)?dotnet\b", source );
}
}
[Fact]
public void WorkflowRunStepsUseTheCsharpEntryPoint( )
{
foreach ( var path in new[] { ".github/workflows/ci.yml", ".github/workflows/build-packages.yml" } )
{
var text = File.ReadAllText( Path.Combine( FindRepository( ), path ) );
var commands = Regex.Matches( text, @"(?m)^\s+run:\s*(.+)$" ).Select( match => match.Groups[1].Value.Trim( ) ).ToArray( );
Assert.NotEmpty( commands );
Assert.All( commands, command => Assert.StartsWith( "dotnet ", command ) );
Assert.DoesNotContain( "bash", text, StringComparison.Ordinal );
Assert.DoesNotContain( "python", text, StringComparison.Ordinal );
}
}
[Fact]
public void CanonicalCsharpGateEnforcesCsharpFormatting( )
{
var source = File.ReadAllText( Path.Combine( FindRepository( ), "tools/csharp/Commands/DevelopmentCommands.cs" ) );
Assert.Contains( "CsharpFileApps", source, StringComparison.Ordinal );
Assert.Contains( "CsharpFormatModes", source, StringComparison.Ordinal );
Assert.Contains( "RunCsharpFormattingChecks( context )", source, StringComparison.Ordinal );
Assert.Contains( "CommandLineOptions.VerifyNoChanges", source, StringComparison.Ordinal );
}
[Fact]
public void CancellationDiagnosticDoesNotReuseTheCanceledToken( )
{
var source = File.ReadAllText( Path.Combine( FindRepository( ), "tools/csharp/Application/ToolApplication.cs" ) );
Assert.Contains( "WriteLineAsync( ToolMessages.Canceled );", source, StringComparison.Ordinal );
Assert.DoesNotContain( "WriteLineAsync( ToolMessages.Canceled, cancellation.Token )", source, StringComparison.Ordinal );
}
[Fact]
public void ToolModulesAreExplicitlyIncluded( )
{
var root = FindRepository( );
var csharpRoot = Path.Combine( root, "tools/csharp" );
var actual = Directory.EnumerateFiles( csharpRoot, "*.cs", SearchOption.AllDirectories )
.Where( path => Path.GetFileName( path ) != "includes.cs" )
.Select( path => Path.GetRelativePath( csharpRoot, path ).Replace( Path.DirectorySeparatorChar, '/' ) ).Order( ).ToArray( );
var included = File.ReadAllLines( Path.Combine( csharpRoot, "includes.cs" ) )
.Select( line => Regex.Match( line, @"^#:include (.+)$" ) ).Where( match => match.Success )
.Select( match => match.Groups[1].Value ).Order( ).ToArray( );
Assert.Equal( actual, included );
}
[Fact]
public void ToolSdkIsPinnedInGlobalJson( )
{
var root = FindRepository( );
using var document = JsonDocument.Parse( File.ReadAllText( Path.Combine( root, "global.json" ) ) );
var sdk = document.RootElement.GetProperty( "sdk" );
Assert.Equal( VersionCommands.ToolSdkVersion, sdk.GetProperty( "version" ).GetString( ) );
Assert.Equal( VersionCommands.ToolSdkRollForward, sdk.GetProperty( "rollForward" ).GetString( ) );
Assert.True( sdk.GetProperty( "allowPrerelease" ).GetBoolean( ) );
}
[Fact]
public void CsharpCommandsNeverLaunchAShellOrPythonInterpreter( )
{
var root = Path.Combine( FindRepository( ), "tools/csharp" );
foreach ( var path in Directory.EnumerateFiles( root, "*.cs", SearchOption.AllDirectories ) )
{
var source = File.ReadAllText( path );
Assert.DoesNotMatch( "(?:context\\.Run|ProcessRequest)\\(\\s*\"(?:(?:ba|z)?sh|python(?:3(?:\\.\\d+)?)?)\"", source );
}
}
[Fact]
public void CsharpProcessAndEnvironmentContractsUseNamedConstants( )
{
var root = Path.Combine( FindRepository( ), "tools/csharp" );
foreach ( var path in Directory.EnumerateFiles( root, "*.cs", SearchOption.AllDirectories )
.Where( path => Path.GetFileName( path ) != "ToolConstants.cs" ) )
{
var source = File.ReadAllText( path );
Assert.DoesNotMatch( "(?:context\\.Run|ProcessRequest|ProcessStartInfo)\\(\\s*\\\"", source );
Assert.DoesNotMatch( "context\\.Environment\\(\\s*\\\"[A-Z][A-Z0-9_]+\\\"", source );
Assert.DoesNotMatch( "ToolException\\([^\\n]*,\\s*(?:2|70|127|130)\\s*\\)", source );
}
}
[Fact]
public async Task ProcessRunnerPreservesArgumentBoundaries( )
{
var output = new StringWriter( );
var error = new StringWriter( );
var result = await new ProcessRunner( output, error ).RunAsync(
new ProcessRequest( "/usr/bin/printf", ["%s", "two words;$(ignored)"], FindRepository( ), CaptureOutput: true ), CancellationToken.None );
Assert.Equal( "two words;$(ignored)", result.StandardOutput );
}
[Fact]
public async Task ProcessRunnerAllowsDeclaredNonzeroExit( )
{
var runner = new ProcessRunner( TextWriter.Null, TextWriter.Null );
var result = await runner.RunAsync( new ProcessRequest( "/usr/bin/false", [], FindRepository( ), AllowedExitCodes: new HashSet<int> { ExitCodes.Failure } ), CancellationToken.None );
Assert.Equal( ExitCodes.Failure, result.ExitCode );
}
[Fact]
public async Task ConcurrentProcessRunsDoNotShareALock( )
{
var runner = new ProcessRunner( TextWriter.Null, TextWriter.Null );
var runs = Enumerable.Range( 0, 12 ).Select( value => runner.RunAsync(
new ProcessRequest( "/usr/bin/printf", ["%s", value.ToString( )], FindRepository( ), CaptureOutput: true ), CancellationToken.None ) );
var results = await Task.WhenAll( runs );
Assert.Equal( Enumerable.Range( 0, 12 ).Select( value => value.ToString( ) ).Order( ), results.Select( result => result.StandardOutput ).Order( ) );
}
[Fact]
public void AssetRecipesAreDeterministicUnderConcurrency( )
{
var values = Enumerable.Range( 0, 16 ).AsParallel( ).Select( _ => AssetsCommand.CreateRecipe( FindRepository( ) ).ToJsonString( ) ).ToArray( );
Assert.Single( values.Distinct( StringComparer.Ordinal ) );
}
[Fact]
public async Task StandaloneAndCsharpAssetRecipesHaveExactParity( )
{
var root = FindRepository( );
var runner = new ProcessRunner( TextWriter.Null, TextWriter.Null );
var result = await runner.RunAsync( new ProcessRequest( "/usr/bin/bash", [Path.Combine( root, "tools/aur-desktop-assets.sh" ), root],
root, CaptureOutput: true, Trace: false ), CancellationToken.None );
Assert.Equal( AssetsCommand.CreateRecipe( root ).ToJsonString( ), result.StandardOutput.Trim( ) );
}
[Fact]
public async Task AurSourceUpdateDoesNotNeedMakepkgAndIsIdempotent( )
{
using var fixture = new TemporaryDirectory( "wayscriber-aur-test" );
var source = Path.Combine( fixture.Path, "source" );
Directory.CreateDirectory( source );
File.WriteAllText( Path.Combine( source, "PKGBUILD" ), """
pkgname=wayscriber
pkgver=1.0.0
pkgrel=7
install=wayscriber.install
depends=(
'gcc-libs'
'wl-clipboard'
)
makedepends=(
'git'
)
source=("old.tar.gz::https://example.invalid/old.tar.gz")
sha256sums=('old')
package() {
cd "$pkgname"
install -Dm755 "target/release/wayscriber" "$pkgdir/usr/bin/wayscriber"
}
""" );
File.WriteAllText( Path.Combine( source, ".SRCINFO" ), """
pkgbase = wayscriber
pkgver = 1.0.0
pkgrel = 7
install = wayscriber.install
makedepends = git
depends = gcc-libs
depends = wl-clipboard
source = old.tar.gz::https://example.invalid/old.tar.gz
sha256sums = old
pkgname = wayscriber
""" );
File.WriteAllText( Path.Combine( source, "wayscriber.install" ), "obsolete" );
var manifest = Path.Combine( fixture.Path, "manifest.json" );
File.WriteAllText( manifest, """{"version":"9.9.9","artifacts":[]}""" );
var runner = new RecordingAurRunner( );
var context = new ToolContext( FindRepository( ), TextWriter.Null, TextWriter.Null, runner, CancellationToken.None );
var command = ReleaseAurCommands.Commands.Single( item => item.Area == "aur" && item.Name == "update" );
var arguments = new[] { "--manifest", manifest, "--source-dir", source, "--bin-dir", Path.Combine( fixture.Path, "missing" ),
"--no-configurator", "--source-sha256", new string( 'a', HashingConstants.Sha256HexLength ) };
Assert.Equal( ExitCodes.Success, await command.Handler( context, arguments ) );
Assert.Equal( ExitCodes.Success, await command.Handler( context, arguments ) );
var pkgbuild = File.ReadAllText( Path.Combine( source, "PKGBUILD" ) );
var srcinfo = File.ReadAllText( Path.Combine( source, ".SRCINFO" ) );
Assert.Contains( "pkgver=9.9.9", pkgbuild );
Assert.Contains( "pkgrel=2", pkgbuild );
Assert.Contains( "# End Wayscriber desktop integration", pkgbuild );
Assert.Single( Regex.Matches( pkgbuild, "# Wayscriber desktop integration" ).Cast<Match>( ) );
Assert.Contains( "\tpkgver = 9.9.9", srcinfo );
Assert.Contains( "\tpkgrel = 2", srcinfo );
Assert.Contains( "\tdepends = gtk4-layer-shell", srcinfo );
Assert.DoesNotContain( "makedepends = git", srcinfo );
Assert.DoesNotContain( "install = ", srcinfo );
Assert.False( File.Exists( Path.Combine( source, "wayscriber.install" ) ) );
Assert.DoesNotContain( runner.Requests, request => request.FileName is "makepkg" or "bash" or "sh" );
}
[Fact]
public async Task RepositoryBuildReplacesCompleteOutputFromAStagingDirectory( )
{
using var fixture = new TemporaryDirectory( "wayscriber-repository-test" );
var artifacts = Path.Combine( fixture.Path, "artifacts" );
var output = Path.Combine( fixture.Path, "repository" );
Directory.CreateDirectory( artifacts );
Directory.CreateDirectory( output );
File.WriteAllText( Path.Combine( output, "stale" ), "old" );
foreach ( var name in new[] { "wayscriber-amd64.deb", "wayscriber-configurator-amd64.deb", "wayscriber-x86_64.rpm", "wayscriber-configurator-x86_64.rpm" } )
{
File.WriteAllText( Path.Combine( artifacts, name ), name );
}
var runner = new RepositoryLayoutRunner( );
var context = new ToolContext( FindRepository( ), TextWriter.Null, TextWriter.Null, runner, CancellationToken.None );
var command = PackagingCommands.Commands.Single( item => item.Area == "package" && item.Name == "build-repositories" );
Assert.Equal( ExitCodes.Success, await command.Handler( context, ["--artifact-root", artifacts, "--output-root", output] ) );
Assert.False( File.Exists( Path.Combine( output, "stale" ) ) );
Assert.True( File.Exists( Path.Combine( output, "apt/dists/stable/main/binary-amd64/Packages" ) ) );
Assert.True( File.Exists( Path.Combine( output, "apt/dists/stable/main/binary-amd64/Packages.gz" ) ) );
Assert.True( File.Exists( Path.Combine( output, "rpm/repodata/repomd.xml" ) ) );
Assert.DoesNotContain( runner.Requests, request => request.FileName is "bash" or "sh" );
}
[Theory]
[InlineData( "mode: 493", "mode: nope" )]
[InlineData( "mode: 493", "mode: '493'" )]
public void AssetRecipesRejectMalformedModes( string oldValue, string newValue )
{
using var fixture = AssetFixture( );
var path = Path.Combine( fixture.Path, "packaging/package.wayscriber.yaml" );
File.WriteAllText( path, File.ReadAllText( path ).Replace( oldValue, newValue, StringComparison.Ordinal ) );
Assert.Throws<ToolException>( ( ) => AssetsCommand.CreateRecipe( fixture.Path ) );
}
[Theory]
[InlineData( "'0644 ../escape'" )]
[InlineData( "'0644 bin/wayscriber' \\\n'0644 bin/wayscriber'" )]
public void InstallerManifestRejectsUnsafeEntries( string entry )
{
var source = $"# ARCH_INSTALL_MANIFEST_BEGIN\nrelease_manifest() {{\nprintf '%s\\n' \\\n{entry}\n}}\n# ARCH_INSTALL_MANIFEST_END\n";
Assert.Throws<ToolException>( ( ) => PackagingCommands.ParseInstallerManifest( source ) );
}
[Fact]
public void VersionCheckAcceptsPackagingHotfixOfCurrentCargoVersion( )
{
var current = ReleaseVersion.Parse( VersionCommands.ReadCargoVersion( Path.Combine( FindRepository( ), "Cargo.toml" ) ) );
var packageVersion = File.ReadAllText( Path.Combine( FindRepository( ), "packaging/PKGBUILD" ) );
var currentPackage = Regex.Match( packageVersion, @"(?m)^pkgver=(.+)$" ).Groups[1].Value;
var release = currentPackage.StartsWith( current.CargoVersion + ".", StringComparison.Ordinal ) ? currentPackage : current.CargoVersion;
Assert.Empty( VersionCommands.Validate( FindRepository( ), release ) );
}
[Fact]
public void VersionCheckRejectsUnrelatedReleaseVersion( )
{
var errors = VersionCommands.Validate( FindRepository( ), "99.98.97" );
Assert.Contains( errors, error => error.Contains( "must equal Cargo version", StringComparison.Ordinal ) );
}
[Fact]
public void ReplaceSingleRejectsAmbiguousMutation( )
{
Assert.Throws<ToolException>( ( ) => Files.ReplaceSingle( "x x", "x", "y", "fixture" ) );
}
[Fact]
public void AtomicFileSetRestoresEarlierFilesWhenCommitFails( )
{
using var directory = new TemporaryDirectory( "wayscriber-atomic-test" );
var first = Path.Combine( directory.Path, "first" );
var blocker = Path.Combine( directory.Path, "blocker" );
File.WriteAllText( first, "before" );
File.WriteAllText( blocker, "file" );
var files = new AtomicFileSet( );
files.Add( first, "after" );
files.Add( Path.Combine( blocker, "child" ), "never" );
Assert.ThrowsAny<IOException>( files.Commit );
Assert.Equal( "before", File.ReadAllText( first ) );
}
private static TemporaryDirectory AssetFixture( )
{
var fixture = new TemporaryDirectory( "wayscriber-assets-test" );
Directory.CreateDirectory( Path.Combine( fixture.Path, "packaging" ) );
foreach ( var name in new[] { "package.wayscriber.yaml", "package.configurator.yaml" } )
{
File.Copy( Path.Combine( FindRepository( ), "packaging", name ), Path.Combine( fixture.Path, "packaging", name ) );
}
return fixture;
}
private static string FindRepository( )
{
var directory = AppContext.GetData( "EntryPointFileDirectoryPath" ) as string ?? Environment.CurrentDirectory;
return Path.GetFullPath( Path.Combine( directory, ".." ) );
}
private sealed class RecordingAurRunner : IProcessRunner
{
public List<ProcessRequest> Requests { get; } = [];
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
Requests.Add( request );
if ( request.FileName == "git" && request.Arguments.Count >= 3 && request.Arguments[0] == "-C" && request.Arguments[2] == "rev-parse" )
{
return Task.FromResult( new ProcessResult( ExitCodes.Success, Path.GetFullPath( request.Arguments[1] ) + "\n", string.Empty ) );
}
throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" );
}
}
private sealed class RepositoryLayoutRunner : IProcessRunner
{
public List<ProcessRequest> Requests { get; } = [];
public Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
Requests.Add( request );
if ( request.FileName == "apt-ftparchive" )
{
var output = request.Arguments.Contains( "packages" ) ? "Package: wayscriber\n" : "Suite: stable\n";
return Task.FromResult( new ProcessResult( ExitCodes.Success, output, string.Empty ) );
}
if ( request.FileName == "createrepo_c" )
{
var root = request.Arguments[^1];
Directory.CreateDirectory( Path.Combine( root, "repodata" ) );
File.WriteAllText( Path.Combine( root, "repodata/repomd.xml" ), "<repomd/>" );
return Task.FromResult( new ProcessResult( ExitCodes.Success, string.Empty, string.Empty ) );
}
throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" );
}
}
}
@@ -0,0 +1,130 @@
using System.Runtime.Versioning;
using System.Text;
using Xunit;
namespace Wayscriber.Tools.Tests;
public sealed class SignedRepositoryIntegrationTests
{
private const string Architecture = "x86_64";
private const string KeyIdentity = "Wayscriber repository test <repository-test@wayscriber.invalid>";
private const string KeyPassphrase = "wayscriber-repository-test-passphrase";
private const string PackageVersion = "1.0.0";
[Fact]
public async Task RepositoryBuildProducesAVerifiableSignedRpm( )
{
if ( !OperatingSystem.IsLinux( ) )
{
return;
}
using var fixture = new TemporaryDirectory( "wayscriber-signed-repository-test" );
var artifacts = Path.Combine( fixture.Path, "artifacts" );
var output = Path.Combine( fixture.Path, "repository" );
Directory.CreateDirectory( artifacts );
await CreateDebPackage( fixture.Path, artifacts );
await CreateRpmPackage( fixture.Path, artifacts );
var privateKey = await CreateSigningKey( fixture.Path );
var environment = new Dictionary<string, string?>( StringComparer.Ordinal )
{
[EnvironmentVariables.GpgPrivateKeyBase64] = Convert.ToBase64String( Encoding.UTF8.GetBytes( privateKey ) ),
[EnvironmentVariables.GpgPassphrase] = KeyPassphrase,
[EnvironmentVariables.SignRpms] = "1",
};
var context = new ToolContext( FindRepository( ), TextWriter.Null, TextWriter.Null,
new ProcessRunner( TextWriter.Null, TextWriter.Null ), CancellationToken.None,
name => environment.GetValueOrDefault( name ) );
var command = PackagingCommands.Commands.Single( item => item.Area == "package" && item.Name == "build-repositories" );
Assert.Equal( ExitCodes.Success, await command.Handler( context, ["--artifact-root", artifacts, "--output-root", output] ) );
var rpmDatabase = Path.Combine( fixture.Path, "rpm-database" );
Directory.CreateDirectory( rpmDatabase );
var publicKey = Path.Combine( output, "rpm/RPM-GPG-KEY-wayscriber.asc" );
var signedRpm = Path.Combine( output, $"rpm/{RepositoryNames.MainPackage}-{Architecture}.rpm" );
await Run( fixture.Path, "rpmkeys", ["--dbpath", rpmDatabase, "--import", publicKey] );
var verification = await Run( fixture.Path, "rpmkeys", ["--dbpath", rpmDatabase, "--checksig", signedRpm],
allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
Assert.True( verification.IsSuccess, verification.StandardOutput + verification.StandardError );
Assert.Contains( "digests signatures OK", verification.StandardOutput, StringComparison.OrdinalIgnoreCase );
}
private static async Task CreateDebPackage( string root, string artifacts )
{
var packageRoot = Path.Combine( root, "deb-package" );
var metadata = Path.Combine( packageRoot, "DEBIAN" );
Directory.CreateDirectory( metadata );
File.WriteAllText( Path.Combine( metadata, "control" ), $"""
Package: wayscriber
Version: {PackageVersion}
Architecture: amd64
Maintainer: Wayscriber Tests <repository-test@wayscriber.invalid>
Description: Wayscriber repository signing fixture
""" );
Directory.CreateDirectory( Path.Combine( packageRoot, "usr/share/wayscriber" ) );
File.WriteAllText( Path.Combine( packageRoot, "usr/share/wayscriber/fixture" ), "fixture\n" );
await Run( root, "dpkg-deb", ["--build", packageRoot, Path.Combine( artifacts, $"{RepositoryNames.MainPackage}-amd64.deb" )] );
}
private static async Task CreateRpmPackage( string root, string artifacts )
{
var topDirectory = Path.Combine( root, "rpmbuild" );
var specDirectory = Path.Combine( topDirectory, "SPECS" );
Directory.CreateDirectory( specDirectory );
var spec = Path.Combine( specDirectory, "wayscriber.spec" );
File.WriteAllText( spec, $$"""
Name: wayscriber
Version: {{PackageVersion}}
Release: 1
Summary: Wayscriber repository signing fixture
License: MIT
BuildArch: {{Architecture}}
%description
Wayscriber repository signing fixture.
%install
mkdir -p %{buildroot}/usr/share/wayscriber
echo fixture > %{buildroot}/usr/share/wayscriber/fixture
%files
/usr/share/wayscriber/fixture
""" );
await Run( root, "rpmbuild", ["--define", $"_topdir {topDirectory}", "--define", "_build_id_links none", "-bb", spec] );
var builtRpm = Directory.EnumerateFiles( Path.Combine( topDirectory, "RPMS" ), "*.rpm", SearchOption.AllDirectories ).Single( );
File.Copy( builtRpm, Path.Combine( artifacts, $"{RepositoryNames.MainPackage}-{Architecture}.rpm" ) );
}
[SupportedOSPlatform( "linux" )]
private static async Task<string> CreateSigningKey( string root )
{
var home = Path.Combine( root, "signing-key" );
Directory.CreateDirectory( home );
File.SetUnixFileMode( home, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute );
var environment = new Dictionary<string, string?> { [EnvironmentVariables.GnuPgHome] = home };
await Run( root, "gpg", ["--batch", "--pinentry-mode", "loopback", "--passphrase", KeyPassphrase,
"--quick-generate-key", KeyIdentity, "rsa2048", "sign", "0"], environment );
var exported = await Run( root, "gpg", ["--batch", "--pinentry-mode", "loopback", "--passphrase", KeyPassphrase,
"--armor", "--export-secret-keys", KeyIdentity], environment );
return exported.StandardOutput;
}
private static Task<ProcessResult> Run( string workingDirectory, string fileName, IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string?>? environment = null, IReadOnlySet<int>? allowedExitCodes = null )
{
var runner = new ProcessRunner( TextWriter.Null, TextWriter.Null );
var request = new ProcessRequest( fileName, arguments, workingDirectory, environment, CaptureOutput: true,
Trace: false, AllowedExitCodes: allowedExitCodes );
return runner.RunAsync( request, CancellationToken.None );
}
private static string FindRepository( )
{
var directory = AppContext.GetData( "EntryPointFileDirectoryPath" ) as string ?? Environment.CurrentDirectory;
return Path.GetFullPath( Path.Combine( directory, ".." ) );
}
}
+5
View File
@@ -0,0 +1,5 @@
#:include CoreTests.cs
#:include RepositoryContractTests.cs
#:include ReleaseParityRegressionTests.cs
#:include ReleaseRegressionTests.cs
#:include SignedRepositoryIntegrationTests.cs
@@ -0,0 +1,96 @@
namespace Wayscriber.Tools;
internal static class ToolApplication
{
private const int HelpAreaWidth = 10;
private const int HelpCommandWidth = 26;
private static readonly IReadOnlyList<ToolCommand> Commands =
[
.. AssetsCommand.Commands,
.. DevelopmentCommands.Commands,
.. ChecksCommand.Commands,
.. VersionCommands.Commands,
.. NativeDesktopCommands.Commands,
.. PackagingCommands.Commands,
.. ReleaseAurCommands.Commands,
.. ReportCommands.Commands,
];
public static async Task<int> RunAsync( string[] args )
{
using var cancellation = new CancellationTokenSource( );
Console.CancelKeyPress += ( _, eventArgs ) =>
{
eventArgs.Cancel = true;
cancellation.Cancel( );
};
try
{
if ( args.Length == 0 || args is [CommandLineOptions.ShortHelp or CommandLineOptions.Help] )
{
PrintHelp( Console.Out );
return ExitCodes.Success;
}
if ( args is [CommandNames.Help, ..] )
{
PrintHelp( Console.Out, args.Length > 1 ? args[1] : null );
return ExitCodes.Success;
}
if ( args.Length < 2 )
{
throw new ToolException( "Expected AREA COMMAND. Run with --help for available commands.", ExitCodes.InvalidArguments );
}
var command = Commands.SingleOrDefault( candidate =>
candidate.Area == args[0] && candidate.Name == args[1] );
if ( command is null )
{
throw new ToolException( $"Unknown command: {args[0]} {args[1]}", ExitCodes.InvalidArguments );
}
var root = RepositoryLocator.Locate( );
var context = new ToolContext( root, Console.Out, Console.Error, new ProcessRunner( Console.Out, Console.Error ), cancellation.Token );
return await command.Handler( context, args[2..] );
}
catch ( OperationCanceledException )
{
await Console.Error.WriteLineAsync( ToolMessages.Canceled );
return ExitCodes.Canceled;
}
catch ( ToolException error )
{
await Console.Error.WriteLineAsync( error.Message );
return error.ExitCode;
}
catch ( Exception error )
{
await Console.Error.WriteLineAsync( $"{ToolMessages.UnexpectedFailurePrefix}{error.Message}" );
return ExitCodes.SoftwareError;
}
}
internal static Task<int> RunNestedAsync( ToolContext context, string area, string name, string[] args )
{
var command = Commands.SingleOrDefault( candidate => candidate.Area == area && candidate.Name == name );
if ( command is null )
{
throw new ToolException( $"Unknown command: {area} {name}", ExitCodes.InvalidArguments );
}
return command.Handler( context, args );
}
private static void PrintHelp( TextWriter output, string? area = null )
{
output.WriteLine( "Wayscriber repository automation" );
output.WriteLine( "Usage: dotnet run tools/wayscriber.cs --no-build -- AREA COMMAND [OPTIONS]" );
output.WriteLine( );
foreach ( var command in Commands.Where( command => area is null || command.Area == area )
.OrderBy( command => command.Area ).ThenBy( command => command.Name ) )
{
output.WriteLine( $" {command.Area,-HelpAreaWidth} {command.Name,-HelpCommandWidth} {command.Description} [{command.SideEffect}]" );
}
}
}
+172
View File
@@ -0,0 +1,172 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using YamlDotNet.Core;
using YamlDotNet.RepresentationModel;
namespace Wayscriber.Tools;
internal static class AssetsCommand
{
private const int OctalRadix = 8;
private const int DecimalRadix = 10;
private const int DesktopAssetUnixMode = 420;
private const string DesktopAssetMode = "0644";
public static IReadOnlyList<ToolCommand> Commands
{
get;
} =
[
new( CommandAreas.Assets, CommandNames.Emit, "Emit AUR desktop asset recipe JSON.", SideEffect.ReadOnly, Emit ),
new( CommandAreas.Assets, CommandNames.Check, "Validate desktop assets in package manifests.", SideEffect.ReadOnly, Check ),
];
private static Task<int> Emit( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "assets emit" );
context.Output.WriteLine( CreateRecipe( context.RepositoryRoot ).ToJsonString( ) );
return Task.FromResult( ExitCodes.Success );
}
private static Task<int> Check( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "assets check" );
_ = CreateRecipe( context.RepositoryRoot );
context.Output.WriteLine( "AUR desktop asset manifests passed." );
return Task.FromResult( ExitCodes.Success );
}
internal static JsonObject CreateRecipe( string root )
{
try
{
var main = ReadAssets( root, RepositoryNames.MainPackage, "package.wayscriber.yaml" );
var configurator = ReadAssets( root, RepositoryNames.ConfiguratorPackage, "package.configurator.yaml" );
return new JsonObject
{
["desktop_path_pattern"] = string.Join( "|", DesktopPrefixes.Select( Regex.Escape ) ),
[PackageChannels.Source] = Recipe( "wayscriber source", "Wayscriber", "target/release/wayscriber", RepositoryNames.MainPackage, main, false ),
[PackageChannels.Binary] = Recipe( "wayscriber bin", "Wayscriber", "${srcdir_tmp}/usr/bin/wayscriber", RepositoryNames.MainPackage, main, true ),
[PackageChannels.Configurator] = Recipe( RepositoryNames.ConfiguratorPackage, "Wayscriber configurator", "target/release/wayscriber-configurator",
RepositoryNames.ConfiguratorPackage, configurator, false ),
};
}
catch ( Exception error ) when ( error is IOException or UnauthorizedAccessException or YamlException or InvalidDataException )
{
throw new ToolException( $"Desktop asset manifest error: {error.Message}" );
}
}
private static List<(string Source, string Destination)> ReadAssets( string root, string package, string file )
{
using var reader = File.OpenText( Path.Combine( root, RepositoryPaths.PackagingDirectory, file ) );
var yaml = new YamlStream( );
yaml.Load( reader );
if ( yaml.Documents.Count != 1 || yaml.Documents[0].RootNode is not YamlMappingNode document ||
!document.Children.TryGetValue( new YamlScalarNode( "contents" ), out var contentNode ) || contentNode is not YamlSequenceNode contents )
{
throw new InvalidDataException( $"{file}: expected one package contents sequence" );
}
var assets = new List<(string, string)>( );
var destinations = new HashSet<string>( StringComparer.Ordinal );
foreach ( var node in contents.Children )
{
if ( node is not YamlMappingNode entry )
{
throw new InvalidDataException( $"{file}: invalid content entry" );
}
var destination = Scalar( entry, "dst" );
if ( !DesktopPrefixes.Any( prefix => destination.StartsWith( prefix, StringComparison.Ordinal ) ) )
{
continue;
}
var source = Scalar( entry, "src" );
ValidateAsset( root, file, entry, source, destination );
if ( !destinations.Add( destination ) )
{
throw new InvalidDataException( $"{file}: duplicate desktop destination {destination}" );
}
assets.Add( (source, destination) );
}
if ( !destinations.Contains( $"/usr/share/applications/{package}.desktop" ) ||
!destinations.Any( path => path.StartsWith( "/usr/share/icons/", StringComparison.Ordinal ) ) )
{
throw new InvalidDataException( $"{file}: launcher and icons are required" );
}
return assets;
}
private static void ValidateAsset( string root, string file, YamlMappingNode entry, string source, string destination )
{
if ( !Regex.IsMatch( source, @"^packaging/[A-Za-z0-9_./-]+$" ) ||
!Regex.IsMatch( destination, @"^/usr/share/[A-Za-z0-9_./-]+$" ) ||
source.Split( '/' ).Contains( ".." ) || destination.Split( '/' ).Contains( ".." ) )
{
throw new InvalidDataException( $"{file}: unsupported asset path {source} -> {destination}" );
}
if ( !entry.Children.TryGetValue( new YamlScalarNode( "file_info" ), out var infoNode ) || infoNode is not YamlMappingNode info )
{
throw new InvalidDataException( $"{file}: desktop asset {destination} has no file_info mapping" );
}
var modeError = $"{file}: desktop asset {destination} must have mode {DesktopAssetMode} " +
$"(plain YAML integer: {DesktopAssetMode}, 0o644, or {DesktopAssetUnixMode})";
if ( !info.Children.TryGetValue( new YamlScalarNode( "mode" ), out var modeNode ) ||
modeNode is not YamlScalarNode { Style: ScalarStyle.Plain, Value: { } mode } || modeNode.Tag.ToString( ) == "tag:yaml.org,2002:str" )
{
throw new InvalidDataException( modeError );
}
var explicitOctal = mode.StartsWith( "0o", StringComparison.OrdinalIgnoreCase );
var digits = explicitOctal ? mode[2..] : mode;
var numberBase = explicitOctal || mode.StartsWith( '0' ) ? OctalRadix : DecimalRadix;
try
{
if ( Convert.ToInt32( digits, numberBase ) != DesktopAssetUnixMode )
{
throw new InvalidDataException( modeError );
}
}
catch ( Exception error ) when ( error is FormatException or OverflowException or ArgumentException )
{
throw new InvalidDataException( $"{modeError}; got '{mode}'", error );
}
if ( !File.Exists( Path.Combine( root, source ) ) )
{
throw new InvalidDataException( $"{file}: missing desktop asset {source}" );
}
}
private static string Scalar( YamlMappingNode node, string key )
{
if ( !node.Children.TryGetValue( new YamlScalarNode( key ), out var value ) || value is not YamlScalarNode { Value: { } text } )
{
throw new InvalidDataException( $"Expected scalar {key}" );
}
return text;
}
private static JsonObject Recipe( string label, string title, string binary, string package,
List<(string Source, string Destination)> assets, bool fromArchive )
{
var lines = new JsonArray( );
var destinations = new JsonArray( );
foreach ( var (source, destination) in assets )
{
var input = fromArchive ? $"\"${{srcdir_tmp}}{destination}\"" : source;
lines.Add( JsonValue.Create( $" install -Dm644 {input} \"$pkgdir{destination}\"" ) );
destinations.Add( JsonValue.Create( destination ) );
}
return new JsonObject
{
["label"] = label,
["marker"] = $"# {title} desktop integration",
["end_marker"] = $"# End {title} desktop integration",
["anchor"] = $" install -Dm755 \"{binary}\" \"$pkgdir/usr/bin/{package}\"",
["lines"] = lines,
["destinations"] = destinations,
};
}
private static readonly string[] DesktopPrefixes = ["/usr/share/applications/", "/usr/share/icons/", "/usr/share/pixmaps/"];
}
+713
View File
@@ -0,0 +1,713 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Wayscriber.Tools;
internal static class ChecksCommand
{
private const string LibraryFilePrefix = "lib";
private static readonly string[] StandaloneToolFiles =
[
"build-package-repos.sh", "build.sh", "bump-version.sh", "check-arch-installer-manifest.sh",
"check-config-writers.py", "check-nixpkgs-recipe.py", "check-process-sites.py",
"check-rust-source-coverage.py", "check-shared-dependencies.py", "check-version-consistency.sh",
"code-health-report.sh", "create-release-tag.sh", "fetch-all-deps.sh", "install-configurator.sh",
"install-gtk4-layer-shell.sh", "install.sh", "lint-and-test.sh", "package.sh",
"publish-release-tag.sh", "reload-daemon.sh", "run.sh", "set-portal-shortcut.sh", "test-aur-desktop-assets.sh", "test-gtk-widgets.sh",
"test-package-repo-layout.sh", "test-release-packaging.sh", "test.sh", "update-aur-from-manifest.sh",
"update-aur.sh", "verify-static-gtk4-layer-shell.sh", "aur-desktop-assets.sh", "aur-desktop-assets.py",
];
public static IReadOnlyList<ToolCommand> Commands
{
get;
} =
[
new( CommandAreas.Check, CommandNames.SharedDependencies, "Guard shared-layer Rust dependencies.", SideEffect.ReadOnly, SharedDependencies ),
new( CommandAreas.Check, CommandNames.ProcessSites, "Audit Rust process-creation ownership.", SideEffect.ReadOnly, ProcessSites ),
new( CommandAreas.Check, CommandNames.RustSourceCoverage, "Verify every Rust source is compiled.", SideEffect.ReadOnly, RustSourceCoverage ),
new( CommandAreas.Check, CommandNames.NixpkgsRecipe, "Check Cargo native dependencies against Nix.", SideEffect.ReadOnly, NixpkgsRecipe ),
new( CommandAreas.Check, CommandNames.ConfigWriters, "Audit config write-capability ownership.", SideEffect.ReadOnly, ConfigWriters ),
new( CommandAreas.Check, CommandNames.LegacyTools, "Verify standalone scripts remain independent fallbacks.", SideEffect.ReadOnly, LegacyTools ),
];
private static Task<int> SharedDependencies( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "check shared-dependencies" );
var errors = new List<string>( );
foreach ( var (directory, forbidden) in new[]
{
("src/domain", "config|input|draw|backend|ui|session"),
("src/config/validate", "input|backend"),
} )
{
foreach ( var path in Directory.EnumerateFiles( context.Path( directory.Split( '/' ) ), "*.rs", SearchOption.AllDirectories ) )
{
if ( Path.GetRelativePath( context.RepositoryRoot, path ) == "src/domain/tests.rs" )
{
continue;
}
var source = Regex.Replace( Files.Read( path ), @"/\*.*?\*/|//[^\n]*", string.Empty, RegexOptions.Singleline );
var pattern = $@"crate\s*::\s*(?:{forbidden})\b|use\s+crate\s*::\s*\{{[^;]*\b(?:{forbidden})\s*::";
if ( Regex.IsMatch( source, pattern ) )
{
errors.Add( $"{Path.GetRelativePath( context.RepositoryRoot, path )}: upward dependency in shared layer" );
}
}
}
Failures( context, errors, "Shared domain and configuration-validation dependency paths passed." );
return Task.FromResult( ExitCodes.Success );
}
private static Task<int> ProcessSites( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "check process-sites" );
var errors = new List<string>( );
var allow = new HashSet<string>( StringComparer.Ordinal )
{
"configurator/src/app/session_catalog.rs",
"configurator/src/app/daemon_setup/command.rs",
"configurator/src/app/daemon_setup/service.rs",
};
Regex[] patterns =
[
new( @"\b(?:std::process::)?Command::new\b" ),
new( @"\bstd::process::Child\b" ),
new( @"\blibc::(?:fork|vfork|posix_spawn|posix_spawnp|pthread_atfork)\b" ),
new( @"\blibc::SYS_(?:clone|clone3|fork|vfork)\b" ),
new( @"\b(?:sh|bash|zsh)\s+-c\b" ),
];
foreach ( var root in new[] { "src", "configurator/src", "tests" } )
{
foreach ( var path in Directory.EnumerateFiles( context.Path( root.Split( '/' ) ), "*.rs", SearchOption.AllDirectories ) )
{
var relative = Path.GetRelativePath( context.RepositoryRoot, path ).Replace( Path.DirectorySeparatorChar, '/' );
var parts = relative.Split( '/' );
var allowed = relative.StartsWith( "src/process_broker/", StringComparison.Ordinal ) || allow.Contains( relative ) ||
parts.Contains( "tests" ) || Path.GetFileName( relative ) == "tests.rs";
var lines = File.ReadAllLines( path );
for ( var index = 0; index < lines.Length; index++ )
{
var code = lines[index].Split( "//", 2 )[0];
if ( !allowed && patterns.Any( pattern => pattern.IsMatch( code ) ) )
{
errors.Add( $"{relative}:{index + 1}: unclassified process site: {lines[index].Trim( )}" );
}
}
}
}
var bootstrap = context.Path( "src", "process_broker", "bootstrap.rs" );
var source = Files.Read( bootstrap );
const string start = " if pid == 0 {";
const string end = " drop(child_socket);";
if ( !source.Contains( start, StringComparison.Ordinal ) || !source.Contains( end, StringComparison.Ordinal ) )
{
errors.Add( "src/process_broker/bootstrap.rs: raw-clone child-stub markers changed" );
}
else
{
var stub = source.Split( start, 2, StringSplitOptions.None )[1].Split( end, 2, StringSplitOptions.None )[0];
foreach ( var token in new[] { "format!(", "log::", "panic!(", ".unwrap(", ".expect(", "drop(", "Command::", "CString::", "Vec::", "String::", "Box::" } )
{
if ( stub.Contains( token, StringComparison.Ordinal ) )
{
errors.Add( $"src/process_broker/bootstrap.rs: child stub reaches banned token '{token}'" );
}
}
var libcCalls = Regex.Matches( stub, @"libc::([A-Za-z0-9_]+)\s*\(" ).Select( match => match.Groups[1].Value ).ToHashSet( );
foreach ( var unexpected in libcCalls.Except( ["syscall", "_exit"] ).Order( ) )
{
errors.Add( $"src/process_broker/bootstrap.rs: child stub reaches unapproved libc call: {unexpected}" );
}
var syscalls = Regex.Matches( stub, @"libc::SYS_([A-Za-z0-9_]+)" ).Select( match => match.Groups[1].Value ).ToHashSet( );
foreach ( var unexpected in syscalls.Except( ["fcntl", "dup3", "setpgid", "exit_group", "close_range", "execve"] ).Order( ) )
{
errors.Add( $"src/process_broker/bootstrap.rs: child stub reaches unapproved syscall: {unexpected}" );
}
}
Failures( context, errors, "process-site audit passed", "process-site audit failed:" );
return Task.FromResult( ExitCodes.Success );
}
private static async Task<int> RustSourceCoverage( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "check rust-source-coverage" );
var metadataResult = await context.Run( Programs.Cargo, ["metadata", CommandLineOptions.Locked, "--no-deps", "--format-version", "1"], capture: true );
using var metadata = JsonDocument.Parse( metadataResult.StandardOutput );
var packageIds = metadata.RootElement.GetProperty( "packages" ).EnumerateArray( )
.Select( package => package.GetProperty( "id" ).GetString( )! ).ToHashSet( StringComparer.Ordinal );
if ( packageIds.Count == 0 )
{
throw new ToolException( "cargo metadata returned no workspace packages", ExitCodes.InvalidArguments );
}
var depInfos = await CollectRustDepInfos( context, packageIds );
var covered = CollectCoveredRustSources( context, depInfos );
var git = await context.Run( Programs.Git, ["ls-files", "-co", "--exclude-standard", CommandLineOptions.EndOfOptions, "*.rs"], capture: true );
var repositorySources = git.StandardOutput.Split( '\n', StringSplitOptions.RemoveEmptyEntries )
.Where( path => File.Exists( context.Path( path.Split( '/' ) ) ) ).ToHashSet( StringComparer.Ordinal );
var missing = repositorySources.Except( covered ).Order( ).ToArray( );
if ( missing.Length > 0 )
{
throw new ToolException( $"Rust source coverage check failed: {missing.Length} source file(s) are not compiled by the supported Cargo matrix:\n" +
string.Join( '\n', missing.Select( path => $"- {path}" ) ) );
}
await context.Output.WriteLineAsync( $"Rust source coverage OK: {repositorySources.Count} source files covered across 2 Cargo configurations ({depInfos.Count} current dep-info files)." );
return ExitCodes.Success;
}
private static async Task<HashSet<string>> CollectRustDepInfos( ToolContext context, HashSet<string> packageIds )
{
var depInfos = new HashSet<string>( StringComparer.Ordinal );
foreach ( var (label, flag) in new[] { ("all features", CommandLineOptions.AllFeatures), ("no default features", CommandLineOptions.NoDefaultFeatures) } )
{
await context.Error.WriteLineAsync( $"Checking Rust source coverage ({label})..." );
var result = await context.Run( Programs.Cargo, ["check", CommandLineOptions.Workspace, CommandLineOptions.Locked, CommandLineOptions.AllTargets, flag,
"--message-format=json-render-diagnostics"], capture: true );
var lineNumber = 0;
foreach ( var line in result.StandardOutput.Split( '\n', StringSplitOptions.RemoveEmptyEntries ) )
{
lineNumber++;
JsonDocument message;
try
{
message = JsonDocument.Parse( line );
}
catch ( JsonException error )
{
throw new ToolException( $"invalid cargo JSON at line {lineNumber}: {error.Message}", ExitCodes.InvalidArguments );
}
using ( message )
{
var root = message.RootElement;
if ( !root.TryGetProperty( "reason", out var reason ) || reason.GetString( ) != "compiler-artifact" ||
!root.TryGetProperty( "package_id", out var id ) || !packageIds.Contains( id.GetString( )! ) )
{
continue;
}
if ( root.GetProperty( "target" ).GetProperty( "kind" ).EnumerateArray( ).Any( value => value.GetString( ) == "custom-build" ) )
{
continue;
}
var found = false;
foreach ( var filenameValue in root.GetProperty( "filenames" ).EnumerateArray( ) )
{
var filename = filenameValue.GetString( )!;
var directory = Path.GetDirectoryName( filename )!;
var stem = Path.GetFileNameWithoutExtension( filename );
foreach ( var candidateStem in stem.StartsWith( LibraryFilePrefix, StringComparison.Ordinal )
? new[] { stem, stem[LibraryFilePrefix.Length..] }
: [stem] )
{
var candidate = Path.Combine( directory, candidateStem + ".d" );
if ( File.Exists( candidate ) )
{
depInfos.Add( candidate );
found = true;
}
}
}
if ( !found )
{
throw new ToolException( $"cargo artifact for {root.GetProperty( "target" ).GetProperty( "name" ).GetString( )} has no adjacent dep-info file", ExitCodes.InvalidArguments );
}
}
}
}
return depInfos;
}
private static HashSet<string> CollectCoveredRustSources( ToolContext context, HashSet<string> depInfos )
{
var covered = new HashSet<string>( StringComparer.Ordinal ) { "build.rs" };
foreach ( var depInfo in depInfos )
{
var firstRule = Files.Read( depInfo ).Replace( "\\\n", " ", StringComparison.Ordinal ).Split( "\n\n", 2 )[0];
var separator = firstRule.IndexOf( ':' );
if ( separator < 0 )
{
throw new ToolException( $"dep-info has no dependency rule: {depInfo}", ExitCodes.InvalidArguments );
}
foreach ( var token in ParseMakeWords( firstRule[(separator + 1)..] ).Where( token => token.EndsWith( ".rs", StringComparison.Ordinal ) ) )
{
var absolute = Path.IsPathRooted( token ) ? token : context.Path( token.Split( '/' ) );
if ( File.Exists( absolute ) && Path.GetRelativePath( context.RepositoryRoot, absolute ) is var relative && !relative.StartsWith( ".." ) )
{
covered.Add( relative.Replace( Path.DirectorySeparatorChar, '/' ) );
}
}
}
return covered;
}
private static IEnumerable<string> ParseMakeWords( string text )
{
var current = new StringBuilder( );
var escaped = false;
foreach ( var character in text )
{
if ( escaped )
{
current.Append( character );
escaped = false;
}
else if ( character == '\\' )
{
escaped = true;
}
else if ( char.IsWhiteSpace( character ) )
{
if ( current.Length > 0 )
{
yield return current.ToString( );
current.Clear( );
}
}
else
{
current.Append( character );
}
}
if ( current.Length > 0 )
{
yield return current.ToString( );
}
}
private static async Task<int> NixpkgsRecipe( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "check nixpkgs-recipe" );
var result = await context.Run( Programs.Cargo, ["metadata", CommandLineOptions.Locked, "--format-version", "1", "--filter-platform", "x86_64-unknown-linux-gnu"], capture: true );
using var metadata = JsonDocument.Parse( result.StandardOutput );
var manifestPath = context.Path( RepositoryPaths.CargoManifest );
var packages = metadata.RootElement.GetProperty( "packages" ).EnumerateArray( ).ToArray( );
var manifest = packages.SingleOrDefault( package => package.GetProperty( "manifest_path" ).GetString( ) == manifestPath );
if ( manifest.ValueKind == JsonValueKind.Undefined )
{
throw new ToolException( "cargo metadata did not contain Cargo.toml" );
}
var packageId = manifest.GetProperty( "id" ).GetString( );
var node = metadata.RootElement.GetProperty( "resolve" ).GetProperty( "nodes" ).EnumerateArray( )
.Single( entry => entry.GetProperty( "id" ).GetString( ) == packageId );
var packageNames = packages.ToDictionary( package => package.GetProperty( "id" ).GetString( )!, package => package.GetProperty( "name" ).GetString( )! );
var direct = manifest.GetProperty( "dependencies" ).EnumerateArray( )
.Where( dependency => dependency.GetProperty( "kind" ).ValueKind == JsonValueKind.Null )
.Select( dependency => dependency.GetProperty( "name" ).GetString( )! ).ToHashSet( );
var enabled = node.GetProperty( "deps" ).EnumerateArray( )
.Where( dependency => dependency.GetProperty( "dep_kinds" ).EnumerateArray( ).Any( kind => kind.GetProperty( "kind" ).ValueKind == JsonValueKind.Null ) )
.Select( dependency => packageNames[dependency.GetProperty( "pkg" ).GetString( )!] ).ToHashSet( );
var errors = new List<string>( );
foreach ( var crate in direct.Except( SystemLibraries.Keys ).Order( ) )
{
errors.Add( $"dependency `{crate}` has no system-library declaration" );
}
var required = enabled.SelectMany( crate => SystemLibraries.GetValueOrDefault( crate, [] ).Select( attribute => (crate, attribute) ) )
.GroupBy( pair => pair.attribute ).ToDictionary( group => group.Key, group => group.Select( pair => pair.crate ).ToArray( ) );
var recipe = Files.Read( context.Path( RepositoryPaths.PackagingDirectory, "nixpkgs", "package.nix" ) );
var flake = Files.Read( context.Path( "flake.nix" ) );
var marker = flake.IndexOf( "wayscriber = rustPlatform.buildRustPackage", StringComparison.Ordinal );
if ( marker < 0 )
{
errors.Add( "flake.nix: Wayscriber package marker is missing" );
marker = 0;
}
var recipeNative = NixList( recipe, "nativeBuildInputs" );
var recipeBuild = NixList( recipe, "buildInputs" );
var flakeNative = NixList( flake, "nativeBuildInputs", marker );
var flakeBuild = NixList( flake, "buildInputs", marker );
var argumentsMatch = Regex.Match( recipe, @"\A\s*\{(.*?)\}\s*:", RegexOptions.Singleline );
var recipeArguments = argumentsMatch.Success
? Regex.Matches( argumentsMatch.Groups[1].Value, @"[A-Za-z_][A-Za-z0-9_'-]*" ).Select( match => match.Value ).ToHashSet( )
: [];
foreach ( var attribute in new[] { "pkg-config", "wrapGAppsHook4" } )
{
RequireNixInput( errors, attribute, recipeNative, recipeArguments, flakeNative, native: true );
}
foreach ( var pair in required.OrderBy( pair => pair.Key ) )
{
RequireNixInput( errors, pair.Key, recipeBuild, recipeArguments, flakeBuild, native: false, string.Join( ", ", pair.Value.Order( ) ) );
}
Failures( context, errors,
$"nixpkgs recipe OK: {enabled.Count} default-feature dependencies require {required.Count} system package(s) ({string.Join( ", ", required.Keys.Order( ) )}), plus 2 native input(s), all declared in packaging/nixpkgs/package.nix and flake.nix.",
"nixpkgs recipe check failed:" );
return ExitCodes.Success;
}
private static HashSet<string> NixList( string text, string attribute, int start = 0 )
{
var match = Regex.Match( text[start..], $@"(?<![A-Za-z]){Regex.Escape( attribute )}\s*=\s*(?:with\s+[\w.]+;\s*)?\[(.*?)\]", RegexOptions.Singleline );
if ( !match.Success )
{
return [];
}
var body = Regex.Replace( match.Groups[1].Value, @"#[^\n]*", string.Empty );
return Regex.Matches( body, @"[A-Za-z_][A-Za-z0-9_'-]*(?:\.[A-Za-z0-9_'-]+)*" ).Select( item => item.Value ).ToHashSet( );
}
private static void RequireNixInput( List<string> errors, string attribute, HashSet<string> recipe, HashSet<string> arguments,
HashSet<string> flake, bool native, string? reason = null )
{
var list = native ? "nativeBuildInputs" : "buildInputs";
var suffix = reason is null ? string.Empty : $" (required by {reason})";
if ( !recipe.Contains( attribute ) )
{
errors.Add( $"packaging/nixpkgs/package.nix: {list} is missing `{attribute}`{suffix}" );
}
if ( !arguments.Contains( attribute ) )
{
errors.Add( $"packaging/nixpkgs/package.nix: function arguments are missing `{attribute}`" );
}
if ( !flake.Contains( attribute ) )
{
errors.Add( $"flake.nix: {list} is missing `{attribute}`{suffix}" );
}
}
private static Task<int> ConfigWriters( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "check config-writers" );
var errors = ConfigWriterAudit.Run( context.RepositoryRoot );
Failures( context, errors, $"config-writer audit passed ({ConfigWriterAudit.LastScannedCount} sources)", "config-writer audit failed:" );
return Task.FromResult( ExitCodes.Success );
}
private static Task<int> LegacyTools( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "check legacy-tools" );
var expected = StandaloneToolFiles.ToHashSet( StringComparer.Ordinal );
var actual = Directory.EnumerateFiles( context.Path( RepositoryPaths.ToolsDirectory ), "*", SearchOption.TopDirectoryOnly )
.Where( path => Path.GetExtension( path ) is ".sh" or ".py" )
.Select( Path.GetFileName ).ToHashSet( StringComparer.Ordinal )!;
var missing = expected.Except( actual ).Order( ).ToArray( );
if ( missing.Length > 0 )
{
throw new ToolException( "Standalone fallback scripts are missing:\n" + string.Join( '\n', missing.Select( name => $"- {name}" ) ) );
}
var unlisted = actual.Except( expected ).Order( ).ToArray( );
if ( unlisted.Length > 0 )
{
throw new ToolException( "Standalone fallback inventory is incomplete:\n" + string.Join( '\n', unlisted.Select( name => $"- {name}" ) ) );
}
foreach ( var name in StandaloneToolFiles.Where( name => name.EndsWith( ".sh", StringComparison.Ordinal ) ) )
{
var source = Files.Read( context.Path( RepositoryPaths.ToolsDirectory, name ) );
var toolRedirect = $"dotnet run {RepositoryPaths.ToolsDirectory}/{RepositoryNames.ToolEntryFile}";
if ( source.Contains( toolRedirect, StringComparison.OrdinalIgnoreCase ) || Regex.IsMatch( source, @"(?m)^\s*(?:exec\s+)?dotnet\b" ) )
{
throw new ToolException( $"Standalone fallback redirects to .NET: tools/{name}" );
}
}
foreach ( var path in Directory.EnumerateFiles( context.Path( RepositoryPaths.ToolsDirectory, "csharp" ), "*.cs", SearchOption.AllDirectories ) )
{
var source = Files.Read( path );
if ( Regex.IsMatch( source, "(?:ProcessRequest|context\\.Run)\\s*\\(\\s*\\\"(?:(?:ba|z)?sh|python(?:3(?:\\.\\d+)?)?)\\\"" ) )
{
throw new ToolException( $"C# automation invokes a shell or Python interpreter: {Path.GetRelativePath( context.RepositoryRoot, path )}" );
}
}
context.Output.WriteLine( "Standalone fallback scripts are inventoried and C# does not invoke a shell or Python interpreter." );
return Task.FromResult( ExitCodes.Success );
}
private static void Failures( ToolContext context, IReadOnlyCollection<string> errors, string success, string heading = "Check failed:" )
{
if ( errors.Count > 0 )
{
throw new ToolException( heading + "\n" + string.Join( '\n', errors.Select( error => $"- {error}" ) ) );
}
context.Output.WriteLine( success );
}
private static readonly IReadOnlyDictionary<string, string[]> SystemLibraries = new Dictionary<string, string[]>( StringComparer.Ordinal )
{
["anyhow"] = [],
["cairo-rs"] = ["cairo"],
["flate2"] = [],
["getrandom"] = [],
["glib"] = [],
["gtk4"] = ["gtk4"],
[RepositoryNames.Gtk4LayerShell] = [RepositoryNames.Gtk4LayerShell],
["input"] = ["libinput"],
["ksni"] = [],
["libc"] = [],
["log"] = [],
["pango"] = ["pango"],
["pangocairo"] = ["pango", "cairo"],
["png"] = [],
["schemars"] = [],
["serde"] = [],
["serde_ignored"] = [],
["serde_json"] = [],
["smithay-client-toolkit"] = ["libxkbcommon"],
["tempfile"] = [],
["tokio"] = [],
["toml"] = [],
["toml_edit"] = [],
["udev"] = ["udev"],
["unicode-segmentation"] = [],
["wayland-client"] = ["wayland"],
["wayland-protocols"] = [],
["wayland-protocols-wlr"] = [],
["xkbcommon"] = ["libxkbcommon"],
["zbus"] = [],
["zune-jpeg"] = [],
};
}
internal static class ConfigWriterAudit
{
internal static int LastScannedCount
{
get; private set;
}
internal static List<string> Run( string root )
{
var errors = new List<string>( );
var sources = new[] { "src", "configurator/src" }.SelectMany( directory =>
Directory.EnumerateFiles( Path.Combine( root, directory ), "*.rs", SearchOption.AllDirectories ) ).Order( ).ToArray( );
LastScannedCount = sources.Length;
var owners = new HashSet<string>( StringComparer.Ordinal )
{
"src/config/document.rs", "src/config/io.rs", "configurator/src/app/io.rs",
};
string[] primitives = ["save_with_backup", "write_config_text_atomic", "create_config_backup", "prepare_config_parent"];
string[] writers = ["persist_keybinding_edit", "persist_preset_slot", "persist_quick_color"];
const string expectedCaller = "src/backend/wayland/config_edits.rs";
var callers = writers.ToDictionary( name => name, _ => new HashSet<string>( StringComparer.Ordinal ) );
ScanSources( root, sources, owners, primitives, writers, callers, errors );
ValidateWriterCallers( writers, expectedCaller, callers, errors );
ValidateWriterDefinitions( root, primitives, writers, errors );
return errors;
}
private static void ScanSources( string root, IEnumerable<string> sources, HashSet<string> owners,
IEnumerable<string> primitives, IEnumerable<string> writers, Dictionary<string, HashSet<string>> callers, List<string> errors )
{
foreach ( var path in sources )
{
var relative = Path.GetRelativePath( root, path ).Replace( Path.DirectorySeparatorChar, '/' );
if ( IsTestSource( relative ) )
{
continue;
}
var masked = RemoveCfgTestBlocks( StripRustCommentsAndStrings( Files.Read( path ) ) );
foreach ( var primitive in primitives )
{
if ( !owners.Contains( relative ) && Regex.IsMatch( masked, $@"\b{primitive}\b" ) )
{
errors.Add( $"{relative}: config write capability `{primitive}` outside the reviewed writers" );
}
}
foreach ( var writer in writers )
{
if ( relative is not "src/config/io.rs" and not "src/config/mod.rs" && Regex.IsMatch( masked, $@"\b{writer}\b" ) )
{
callers[writer].Add( relative );
}
if ( Regex.IsMatch( masked, $@"\b{writer}\s+as\s+\w+" ) )
{
errors.Add( $"{relative}: renames config writer `{writer}`" );
}
if ( relative is not "src/config/io.rs" and not "src/config/mod.rs" && Regex.IsMatch( masked, $@"\b{writer}_at\b" ) )
{
errors.Add( $"{relative}: production code names `{writer}_at`" );
}
}
}
}
private static void ValidateWriterCallers( IEnumerable<string> writers, string expectedCaller,
Dictionary<string, HashSet<string>> callers, List<string> errors )
{
foreach ( var writer in writers )
{
foreach ( var unexpected in callers[writer].Where( path => path != expectedCaller ) )
{
errors.Add( $"{unexpected}: unreviewed caller of `{writer}`" );
}
if ( !callers[writer].Contains( expectedCaller ) )
{
errors.Add( $"{expectedCaller}: expected to call `{writer}` but does not" );
}
}
}
private static void ValidateWriterDefinitions( string root, IEnumerable<string> primitives, IEnumerable<string> writers, List<string> errors )
{
var document = Files.Read( Path.Combine( root, "src/config/document.rs" ) );
var io = Files.Read( Path.Combine( root, "src/config/io.rs" ) );
if ( !document.Contains( "pub fn save_with_backup", StringComparison.Ordinal ) )
{
errors.Add( "src/config/document.rs: save_with_backup is gone or renamed" );
}
foreach ( var primitive in primitives.Where( primitive => primitive != "save_with_backup" ) )
{
if ( !io.Contains( $"pub(super) fn {primitive}", StringComparison.Ordinal ) )
{
errors.Add( $"src/config/io.rs: `{primitive}` is no longer pub(super)" );
}
}
foreach ( var writer in writers )
{
if ( !io.Contains( $"pub fn {writer}", StringComparison.Ordinal ) )
{
errors.Add( $"src/config/io.rs: narrow config writer `{writer}` is gone" );
}
if ( !Regex.IsMatch( io, $@"#\[cfg\(test\)\]\s*pub\(crate\) fn {writer}_at\b" ) )
{
errors.Add( $"src/config/io.rs: `{writer}_at` is no longer a #[cfg(test)] pub(crate) fn" );
}
}
if ( Regex.IsMatch( RemoveCfgTestBlocks( StripRustCommentsAndStrings( io ) ), @"\bauthored_config\b" ) )
{
errors.Add( "src/config/io.rs: a narrow writer reads authored_config()" );
}
}
private static bool IsTestSource( string relative ) =>
relative.StartsWith( "tests/", StringComparison.Ordinal ) || relative.Split( '/' ).Contains( "tests" ) ||
Path.GetFileName( relative ) is "tests.rs" or "test_helpers.rs" or "test_support.rs" ||
Path.GetFileName( relative ).StartsWith( "test_", StringComparison.Ordinal ) ||
Path.GetFileName( relative ).EndsWith( "_tests.rs", StringComparison.Ordinal );
internal static string RemoveCfgTestBlocks( string text )
{
var chars = text.ToCharArray( );
foreach ( Match marker in Regex.Matches( text, @"#\[cfg\((?!\s*not\s*\(\s*test\s*\))(?=[^]]*\btest\b)[^]]*\)\]" ) )
{
var opening = text.IndexOfAny( ['{', ';'], marker.Index + marker.Length );
if ( opening < 0 )
{
continue;
}
var end = opening + 1;
if ( text[opening] == '{' )
{
var depth = 1;
while ( end < text.Length && depth > 0 )
{
if ( text[end] == '{' )
{
depth++;
}
else if ( text[end] == '}' )
{
depth--;
}
end++;
}
}
for ( var index = marker.Index; index < end; index++ )
{
if ( chars[index] != '\n' )
{
chars[index] = ' ';
}
}
}
return new string( chars );
}
internal static string StripRustCommentsAndStrings( string text )
{
var output = text.ToCharArray( );
var index = 0;
var blockDepth = 0;
while ( index < text.Length )
{
if ( blockDepth == 0 && StartsWith( text, index, '/', '/' ) )
{
MaskLineComment( text, output, ref index );
}
else if ( StartsWith( text, index, '/', '*' ) )
{
MaskPair( output, ref index );
blockDepth++;
}
else if ( blockDepth > 0 )
{
MaskBlockCommentCharacter( text, output, ref index, ref blockDepth );
}
else if ( text[index] == '"' )
{
MaskString( text, output, ref index );
}
else
{
index++;
}
}
return new string( output );
}
private static bool StartsWith( string text, int index, char first, char second ) =>
index + 1 < text.Length && text[index] == first && text[index + 1] == second;
private static void MaskLineComment( string text, char[] output, ref int index )
{
while ( index < text.Length && text[index] != '\n' )
{
output[index++] = ' ';
}
}
private static void MaskBlockCommentCharacter( string text, char[] output, ref int index, ref int blockDepth )
{
if ( StartsWith( text, index, '*', '/' ) )
{
MaskPair( output, ref index );
blockDepth--;
return;
}
if ( text[index] != '\n' )
{
output[index] = ' ';
}
index++;
}
private static void MaskPair( char[] output, ref int index )
{
output[index++] = ' ';
output[index++] = ' ';
}
private static void MaskString( string text, char[] output, ref int index )
{
output[index++] = ' ';
while ( index < text.Length )
{
var character = text[index];
if ( character != '\n' )
{
output[index] = ' ';
}
index++;
if ( character == '\\' && index < text.Length )
{
if ( text[index] != '\n' )
{
output[index] = ' ';
}
index++;
continue;
}
if ( character == '"' )
{
return;
}
}
}
}
@@ -0,0 +1,317 @@
namespace Wayscriber.Tools;
internal static class DevelopmentCommands
{
private const int WestonStartupAttempts = 100;
private const int WestonPollDelayMilliseconds = 100;
private static readonly string[] CsharpFileApps = ["tools/wayscriber.cs", "tools/install.cs", "tools/wayscriber.tests.cs"];
private static readonly string[] CsharpFormatModes = ["style", "whitespace"];
public static IReadOnlyList<ToolCommand> Commands
{
get;
} =
[
new(CommandAreas.Development, CommandNames.Build, "Build release binaries.", SideEffect.ReadOnly, Build),
new(CommandAreas.Development, CommandNames.Test, "Run workspace tests.", SideEffect.ReadOnly, Test),
new(CommandAreas.Development, CommandNames.Fetch, "Fetch locked Cargo dependencies.", SideEffect.ReadOnly, Fetch),
new(CommandAreas.Development, CommandNames.FormatAndLint, "Format and lint the workspace with all features.",
SideEffect.FixtureMutating, FormatAndLint),
new(CommandAreas.ContinuousIntegration, CommandNames.LintAndTest, "Run the canonical repository gate.", SideEffect.ReadOnly,
LintAndTest),
new(CommandAreas.ContinuousIntegration, CommandNames.GtkWidgets, "Run GTK contracts on a private Weston display.",
SideEffect.ForegroundSensitive, GtkWidgets),
new(CommandAreas.ContinuousIntegration, CommandNames.InstallDependencies, "Install CI system packages.", SideEffect.MachineMutating,
InstallDependencies),
new(CommandAreas.ContinuousIntegration, CommandNames.PrepareGtk4LayerShell, "Install gtk4-layer-shell and export its CI paths.",
SideEffect.MachineMutating, PrepareGtkLayerShell),
new(CommandAreas.ContinuousIntegration, CommandNames.BuildLinkage, "Build Wayscriber and verify native linkage.",
SideEffect.ReadOnly, BuildLinkage),
new(CommandAreas.ContinuousIntegration, CommandNames.RequireEnvironment, "Fail when required environment values are empty.",
SideEffect.ReadOnly, RequireEnvironment),
new(CommandAreas.Check, CommandNames.NixVersions, "Compare Nix package versions with Cargo.", SideEffect.ReadOnly, NixVersions),
new(CommandAreas.Check, CommandNames.NixInstantiation, "Instantiate Nix packages and the development shell.", SideEffect.ReadOnly,
NixInstantiation),
];
private static async Task<int> Build( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "dev build" );
await context.Output.WriteLineAsync( "Building wayscriber (default features)..." );
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Release, CommandLineOptions.Binaries] );
await context.Output.WriteLineAsync( "Build complete." );
return ExitCodes.Success;
}
private static async Task<int> Test( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "dev test" );
await context.Output.WriteLineAsync( "Running tests (default features)..." );
await context.Run( Programs.Cargo, ["test", CommandLineOptions.Workspace] );
await context.Output.WriteLineAsync( "Tests complete." );
return ExitCodes.Success;
}
private static async Task<int> Fetch( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var target = parsed.TakeOption( "--target" );
parsed.RequireEmpty( "dev fetch [--target TRIPLE]" );
await context.Output.WriteLineAsync( "Fetching wayscriber dependencies..." );
await FetchManifest( context, RepositoryPaths.CargoManifest, target );
if ( File.Exists( context.Path( RepositoryPaths.ConfiguratorCargoManifest ) ) )
{
await context.Output.WriteLineAsync( "Fetching configurator dependencies..." );
await FetchManifest( context, RepositoryPaths.ConfiguratorCargoManifest, target );
}
await context.Output.WriteLineAsync( "Dependency fetch complete." );
return ExitCodes.Success;
}
private static async Task<int> FormatAndLint( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "dev format-and-lint" );
await context.Run( Programs.Cargo, ["fmt"] );
await context.Run( Programs.Cargo, ["clippy", CommandLineOptions.Workspace, CommandLineOptions.AllFeatures] );
return ExitCodes.Success;
}
private static Task<ProcessResult> FetchManifest( ToolContext context, string manifest, string? target )
{
var arguments = new List<string> { "fetch", CommandLineOptions.Locked, "--manifest-path", context.Path( manifest.Split( '/' ) ) };
if ( target is not null )
{
arguments.AddRange( ["--target", target] );
}
return context.Run( Programs.Cargo, arguments );
}
private static async Task<int> LintAndTest( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "ci lint-and-test" );
await RunTool( context, CommandAreas.Assets, CommandNames.Check );
await RunTool( context, CommandAreas.Version, CommandNames.Check );
await RunTool( context, CommandAreas.Check, CommandNames.NixpkgsRecipe );
await RunTool( context, CommandAreas.Check, CommandNames.RustSourceCoverage );
await RunTool( context, CommandAreas.Check, CommandNames.ProcessSites );
await RunTool( context, CommandAreas.Check, CommandNames.ConfigWriters );
await RunTool( context, CommandAreas.Check, CommandNames.SharedDependencies );
await RunTool( context, CommandAreas.Check, CommandNames.LegacyTools );
await RunCsharpFormattingChecks( context );
await context.Run( Programs.Dotnet, ["run", "tools/wayscriber.tests.cs", "--no-build", CommandLineOptions.EndOfOptions] );
await RunCargo( context, ["fmt", "--all", CommandLineOptions.EndOfOptions, "--check"] );
await RunCargo( context, ["clippy", CommandLineOptions.Locked, CommandLineOptions.Workspace, CommandLineOptions.AllTargets, CommandLineOptions.AllFeatures, CommandLineOptions.EndOfOptions, "-D", "warnings"] );
await RunCargo( context, ["build", CommandLineOptions.Locked, CommandLineOptions.Workspace, CommandLineOptions.AllFeatures, CommandLineOptions.Binaries] );
await RunCargo( context, ["test", CommandLineOptions.Locked, CommandLineOptions.Workspace, CommandLineOptions.AllFeatures, CommandLineOptions.EndOfOptions, CommandLineOptions.SingleTestThread] );
await RunIsolatedRenderTests( context, CommandLineOptions.AllFeatures );
await RunCargo( context, ["clippy", CommandLineOptions.Locked, CommandLineOptions.Workspace, CommandLineOptions.AllTargets, CommandLineOptions.NoDefaultFeatures, CommandLineOptions.EndOfOptions, "-D", "warnings"] );
await RunCargo( context, ["build", CommandLineOptions.Locked, CommandLineOptions.Workspace, CommandLineOptions.NoDefaultFeatures, CommandLineOptions.Binaries] );
await RunCargo( context, ["test", CommandLineOptions.Locked, CommandLineOptions.Workspace, CommandLineOptions.NoDefaultFeatures, CommandLineOptions.EndOfOptions, CommandLineOptions.SingleTestThread] );
await RunIsolatedRenderTests( context, CommandLineOptions.NoDefaultFeatures );
return ExitCodes.Success;
}
private static async Task RunCsharpFormattingChecks( ToolContext context )
{
foreach ( var fileApp in CsharpFileApps )
{
foreach ( var formatMode in CsharpFormatModes )
{
await context.Run( Programs.Dotnet,
["format", formatMode, fileApp, CommandLineOptions.NoRestore, CommandLineOptions.VerifyNoChanges] );
}
}
}
private static async Task RunTool( ToolContext context, string area, string command )
{
await context.Output.WriteLineAsync( $"\nRunning: wayscriber {area} {command}" );
var result = await ToolApplication.RunNestedAsync( context, area, command, [] );
if ( result != ExitCodes.Success )
{
throw new ToolException( $"{area} {command} failed.", result );
}
}
private static async Task RunCargo( ToolContext context, IReadOnlyList<string> arguments )
{
await context.Output.WriteLineAsync( $"\nRunning: {ProcessRunner.FormatCommand( Programs.Cargo, arguments )}" );
await context.Run( Programs.Cargo, arguments, trace: false );
}
private static async Task RunIsolatedRenderTests( ToolContext context, string feature )
{
string[] tests =
[
"ui::context_menu::engine_tests::retained_context_menu_owner_preserves_layout_pixels_and_row_hits",
"ui::board_picker::tests::retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layouts",
];
foreach ( var test in tests )
{
var result = await context.Run( Programs.Cargo, ["test", CommandLineOptions.Locked, "-p", RepositoryNames.MainPackage, feature, "--lib", test,
CommandLineOptions.EndOfOptions, "--exact", "--ignored", CommandLineOptions.SingleTestThread], capture: true );
await context.Output.WriteAsync( result.StandardOutput );
if ( !result.StandardOutput.Contains( "test result: ok. 1 passed; 0 failed;", StringComparison.Ordinal ) )
{
throw new ToolException( $"Expected exactly one passing isolated render test: {test} ({feature})" );
}
}
}
private static async Task<int> GtkWidgets( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "ci gtk-widgets" );
using var runtime = new TemporaryDirectory( "wayscriber-gtk-tests" );
const string display = "wayscriber-widget-tests";
var environment = new Dictionary<string, string?>
{
[EnvironmentVariables.XdgRuntimeDirectory] = runtime.Path,
[EnvironmentVariables.WaylandDisplay] = display,
[EnvironmentVariables.Display] = null,
[EnvironmentVariables.GdkBackend] = "wayland",
[EnvironmentVariables.GtkAccessibility] = "test",
[EnvironmentVariables.WayscriberRequireGtkTests] = EnvironmentVariables.Enabled,
};
using var westonCancellation = CancellationTokenSource.CreateLinkedTokenSource( context.CancellationToken );
var weston = context.Processes.RunAsync( new ProcessRequest( Programs.Weston,
["--backend=headless-backend.so", "--renderer=pixman", "--no-config", $"--socket={display}", "--idle-time=0", $"--log={Path.Combine( runtime.Path, "weston.log" )}"],
context.RepositoryRoot, environment, CaptureOutput: true ), westonCancellation.Token );
try
{
var socket = Path.Combine( runtime.Path, display );
for ( var attempt = 0; attempt < WestonStartupAttempts && !File.Exists( socket ); attempt++ )
{
if ( weston.IsCompleted )
{
_ = await weston;
throw new ToolException( Files.Read( Path.Combine( runtime.Path, "weston.log" ) ) );
}
await Task.Delay( WestonPollDelayMilliseconds, context.CancellationToken );
}
if ( !File.Exists( socket ) )
{
throw new ToolException( "Weston did not create its Wayland socket." );
}
var result = await context.Run( Programs.DbusRunSession, [CommandLineOptions.EndOfOptions, Programs.Cargo, CommandNames.Test, CommandLineOptions.Locked, "-p",
RepositoryNames.MainPackage, CommandLineOptions.AllFeatures, "--lib",
"toolbar_gtk::", CommandLineOptions.EndOfOptions, CommandLineOptions.SingleTestThread, "--nocapture"], environment: environment, capture: true );
await context.Output.WriteAsync( result.StandardOutput );
await context.Error.WriteAsync( result.StandardError );
var combinedOutput = result.StandardOutput + result.StandardError;
foreach ( var marker in new[] { "EXECUTED: GTK focus and slider assertions", "EXECUTED: GTK widget contract assertions" } )
{
if ( !combinedOutput.Contains( marker, StringComparison.Ordinal ) )
{
throw new ToolException( $"GTK test output lacks marker: {marker}" );
}
}
return ExitCodes.Success;
}
finally
{
westonCancellation.Cancel( );
try
{
_ = await weston;
}
catch ( OperationCanceledException ) { }
}
}
private static async Task<int> InstallDependencies( ToolContext context, string[] args )
{
var profile = new Arguments( args ).SinglePositional( "ci install-dependencies checks|widgets|package|repositories" );
var common = new[] { "build-essential", "pkg-config", "libwayland-dev", "libxkbcommon-dev", "libcairo2-dev", "libpango1.0-dev",
"libgtk-3-dev", "libgtk-4-dev", "libadwaita-1-dev", "meson", "ninja-build", "wayland-protocols", "libssl-dev",
"libxcb-shape0-dev", "libxcb-xfixes0-dev", "libxcb-render0-dev", "libxcb1-dev", "libx11-dev" };
var repositoryPackages = new[] { "apt-utils", "dpkg-dev", "rpm", "createrepo-c", "rsync", "gnupg" };
IEnumerable<string>? packages = profile switch
{
"checks" => common.Concat( ["clang", "cmake", "libxkbcommon-x11-dev", "libegl1-mesa-dev", "libgles2-mesa-dev", "libdbus-1-dev", "libinput-dev", "libudev-dev", "libpixman-1-dev", "libxcb-randr0-dev"] )
.Concat( repositoryPackages ),
"widgets" => common.Concat( ["weston", "dbus-x11", "fonts-dejavu-core", "clang", "cmake", "libxkbcommon-x11-dev", "libegl1-mesa-dev", "libgles2-mesa-dev", "libdbus-1-dev", "libinput-dev", "libudev-dev", "libpixman-1-dev", "libxcb-randr0-dev"] ),
"package" => common.Concat( ["rpm"] ),
"repositories" => repositoryPackages,
_ => null,
};
if ( packages is null )
{
throw new ToolException( $"Unknown dependency profile: {profile}", ExitCodes.InvalidArguments );
}
await context.Run( Programs.Sudo, [Programs.AptGet, "update"] );
await context.Run( Programs.Sudo, [Programs.AptGet, "install", "-y", .. packages] );
return ExitCodes.Success;
}
private static async Task<int> PrepareGtkLayerShell( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var prefix = parsed.TakeOption( "--prefix" ) ?? (context.Environment( EnvironmentVariables.RunnerTemporary ) is { Length: > 0 } runner
? Path.Combine( runner, RepositoryNames.Gtk4LayerShell )
: context.Path( RepositoryPaths.TargetDirectory, "ci-deps", RepositoryNames.Gtk4LayerShell ));
var mode = parsed.TakeOption( "--library-mode" ) ?? NativeLibraryModes.Both;
var githubEnvironment = parsed.TakeOption( "--github-env" ) ?? context.Environment( EnvironmentVariables.GitHubEnvironment );
parsed.RequireEmpty( "ci prepare-gtk4-layer-shell [--prefix PATH] [--library-mode MODE] [--github-env FILE]" );
await ToolApplication.RunNestedAsync( context, CommandAreas.Native, CommandNames.InstallGtk4LayerShell, ["--prefix", prefix, "--library-mode", mode] );
if ( githubEnvironment is not null )
{
var pkg = Path.Combine( prefix, "lib/pkgconfig" ) + (context.Environment( EnvironmentVariables.PackageConfigPath ) is { Length: > 0 } oldPkg ? $":{oldPkg}" : string.Empty);
var library = Path.Combine( prefix, "lib" ) + (context.Environment( EnvironmentVariables.LibraryPath ) is { Length: > 0 } oldLibrary ? $":{oldLibrary}" : string.Empty);
await File.AppendAllTextAsync( githubEnvironment, $"PKG_CONFIG_PATH={pkg}\nLD_LIBRARY_PATH={library}\n", context.CancellationToken );
}
return ExitCodes.Success;
}
private static async Task<int> BuildLinkage( ToolContext context, string[] args )
{
var mode = new Arguments( args ).SinglePositional( "ci build-linkage dynamic|static" );
if ( mode is not (NativeLibraryModes.Dynamic or NativeLibraryModes.Static) )
{
throw new ToolException( "ci build-linkage dynamic|static", ExitCodes.InvalidArguments );
}
IReadOnlyDictionary<string, string?>? environment = mode == NativeLibraryModes.Static
? new Dictionary<string, string?> { [EnvironmentVariables.SystemGtk4LayerShellLink] = NativeLibraryModes.Static }
: null;
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Locked, "--bin", RepositoryNames.MainPackage], environment: environment );
var verification = mode == NativeLibraryModes.Static ? CommandNames.VerifyStatic : CommandNames.VerifyDynamic;
return await ToolApplication.RunNestedAsync( context, CommandAreas.Elf, verification,
[context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.DebugDirectory, RepositoryNames.MainPackage )] );
}
private static Task<int> RequireEnvironment( ToolContext context, string[] args )
{
if ( args.Length == 0 )
{
throw new ToolException( "ci require-environment NAME [NAME ...]", ExitCodes.InvalidArguments );
}
var missing = args.Where( name => string.IsNullOrWhiteSpace( context.Environment( name ) ) ).ToArray( );
if ( missing.Length > 0 )
{
throw new ToolException( $"Required environment values are missing: {string.Join( ", ", missing )}" );
}
return Task.FromResult( ExitCodes.Success );
}
private static async Task<int> NixVersions( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "check nix-versions" );
var cargoVersion = VersionCommands.ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) );
foreach ( var attribute in new[] { ".#packages.x86_64-linux.wayscriber.version", ".#packages.x86_64-linux.wayscriber-configurator.version" } )
{
var result = await context.Run( Programs.Nix, ["eval", attribute, "--raw"], capture: true );
if ( result.StandardOutput.Trim( ) != cargoVersion )
{
throw new ToolException( $"{attribute}: expected {cargoVersion}, got {result.StandardOutput.Trim( )}." );
}
}
return ExitCodes.Success;
}
private static async Task<int> NixInstantiation( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "check nix-instantiation" );
await context.Run( Programs.Nix, ["build", ".#wayscriber", ".#wayscriber-configurator", ".#devShells.x86_64-linux.default", "--no-link", "--dry-run"] );
return ExitCodes.Success;
}
}
@@ -0,0 +1,548 @@
namespace Wayscriber.Tools;
internal static class NativeDesktopCommands
{
private const uint RootUserId = 0;
private const int DownloadTimeoutSeconds = 60;
private const int DaemonRestartDelayMilliseconds = 500;
private const string GlsVersion = "1.3.0";
private const string GlsCommit = "1c963c51514581c41b9bdae08cdf69171265cdda";
private const string GlsArchiveSha256 = "22be5f5edf487cfb87266f0e71c400b11322082a4dc99832e5a54a4fca3d5a7c";
private static readonly int[] ConfiguratorIconSizes = [16, 19, 22, 24, 38, 64, 128];
public static IReadOnlyList<ToolCommand> Commands
{
get;
} =
[
new( CommandAreas.Elf, CommandNames.VerifyStatic, "Verify static gtk4-layer-shell linkage.", SideEffect.ReadOnly, VerifyStatic ),
new( CommandAreas.Elf, CommandNames.VerifyDynamic, "Verify dynamic gtk4-layer-shell linkage.", SideEffect.ReadOnly, VerifyDynamic ),
new( CommandAreas.Native, CommandNames.InstallGtk4LayerShell, "Build the pinned gtk4-layer-shell source.", SideEffect.MachineMutating, InstallGtkLayerShell ),
new( CommandAreas.Desktop, CommandNames.SetPortalShortcut, "Set the portal shortcut service environment.", SideEffect.MachineMutating, SetPortalShortcut ),
new( CommandAreas.Desktop, CommandNames.ReloadDaemon, "Restart the Wayscriber daemon.", SideEffect.MachineMutating, ReloadDaemon ),
new( CommandAreas.Install, CommandNames.App, "Build and install Wayscriber locally.", SideEffect.MachineMutating, InstallApp ),
new( CommandAreas.Install, CommandNames.Configurator, "Build and install the configurator locally.", SideEffect.MachineMutating, InstallConfigurator ),
];
private static async Task<int> VerifyStatic( ToolContext context, string[] args )
{
var binary = Path.GetFullPath( new Arguments( args ).SinglePositional( "elf verify-static BINARY" ), Environment.CurrentDirectory );
if ( !File.Exists( binary ) )
{
throw new ToolException( $"Missing Wayscriber binary: {binary}" );
}
var dynamic = await context.Run( Programs.ReadElf, ["-d", binary], capture: true );
if ( dynamic.StandardOutput.Contains( "Shared library: [libgtk4-layer-shell.so", StringComparison.Ordinal ) )
{
throw new ToolException( $"{binary} still dynamically requires gtk4-layer-shell" );
}
if ( !dynamic.StandardOutput.Contains( "Shared library: [libwayland-client.so.0]", StringComparison.Ordinal ) )
{
throw new ToolException( $"{binary} does not retain libwayland-client.so.0 in DT_NEEDED" );
}
var symbols = await context.Run( Programs.Nm, ["-D", "--defined-only", binary], capture: true );
var names = symbols.StandardOutput.Split( '\n', StringSplitOptions.RemoveEmptyEntries )
.Select( line => line.Split( ' ', StringSplitOptions.RemoveEmptyEntries ).LastOrDefault( ) ).ToHashSet( StringComparer.Ordinal );
foreach ( var symbol in new[] { "wl_proxy_destroy", "wl_proxy_marshal_array_flags", "wl_proxy_marshal_flags", "wl_proxy_marshal",
"wl_proxy_marshal_array", "wl_proxy_marshal_constructor", "wl_proxy_marshal_constructor_versioned",
"wl_proxy_marshal_array_constructor", "wl_proxy_marshal_array_constructor_versioned" } )
{
if ( !names.Contains( symbol ) )
{
throw new ToolException( $"{binary} does not export required gtk4-layer-shell shim {symbol}" );
}
}
await context.Output.WriteLineAsync( $"Verified static gtk4-layer-shell linkage and exported Wayland shims in {binary}" );
return ExitCodes.Success;
}
private static async Task<int> VerifyDynamic( ToolContext context, string[] args )
{
var binary = Path.GetFullPath( new Arguments( args ).SinglePositional( "elf verify-dynamic BINARY" ), Environment.CurrentDirectory );
if ( !File.Exists( binary ) )
{
throw new ToolException( $"Missing Wayscriber binary: {binary}" );
}
var dynamic = await context.Run( Programs.ReadElf, ["-d", binary], capture: true );
if ( !dynamic.StandardOutput.Contains( "Shared library: [libgtk4-layer-shell.so", StringComparison.Ordinal ) )
{
throw new ToolException( $"{binary} does not dynamically require gtk4-layer-shell" );
}
await context.Output.WriteLineAsync( $"Verified dynamic gtk4-layer-shell linkage in {binary}" );
return ExitCodes.Success;
}
private static async Task<int> InstallGtkLayerShell( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var prefix = parsed.TakeOption( "--prefix" ) ?? context.Environment( EnvironmentVariables.Gtk4LayerShellPrefix ) ?? "/usr";
var systemPrefix = parsed.TakeOption( "--system-prefix" ) ?? context.Environment( EnvironmentVariables.Gtk4LayerShellSystemPrefix ) ?? "/usr";
var mode = parsed.TakeOption( "--library-mode" ) ?? context.Environment( EnvironmentVariables.Gtk4LayerShellLibraryMode ) ?? NativeLibraryModes.Shared;
var force = parsed.TakeFlag( "--force" ) || context.Environment( EnvironmentVariables.ForceBuild ) == EnvironmentVariables.Enabled;
parsed.RequireEmpty( "native install-gtk4-layer-shell [--prefix PATH] [--library-mode shared|static|both] [--force]" );
if ( !Path.IsPathFullyQualified( prefix ) || !Path.IsPathFullyQualified( systemPrefix ) )
{
throw new ToolException( "gtk4-layer-shell prefixes must be absolute." );
}
if ( mode is not (NativeLibraryModes.Shared or NativeLibraryModes.Static or NativeLibraryModes.Both) )
{
throw new ToolException( "library mode must be shared, static, or both." );
}
var pin = $"version={GlsVersion}\ncommit={GlsCommit}\narchive_sha256={GlsArchiveSha256}\nlibrary_mode={mode}\n";
var stamp = Path.Combine( prefix, "share/wayscriber/build-deps/gtk4-layer-shell.pin" );
var pkgEnvironment = new Dictionary<string, string?>
{
[EnvironmentVariables.PackageConfigPath] = Path.Combine( prefix, "lib/pkgconfig" ) + (context.Environment( EnvironmentVariables.PackageConfigPath ) is { Length: > 0 } existing ? $":{existing}" : string.Empty),
};
if ( !force && await GtkArtifactsExist( context, prefix, systemPrefix, mode, stamp, pin, pkgEnvironment ) )
{
var version = await context.Run( Programs.PkgConfig, ["--modversion", "gtk4-layer-shell-0"], environment: pkgEnvironment, capture: true );
await context.Output.WriteLineAsync( $"[install-gtk4-layer-shell] gtk4-layer-shell {version.StandardOutput.Trim( )} ({mode}) already available in {prefix}; skipping build" );
return ExitCodes.Success;
}
using var temporary = new TemporaryDirectory( RepositoryNames.Gtk4LayerShell );
var archive = Path.Combine( temporary.Path, "source.tar.gz" );
await context.Output.WriteLineAsync( $"[install-gtk4-layer-shell] Downloading gtk4-layer-shell {GlsVersion} (commit {GlsCommit})" );
using ( var client = new HttpClient { Timeout = TimeSpan.FromSeconds( DownloadTimeoutSeconds ) } )
using ( var response = await client.GetAsync( $"https://github.com/wmww/gtk4-layer-shell/archive/{GlsCommit}.tar.gz", HttpCompletionOption.ResponseHeadersRead, context.CancellationToken ) )
{
response.EnsureSuccessStatusCode( );
await using var input = await response.Content.ReadAsStreamAsync( context.CancellationToken );
await using var output = File.Create( archive );
await input.CopyToAsync( output, context.CancellationToken );
}
if ( Files.Sha256( archive ) != GlsArchiveSha256 )
{
throw new ToolException( "gtk4-layer-shell archive checksum mismatch." );
}
await context.Run( Programs.Tar, ["-xzf", archive, CommandLineOptions.ChangeDirectory, temporary.Path] );
var source = Path.Combine( temporary.Path, $"gtk4-layer-shell-{GlsCommit}" );
if ( !File.ReadAllBytes( Path.Combine( source, "LICENSE" ) ).SequenceEqual( File.ReadAllBytes( context.Path( RepositoryPaths.PackagingDirectory, "licenses", "gtk4-layer-shell.LICENSE" ) ) ) )
{
throw new ToolException( "Pinned gtk4-layer-shell LICENSE differs from the tracked notice." );
}
var build = Path.Combine( source, "_build" );
await context.Run( Programs.Meson, ["setup", build, source, $"--prefix={prefix}", "--libdir=lib", "--buildtype=release", $"--default-library={mode}",
"-Dexamples=false", "-Ddocs=false", "-Dtests=false", "-Dintrospection=false", "-Dvapi=false"] );
await context.Run( Programs.Meson, ["compile", CommandLineOptions.ChangeDirectory, build] );
var privileged = prefix == systemPrefix || prefix.StartsWith( systemPrefix.TrimEnd( '/' ) + "/", StringComparison.Ordinal );
await RunPossiblyRoot( context, privileged, Programs.Meson, ["install", CommandLineOptions.ChangeDirectory, build] );
var pinFile = Path.Combine( temporary.Path, "gtk4-layer-shell.pin" );
Files.WriteAtomic( pinFile, pin );
await RunPossiblyRoot( context, privileged, Programs.Install, [InstallArguments.DataFile, pinFile, stamp] );
if ( privileged )
{
await RunPossiblyRoot( context, true, Programs.LdConfig, [], allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
}
if ( !await GtkArtifactsExist( context, prefix, systemPrefix, mode, stamp, pin, pkgEnvironment ) )
{
throw new ToolException( $"{mode} gtk4-layer-shell artifacts unavailable after install." );
}
await context.Output.WriteLineAsync( $"[install-gtk4-layer-shell] Installed gtk4-layer-shell {GlsVersion} ({mode}) to {prefix}" );
return ExitCodes.Success;
}
internal static async Task<bool> GtkArtifactsExist( ToolContext context, string prefix, string systemPrefix, string mode, string stamp, string pin,
IReadOnlyDictionary<string, string?> environment )
{
if ( File.Exists( stamp ) )
{
if ( Files.Read( stamp ) != pin )
{
return false;
}
}
else if ( prefix != systemPrefix )
{
return false;
}
var probe = await context.Run( Programs.PkgConfig, [$"--atleast-version={GlsVersion}", "gtk4-layer-shell-0"], environment: environment,
capture: true, trace: false, allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
if ( probe.ExitCode != ExitCodes.Success )
{
return false;
}
var dirResult = await context.Run( Programs.PkgConfig, ["--variable=libdir", "gtk4-layer-shell-0"], environment: environment, capture: true, trace: false );
var directory = dirResult.StandardOutput.Trim( );
if ( !(directory == prefix || directory.StartsWith( prefix.TrimEnd( '/' ) + "/", StringComparison.Ordinal )) )
{
return false;
}
var shared = File.Exists( Path.Combine( directory, "libgtk4-layer-shell.so" ) ) || File.Exists( Path.Combine( directory, "libgtk4-layer-shell.so.0" ) );
var @static = File.Exists( Path.Combine( directory, "libgtk4-layer-shell.a" ) );
return mode switch
{
NativeLibraryModes.Shared => shared,
NativeLibraryModes.Static => @static,
_ => shared && @static
};
}
private static async Task<int> SetPortalShortcut( ToolContext context, string[] args )
{
var shortcut = args.Length > 0 ? args[0] : "<Ctrl><Shift>g";
var appId = args.Length > 1 ? args[1] : RepositoryNames.MainPackage;
if ( args.Length > 2 )
{
throw new ToolException( "desktop set-portal-shortcut [SHORTCUT] [APP_ID]", ExitCodes.InvalidArguments );
}
var home = context.Environment( EnvironmentVariables.Home );
if ( home is null )
{
throw new ToolException( ToolMessages.HomeNotSet );
}
var path = Path.Combine( home, ".config/systemd/user/wayscriber.service.d/shortcut.conf" );
string Escape( string value ) => value.Replace( "\\", "\\\\", StringComparison.Ordinal ).Replace( "\"", "\\\"", StringComparison.Ordinal );
Files.WriteAtomic( path, $"[Service]\nEnvironment=\"WAYSCRIBER_PORTAL_SHORTCUT={Escape( shortcut )}\"\nEnvironment=\"WAYSCRIBER_PORTAL_APP_ID={Escape( appId )}\"\n" );
await context.Run( Programs.SystemControl, ["--user", "daemon-reload"] );
await context.Run( Programs.SystemControl, ["--user", "restart", RepositoryNames.UserServiceFile] );
await context.Output.WriteLineAsync( $"Updated {path}\nShortcut set to: {shortcut}\nPortal app id set to: {appId}\nwayscriber.service restarted." );
return ExitCodes.Success;
}
private static async Task<int> ReloadDaemon( ToolContext context, string[] args )
{
new Arguments( args ).RequireEmpty( "desktop reload-daemon" );
await context.Output.WriteLineAsync( "Stopping wayscriber daemon..." );
await context.Run( Programs.Pkill, [RepositoryNames.MainPackage], allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
await Task.Delay( DaemonRestartDelayMilliseconds, context.CancellationToken );
await context.Output.WriteLineAsync( "Starting wayscriber daemon..." );
_ = System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo( Programs.Wayscriber ) { UseShellExecute = false, ArgumentList = { "--daemon" } } );
await Task.Delay( DaemonRestartDelayMilliseconds, context.CancellationToken );
var probe = await context.Run( Programs.Pgrep, ["-x", RepositoryNames.MainPackage], capture: true, allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
if ( probe.ExitCode != ExitCodes.Success )
{
throw new ToolException( "Failed to start daemon." );
}
await context.Output.WriteLineAsync( $"Daemon restarted successfully (PID: {probe.StandardOutput.Trim( )})\nPress Super+D to toggle overlay" );
return ExitCodes.Success;
}
private static async Task<int> InstallApp( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var replaceOther = parsed.TakeFlag( "--replace-other" );
var skipAutostart = parsed.TakeFlag( "--skip-autostart" );
var addHyprlandKeybind = parsed.TakeFlag( "--add-hyprland-keybind" );
var autostart = parsed.TakeOption( "--autostart" );
var installDir = parsed.TakeOption( "--install-dir" ) ?? context.Environment( EnvironmentVariables.WayscriberInstallDirectory ) ?? "/usr/bin";
parsed.RequireEmpty( "install app [--install-dir PATH] [--replace-other] [--skip-autostart] [--autostart systemd|hyprland|none] [--add-hyprland-keybind]" );
ValidateInstallOptions( skipAutostart, autostart );
installDir = NormalizeBinDirectory( installDir );
var destination = Path.Combine( installDir, RepositoryNames.MainPackage );
var home = context.Environment( EnvironmentVariables.Home );
if ( home is null )
{
throw new ToolException( ToolMessages.HomeNotSet );
}
var systemdUser = Path.Combine( context.Environment( EnvironmentVariables.XdgConfigHome ) ?? Path.Combine( home, ".config" ), "systemd/user" );
var conflicts = FindInstallConflicts( home, systemdUser, destination );
await ConfirmInstallConflicts( context, conflicts, destination, replaceOther );
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Release, CommandLineOptions.Binaries] );
await RemoveInstallConflicts( context, conflicts );
await RunPossiblyRoot( context, NeedsPrivilege( installDir ), Programs.Install, ["-d", installDir] );
await RunPossiblyRoot( context, NeedsPrivilege( installDir ), Programs.Install,
[InstallArguments.ExecutableFile, context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.MainPackage ), destination] );
var configDir = Path.Combine( home, ".config/wayscriber" );
Directory.CreateDirectory( configDir );
var config = Path.Combine( configDir, "config.toml" );
if ( !File.Exists( config ) )
{
File.Copy( context.Path( "config.example.toml" ), config );
}
autostart = await SelectAutostart( context, skipAutostart, autostart );
if ( autostart == AutostartModes.Systemd )
{
await InstallSystemdService( context, destination, installDir, systemdUser );
}
else if ( autostart == AutostartModes.Hyprland )
{
ConfigureHyprland( home, destination, includeAutostart: true );
}
if ( autostart == AutostartModes.Systemd && addHyprlandKeybind )
{
ConfigureHyprland( home, destination, includeAutostart: false );
}
await context.Output.WriteLineAsync( $"Installed: {destination}\nSHA256: {Files.Sha256( destination )}" );
return ExitCodes.Success;
}
private static void ValidateInstallOptions( bool skipAutostart, string? autostart )
{
if ( skipAutostart && autostart is not null )
{
throw new ToolException( "--skip-autostart and --autostart cannot be combined.", ExitCodes.InvalidArguments );
}
if ( autostart is not null && autostart is not (AutostartModes.Systemd or AutostartModes.Hyprland or AutostartModes.None) )
{
throw new ToolException( "--autostart must be systemd, hyprland, or none.", ExitCodes.InvalidArguments );
}
}
private static string[] FindInstallConflicts( string home, string systemdUser, string destination )
{
var known = new[] { "/usr/bin/wayscriber", "/usr/local/bin/wayscriber", Path.Combine( home, ".local/bin/wayscriber" ) };
var overrideDirectory = Path.Combine( systemdUser, "wayscriber.service.d" );
var userUnits = new[] { Path.Combine( systemdUser, RepositoryNames.UserServiceFile ) }
.Concat( Directory.Exists( overrideDirectory ) ? Directory.EnumerateFiles( overrideDirectory, "*.conf" ) : [] );
var systemUnits = new[] { "/usr/lib/systemd/user/wayscriber.service", "/usr/local/lib/systemd/user/wayscriber.service" };
var binaries = known.Where( path => path != destination && (File.Exists( path ) || Directory.Exists( path )) && !SameFile( path, destination ) );
var units = systemUnits.Concat( userUnits ).Where( path => HasConflictingExecStart( path, destination, known ) );
return binaries.Concat( units ).Distinct( ).ToArray( );
}
private static async Task ConfirmInstallConflicts( ToolContext context, string[] conflicts, string destination, bool replaceOther )
{
if ( conflicts.Length == 0 || replaceOther )
{
return;
}
if ( Console.IsInputRedirected )
{
throw new ToolException( $"Another Wayscriber install exists ({string.Join( ", ", conflicts )}); pass --replace-other to replace it." );
}
await context.Output.WriteLineAsync( $"Another Wayscriber install exists:\n{string.Join( '\n', conflicts.Select( path => $" {path}" ) )}" );
await context.Output.WriteAsync( $"Remove those files so only {destination} remains? [y/N] " );
var response = Console.ReadLine( );
var confirmed = response is not null && (response.Equals( "y", StringComparison.OrdinalIgnoreCase ) || response.Equals( "yes", StringComparison.OrdinalIgnoreCase ));
if ( !confirmed )
{
throw new ToolException( "Refusing to install beside the other copy. Re-run with --replace-other after removing it." );
}
}
private static async Task RemoveInstallConflicts( ToolContext context, string[] conflicts )
{
foreach ( var conflict in conflicts )
{
if ( await IsPackageOwned( context, conflict ) )
{
if ( Path.GetFileName( conflict ) == RepositoryNames.MainPackage )
{
throw new ToolException( $"{conflict} is package-owned; remove or update that package instead." );
}
await context.Error.WriteLineAsync( $"Leaving package-owned {conflict}." );
continue;
}
await RunPossiblyRoot( context, NeedsPrivilege( conflict ), Programs.Remove, ["-f", CommandLineOptions.EndOfOptions, conflict] );
}
}
private static async Task<string> SelectAutostart( ToolContext context, bool skipAutostart, string? autostart )
{
if ( skipAutostart || autostart is not null )
{
return autostart ?? AutostartModes.None;
}
if ( Console.IsInputRedirected )
{
throw new ToolException( "Choose --autostart systemd|hyprland|none for a non-interactive install." );
}
await context.Output.WriteAsync( "Autostart method: 1) systemd 2) Hyprland 3) none [1-3]: " );
return Console.ReadLine( ) switch
{
"1" => AutostartModes.Systemd,
"2" => AutostartModes.Hyprland,
_ => AutostartModes.None
};
}
private static async Task<int> InstallConfigurator( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var installDir = parsed.TakeOption( "--install-dir" ) ?? context.Environment( EnvironmentVariables.WayscriberInstallDirectory ) ?? "/usr/bin";
var dataDir = parsed.TakeOption( "--data-dir" ) ?? context.Environment( EnvironmentVariables.WayscriberDataDirectory );
parsed.RequireEmpty( "install configurator [--install-dir PATH] [--data-dir PATH]" );
installDir = Path.GetFullPath( installDir );
if ( dataDir is null )
{
if ( Path.GetFileName( installDir ) != "bin" )
{
throw new ToolException( $"Cannot derive data directory from {installDir}; pass --data-dir." );
}
dataDir = Path.Combine( Path.GetDirectoryName( installDir )!, "share" );
}
dataDir = Path.GetFullPath( dataDir );
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Release, CommandLineOptions.Binaries, "--manifest-path", RepositoryPaths.ConfiguratorCargoManifest] );
var binary = new[] { context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.ConfiguratorPackage ),
context.Path( RepositoryPaths.ConfiguratorDirectory, RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.ConfiguratorPackage ) }
.FirstOrDefault( File.Exists );
if ( binary is null )
{
throw new ToolException( "Configurator binary not found after build." );
}
var destination = Path.Combine( installDir, RepositoryNames.ConfiguratorPackage );
await RunPossiblyRoot( context, NeedsPrivilege( installDir ), Programs.Install, [InstallArguments.ExecutableFile, binary, destination] );
var desktop = Files.Read( context.Path( RepositoryPaths.PackagingDirectory, "wayscriber-configurator.desktop" ) );
var escaped = EscapeDesktopExecPath( destination );
desktop = System.Text.RegularExpressions.Regex.Replace( desktop, @"(?m)^Exec=.*$", $"Exec=\"{escaped}\"" );
desktop = System.Text.RegularExpressions.Regex.Replace( desktop, @"(?m)^TryExec=.*$", $"TryExec={destination.Replace( "\\", "\\\\", StringComparison.Ordinal )}" );
using var temporary = new TemporaryDirectory( "wayscriber-desktop" );
var desktopFile = Path.Combine( temporary.Path, "wayscriber-configurator.desktop" );
Files.WriteAtomic( desktopFile, desktop );
await InstallData( context, desktopFile, Path.Combine( dataDir, "applications/wayscriber-configurator.desktop" ) );
foreach ( var size in ConfiguratorIconSizes )
{
await InstallData( context, context.Path( RepositoryPaths.PackagingDirectory, "icons", $"wayscriber-configurator-{size}.png" ), Path.Combine( dataDir, $"icons/hicolor/{size}x{size}/apps/wayscriber-configurator.png" ) );
}
await InstallData( context, context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-configurator.svg" ), Path.Combine( dataDir, "icons/hicolor/scalable/apps/wayscriber-configurator.svg" ) );
await InstallData( context, context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-configurator-128.png" ), Path.Combine( dataDir, "pixmaps/wayscriber-configurator.png" ) );
if ( await CommandExists( context, Programs.UpdateDesktopDatabase ) )
{
await RunPossiblyRoot( context, NeedsPrivilege( dataDir ), Programs.UpdateDesktopDatabase, [Path.Combine( dataDir, "applications" )] );
}
if ( File.Exists( Path.Combine( dataDir, "icons/hicolor/index.theme" ) ) && await CommandExists( context, Programs.GtkUpdateIconCache ) )
{
await RunPossiblyRoot( context, NeedsPrivilege( dataDir ), Programs.GtkUpdateIconCache, ["-q", "-f", "-t", Path.Combine( dataDir, "icons/hicolor" )] );
}
await context.Output.WriteLineAsync( $"Configurator installation complete.\nRun: {destination} --help" );
return ExitCodes.Success;
}
internal static string EscapeDesktopExecPath( string path ) => path.Replace( "\\", "\\\\\\\\", StringComparison.Ordinal )
.Replace( "\"", "\\\\\"", StringComparison.Ordinal ).Replace( "`", "\\\\`", StringComparison.Ordinal )
.Replace( "$", "\\\\$", StringComparison.Ordinal ).Replace( "%", "%%", StringComparison.Ordinal );
private static async Task InstallSystemdService( ToolContext context, string destination, string installDir, string userDirectory )
{
var userService = Path.Combine( userDirectory, RepositoryNames.UserServiceFile );
var systemService = "/usr/lib/systemd/user/wayscriber.service";
var target = installDir == "/usr/bin" ? systemService : userService;
if ( File.Exists( userService ) && target != userService )
{
File.Delete( userService );
}
var service = Files.Read( context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.UserServiceFile ) );
if ( target == userService )
{
service = ReplaceExactlyOnce( service, "ExecStart=/usr/bin/wayscriber --daemon", $"ExecStart={destination} --daemon", "service ExecStart" );
service = ReplaceExactlyOnce( service, "Environment=\"PATH=/usr/local/bin:/usr/bin:/bin\"", $"Environment=\"PATH={installDir}:/usr/local/bin:/usr/bin:/bin\"", "service PATH" );
}
using var temporary = new TemporaryDirectory( "wayscriber-service" );
var source = Path.Combine( temporary.Path, RepositoryNames.UserServiceFile );
Files.WriteAtomic( source, service );
await RunPossiblyRoot( context, NeedsPrivilege( Path.GetDirectoryName( target )! ), Programs.Install, [InstallArguments.DataFile, source, target] );
await context.Run( Programs.SystemControl, ["--user", "daemon-reload"] );
await context.Run( Programs.SystemControl, ["--user", "enable", RepositoryNames.UserServiceFile] );
var restart = await context.Run( Programs.SystemControl, ["--user", "restart", RepositoryNames.UserServiceFile], allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
if ( restart.ExitCode != ExitCodes.Success )
{
await context.Run( Programs.SystemControl, ["--user", "start", RepositoryNames.UserServiceFile] );
}
}
private static void ConfigureHyprland( string home, string destination, bool includeAutostart )
{
var path = Path.Combine( home, ".config/hypr/hyprland.conf" );
if ( !File.Exists( path ) )
{
throw new ToolException( $"Hyprland config not found: {path}" );
}
var text = Files.Read( path );
var additions = new List<string>( );
if ( includeAutostart && !text.Contains( "wayscriber --daemon", StringComparison.Ordinal ) && !text.Contains( $"{destination} --daemon", StringComparison.Ordinal ) )
{
additions.Add( $"exec-once = {destination} --daemon" );
}
if ( !text.Contains( "wayscriber --daemon-toggle", StringComparison.Ordinal ) && !text.Contains( "pkill -SIGUSR1 wayscriber", StringComparison.Ordinal ) )
{
additions.Add( $"bind = SUPER, D, exec, {destination} --daemon-toggle" );
}
if ( additions.Count > 0 )
{
Files.WriteAtomic( path, text.TrimEnd( ) + "\n\n# wayscriber\n" + string.Join( '\n', additions ) + "\n" );
}
}
private static bool HasConflictingExecStart( string path, string destination, IEnumerable<string> known )
{
if ( !File.Exists( path ) )
{
return false;
}
var lines = File.ReadLines( path ).Where( line => line.TrimStart( ).StartsWith( "ExecStart=", StringComparison.Ordinal ) ).ToArray( );
return lines.Length > 0 && !lines.Any( line => line.Contains( destination, StringComparison.Ordinal ) ) && known.Any( binary => lines.Any( line => line.Contains( binary, StringComparison.Ordinal ) ) );
}
private static bool SameFile( string left, string right )
{
if ( !File.Exists( left ) || !File.Exists( right ) )
{
return false;
}
return Path.GetFullPath( File.ResolveLinkTarget( left, true )?.FullName ?? left ) == Path.GetFullPath( File.ResolveLinkTarget( right, true )?.FullName ?? right );
}
private static async Task<bool> IsPackageOwned( ToolContext context, string path )
{
if ( !await CommandExists( context, Programs.Pacman ) )
{
return false;
}
var result = await context.Run( Programs.Pacman, ["-Qoq", path], capture: true, trace: false, allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
return result.ExitCode == ExitCodes.Success;
}
private static async Task<bool> CommandExists( ToolContext context, string command ) =>
(await context.Run( Programs.Which, [command], capture: true, trace: false, allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } )).ExitCode == ExitCodes.Success;
private static string ReplaceExactlyOnce( string text, string oldValue, string newValue, string label )
{
if ( text.Split( oldValue ).Length != 2 )
{
throw new ToolException( $"Expected exactly one {label}." );
}
return text.Replace( oldValue, newValue, StringComparison.Ordinal );
}
private static Task<ProcessResult> InstallData( ToolContext context, string source, string destination ) =>
RunPossiblyRoot( context, NeedsPrivilege( Path.GetDirectoryName( destination )! ), Programs.Install, [InstallArguments.DataFile, source, destination] );
internal static string NormalizeBinDirectory( string value )
{
var path = Path.GetFullPath( Path.TrimEndingDirectorySeparator( value ) );
return path switch
{
"/usr" => "/usr/bin",
"/usr/local" => "/usr/local/bin",
_ => path
};
}
private static bool NeedsPrivilege( string path )
{
var current = Path.GetFullPath( path );
while ( !Directory.Exists( current ) && Path.GetDirectoryName( current ) is { } parent && parent != current )
{
current = parent;
}
try
{
using var stream = new FileStream( Path.Combine( current, $".wayscriber-write-{Guid.NewGuid( ):N}" ), FileMode.CreateNew, FileAccess.Write, FileShare.None, 1, FileOptions.DeleteOnClose );
return false;
}
catch ( UnauthorizedAccessException ) { return true; }
}
internal static bool ShouldUseSudo( bool requiresPrivilege, uint effectiveUserId ) =>
requiresPrivilege && effectiveUserId != RootUserId;
[System.Runtime.InteropServices.DllImport( "libc", EntryPoint = "geteuid" )]
private static extern uint GetEffectiveUserId( );
private static Task<ProcessResult> RunPossiblyRoot( ToolContext context, bool requiresPrivilege, string program, IReadOnlyList<string> arguments,
IReadOnlySet<int>? allowedExitCodes = null ) => ShouldUseSudo( requiresPrivilege, GetEffectiveUserId( ) )
? context.Run( Programs.Sudo, [program, .. arguments], allowedExitCodes: allowedExitCodes )
: context.Run( program, arguments, allowedExitCodes: allowedExitCodes );
}
+935
View File
@@ -0,0 +1,935 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Wayscriber.Tools;
internal static class PackagingCommands
{
private const string PublicKeyFileName = "WAYSCRIBER-GPG-KEY.asc";
private const int UnixExitCodeCount = 256;
private const int UnixPermissionBitsMask = 0x1ff;
private const int OctalNumberBase = 8;
private const int UnixModeDigitCount = 4;
private const int MinimumInstallerManifestBodyLines = 4;
private const int BytesPerMebibyte = 1024 * 1024;
private const int MaximumInstallerMebibytes = 1;
private const int MaximumInstallerBytes = MaximumInstallerMebibytes * BytesPerMebibyte;
private const int InstallerDownloadTimeoutMinutes = 1;
private const int NfpmDownloadTimeoutMinutes = 2;
private const int GpgSecretKeyIdFieldIndex = 4;
private const string DefaultNfpmVersion = "2.43.4";
private const string InstallerManifestBeginMarker = "# ARCH_INSTALL_MANIFEST_BEGIN";
private const string InstallerManifestEndMarker = "# ARCH_INSTALL_MANIFEST_END";
private const string InstallerManifestFunction = "release_manifest() {";
private const string InstallerManifestPrint = "printf '%s\\n' \\";
private const string ReleaseArchiveRootPattern = @"^wayscriber-v\d+\.\d+\.\d+(?:\.\d+)?-linux-x86_64$";
private const string ReleaseArchivePathPattern = @"^[-A-Za-z0-9._/+]+$";
private const string ValidServiceCommandPattern = """^ExecStart=(?:")?/usr/bin/wayscriber(?:")? --daemon$""";
private const string SystemdExecDirectivePattern = @"^\s*Exec[A-Za-z]*=";
private static readonly int[] ConfiguratorIconSizes = [24, 64, 128];
private static readonly int[] WayscriberIconSizes = [16, 19, 22, 24, 38, 64, 128];
public static IReadOnlyList<ToolCommand> Commands
{
get;
} =
[
new( CommandAreas.Package, CommandNames.Build, "Build tar, deb, and rpm artifacts.", SideEffect.FixtureMutating, Build ),
new( CommandAreas.Package, CommandNames.CheckArchInstaller, "Compare an installer manifest with a tarball.", SideEffect.ReadOnly, CheckArchInstaller ),
new( CommandAreas.Package, CommandNames.CheckLiveArchInstaller, "Download and check the deployed Arch installer.", SideEffect.ReadOnly, CheckLiveArchInstaller ),
new( CommandAreas.Package, CommandNames.VerifyArtifacts, "Verify release artifact metadata and layouts.", SideEffect.ReadOnly, VerifyArtifacts ),
new( CommandAreas.Package, CommandNames.SmokeUbuntu, "Install packages in a disposable Ubuntu container.", SideEffect.MachineMutating, SmokeUbuntu ),
new( CommandAreas.Package, CommandNames.BuildRepositories, "Build apt and rpm repositories.", SideEffect.FixtureMutating, BuildRepositories ),
new( CommandAreas.ContinuousIntegration, CommandNames.InstallNfpm, "Install a pinned nfpm binary.", SideEffect.MachineMutating, InstallNfpm ),
];
private static async Task<int> Build( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var version = parsed.TakeOption( CommandLineOptions.Version ) ?? context.Environment( EnvironmentVariables.Version ) ?? VersionCommands.ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) );
_ = ReleaseVersion.Parse( version );
var defaultFormats = $"{PackageFormats.Tar},{PackageFormats.Debian},{PackageFormats.Rpm}";
var formats = (parsed.TakeOption( "--formats" ) ?? context.Environment( EnvironmentVariables.Formats ) ?? defaultFormats)
.Split( ',', StringSplitOptions.RemoveEmptyEntries );
var artifactRoot = Path.GetFullPath( parsed.TakeOption( "--artifact-root" ) ?? context.Environment( EnvironmentVariables.ArtifactRoot ) ?? context.Path( "dist" ), context.RepositoryRoot );
var strip = !parsed.TakeFlag( "--no-strip" );
_ = parsed.TakeFlag( "--strip" );
var skipBuild = parsed.TakeFlag( "--skip-build" ) || context.Environment( EnvironmentVariables.SkipBuild ) == EnvironmentVariables.Enabled;
var configurator = !parsed.TakeFlag( "--no-configurator" ) &&
context.Environment( EnvironmentVariables.PackageConfigurator ) != EnvironmentVariables.Disabled;
parsed.RequireEmpty( "package build [--version VERSION] [--formats tar,deb,rpm] [--artifact-root PATH] [--no-strip] [--skip-build] [--no-configurator]" );
var gtkPrefix = context.Environment( EnvironmentVariables.Gtk4LayerShellPrefix ) ??
context.Path( RepositoryPaths.TargetDirectory, "release-deps", RepositoryNames.Gtk4LayerShell );
var environment = new Dictionary<string, string?>
{
[EnvironmentVariables.Version] = version,
[EnvironmentVariables.WayscriberReleaseVersion] = version,
[EnvironmentVariables.PackageConfigPath] = Path.Combine( gtkPrefix, "lib/pkgconfig" ) + (context.Environment( EnvironmentVariables.PackageConfigPath ) is { Length: > 0 } existing ? $":{existing}" : string.Empty),
[EnvironmentVariables.SystemGtk4LayerShellLink] = NativeLibraryModes.Static,
};
Directory.CreateDirectory( artifactRoot );
if ( !skipBuild )
{
await ToolApplication.RunNestedAsync( context, CommandAreas.Native, CommandNames.InstallGtk4LayerShell,
["--prefix", gtkPrefix, "--library-mode", NativeLibraryModes.Static] );
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Locked, CommandLineOptions.Release, CommandLineOptions.Binaries], environment: environment );
if ( configurator )
{
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Locked, CommandLineOptions.Release, CommandLineOptions.Binaries, "--manifest-path", RepositoryPaths.ConfiguratorCargoManifest], environment: environment );
}
}
var mainBinary = context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.MainPackage );
var configBinary = context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.ConfiguratorPackage );
await ToolApplication.RunNestedAsync( context, CommandAreas.Elf, CommandNames.VerifyStatic, [mainBinary] );
await VerifyGlibc( context, mainBinary );
if ( configurator )
{
if ( !File.Exists( configBinary ) )
{
throw new ToolException( $"Missing release binary: {configBinary}" );
}
await VerifyGlibc( context, configBinary );
}
if ( strip )
{
await StripBinaries( context, mainBinary, configBinary, configurator );
}
var artifacts = await BuildArtifacts( context, formats, artifactRoot, version, configurator, environment );
WriteManifest( artifactRoot, version, artifacts );
return ExitCodes.Success;
}
private static async Task StripBinaries( ToolContext context, string mainBinary, string configuratorBinary, bool includeConfigurator )
{
var available = await context.Run( Programs.Which, [Programs.Strip], capture: true, trace: false,
allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
if ( available.ExitCode != ExitCodes.Success )
{
await context.Error.WriteLineAsync( "[WARN] 'strip' not found; binaries will remain unstripped" );
return;
}
var nonFatalExitCodes = Enumerable.Range( 0, UnixExitCodeCount ).ToHashSet( );
var mainStrip = await context.Run( Programs.Strip, [mainBinary], allowedExitCodes: nonFatalExitCodes );
if ( mainStrip.ExitCode != ExitCodes.Success )
{
await context.Error.WriteLineAsync( "[WARN] strip failed for wayscriber" );
}
if ( includeConfigurator )
{
var configuratorStrip = await context.Run( Programs.Strip, [configuratorBinary], allowedExitCodes: nonFatalExitCodes );
if ( configuratorStrip.ExitCode != ExitCodes.Success )
{
await context.Error.WriteLineAsync( "[WARN] strip failed for configurator" );
}
}
await ToolApplication.RunNestedAsync( context, CommandAreas.Elf, CommandNames.VerifyStatic, [mainBinary] );
}
private static async Task<List<string>> BuildArtifacts( ToolContext context, IEnumerable<string> formats, string artifactRoot,
string version, bool includeConfigurator, IReadOnlyDictionary<string, string?> environment )
{
var artifacts = new List<string>( );
foreach ( var format in formats )
{
if ( format == PackageFormats.Tar )
{
artifacts.Add( await BuildTar( context, artifactRoot, version, configurator: false ) );
if ( includeConfigurator )
{
artifacts.Add( await BuildTar( context, artifactRoot, version, configurator: true ) );
}
continue;
}
if ( format is PackageFormats.Debian or PackageFormats.Rpm )
{
artifacts.Add( await BuildNfpm( context, artifactRoot, format, configurator: false, environment ) );
if ( includeConfigurator )
{
artifacts.Add( await BuildNfpm( context, artifactRoot, format, configurator: true, environment ) );
}
continue;
}
await context.Error.WriteLineAsync( $"Unknown format '{format}', skipping" );
}
return artifacts;
}
private static async Task VerifyGlibc( ToolContext context, string binary )
{
if ( !File.Exists( binary ) )
{
throw new ToolException( $"Missing release binary: {binary}" );
}
var result = await context.Run( Programs.ReadElf, ["--version-info", binary], capture: true );
var versions = Regex.Matches( result.StandardOutput, @"GLIBC_([0-9]+(?:\.[0-9]+)+)" ).Select( match => Version.Parse( match.Groups[1].Value ) ).ToArray( );
if ( versions.Length == 0 )
{
throw new ToolException( $"Could not determine glibc requirement for {binary}" );
}
var maximum = versions.Max( )!;
if ( maximum > Version.Parse( PackagingPlatform.MaximumGlibcVersion ) )
{
throw new ToolException( $"{binary} requires GLIBC_{maximum}; release floor is GLIBC_{PackagingPlatform.MaximumGlibcVersion}" );
}
await context.Error.WriteLineAsync(
$"[INFO] Verified {Path.GetFileName( binary )} requires at most GLIBC_{PackagingPlatform.MaximumGlibcVersion} (found GLIBC_{maximum})" );
}
private static async Task<string> BuildTar( ToolContext context, string output, string version, bool configurator )
{
var name = configurator ? $"wayscriber-configurator-v{version}-linux-x86_64" : $"wayscriber-v{version}-linux-x86_64";
var root = Path.Combine( output, name );
if ( Directory.Exists( root ) )
{
Directory.Delete( root, recursive: true );
}
Directory.CreateDirectory( root );
try
{
void Copy( string source, string relative, UnixFileMode mode )
{
var destination = Path.Combine( root, relative );
Directory.CreateDirectory( Path.GetDirectoryName( destination )! );
File.Copy( source, destination, overwrite: true );
if ( !OperatingSystem.IsWindows( ) )
{
File.SetUnixFileMode( destination, mode );
}
}
var regular = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
var executable = regular | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
if ( configurator )
{
Copy( context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.ConfiguratorPackage ),
"usr/bin/wayscriber-configurator", executable );
Copy( context.Path( RepositoryPaths.PackagingDirectory, "wayscriber-configurator.desktop" ), "usr/share/applications/wayscriber-configurator.desktop", regular );
foreach ( var size in ConfiguratorIconSizes )
{
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", $"wayscriber-configurator-{size}.png" ), $"usr/share/icons/hicolor/{size}x{size}/apps/wayscriber-configurator.png", regular );
}
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-configurator.svg" ), "usr/share/icons/hicolor/scalable/apps/wayscriber-configurator.svg", regular );
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-configurator-128.png" ), "usr/share/pixmaps/wayscriber-configurator.png", regular );
Copy( context.Path( "README.md" ), "usr/share/doc/wayscriber-configurator/README.md", regular );
Copy( context.Path( "LICENSE" ), "usr/share/doc/wayscriber-configurator/LICENSE", regular );
}
else
{
Copy( context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.MainPackage ),
"usr/bin/wayscriber", executable );
Copy( context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.UserServiceFile ), "usr/lib/systemd/user/wayscriber.service", regular );
Copy( context.Path( RepositoryPaths.PackagingDirectory, "wayscriber.desktop" ), "usr/share/applications/wayscriber.desktop", regular );
foreach ( var size in WayscriberIconSizes )
{
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", $"wayscriber-{size}.png" ), $"usr/share/icons/hicolor/{size}x{size}/apps/wayscriber.png", regular );
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", $"wayscriber-{size}.png" ), $"usr/share/icons/hicolor/{size}x{size}/status/wayscriber.png", regular );
}
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber.svg" ), "usr/share/icons/hicolor/scalable/apps/wayscriber.svg", regular );
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-symbolic.svg" ), "usr/share/icons/hicolor/symbolic/apps/wayscriber-symbolic.svg", regular );
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-128.png" ), "usr/share/pixmaps/wayscriber.png", regular );
Copy( context.Path( "README.md" ), "usr/share/doc/wayscriber/README.md", regular );
Copy( context.Path( "config.example.toml" ), "usr/share/doc/wayscriber/config.example.toml", regular );
Copy( context.Path( "LICENSE" ), "usr/share/doc/wayscriber/LICENSE", regular );
Copy( context.Path( "LICENSE" ), "usr/share/licenses/wayscriber/LICENSE", regular );
Copy( context.Path( RepositoryPaths.PackagingDirectory, "licenses", "gtk4-layer-shell.LICENSE" ), "usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell", regular );
}
var tarball = Path.Combine( output, name + ".tar.gz" );
File.Delete( tarball );
await context.Run( Programs.Tar, [CommandLineOptions.ChangeDirectory, output, "-czf", tarball, name] );
if ( !File.Exists( tarball ) )
{
throw new ToolException( $"tar did not create {tarball}" );
}
return tarball;
}
finally
{
if ( Directory.Exists( root ) )
{
Directory.Delete( root, recursive: true );
}
}
}
private static async Task<string> BuildNfpm( ToolContext context, string output, string format, bool configurator,
IReadOnlyDictionary<string, string?> environment )
{
var prefix = configurator ? RepositoryNames.ConfiguratorPackage : RepositoryNames.MainPackage;
var architecture = format == PackageFormats.Debian ? "amd64" : "x86_64";
var target = Path.Combine( output, $"{prefix}-{architecture}.{format}" );
var configVariable = configurator ? EnvironmentVariables.NfpmConfiguratorConfig : EnvironmentVariables.NfpmMainConfig;
var config = context.Environment( configVariable ) ??
context.Path( RepositoryPaths.PackagingDirectory, configurator ? "package.configurator.yaml" : "package.wayscriber.yaml" );
File.Delete( target );
await context.Run( Programs.Nfpm, ["pkg", "--packager", format, "--config", config, "--target", target], environment: environment );
if ( !File.Exists( target ) )
{
throw new ToolException( $"nfpm did not create {target}" );
}
return target;
}
private static void WriteManifest( string output, string version, IReadOnlyList<string> artifacts )
{
var entries = artifacts.Select( path => new { name = Path.GetFileName( path ), sha256 = Files.Sha256( path ), size = new FileInfo( path ).Length } ).ToArray( );
Files.WriteAtomic( Path.Combine( output, "checksums.txt" ), string.Concat( entries.Select( entry => $"{entry.sha256} {entry.name}\n" ) ) );
Files.WriteAtomic( Path.Combine( output, "manifest.json" ), JsonSerializer.Serialize( new
{
version,
artifacts = entries
}, new JsonSerializerOptions { WriteIndented = true } ) + "\n" );
}
private static async Task<int> CheckArchInstaller( ToolContext context, string[] args )
{
if ( OperatingSystem.IsWindows( ) )
{
throw new ToolException( "Arch installer checks require Linux." );
}
var parsed = new Arguments( args );
var installer = Path.GetFullPath( parsed.TakeOption( "--installer", required: true )!, Environment.CurrentDirectory );
var archive = Path.GetFullPath( parsed.TakeOption( "--archive", required: true )!, Environment.CurrentDirectory );
parsed.RequireEmpty( "package check-arch-installer --installer FILE --archive FILE" );
if ( !File.Exists( installer ) || !File.Exists( archive ) )
{
throw new ToolException( "Installer or archive was not found." );
}
var entries = ParseInstallerManifest( Files.Read( installer ) );
var listing = await context.Run( Programs.Tar, ["-tzf", archive], capture: true );
var archiveRoot = ValidateArchiveListing( listing.StandardOutput );
using var temporary = new TemporaryDirectory( "wayscriber-arch-manifest" );
await context.Run( Programs.Tar, ["-xzf", archive, CommandLineOptions.ChangeDirectory, temporary.Path] );
var roots = Directory.GetDirectories( temporary.Path );
if ( roots.Length != 1 || Path.GetFileName( roots[0] ) != archiveRoot )
{
throw new ToolException( "Archive has an unexpected top-level directory." );
}
await ValidateExtractedArchive( context, roots[0] );
var usr = Path.Combine( roots[0], "usr" );
if ( !Directory.Exists( usr ) )
{
throw new ToolException( "Archive does not contain usr/." );
}
var files = Directory.EnumerateFiles( usr, "*", SearchOption.AllDirectories ).Select( path => Path.GetRelativePath( usr, path ).Replace( Path.DirectorySeparatorChar, '/' ) ).Order( ).ToArray( );
if ( !entries.Keys.Order( ).SequenceEqual( files ) )
{
throw new ToolException( "Installer manifest and release archive file paths differ." );
}
foreach ( var pair in entries )
{
var path = Path.Combine( usr, pair.Key );
if ( File.ResolveLinkTarget( path, returnFinalTarget: false ) is not null )
{
throw new ToolException( $"Archive contains a symbolic link: {pair.Key}" );
}
var actual = Convert.ToString( ( int ) File.GetUnixFileMode( path ) & UnixPermissionBitsMask, OctalNumberBase )!
.PadLeft( UnixModeDigitCount, '0' );
if ( actual != pair.Value )
{
throw new ToolException( $"mode mismatch for usr/{pair.Key}: expected {pair.Value}, found {actual}" );
}
}
var service = Files.Read( Path.Combine( usr, "lib/systemd/user/wayscriber.service" ) );
var serviceDirectives = ParseSystemdExecDirectives( service );
var wayscriberCommands = serviceDirectives.Where( line =>
line.Contains( RepositoryNames.MainPackage, StringComparison.Ordinal ) ).ToArray( );
var validCommands = wayscriberCommands.Count( line => Regex.IsMatch( line, ValidServiceCommandPattern ) );
if ( validCommands != 1 || wayscriberCommands.Length != validCommands )
{
throw new ToolException( "Release user service is incompatible with the direct installer rewrite." );
}
await context.Output.WriteLineAsync( $"Arch installer manifest matches {Path.GetFileName( archive )}." );
return ExitCodes.Success;
}
private static string[] ParseSystemdExecDirectives( string service )
{
var directives = new List<string>( );
var current = string.Empty;
foreach ( var physicalLine in service.Replace( "\r\n", "\n", StringComparison.Ordinal ).Split( '\n' ) )
{
var line = physicalLine.TrimStart( );
if ( current.Length > 0 && IsSystemdComment( line ) )
{
continue;
}
current += line;
if ( current.EndsWith( '\\' ) )
{
current = current.TrimEnd( '\\' ) + " ";
continue;
}
if ( Regex.IsMatch( current, SystemdExecDirectivePattern ) )
{
directives.Add( current );
}
current = string.Empty;
}
if ( Regex.IsMatch( current, SystemdExecDirectivePattern ) )
{
directives.Add( current );
}
return directives.ToArray( );
}
private static bool IsSystemdComment( string line ) => line.StartsWith( '#' ) || line.StartsWith( ';' );
private static string ValidateArchiveListing( string listing )
{
var paths = listing.Split( '\n', StringSplitOptions.RemoveEmptyEntries );
var root = paths.Select( path => path.TrimEnd( '/' ).Split( '/', 2 )[0] ).FirstOrDefault( );
if ( root is null || !Regex.IsMatch( root, ReleaseArchiveRootPattern ) )
{
throw new ToolException( $"Archive has an unexpected top-level directory: {root ?? "<none>"}." );
}
foreach ( var path in paths )
{
var isExpected = path == root || path == root + "/" || path == root + "/usr" || path == root + "/usr/" ||
path.StartsWith( root + "/usr/", StringComparison.Ordinal );
var isUnsafe = path.StartsWith( "/", StringComparison.Ordinal ) || path.Split( '/' ).Contains( ".." );
if ( !isExpected || isUnsafe || !Regex.IsMatch( path, ReleaseArchivePathPattern ) )
{
throw new ToolException( $"Archive contains an unexpected or unsafe path: {path}" );
}
}
return root;
}
private static async Task ValidateExtractedArchive( ToolContext context, string root )
{
var unsupportedType = await context.Run(
Programs.Find,
[root, "!", "-type", "d", "!", "-type", "f", "-print", "-quit"],
capture: true,
trace: false );
if ( unsupportedType.StandardOutput.Length > 0 )
{
throw new ToolException( $"Archive contains a symbolic link or special file: {unsupportedType.StandardOutput.Trim( )}" );
}
var hardLink = await context.Run( Programs.Find, [root, "-type", "f", "-links", "+1", "-print", "-quit"], capture: true, trace: false );
if ( hardLink.StandardOutput.Length > 0 )
{
throw new ToolException( $"Archive contains a hard-linked file: {hardLink.StandardOutput.Trim( )}" );
}
}
internal static Dictionary<string, string> ParseInstallerManifest( string source )
{
var lines = source.Replace( "\r\n", "\n", StringComparison.Ordinal ).Split( '\n' );
if ( lines.Count( line => line == InstallerManifestBeginMarker ) != 1 || lines.Count( line => line == InstallerManifestEndMarker ) != 1 )
{
throw new ToolException( "installer must contain one static manifest block" );
}
var beginIndex = Array.IndexOf( lines, InstallerManifestBeginMarker );
var endIndex = Array.IndexOf( lines, InstallerManifestEndMarker );
var body = endIndex > beginIndex ? lines[(beginIndex + 1)..endIndex] : [];
if ( body.Length < MinimumInstallerManifestBodyLines || body[0].Trim( ) != InstallerManifestFunction || body[1].Trim( ) != InstallerManifestPrint ||
body[^1].Trim( ) != "}" )
{
throw new ToolException( "installer manifest block is malformed or uses unsupported syntax" );
}
var result = new Dictionary<string, string>( StringComparer.Ordinal );
var entries = body[2..^1];
for ( var index = 0; index < entries.Length; index++ )
{
var match = Regex.Match( entries[index], @"^\s*'(?<mode>0[0-7]{3}) (?<path>[-A-Za-z0-9._/+]+)'\s*(?<continuation>\\)?\s*$" );
var shouldContinue = index < entries.Length - 1;
if ( !match.Success || match.Groups["continuation"].Success != shouldContinue )
{
throw new ToolException( $"unsupported installer manifest syntax: {entries[index].Trim( )}" );
}
var path = match.Groups["path"].Value;
if ( Path.IsPathRooted( path ) || path.Split( '/' ).Contains( ".." ) || !result.TryAdd( path, match.Groups["mode"].Value ) )
{
throw new ToolException( $"unsafe or duplicate manifest path: {path}" );
}
}
if ( result.Count == 0 )
{
throw new ToolException( "installer manifest is empty or malformed" );
}
return result;
}
private static async Task<int> CheckLiveArchInstaller( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var archive = parsed.TakeOption( "--archive", required: true )!;
var url = parsed.TakeOption( "--url" ) ?? "https://wayscriber.com/arch-install.sh";
parsed.RequireEmpty( "package check-live-arch-installer --archive FILE [--url URL]" );
using var temporary = new TemporaryDirectory( "wayscriber-installer" );
var installer = Path.Combine( temporary.Path, "arch-install.sh" );
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes( InstallerDownloadTimeoutMinutes ), MaxResponseContentBufferSize = MaximumInstallerBytes };
var bytes = await client.GetByteArrayAsync( new Uri( url ), context.CancellationToken );
if ( bytes.Length > MaximumInstallerBytes )
{
throw new ToolException( $"Installer exceeds the {MaximumInstallerMebibytes} MiB limit." );
}
await File.WriteAllBytesAsync( installer, bytes, context.CancellationToken );
return await CheckArchInstaller( context, ["--installer", installer, "--archive", archive] );
}
private static async Task<int> VerifyArtifacts( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var root = Path.GetFullPath( parsed.TakeOption( "--artifact-root" ) ?? context.Environment( EnvironmentVariables.ArtifactRoot ) ?? "dist", context.RepositoryRoot );
var version = parsed.TakeOption( CommandLineOptions.Version ) ?? context.Environment( EnvironmentVariables.Version ) ?? VersionCommands.ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) );
parsed.RequireEmpty( "package verify-artifacts [--artifact-root PATH] [--version VERSION]" );
var expected = version + "-1";
foreach ( var package in new[] { "wayscriber-amd64.deb", "wayscriber-configurator-amd64.deb" } )
{
var result = await context.Run( Programs.DpkgDeb, ["-f", Path.Combine( root, package ), "Version"], capture: true );
if ( result.StandardOutput.Trim( ) != expected )
{
throw new ToolException( $"{package} version mismatch." );
}
}
var mainDepends = await context.Run( Programs.DpkgDeb, ["-f", Path.Combine( root, "wayscriber-amd64.deb" ), "Depends"], capture: true );
RequireContains( mainDepends.StandardOutput,
[$"libc6 (>= {PackagingPlatform.MaximumGlibcVersion})", $"libgtk-4-1 (>= {PackagingPlatform.MinimumGtkVersion})"],
"wayscriber deb dependencies" );
if ( mainDepends.StandardOutput.Contains( "libgtk4-layer-shell" ) )
{
throw new ToolException( "Main deb retains dynamic gtk4-layer-shell dependency." );
}
var mainDebFiles = await context.Run( Programs.DpkgDeb, ["-c", Path.Combine( root, "wayscriber-amd64.deb" )], capture: true );
RequireContains( mainDebFiles.StandardOutput, ["/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell"], "wayscriber deb files" );
var configDepends = await context.Run( Programs.DpkgDeb, ["-f", Path.Combine( root, "wayscriber-configurator-amd64.deb" ), "Depends"], capture: true );
RequireContains( configDepends.StandardOutput,
[$"libc6 (>= {PackagingPlatform.MaximumGlibcVersion})", $"libadwaita-1-0 (>= {VersionCommands.SupportedLibadwaitaFloor})"],
"configurator deb dependencies" );
using var rpmDatabase = new TemporaryDirectory( "wayscriber-rpmdb" );
foreach ( var package in new[] { "wayscriber-x86_64.rpm", "wayscriber-configurator-x86_64.rpm" } )
{
var result = await context.Run( Programs.Rpm, ["--dbpath", rpmDatabase.Path, "-qp", "--qf", "%{VERSION}-%{RELEASE}\\n", Path.Combine( root, package )], capture: true );
if ( result.StandardOutput.Trim( ) != expected )
{
throw new ToolException( $"{package} version mismatch." );
}
}
var mainRpmRequires = await context.Run( Programs.Rpm, ["--dbpath", rpmDatabase.Path, "-qp", "--requires", Path.Combine( root, "wayscriber-x86_64.rpm" )], capture: true );
RequireLines( mainRpmRequires.StandardOutput,
[$"glibc >= {PackagingPlatform.MaximumGlibcVersion}", $"gtk4 >= {PackagingPlatform.MinimumGtkVersion}"],
"wayscriber rpm dependencies" );
if ( mainRpmRequires.StandardOutput.Contains( RepositoryNames.Gtk4LayerShell, StringComparison.Ordinal ) )
{
throw new ToolException( "Main rpm retains dynamic gtk4-layer-shell dependency." );
}
var mainRpmFiles = await context.Run( Programs.Rpm, ["--dbpath", rpmDatabase.Path, "-qlp", Path.Combine( root, "wayscriber-x86_64.rpm" )], capture: true );
RequireLines( mainRpmFiles.StandardOutput, ["/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell"], "wayscriber rpm files" );
var configRpmRequires = await context.Run( Programs.Rpm, ["--dbpath", rpmDatabase.Path, "-qp", "--requires", Path.Combine( root, "wayscriber-configurator-x86_64.rpm" )], capture: true );
RequireLines( configRpmRequires.StandardOutput,
[$"glibc >= {PackagingPlatform.MaximumGlibcVersion}", $"libadwaita >= {VersionCommands.SupportedLibadwaitaFloor}"],
"configurator rpm dependencies" );
var mainTar = await context.Run( Programs.Tar, ["-tzf", Path.Combine( root, $"wayscriber-v{version}-linux-x86_64.tar.gz" )], capture: true );
RequireSuffixes( mainTar.StandardOutput, ["/usr/bin/wayscriber", "/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell"], "wayscriber tar files" );
var configTar = await context.Run( Programs.Tar, ["-tzf", Path.Combine( root, $"wayscriber-configurator-v{version}-linux-x86_64.tar.gz" )], capture: true );
RequireSuffixes( configTar.StandardOutput, ["/usr/bin/wayscriber-configurator"], "configurator tar files" );
await context.Output.WriteLineAsync( $"Verified release artifacts for {version}." );
return ExitCodes.Success;
}
private static async Task<int> SmokeUbuntu( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var image = parsed.TakeOption( "--image" ) ?? PackagingPlatform.UbuntuImage;
var root = Path.GetFullPath( parsed.TakeOption( "--artifact-root" ) ?? "dist", context.RepositoryRoot );
parsed.RequireEmpty( "package smoke-ubuntu [--image IMAGE] [--artifact-root PATH]" );
var name = $"wayscriber-package-smoke-{Guid.NewGuid( ):N}";
try
{
await context.Run( Programs.Docker, ["create", "--name", name, "-v", $"{root}:/dist:ro", image, Programs.Sleep, "infinity"] );
await context.Run( Programs.Docker, ["start", name] );
await context.Run( Programs.Docker, ["exec", name, Programs.AptGet, "update"] );
await context.Run( Programs.Docker, ["exec", "-e", "DEBIAN_FRONTEND=noninteractive", name, Programs.AptGet, "install", "-y", "/dist/wayscriber-amd64.deb", "/dist/wayscriber-configurator-amd64.deb"] );
await context.Run( Programs.Docker, ["exec", name, RepositoryNames.MainPackage, CommandLineOptions.Version] );
await context.Run( Programs.Docker, ["exec", name, Programs.Test, "-x", "/usr/bin/wayscriber-configurator"] );
}
finally
{
await context.Run( Programs.Docker, ["rm", "-f", name], allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
}
return ExitCodes.Success;
}
private static async Task<int> InstallNfpm( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var version = parsed.TakeOption( CommandLineOptions.Version ) ?? DefaultNfpmVersion;
parsed.RequireEmpty( "ci install-nfpm [--version VERSION]" );
using var temporary = new TemporaryDirectory( Programs.Nfpm );
var archive = Path.Combine( temporary.Path, "nfpm.tar.gz" );
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes( NfpmDownloadTimeoutMinutes ) };
await using ( var output = File.Create( archive ) )
await using ( var input = await client.GetStreamAsync( $"https://github.com/goreleaser/nfpm/releases/download/v{version}/nfpm_{version}_Linux_x86_64.tar.gz", context.CancellationToken ) )
{
await input.CopyToAsync( output, context.CancellationToken );
}
await context.Run( Programs.Tar, ["-xzf", archive, CommandLineOptions.ChangeDirectory, temporary.Path, Programs.Nfpm] );
await context.Run( Programs.Sudo, [Programs.Install, InstallArguments.ExecutableFile, Path.Combine( temporary.Path, Programs.Nfpm ), "/usr/local/bin/nfpm"] );
await context.Run( Programs.Nfpm, [CommandLineOptions.Version] );
return ExitCodes.Success;
}
private static async Task<int> BuildRepositories( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var artifacts = Path.GetFullPath( parsed.TakeOption( "--artifact-root" ) ?? context.Environment( EnvironmentVariables.ArtifactRoot ) ?? "dist", context.RepositoryRoot );
var output = Path.GetFullPath( parsed.TakeOption( "--output-root" ) ?? context.Environment( EnvironmentVariables.OutputRoot ) ?? "dist/repos", context.RepositoryRoot );
var suite = parsed.TakeOption( "--deb-suite" ) ?? context.Environment( EnvironmentVariables.DebSuite ) ?? "stable";
var component = parsed.TakeOption( "--deb-component" ) ?? context.Environment( EnvironmentVariables.DebComponent ) ?? "main";
var arch = parsed.TakeOption( "--deb-arch" ) ?? context.Environment( EnvironmentVariables.DebArchitecture ) ?? "amd64";
var rpmArch = parsed.TakeOption( "--rpm-arch" ) ?? context.Environment( EnvironmentVariables.RpmArchitecture ) ?? "x86_64";
parsed.RequireEmpty( "package build-repositories [--artifact-root PATH] [--output-root PATH] [--deb-suite NAME] [--deb-component NAME] [--deb-arch ARCH] [--rpm-arch ARCH]" );
var mainDebName = $"{RepositoryNames.MainPackage}-amd64.deb";
var configuratorDebName = $"{RepositoryNames.ConfiguratorPackage}-amd64.deb";
var mainRpmName = $"{RepositoryNames.MainPackage}-{rpmArch}.rpm";
var configuratorRpmName = $"{RepositoryNames.ConfiguratorPackage}-{rpmArch}.rpm";
var mainDeb = Path.Combine( artifacts, mainDebName );
var mainRpm = Path.Combine( artifacts, mainRpmName );
if ( !File.Exists( mainDeb ) )
{
throw new ToolException( $"Missing deb package at {mainDeb}" );
}
if ( !File.Exists( mainRpm ) )
{
throw new ToolException( $"Missing rpm package at {mainRpm}" );
}
var outputParent = Path.GetDirectoryName( output );
if ( outputParent is null )
{
throw new ToolException( $"Cannot determine output parent for {output}." );
}
Directory.CreateDirectory( outputParent );
var staging = Path.Combine( outputParent, $".{Path.GetFileName( output )}.staging-{Guid.NewGuid( ):N}" );
Directory.CreateDirectory( staging );
try
{
using var signing = await RepositorySigning.Create( context, staging );
await BuildAptRepository( context, signing, staging, artifacts, [mainDebName, configuratorDebName], suite, component, arch );
await BuildRpmRepository( context, signing, staging, artifacts, [mainRpmName, configuratorRpmName] );
ReplaceRepositoryOutput( staging, output, outputParent );
await context.Error.WriteLineAsync( $"[build-repos] Repository build complete under {output}" );
return ExitCodes.Success;
}
finally
{
if ( Directory.Exists( staging ) )
{
Directory.Delete( staging, recursive: true );
}
}
}
private static async Task BuildAptRepository( ToolContext context, RepositorySigning signing, string staging,
string artifacts, IEnumerable<string> packageNames, string suite, string component, string arch )
{
var aptRoot = Path.Combine( staging, "apt" );
var pool = Path.Combine( aptRoot, "pool/main/w/wayscriber" );
var binaryDirectory = Path.Combine( aptRoot, $"dists/{suite}/{component}/binary-{arch}" );
Directory.CreateDirectory( pool );
Directory.CreateDirectory( binaryDirectory );
CopyExistingPackages( artifacts, pool, packageNames );
var packages = await context.Run( Programs.AptFileArchive, ["packages", "pool"], aptRoot, capture: true );
var packageFile = Path.Combine( binaryDirectory, "Packages" );
Files.WriteAtomic( packageFile, packages.StandardOutput );
await using ( var input = File.OpenRead( packageFile ) )
await using ( var outputStream = File.Create( packageFile + ".gz" ) )
await using ( var gzip = new GZipStream( outputStream, CompressionLevel.SmallestSize ) )
{
await input.CopyToAsync( gzip, context.CancellationToken );
}
var release = await context.Run( Programs.AptFileArchive, ["-o", $"APT::FTPArchive::Release::Origin={context.Environment( EnvironmentVariables.RepositoryOrigin ) ?? "Wayscriber"}",
"-o", $"APT::FTPArchive::Release::Label={context.Environment( EnvironmentVariables.RepositoryLabel ) ?? "Wayscriber"}", "-o", $"APT::FTPArchive::Release::Suite={suite}",
"-o", $"APT::FTPArchive::Release::Codename={suite}", "-o", $"APT::FTPArchive::Release::Architectures={arch}",
"-o", $"APT::FTPArchive::Release::Components={component}", "release", $"dists/{suite}"], aptRoot, capture: true );
var releaseFile = Path.Combine( aptRoot, $"dists/{suite}/Release" );
Files.WriteAtomic( releaseFile, release.StandardOutput );
signing.CopyPublicKeys( aptRoot );
await signing.SignFile( context, releaseFile, Path.Combine( aptRoot, $"dists/{suite}/InRelease" ), clearSign: true );
await signing.SignFile( context, releaseFile, Path.Combine( aptRoot, $"dists/{suite}/Release.gpg" ), armor: true );
}
private static async Task BuildRpmRepository( ToolContext context, RepositorySigning signing, string staging,
string artifacts, IEnumerable<string> packageNames )
{
var rpmRoot = Path.Combine( staging, PackageFormats.Rpm );
Directory.CreateDirectory( rpmRoot );
CopyExistingPackages( artifacts, rpmRoot, packageNames );
signing.CopyRpmPublicKeys( rpmRoot );
if ( context.Environment( EnvironmentVariables.SignRpms ) != EnvironmentVariables.Disabled )
{
foreach ( var rpm in Directory.EnumerateFiles( rpmRoot, "*.rpm" ) )
{
await signing.SignRpm( context, rpm );
}
}
await context.Run( Programs.CreateRpmRepository, ["--update", rpmRoot] );
await signing.SignFile( context, Path.Combine( rpmRoot, "repodata/repomd.xml" ), Path.Combine( rpmRoot, "repodata/repomd.xml.asc" ), armor: true );
}
private static void CopyExistingPackages( string sourceDirectory, string destinationDirectory, IEnumerable<string> packageNames )
{
foreach ( var packageName in packageNames )
{
var source = Path.Combine( sourceDirectory, packageName );
if ( File.Exists( source ) )
{
File.Copy( source, Path.Combine( destinationDirectory, packageName ), true );
}
}
}
private static void ReplaceRepositoryOutput( string staging, string output, string outputParent )
{
string? backup = null;
if ( Directory.Exists( output ) )
{
backup = Path.Combine( outputParent, $".{Path.GetFileName( output )}.previous-{Guid.NewGuid( ):N}" );
Directory.Move( output, backup );
}
try
{
Directory.Move( staging, output );
}
catch
{
if ( backup is not null && !Directory.Exists( output ) )
{
Directory.Move( backup, output );
}
throw;
}
if ( backup is not null )
{
Directory.Delete( backup, recursive: true );
}
}
private static void RequireContains( string text, IEnumerable<string> values, string label )
{
foreach ( var value in values )
{
if ( !text.Contains( value, StringComparison.Ordinal ) )
{
throw new ToolException( $"{label} lacks {value}" );
}
}
}
private static void RequireLines( string text, IEnumerable<string> values, string label )
{
var lines = text.Split( '\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ).ToHashSet( StringComparer.Ordinal );
foreach ( var value in values )
{
if ( !lines.Contains( value ) )
{
throw new ToolException( $"{label} lacks {value}" );
}
}
}
private static void RequireSuffixes( string text, IEnumerable<string> values, string label )
{
var lines = text.Split( '\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries );
foreach ( var value in values )
{
if ( !lines.Any( line => line.EndsWith( value, StringComparison.Ordinal ) ) )
{
throw new ToolException( $"{label} lacks {value}" );
}
}
}
private sealed class RepositorySigning : IDisposable
{
private const int ParametricRpmSigningMacroVersion = 6;
private const string RpmMacrosFileName = ".rpmmacros";
private const string RpmPassphraseFileName = "rpm-signing-passphrase";
private readonly TemporaryDirectory? _home;
private readonly string? _key;
private readonly string? _passphrase;
private readonly string? _publicArmor;
private readonly string? _publicBinary;
private readonly IReadOnlyDictionary<string, string?>? _environment;
private RepositorySigning( )
{
}
private RepositorySigning( TemporaryDirectory home, string key, string? passphrase, string armor, string binary )
{
_home = home;
_key = key;
_passphrase = passphrase;
_publicArmor = armor;
_publicBinary = binary;
_environment = new Dictionary<string, string?> { [EnvironmentVariables.GnuPgHome] = home.Path, [EnvironmentVariables.Home] = home.Path };
}
public static async Task<RepositorySigning> Create( ToolContext context, string output )
{
var encoded = context.Environment( EnvironmentVariables.GpgPrivateKeyBase64 );
if ( string.IsNullOrWhiteSpace( encoded ) )
{
await context.Error.WriteLineAsync( "[WARN] GPG_PRIVATE_KEY_B64 is not set; repositories will be unsigned." );
return new RepositorySigning( );
}
byte[] bytes;
try
{
bytes = Convert.FromBase64String( encoded );
}
catch ( FormatException error )
{
throw new ToolException( $"GPG_PRIVATE_KEY_B64 is invalid: {error.Message}" );
}
var home = new TemporaryDirectory( "wayscriber-gpg" );
if ( !OperatingSystem.IsWindows( ) )
{
File.SetUnixFileMode( home.Path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute );
}
var environment = new Dictionary<string, string?> { [EnvironmentVariables.GnuPgHome] = home.Path, [EnvironmentVariables.Home] = home.Path };
try
{
var privateKey = Path.Combine( home.Path, "repository-signing-key" );
Files.WriteAtomic( privateKey, bytes, UnixFileMode.UserRead | UnixFileMode.UserWrite );
await context.Run( Programs.Gpg, ["--batch", "--import", privateKey], environment: environment, capture: true );
var listed = await context.Run( Programs.Gpg, ["--batch", "--list-secret-keys", "--with-colons"], environment: environment, capture: true );
var detected = listed.StandardOutput.Split( '\n' ).Select( line => line.Split( ':' ) )
.FirstOrDefault( fields => fields.Length > GpgSecretKeyIdFieldIndex && fields[0] == "sec" )?[GpgSecretKeyIdFieldIndex];
var key = context.Environment( EnvironmentVariables.GpgKeyId ) ?? detected;
if ( string.IsNullOrWhiteSpace( key ) )
{
throw new ToolException( "Failed to detect imported GPG key id." );
}
var armor = Path.Combine( output, PublicKeyFileName );
var binary = Path.Combine( output, "WAYSCRIBER-GPG-KEY.gpg" );
await context.Run( Programs.Gpg, ["--batch", "--yes", "--armor", "--output", armor, "--export", key], environment: environment );
await context.Run( Programs.Gpg, ["--batch", "--yes", "--output", binary, "--export", key], environment: environment );
if ( context.Environment( EnvironmentVariables.SignRpms ) != EnvironmentVariables.Disabled )
{
var which = await context.Run( Programs.Which, [Programs.Gpg], environment: environment, capture: true );
var rpmVersion = await context.Run( Programs.Rpm, [CommandLineOptions.Version], environment: environment, capture: true );
var rpmMajorVersion = ParseRpmMajorVersion( rpmVersion.StandardOutput );
var passphraseFile = Path.Combine( home.Path, RpmPassphraseFileName );
Files.WriteAtomic( passphraseFile, (context.Environment( EnvironmentVariables.GpgPassphrase ) ?? string.Empty) + "\n",
UnixFileMode.UserRead | UnixFileMode.UserWrite );
var macros = CreateRpmMacros( rpmMajorVersion, key, home.Path, which.StandardOutput.Trim( ), passphraseFile );
Files.WriteAtomic( Path.Combine( home.Path, RpmMacrosFileName ), macros );
}
await context.Error.WriteLineAsync( $"[build-repos] Using GPG key: {key}" );
return new RepositorySigning( home, key, context.Environment( EnvironmentVariables.GpgPassphrase ), armor, binary );
}
catch { home.Dispose( ); throw; }
}
internal static int ParseRpmMajorVersion( string output )
{
var match = Regex.Match( output, @"\b(?<major>[0-9]+)(?:\.[0-9]+)+\b" );
if ( !match.Success || !int.TryParse( match.Groups["major"].Value, out var majorVersion ) )
{
throw new ToolException( $"Cannot determine RPM version from: {output.Trim( )}" );
}
return majorVersion;
}
internal static string CreateRpmMacros( int rpmMajorVersion, string key, string home, string gpgPath, string passphraseFile )
{
var common = $"%_signature gpg\n%_gpg_name {key}\n%_gpg_path {home}\n%__gpg {gpgPath}\n";
if ( rpmMajorVersion >= ParametricRpmSigningMacroVersion )
{
return common + $"%_openpgp_sign_id {key}\n%_gpg_sign_cmd_extra_args --batch --pinentry-mode loopback --passphrase-file {passphraseFile}\n";
}
return common + $"%__gpg_sign_cmd %{{__gpg}} --batch --pinentry-mode loopback --passphrase-file {passphraseFile} --no-armor " +
"--detach-sign --sign --local-user \"%{_gpg_name}\" --output %{__signature_filename} %{__plaintext_filename}\n";
}
public void CopyPublicKeys( string destination )
{
if ( _publicArmor is null || _publicBinary is null )
{
return;
}
File.Copy( _publicArmor, Path.Combine( destination, Path.GetFileName( _publicArmor ) ), true );
File.Copy( _publicBinary, Path.Combine( destination, Path.GetFileName( _publicBinary ) ), true );
}
public void CopyRpmPublicKeys( string destination )
{
if ( _publicArmor is null )
{
return;
}
File.Copy( _publicArmor, Path.Combine( destination, "RPM-GPG-KEY-wayscriber.asc" ), true );
File.Copy( _publicArmor, Path.Combine( destination, "RPM-GPG-KEY-wayscriber" ), true );
}
public async Task SignRpm( ToolContext context, string rpm )
{
if ( _key is null )
{
return;
}
await context.Run( Programs.RpmSign, ["--addsign", rpm], environment: _environment );
}
public async Task SignFile( ToolContext context, string source, string destination, bool armor = false, bool clearSign = false )
{
if ( _key is null )
{
return;
}
var arguments = new List<string> { "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "0", "--local-user", _key };
if ( clearSign )
{
arguments.Add( "--clearsign" );
}
else
{
arguments.Add( "--detach-sign" );
if ( armor )
{
arguments.Add( "--armor" );
}
}
arguments.AddRange( ["--output", destination, source] );
await context.Run( Programs.Gpg, arguments, environment: _environment, input: (_passphrase ?? string.Empty) + "\n" );
}
public void Dispose( ) => _home?.Dispose( );
}
}
File diff suppressed because it is too large. Load diff
+224
View File
@@ -0,0 +1,224 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Wayscriber.Tools;
internal static class ReportCommands
{
private const int MaximumFunctionLines = 120;
private const int LargeFileLineCount = 500;
private const int OffsetNotFound = -1;
public static IReadOnlyList<ToolCommand> Commands
{
get;
} =
[
new( CommandAreas.Report, CommandNames.CodeHealth, "Report Rust maintainability metrics.", SideEffect.ReadOnly, CodeHealth ),
];
private static async Task<int> CodeHealth( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var outputPath = parsed.TakeOption( "--output" );
var githubSummary = parsed.TakeFlag( "--github-summary" );
parsed.RequireEmpty( "report code-health [--output FILE] [--github-summary]" );
var git = await context.Run( Programs.Git, ["ls-files", "-co", "--exclude-standard", CommandLineOptions.EndOfOptions, "*.rs"], capture: true, trace: false );
var paths = git.StandardOutput.Split( '\n', StringSplitOptions.RemoveEmptyEntries ).Distinct( )
.Where( relative => File.Exists( context.Path( relative.Split( '/' ) ) ) )
.ToArray( );
var files = new List<(int Lines, string Path)>( );
var functions = new List<(int Lines, string Path, int Line, string Name)>( );
var directWrites = new List<string>( );
var counts = new Dictionary<string, int> { ["unwrap"] = 0, ["expect"] = 0, ["panic"] = 0, ["unsafe"] = 0 };
var allowDead = 0;
var allowUnused = 0;
var total = 0;
foreach ( var relative in paths )
{
var text = Files.Read( context.Path( relative.Split( '/' ) ) );
var lines = text.Length == 0 ? 0 : text.Count( character => character == '\n' ) + (text.EndsWith( '\n' ) ? 0 : 1);
total += lines;
files.Add( (lines, relative) );
var code = ConfigWriterAudit.StripRustCommentsAndStrings( text );
foreach ( Match match in Regex.Matches( code, @"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>{;]*>)?\s*\(" ) )
{
var opening = FindBody( code, match.Index + match.Length );
if ( opening < 0 )
{
continue;
}
var end = FindBlockEnd( code, opening );
if ( end < 0 )
{
continue;
}
var startLine = LineAt( code, match.Index );
var endLine = LineAt( code, end );
if ( endLine - startLine + 1 > MaximumFunctionLines )
{
functions.Add( (endLine - startLine + 1, relative, startLine, match.Groups[1].Value) );
}
}
allowDead += Regex.Matches( text, @"#\s*\[\s*allow\s*\([^)]*\bdead_code\b[^)]*\)\s*\]" ).Count;
allowUnused += Regex.Matches( text, @"#\s*\[\s*allow\s*\([^)]*\bunused_imports\b[^)]*\)\s*\]" ).Count;
if ( IsTestPath( relative ) )
{
continue;
}
var production = ConfigWriterAudit.RemoveCfgTestBlocks( code );
counts["unwrap"] += Regex.Matches( production, @"\.\s*unwrap\s*\(" ).Count;
counts["expect"] += Regex.Matches( production, @"\.\s*expect\s*\(" ).Count;
counts["panic"] += Regex.Matches( production, @"\bpanic\s*!" ).Count;
counts["unsafe"] += Regex.Matches( production, @"\bunsafe\b" ).Count;
if ( Regex.IsMatch( production, @"(?<![\w:])(?:std::fs::write|fs::write)\s*\(" ) )
{
directWrites.Add( relative );
}
}
files.Sort( ( left, right ) =>
{
if ( right.Lines != left.Lines )
{
return right.Lines.CompareTo( left.Lines );
}
else
{
return string.CompareOrdinal( right.Path, left.Path );
}
} );
functions.Sort( ( left, right ) =>
{
var comparison = right.Lines.CompareTo( left.Lines );
if ( comparison != 0 )
{
return comparison;
}
comparison = right.Line.CompareTo( left.Line );
if ( comparison != 0 )
{
return comparison;
}
comparison = string.CompareOrdinal( right.Path, left.Path );
return comparison != 0 ? comparison : string.CompareOrdinal( right.Name, left.Name );
} );
directWrites.Sort( StringComparer.Ordinal );
var report = new StringBuilder( );
report.AppendLine( "report=wayscriber-code-health" ).AppendLine( "status=ok" ).AppendLine( $"repo_root={context.RepositoryRoot}" )
.AppendLine( $"rust_files={paths.Length}" ).AppendLine( $"rust_physical_lines={total}" )
.AppendLine( $"files_over_{LargeFileLineCount}={files.Count( item => item.Lines > LargeFileLineCount )}" )
.AppendLine( $"functions_over_120={functions.Count}" ).AppendLine( $"production_unwrap={counts["unwrap"]}" ).AppendLine( $"production_expect={counts["expect"]}" )
.AppendLine( $"production_panic={counts["panic"]}" ).AppendLine( $"production_unsafe={counts["unsafe"]}" ).AppendLine( $"allow_dead_code={allowDead}" )
.AppendLine( $"allow_unused_imports={allowUnused}" ).AppendLine( $"direct_fs_write_files={directWrites.Count}" ).AppendLine( "read_errors=0" );
var filesOverLimit = files.Where( item => item.Lines > LargeFileLineCount ).Select( item => $"{item.Lines}\t{item.Path}" );
Section( report, $"files_over_{LargeFileLineCount}", filesOverLimit );
Section( report, "functions_over_120", functions.Select( item => $"{item.Lines}\t{item.Path}:{item.Line}\t{item.Name}" ) );
Section( report, "direct_fs_write_files", directWrites );
Section( report, "read_errors", [] );
if ( outputPath is not null )
{
Files.WriteAtomic( Path.GetFullPath( outputPath, context.RepositoryRoot ), report.ToString( ) );
}
else
{
await context.Output.WriteAsync( report.ToString( ) );
}
if ( githubSummary )
{
var headline = string.Join( '\n', report.ToString( ).Split( '\n' ).TakeWhile( line => line.Length > 0 ) );
if ( context.Environment( EnvironmentVariables.GitHubStepSummary ) is { Length: > 0 } summaryPath )
{
await File.AppendAllTextAsync( summaryPath, $"### Wayscriber code health\n\n```text\n{headline}\n```\n", context.CancellationToken );
}
else
{
await context.Output.WriteLineAsync( headline );
}
}
return ExitCodes.Success;
}
private static void Section( StringBuilder report, string name, IEnumerable<string> rows )
{
report.AppendLine( ).AppendLine( name + ":" );
var any = false;
foreach ( var row in rows )
{
report.AppendLine( " " + row );
any = true;
}
if ( !any )
{
report.AppendLine( " none" );
}
}
private static bool IsTestPath( string path )
{
var parts = path.Split( '/' );
var name = Path.GetFileName( path );
return parts.Contains( "tests" ) || name is "tests.rs" or "test_helpers.rs" or "test_support.rs" || name.StartsWith( "test_" ) || name.EndsWith( "_tests.rs" );
}
private static int LineAt( string text, int offset ) => text[..Math.Min( offset, text.Length )].Count( character => character == '\n' ) + 1;
private static int FindBody( string code, int start )
{
var parens = 1;
var brackets = 0;
for ( var index = start; index < code.Length; index++ )
{
switch ( code[index] )
{
case '(':
parens++;
break;
case ')':
parens--;
break;
case '[':
brackets++;
break;
case ']':
brackets--;
break;
case '{' when parens == 0 && brackets == 0:
return index;
case ';' when parens == 0 && brackets == 0:
return OffsetNotFound;
}
}
return OffsetNotFound;
}
private static int FindBlockEnd( string code, int opening )
{
var depth = 0;
for ( var index = opening; index < code.Length; index++ )
{
if ( code[index] == '{' )
{
depth++;
}
else if ( code[index] == '}' && --depth == 0 )
{
return index;
}
}
return OffsetNotFound;
}
}
+351
View File
@@ -0,0 +1,351 @@
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Wayscriber.Tools;
internal sealed record ReleaseVersion( int Major, int Minor, int Patch, int? Hotfix )
{
private const string MajorGroup = "major";
private const string MinorGroup = "minor";
private const string PatchGroup = "patch";
private const string HotfixGroup = "hotfix";
public string CargoVersion => $"{Major}.{Minor}.{Patch}";
public bool IsHotfix => Hotfix is not null;
public ReleaseVersion NextPatch( ) => new( Major, Minor, checked(Patch + 1), null );
public override string ToString( ) => Hotfix is null ? CargoVersion : $"{CargoVersion}.{Hotfix}";
public static ReleaseVersion Parse( string value )
{
var match = Regex.Match( value,
$@"^(?<{MajorGroup}>\d+)\.(?<{MinorGroup}>\d+)\.(?<{PatchGroup}>\d+)(?:\.(?<{HotfixGroup}>\d+))?$" );
if ( !match.Success )
{
throw new ToolException( $"invalid version format: {value} (expected MAJOR.MINOR.PATCH[.HOTFIX])", ExitCodes.InvalidArguments );
}
return new( int.Parse( match.Groups[MajorGroup].Value ), int.Parse( match.Groups[MinorGroup].Value ),
int.Parse( match.Groups[PatchGroup].Value ),
match.Groups[HotfixGroup].Success ? int.Parse( match.Groups[HotfixGroup].Value ) : null );
}
}
internal static class VersionCommands
{
private const string WorkflowRunnerGroup = "runner";
internal const string SupportedLibadwaitaFloor = "1.4";
internal const string ToolSdkVersion = "11.0.100-rc.1.26425.128";
internal const string ToolSdkRollForward = "disable";
public static IReadOnlyList<ToolCommand> Commands
{
get;
} =
[
new(CommandAreas.Version, CommandNames.Check, "Check release and package metadata.", SideEffect.ReadOnly, Check),
new(CommandAreas.Version, CommandNames.Bump, "Update Cargo and package versions atomically.", SideEffect.FixtureMutating, Bump),
];
internal static string ReadCargoVersion( string path )
{
var package = Regex.Match( Files.Read( path ), @"(?ms)^\[package\]\s*(.*?)(?:^\[|\z)" );
var version = package.Success ? Regex.Match( package.Groups[1].Value, "(?m)^version\\s*=\\s*\"([^\"]+)\"" ) : Match.Empty;
if ( !version.Success )
{
throw new ToolException( $"{path}: missing [package] version" );
}
return version.Groups[1].Value;
}
private static Task<int> Check( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var releaseText = parsed.TakeOption( "--release-version" );
parsed.RequireEmpty( "version check [--release-version X.Y.Z[.N]]" );
var errors = Validate( context.RepositoryRoot, releaseText );
if ( errors.Count > 0 )
{
throw new ToolException( "Version consistency check failed:\n" + string.Join( '\n', errors.Select( error => $"- {error}" ) ) );
}
var cargo = ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) );
var package = ReadAssignment( Files.Read( context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.PackageBuildFile ) ),
"pkgver" )!;
context.Output.WriteLine(
$"Version consistency OK: Cargo={cargo}, packaging={package}, checksum=SKIP, libadwaita={SupportedLibadwaitaFloor}" );
return Task.FromResult( ExitCodes.Success );
}
internal static List<string> Validate( string root, string? releaseText = null )
{
var errors = new List<string>( );
string Read( params string[] parts ) => Files.Read( Path.Combine( [root, .. parts] ) );
ValidateToolSdk( Read, errors );
var cargo = ReadCargoVersion( Path.Combine( root, RepositoryPaths.CargoManifest ) );
ValidateConfiguratorVersion( root, Read, cargo, errors );
ValidatePackageVersions( Read, cargo, releaseText, errors );
ValidateFlakeAndReadme( Read, errors );
return errors;
}
private static void ValidateToolSdk( Func<string[], string> read, List<string> errors )
{
try
{
using var globalJson = JsonDocument.Parse( read( ["global.json"] ) );
RequireEqual( errors, "global.json SDK", globalJson.RootElement.GetProperty( "sdk" ).GetProperty( "version" ).GetString( ),
ToolSdkVersion );
var sdk = globalJson.RootElement.GetProperty( "sdk" );
if ( !sdk.TryGetProperty( "rollForward", out var rollForward ) || rollForward.GetString( ) != ToolSdkRollForward )
{
errors.Add( "global.json SDK rollForward must be disable" );
}
if ( !sdk.TryGetProperty( "allowPrerelease", out var allowPrerelease ) || allowPrerelease.ValueKind != JsonValueKind.True )
{
errors.Add( "global.json SDK allowPrerelease must be true" );
}
}
catch ( Exception error ) when ( error is JsonException or KeyNotFoundException or InvalidOperationException )
{
errors.Add( $"global.json SDK metadata is invalid: {error.Message}" );
}
}
private static void ValidateConfiguratorVersion( string root, Func<string[], string> read, string cargo, List<string> errors )
{
var configurator = ReadCargoVersion( Path.Combine( root, RepositoryPaths.ConfiguratorCargoManifest ) );
RequireEqual( errors, RepositoryPaths.ConfiguratorCargoManifest, configurator, cargo );
var configManifest = read( [RepositoryPaths.ConfiguratorCargoManifest] );
var featureMatch = Regex.Match( configManifest, @"(?m)^libadwaita\s*=\s*\{[^\n]*features\s*=\s*\[([^]]*)\]" );
var features = featureMatch.Success
? Regex.Matches( featureMatch.Groups[1].Value, "\"([^\"]+)\"" ).Select( item => item.Groups[1].Value ).ToArray( )
: [];
var expectedFeature = "v" + SupportedLibadwaitaFloor.Replace( '.', '_' );
if ( !features.SequenceEqual( [expectedFeature] ) )
{
errors.Add(
$"configurator/Cargo.toml libadwaita features: expected ['{expectedFeature}'], got [{string.Join( ", ", features )}]" );
}
var floors = new Dictionary<string, string?>
{
["configurator deb libadwaita floor"] =
MatchOne( read( [RepositoryPaths.PackagingDirectory, "package.configurator.yaml"] ),
@"(?m)^\s*-\s*libadwaita-1-0 \(>= ([0-9]+\.[0-9]+)\)\s*$", errors, "configurator deb libadwaita floor" ),
["configurator rpm libadwaita floor"] =
MatchOne( read( [RepositoryPaths.PackagingDirectory, "package.configurator.yaml"] ),
@"(?m)^\s*-\s*libadwaita >= ([0-9]+\.[0-9]+)\s*$", errors, "configurator rpm libadwaita floor" ),
["packaging/PKGBUILD libadwaita floor"] =
MatchOne( read( [RepositoryPaths.PackagingDirectory, RepositoryNames.PackageBuildFile] ),
@"(?m)^\s*'libadwaita>=([0-9]+\.[0-9]+)'\s*$", errors, "packaging/PKGBUILD libadwaita floor" ),
["packaging/.SRCINFO libadwaita floor"] =
MatchOne( read( [RepositoryPaths.PackagingDirectory, RepositoryNames.SourceInfoFile] ),
@"(?m)^\s*depends = libadwaita>=([0-9]+\.[0-9]+)\s*$", errors, "packaging/.SRCINFO libadwaita floor" ),
};
foreach ( var pair in floors.Where( pair => pair.Value is not null ) )
{
RequireEqual( errors, pair.Key, pair.Value, SupportedLibadwaitaFloor );
}
var workflow = read( [".github", "workflows", "build-packages.yml"] );
var job = Regex.Match( workflow, @"(?ms)^ package:\s*$.*?(?=^ [A-Za-z0-9_-]+:\s*$|\z)" );
var runner = job.Success ? Regex.Match( job.Value, $@"(?m)^ runs-on:\s*(?<{WorkflowRunnerGroup}>[^#\n]+?)\s*$" ) : Match.Empty;
if ( !runner.Success )
{
errors.Add( ".github/workflows/build-packages.yml package job: expected one literal runs-on value" );
}
else if ( runner.Groups[WorkflowRunnerGroup].Value != PackagingPlatform.UbuntuRunner )
{
errors.Add( $"release package runner libadwaita floor: no reviewed contract for {runner.Groups[WorkflowRunnerGroup].Value}" );
}
}
private static void ValidatePackageVersions( Func<string[], string> read, string cargo, string? releaseText, List<string> errors )
{
RequireEqual( errors, "Cargo.lock wayscriber", LockVersion( read( [RepositoryPaths.CargoLock] ), RepositoryNames.MainPackage ),
cargo );
RequireEqual( errors, "Cargo.lock wayscriber-configurator",
LockVersion( read( [RepositoryPaths.CargoLock] ), RepositoryNames.ConfiguratorPackage ), cargo );
var baseVersion = ReleaseVersion.Parse( cargo );
ReleaseVersion? release = releaseText is null ? null : ReleaseVersion.Parse( releaseText );
if ( release is not null && release.CargoVersion != cargo )
{
errors.Add( $"release version {release} must equal Cargo version {cargo} or be a hotfix of it, such as {cargo}.1" );
}
var pkgbuildVersion = ReadAssignment( read( [RepositoryPaths.PackagingDirectory, RepositoryNames.PackageBuildFile] ), "pkgver" );
var srcinfoVersion = Regex
.Match( read( [RepositoryPaths.PackagingDirectory, RepositoryNames.SourceInfoFile] ), @"(?m)^\s*pkgver = (.+)$" ).Groups[1]
.Value.Trim( );
var expectedPackage = release?.ToString( ) ??
(pkgbuildVersion is not null &&
Regex.IsMatch( pkgbuildVersion, $@"^{Regex.Escape( baseVersion.CargoVersion )}\.\d+$" )
? pkgbuildVersion
: cargo);
RequireEqual( errors, "packaging/PKGBUILD pkgver", pkgbuildVersion, expectedPackage );
RequireEqual( errors, "packaging/.SRCINFO pkgver", srcinfoVersion, expectedPackage );
CheckTemplateChecksum( errors, "packaging/PKGBUILD sha256sums",
Regex.Match( read( [RepositoryPaths.PackagingDirectory, RepositoryNames.PackageBuildFile] ), @"(?ms)^sha256sums=\((.*?)\)" )
.Groups[1].Value );
CheckTemplateChecksum( errors, "packaging/.SRCINFO sha256sums",
string.Join( ' ',
Regex.Matches( read( [RepositoryPaths.PackagingDirectory, RepositoryNames.SourceInfoFile] ), @"(?m)^\s*sha256sums = (.+)$" )
.Select( item => item.Groups[1].Value ) ) );
}
private static void ValidateFlakeAndReadme( Func<string[], string> read, List<string> errors )
{
var flake = read( ["flake.nix"] );
if ( !flake.Contains( "builtins.fromTOML (builtins.readFile ./Cargo.toml)", StringComparison.Ordinal ) )
{
errors.Add( "flake.nix package version should be derived from Cargo.toml" );
}
if ( !(flake.Contains( "package.rust-version" ) && flake.Contains( "rustToolchain.version" ) &&
flake.Contains( "versionAtLeast" )) )
{
errors.Add( "flake.nix should compare selected rustc against Cargo.toml rust-version" );
}
var readme = read( ["README.md"] );
foreach ( Match match in Regex.Matches( readme,
@"wayscriber\?ref=v?\d+\.\d+\.\d+(?:\.\d+)?|/releases/(?:tag|download)/v?\d+\.\d+\.\d+(?:\.\d+)?" ) )
{
errors.Add( $"README.md: pinned release reference '{match.Value}' goes stale on the next release" );
}
}
private static async Task<int> Bump( ToolContext context, string[] args )
{
var parsed = new Arguments( args );
var dryRun = parsed.TakeFlag( "--dry-run" );
if ( parsed.Remaining.Count > 1 )
{
throw new ToolException( "version bump [--dry-run] [X.Y.Z[.N]]", ExitCodes.InvalidArguments );
}
var current = ReleaseVersion.Parse( ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) ) );
var next = parsed.Remaining.Count == 1 ? ReleaseVersion.Parse( parsed.Remaining[0] ) : current.NextPatch( );
await context.Output.WriteLineAsync( $"Current version: {current.CargoVersion}" );
await context.Output.WriteLineAsync( $"Bumping to: {next}" );
if ( next.IsHotfix )
{
await context.Output.WriteLineAsync( $"Cargo version: {next.CargoVersion} (release version has hotfix)" );
}
if ( File.Exists( context.Path( RepositoryPaths.CargoLock ) ) )
{
await context.Run( Programs.Cargo, ["update", CommandLineOptions.Workspace, "--offline", "--dry-run"], capture: true );
}
var outputs = new AtomicFileSet( );
foreach ( var relative in new[] { RepositoryPaths.CargoManifest, RepositoryPaths.ConfiguratorCargoManifest } )
{
var path = context.Path( relative.Split( '/' ) );
outputs.Add( path,
Files.ReplaceSingle( Files.Read( path ), """(?ms)(^\[package\]\s*.*?^version\s*=\s*")[^"]+(")""",
$"${{1}}{next.CargoVersion}$2", relative ) );
}
var pkgbuildPath = context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.PackageBuildFile );
var pkgbuild = Files.ReplaceSingle( Files.Read( pkgbuildPath ), @"(?m)^pkgver=.*$", $"pkgver={next}", "packaging/PKGBUILD pkgver" );
pkgbuild = Files.ReplaceSingle( pkgbuild, "(?m)^sha256sums=.*$", "sha256sums=('SKIP')", "packaging/PKGBUILD checksum" );
outputs.Add( pkgbuildPath, pkgbuild );
if ( dryRun )
{
await context.Output.WriteLineAsync(
"dry-run: would update Cargo.toml, configurator/Cargo.toml, Cargo.lock, packaging/PKGBUILD, and packaging/.SRCINFO" );
await context.Output.WriteLineAsync( "Dry run complete (no changes made)" );
return ExitCodes.Success;
}
var paths = new[]
{
context.Path( RepositoryPaths.CargoManifest ), context.Path( RepositoryPaths.ConfiguratorCargoManifest ),
context.Path( RepositoryPaths.CargoLock ), pkgbuildPath,
context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.SourceInfoFile )
};
var snapshots = paths.ToDictionary( path => path, path => File.Exists( path ) ? File.ReadAllBytes( path ) : null,
StringComparer.Ordinal );
try
{
outputs.Commit( );
if ( File.Exists( context.Path( RepositoryPaths.CargoLock ) ) )
{
await context.Run( Programs.Cargo, ["update", CommandLineOptions.Workspace, "--offline"] );
}
var srcinfo = await context.Run( Programs.Makepkg, ["--printsrcinfo"], context.Path( RepositoryPaths.PackagingDirectory ),
capture: true );
Files.WriteAtomic( context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.SourceInfoFile ), srcinfo.StandardOutput );
var errors = Validate( context.RepositoryRoot, next.ToString( ) );
if ( errors.Count > 0 )
{
throw new ToolException( string.Join( '\n', errors ) );
}
}
catch
{
foreach ( var pair in snapshots )
{
if ( pair.Value is null )
{
File.Delete( pair.Key );
}
else
{
await File.WriteAllBytesAsync( pair.Key, pair.Value );
}
}
throw;
}
await context.Output.WriteLineAsync( $"Updated versions to {next}" );
return ExitCodes.Success;
}
private static string? MatchOne( string text, string pattern, List<string> errors, string label )
{
var matches = Regex.Matches( text, pattern );
if ( matches.Count != 1 )
{
errors.Add( $"{label}: expected one libadwaita floor, found {matches.Count}" );
return null;
}
return matches[0].Groups[1].Value;
}
private static string? LockVersion( string text, string package )
{
var match = Regex.Match( text,
"(?ms)\\[\\[package\\]\\]\\s+name\\s*=\\s*\"" + Regex.Escape( package ) + "\"\\s+version\\s*=\\s*\"([^\"]+)\"" );
return match.Success ? match.Groups[1].Value : null;
}
private static string? ReadAssignment( string text, string name )
{
var match = Regex.Match( text, $@"(?m)^{Regex.Escape( name )}=(.+)$" );
return match.Success ? match.Groups[1].Value.Trim( ) : null;
}
private static void RequireEqual( List<string> errors, string label, string? actual, string expected )
{
if ( actual != expected )
{
errors.Add( $"{label}: expected {expected}, got {actual ?? "missing"}" );
}
}
private static void CheckTemplateChecksum( List<string> errors, string label, string value )
{
var values = Regex.Matches( value, @"[A-Za-z0-9]+" ).Select( match => match.Value ).ToArray( );
if ( !values.SequenceEqual( [EnvironmentVariables.Skip] ) )
{
errors.Add( $"{label}: expected SKIP template checksum, got {(values.Length == 0 ? "missing" : string.Join( ", ", values ))}" );
}
}
}
+134
View File
@@ -0,0 +1,134 @@
using System.Globalization;
namespace Wayscriber.Tools;
internal enum SideEffect
{
ReadOnly,
FixtureMutating,
MachineMutating,
RemoteMutating,
ForegroundSensitive,
}
internal sealed record ToolCommand(
string Area,
string Name,
string Description,
SideEffect SideEffect,
Func<ToolContext, string[], Task<int>> Handler );
internal sealed record ToolContext(
string RepositoryRoot,
TextWriter Output,
TextWriter Error,
IProcessRunner Processes,
CancellationToken CancellationToken,
Func<string, string?>? EnvironmentReader = null )
{
public string Path( params string[] parts ) =>
System.IO.Path.GetFullPath( System.IO.Path.Combine( [RepositoryRoot, .. parts] ) );
public string? Environment( string name )
{
var value = EnvironmentReader is null
? System.Environment.GetEnvironmentVariable( name )
: EnvironmentReader( name );
return string.IsNullOrWhiteSpace( value ) ? null : value;
}
}
internal sealed class ToolException( string message, int exitCode = ExitCodes.Failure ) : Exception( message )
{
public int ExitCode { get; } = exitCode;
}
internal static class RepositoryLocator
{
public static string Locate( )
{
var entryDirectory = AppContext.GetData( "EntryPointFileDirectoryPath" ) as string;
if ( string.IsNullOrWhiteSpace( entryDirectory ) )
{
var entryPath = AppContext.GetData( "EntryPointFilePath" ) as string;
entryDirectory = entryPath is null ? null : System.IO.Path.GetDirectoryName( entryPath );
}
if ( entryDirectory is null )
{
throw new ToolException( "Cannot determine the C# entry-point directory." );
}
var root = System.IO.Path.GetFullPath( System.IO.Path.Combine( entryDirectory, ".." ) );
if ( !File.Exists( System.IO.Path.Combine( root, RepositoryPaths.CargoManifest ) ) ||
!File.Exists( System.IO.Path.Combine( root, RepositoryPaths.ToolsDirectory, RepositoryNames.ToolEntryFile ) ) )
{
throw new ToolException( $"The entry point is not inside a Wayscriber repository: {entryDirectory}" );
}
return root;
}
}
internal sealed class Arguments( IEnumerable<string> values )
{
private const int OptionTokenCount = 2;
private readonly List<string> _values = [.. values];
public IReadOnlyList<string> Remaining => _values;
public bool TakeFlag( string name ) => _values.Remove( name );
public string? TakeOption( string name, bool required = false )
{
var index = _values.IndexOf( name );
if ( index < 0 )
{
if ( required )
{
throw new ToolException( $"{name} requires a value.", ExitCodes.InvalidArguments );
}
return null;
}
if ( index + 1 >= _values.Count )
{
throw new ToolException( $"{name} requires a value.", ExitCodes.InvalidArguments );
}
var result = _values[index + 1];
_values.RemoveRange( index, OptionTokenCount );
return result;
}
public string SinglePositional( string usage )
{
if ( _values.Count != 1 )
{
throw new ToolException( usage, ExitCodes.InvalidArguments );
}
return _values[0];
}
public void RequireEmpty( string usage )
{
if ( _values.Count != 0 )
{
throw new ToolException( $"{usage}\nUnexpected argument: {_values[0]}", ExitCodes.InvalidArguments );
}
}
public int TakeIntOption( string name, int fallback )
{
var text = TakeOption( name );
if ( text is null )
{
return fallback;
}
if ( !int.TryParse( text, NumberStyles.None, CultureInfo.InvariantCulture, out var value ) )
{
throw new ToolException( $"{name} must be an integer.", ExitCodes.InvalidArguments );
}
return value;
}
}
+161
View File
@@ -0,0 +1,161 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace Wayscriber.Tools;
internal sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory( string prefix = "wayscriber-tools" )
{
Path = System.IO.Path.Combine( System.IO.Path.GetTempPath( ), $"{prefix}-{Guid.NewGuid( ):N}" );
Directory.CreateDirectory( Path );
if ( !OperatingSystem.IsWindows( ) )
{
File.SetUnixFileMode( Path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute );
}
}
public string Path
{
get;
}
public void Dispose( )
{
try
{
Directory.Delete( Path, recursive: true );
}
catch ( IOException )
{
}
}
}
internal static partial class Files
{
private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds( 2 );
public static string Read( string path ) => File.ReadAllText( path, Encoding.UTF8 );
public static void WriteAtomic( string path, string content, UnixFileMode? mode = null )
{
var directory = System.IO.Path.GetDirectoryName( path );
if ( directory is null )
{
throw new ToolException( $"No parent directory for {path}." );
}
Directory.CreateDirectory( directory );
var temporary = System.IO.Path.Combine( directory, $".{System.IO.Path.GetFileName( path )}.{Guid.NewGuid( ):N}.tmp" );
try
{
File.WriteAllText( temporary, content, new UTF8Encoding( encoderShouldEmitUTF8Identifier: false ) );
if ( !OperatingSystem.IsWindows( ) && mode is not null )
{
File.SetUnixFileMode( temporary, mode.Value );
}
else if ( File.Exists( path ) && !OperatingSystem.IsWindows( ) )
{
File.SetUnixFileMode( temporary, File.GetUnixFileMode( path ) );
}
File.Move( temporary, path, overwrite: true );
}
finally
{
File.Delete( temporary );
}
}
public static void WriteAtomic( string path, ReadOnlySpan<byte> content, UnixFileMode? mode = null )
{
var directory = System.IO.Path.GetDirectoryName( path );
if ( directory is null )
{
throw new ToolException( $"No parent directory for {path}." );
}
Directory.CreateDirectory( directory );
var temporary = System.IO.Path.Combine( directory, $".{System.IO.Path.GetFileName( path )}.{Guid.NewGuid( ):N}.tmp" );
try
{
File.WriteAllBytes( temporary, content.ToArray( ) );
if ( !OperatingSystem.IsWindows( ) && mode is not null )
{
File.SetUnixFileMode( temporary, mode.Value );
}
else if ( File.Exists( path ) && !OperatingSystem.IsWindows( ) )
{
File.SetUnixFileMode( temporary, File.GetUnixFileMode( path ) );
}
File.Move( temporary, path, overwrite: true );
}
finally { File.Delete( temporary ); }
}
public static string Sha256( string path )
{
using var stream = File.OpenRead( path );
return Convert.ToHexStringLower( SHA256.HashData( stream ) );
}
public static string RequireSingleMatch( string text, string pattern, string label, RegexOptions options = RegexOptions.Multiline )
{
var matches = Regex.Matches( text, pattern, options );
if ( matches.Count != 1 )
{
throw new ToolException( $"{label}: expected one match, found {matches.Count}." );
}
return matches[0].Groups.Count > 1 ? matches[0].Groups[1].Value : matches[0].Value;
}
public static string ReplaceSingle( string text, string pattern, string replacement, string label, RegexOptions options = RegexOptions.Multiline )
{
var matches = Regex.Matches( text, pattern, options );
if ( matches.Count != 1 )
{
throw new ToolException( $"{label}: expected one replacement target, found {matches.Count}." );
}
return Regex.Replace( text, pattern, replacement, options, RegexTimeout );
}
}
internal sealed class AtomicFileSet
{
private readonly Dictionary<string, string> _outputs = new( StringComparer.Ordinal );
public void Add( string path, string content ) => _outputs.Add( System.IO.Path.GetFullPath( path ), content );
public void Commit( )
{
var originals = _outputs.Keys.ToDictionary(
path => path,
path => File.Exists( path ) ? File.ReadAllBytes( path ) : null,
StringComparer.Ordinal );
var committed = new List<string>( );
try
{
foreach ( var pair in _outputs )
{
Files.WriteAtomic( pair.Key, pair.Value );
committed.Add( pair.Key );
}
}
catch
{
foreach ( var path in committed.AsEnumerable( ).Reverse( ) )
{
if ( originals[path] is { } bytes )
{
File.WriteAllBytes( path, bytes );
}
else
{
File.Delete( path );
}
}
throw;
}
}
}
@@ -0,0 +1,156 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
namespace Wayscriber.Tools;
internal sealed record ProcessRequest(
string FileName,
IReadOnlyList<string> Arguments,
string WorkingDirectory,
IReadOnlyDictionary<string, string?>? Environment = null,
bool CaptureOutput = false,
string? StandardInput = null,
bool Trace = true,
IReadOnlySet<int>? AllowedExitCodes = null );
internal sealed record ProcessResult( int ExitCode, string StandardOutput, string StandardError )
{
public bool IsSuccess => ExitCode == ExitCodes.Success;
}
internal interface IProcessRunner
{
Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken );
}
internal sealed class ProcessRunner( TextWriter output, TextWriter error ) : IProcessRunner
{
private const int MaximumDiagnosticCharacters = 32_768;
public async Task<ProcessResult> RunAsync( ProcessRequest request, CancellationToken cancellationToken )
{
if ( request.Trace )
{
await output.WriteLineAsync( $"+ {FormatCommand( request.FileName, request.Arguments )}", cancellationToken );
}
using var process = new Process { StartInfo = CreateStartInfo( request ) };
try
{
if ( !process.Start( ) )
{
throw new ToolException( $"Could not start {request.FileName}.", ExitCodes.CommandNotFound );
}
Task<string>? stdout = request.CaptureOutput ? process.StandardOutput.ReadToEndAsync( cancellationToken ) : null;
Task<string>? stderr = request.CaptureOutput ? process.StandardError.ReadToEndAsync( cancellationToken ) : null;
if ( request.StandardInput is not null )
{
await process.StandardInput.WriteAsync( request.StandardInput.AsMemory( ), cancellationToken );
process.StandardInput.Close( );
}
try
{
await process.WaitForExitAsync( cancellationToken );
}
catch ( OperationCanceledException )
{
TryTerminate( process );
throw;
}
var result = new ProcessResult(
process.ExitCode,
stdout is null ? string.Empty : await stdout,
stderr is null ? string.Empty : await stderr );
if ( request.AllowedExitCodes?.Contains( result.ExitCode ) != true && !result.IsSuccess )
{
if ( request.CaptureOutput && result.StandardError.Length > 0 )
{
await error.WriteAsync( Bound( result.StandardError ) );
}
throw new ToolException( $"{request.FileName} exited with code {result.ExitCode}.", result.ExitCode );
}
return result;
}
catch ( Win32Exception error )
{
throw new ToolException( $"Cannot start {request.FileName}: {error.Message}", ExitCodes.CommandNotFound );
}
}
private static ProcessStartInfo CreateStartInfo( ProcessRequest request )
{
var startInfo = new ProcessStartInfo
{
FileName = request.FileName,
WorkingDirectory = request.WorkingDirectory,
UseShellExecute = false,
RedirectStandardOutput = request.CaptureOutput,
RedirectStandardError = request.CaptureOutput,
RedirectStandardInput = request.StandardInput is not null,
};
foreach ( var argument in request.Arguments )
{
startInfo.ArgumentList.Add( argument );
}
if ( request.Environment is not null )
{
foreach ( var pair in request.Environment )
{
if ( pair.Value is null )
{
startInfo.Environment.Remove( pair.Key );
}
else
{
startInfo.Environment[pair.Key] = pair.Value;
}
}
}
return startInfo;
}
internal static string FormatCommand( string fileName, IReadOnlyList<string> arguments ) =>
string.Join( ' ', new[] { fileName }.Concat( arguments.Select( Quote ) ) );
private static string Quote( string value ) =>
value.Length > 0 && value.All( character => char.IsLetterOrDigit( character ) || "-._/:=@".Contains( character ) )
? value
: $"'{value.Replace( "'", "'\\''", StringComparison.Ordinal )}'";
private static string Bound( string value ) => value.Length <= MaximumDiagnosticCharacters ? value : value[^MaximumDiagnosticCharacters..];
private static void TryTerminate( Process process )
{
try
{
if ( !process.HasExited )
{
process.Kill( entireProcessTree: true );
}
}
catch ( InvalidOperationException )
{
}
}
}
internal static class ProcessExtensions
{
public static Task<ProcessResult> Run(
this ToolContext context,
string fileName,
IReadOnlyList<string> arguments,
string? workingDirectory = null,
IReadOnlyDictionary<string, string?>? environment = null,
bool capture = false,
string? input = null,
bool trace = true,
IReadOnlySet<int>? allowedExitCodes = null ) =>
context.Processes.RunAsync(
new ProcessRequest( fileName, arguments, workingDirectory ?? context.RepositoryRoot, environment, capture, input, trace, allowedExitCodes ),
context.CancellationToken );
}
@@ -0,0 +1,14 @@
namespace Wayscriber.Tools;
internal static class RepositoryNames
{
public const string MainPackage = "wayscriber";
public const string BinaryPackage = "wayscriber-bin";
public const string ConfiguratorPackage = "wayscriber-configurator";
public const string Gtk4LayerShell = "gtk4-layer-shell";
public const string PackageBuildFile = "PKGBUILD";
public const string SourceInfoFile = ".SRCINFO";
public const string UserServiceFile = "wayscriber.service";
public const string ToolEntryFile = "wayscriber.cs";
public const string ToolTestEntryFile = "wayscriber.tests.cs";
}
@@ -0,0 +1,284 @@
namespace Wayscriber.Tools;
internal static class ExitCodes
{
public const int Success = 0;
public const int Failure = 1;
public const int InvalidArguments = 2;
public const int SoftwareError = 70;
public const int CommandNotFound = 127;
public const int Canceled = 130;
}
internal static class CommandAreas
{
public const string Assets = "assets";
public const string Aur = "aur";
public const string Check = "check";
public const string ContinuousIntegration = "ci";
public const string Desktop = "desktop";
public const string Development = "dev";
public const string Elf = "elf";
public const string Install = "install";
public const string Native = "native";
public const string Package = "package";
public const string Release = "release";
public const string Report = "report";
public const string Version = "version";
}
internal static class CommandNames
{
public const string App = "app";
public const string Build = "build";
public const string BuildLinkage = "build-linkage";
public const string BuildRepositories = "build-repositories";
public const string Bump = "bump";
public const string Check = "check";
public const string CheckArchInstaller = "check-arch-installer";
public const string CheckLiveArchInstaller = "check-live-arch-installer";
public const string Clone = "clone";
public const string CodeHealth = "code-health";
public const string ConfigWriters = "config-writers";
public const string Configurator = "configurator";
public const string ConfigureGit = "configure-git";
public const string CreateTag = "create-tag";
public const string DeployPackageRepositories = "deploy-package-repositories";
public const string Emit = "emit";
public const string Fetch = "fetch";
public const string FormatAndLint = "format-and-lint";
public const string GtkWidgets = "gtk-widgets";
public const string Help = "help";
public const string InstallDependencies = "install-dependencies";
public const string InstallGtk4LayerShell = "install-gtk4-layer-shell";
public const string InstallNfpm = "install-nfpm";
public const string LegacyTools = "legacy-tools";
public const string LintAndTest = "lint-and-test";
public const string NixInstantiation = "nix-instantiation";
public const string NixpkgsRecipe = "nixpkgs-recipe";
public const string NixVersions = "nix-versions";
public const string PrepareGtk4LayerShell = "prepare-gtk4-layer-shell";
public const string PrepareSsh = "prepare-ssh";
public const string ProcessSites = "process-sites";
public const string PublishTag = "publish-tag";
public const string ReloadDaemon = "reload-daemon";
public const string RequireEnvironment = "require-environment";
public const string ResolveVersion = "resolve-version";
public const string RustSourceCoverage = "rust-source-coverage";
public const string SetPortalShortcut = "set-portal-shortcut";
public const string SharedDependencies = "shared-dependencies";
public const string SmokeUbuntu = "smoke-ubuntu";
public const string SourceChecksum = "source-checksum";
public const string Test = "test";
public const string Update = "update";
public const string VerifyArtifacts = "verify-artifacts";
public const string VerifyDynamic = "verify-dynamic";
public const string VerifyStatic = "verify-static";
}
internal static class CommandLineOptions
{
public const string AllFeatures = "--all-features";
public const string AllTargets = "--all-targets";
public const string Binaries = "--bins";
public const string ChangeDirectory = "-C";
public const string EndOfOptions = "--";
public const string Help = "--help";
public const string Locked = "--locked";
public const string NoDefaultFeatures = "--no-default-features";
public const string NoRestore = "--no-restore";
public const string Release = "--release";
public const string ShortHelp = "-h";
public const string SingleTestThread = "--test-threads=1";
public const string Version = "--version";
public const string VerifyNoChanges = "--verify-no-changes";
public const string Workspace = "--workspace";
}
internal static class PackageChannels
{
public const string Binary = "bin";
public const string Configurator = "configurator";
public const string Source = "source";
}
internal static class PackageFormats
{
public const string Apt = "apt";
public const string Debian = "deb";
public const string Rpm = "rpm";
public const string Tar = "tar";
}
internal static class AutostartModes
{
public const string Hyprland = "hyprland";
public const string None = "none";
public const string Systemd = "systemd";
}
internal static class NativeLibraryModes
{
public const string Both = "both";
public const string Dynamic = "dynamic";
public const string Shared = "shared";
public const string Static = "static";
}
internal static class InstallArguments
{
public const string DataFile = "-Dm644";
public const string ExecutableFile = "-Dm755";
}
internal static class EnvironmentVariables
{
public const string ArtifactRoot = "ARTIFACT_ROOT";
public const string AurBinaryDirectory = "AUR_BIN_DIR";
public const string AurConfiguratorDirectory = "AUR_CONFIG_DIR";
public const string AurGitEmail = "AUR_GIT_EMAIL";
public const string AurGitUserName = "AUR_GIT_USERNAME";
public const string AurSourceArchiveSha256 = "AUR_SOURCE_ARCHIVE_SHA256";
public const string AurSourceDirectory = "AUR_SOURCE_DIR";
public const string AurSshKnownHosts = "AUR_SSH_KNOWN_HOSTS";
public const string AurSshPassphrase = "AUR_SSH_PASSPHRASE";
public const string AurSshPrivateKey = "AUR_SSH_PRIVATE_KEY";
public const string DebArchitecture = "DEB_ARCH";
public const string DebComponent = "DEB_COMPONENT";
public const string DebSuite = "DEB_SUITE";
public const string DeployHost = "DEPLOY_HOST";
public const string DeployPath = "DEPLOY_PATH";
public const string DeployUser = "DEPLOY_USER";
public const string Display = "DISPLAY";
public const string Disabled = "0";
public const string Enabled = "1";
public const string Force = "force";
public const string ForceBuild = "FORCE_BUILD";
public const string Formats = "FORMATS";
public const string GdkBackend = "GDK_BACKEND";
public const string GitHubEnvironment = "GITHUB_ENV";
public const string GitHubOutput = "GITHUB_OUTPUT";
public const string GitHubRefName = "GITHUB_REF_NAME";
public const string GitHubStepSummary = "GITHUB_STEP_SUMMARY";
public const string GitSshCommand = "GIT_SSH_COMMAND";
public const string GnuPgHome = "GNUPGHOME";
public const string GpgKeyId = "GPG_KEY_ID";
public const string GpgPassphrase = "GPG_PASSPHRASE";
public const string GpgPrivateKeyBase64 = "GPG_PRIVATE_KEY_B64";
public const string Gtk4LayerShellLibraryMode = "GTK4_LAYER_SHELL_LIBRARY_MODE";
public const string Gtk4LayerShellPrefix = "GTK4_LAYER_SHELL_PREFIX";
public const string Gtk4LayerShellSystemPrefix = "GTK4_LAYER_SHELL_SYSTEM_PREFIX";
public const string GtkAccessibility = "GTK_A11Y";
public const string Home = "HOME";
public const string LibraryPath = "LD_LIBRARY_PATH";
public const string NfpmConfiguratorConfig = "NFPM_CONFIG_CONFIG";
public const string NfpmMainConfig = "NFPM_CONFIG_MAIN";
public const string OutputRoot = "OUTPUT_ROOT";
public const string Path = "PATH";
public const string PackageConfigurator = "PACKAGE_CONFIGURATOR";
public const string PackageRepositorySshKey = "PACKAGE_REPO_SSH_KEY";
public const string PackageRepositorySshKnownHosts = "PACKAGE_REPO_SSH_KNOWN_HOSTS";
public const string PackageConfigPath = "PKG_CONFIG_PATH";
public const string RepositoryLabel = "REPO_LABEL";
public const string RepositoryOrigin = "REPO_ORIGIN";
public const string RpmArchitecture = "RPM_ARCH";
public const string RunnerTemporary = "RUNNER_TEMP";
public const string SignRpms = "SIGN_RPMS";
public const string Skip = "SKIP";
public const string SkipBuild = "SKIP_BUILD";
public const string SshAgentProcessId = "SSH_AGENT_PID";
public const string SshAskpass = "SSH_ASKPASS";
public const string SshAskpassRequire = "SSH_ASKPASS_REQUIRE";
public const string SshAuthSocket = "SSH_AUTH_SOCK";
public const string SystemGtk4LayerShellLink = "SYSTEM_DEPS_GTK4_LAYER_SHELL_0_LINK";
public const string Version = "VERSION";
public const string WaylandDisplay = "WAYLAND_DISPLAY";
public const string WayscriberDataDirectory = "WAYSCRIBER_DATA_DIR";
public const string WayscriberInstallDirectory = "WAYSCRIBER_INSTALL_DIR";
public const string WayscriberReleaseVersion = "WAYSCRIBER_RELEASE_VERSION";
public const string WayscriberRequireGtkTests = "WAYSCRIBER_REQUIRE_GTK_TESTS";
public const string WayscriberSshAskpass = "WAYSCRIBER_SSH_ASKPASS";
public const string XdgConfigHome = "XDG_CONFIG_HOME";
public const string XdgRuntimeDirectory = "XDG_RUNTIME_DIR";
}
internal static class Programs
{
public const string AptFileArchive = "apt-ftparchive";
public const string AptGet = "apt-get";
public const string Cargo = "cargo";
public const string CreateRpmRepository = "createrepo_c";
public const string DbusRunSession = "dbus-run-session";
public const string Docker = "docker";
public const string Dotnet = "dotnet";
public const string DpkgDeb = "dpkg-deb";
public const string Find = "find";
public const string Git = "git";
public const string Gpg = "gpg";
public const string GtkUpdateIconCache = "gtk-update-icon-cache";
public const string Install = "install";
public const string LdConfig = "ldconfig";
public const string Makepkg = "makepkg";
public const string Meson = "meson";
public const string Nfpm = "nfpm";
public const string Nix = "nix";
public const string Nm = "nm";
public const string Pacman = "pacman";
public const string Pgrep = "pgrep";
public const string Pkill = "pkill";
public const string PkgConfig = "pkg-config";
public const string ReadElf = "readelf";
public const string Remove = "rm";
public const string Rpm = "rpm";
public const string RpmSign = "rpmsign";
public const string Rsync = "rsync";
public const string Setsid = "setsid";
public const string Ssh = "ssh";
public const string SshAdd = "ssh-add";
public const string SshAgent = "ssh-agent";
public const string SshKeyScan = "ssh-keyscan";
public const string Sleep = "sleep";
public const string Strip = "strip";
public const string Sudo = "sudo";
public const string SystemControl = "systemctl";
public const string Tar = "tar";
public const string Test = "test";
public const string UpdateDesktopDatabase = "update-desktop-database";
public const string Wayscriber = "wayscriber";
public const string Weston = "weston";
public const string Which = "which";
}
internal static class RepositoryPaths
{
public const string CargoLock = "Cargo.lock";
public const string CargoManifest = "Cargo.toml";
public const string ConfiguratorCargoManifest = "configurator/Cargo.toml";
public const string ConfiguratorDirectory = "configurator";
public const string DebugDirectory = "debug";
public const string PackagingDirectory = "packaging";
public const string ReleaseDirectory = "release";
public const string TargetDirectory = "target";
public const string ToolsDirectory = "tools";
}
internal static class ToolMessages
{
public const string Canceled = "Canceled.";
public const string HomeNotSet = "HOME is not set.";
public const string UnexpectedFailurePrefix = "wayscriber tool failed: ";
}
internal static class HashingConstants
{
public const int Sha256HexLength = 64;
public const string Sha256HexPattern = "^[0-9a-fA-F]{64}$";
}
internal static class PackagingPlatform
{
public const string MaximumGlibcVersion = "2.39";
public const string MinimumGtkVersion = "4.12";
public const string UbuntuImage = "ubuntu:24.04";
public const string UbuntuRunner = "ubuntu-24.04";
}
+14
View File
@@ -0,0 +1,14 @@
#:include Infrastructure/Core.cs
#:include Infrastructure/ToolConstants.cs
#:include Infrastructure/RepositoryNames.cs
#:include Infrastructure/ProcessRunner.cs
#:include Infrastructure/Files.cs
#:include Application/ToolApplication.cs
#:include Commands/AssetsCommand.cs
#:include Commands/DevelopmentCommands.cs
#:include Commands/ChecksCommand.cs
#:include Commands/VersionCommands.cs
#:include Commands/NativeDesktopCommands.cs
#:include Commands/PackagingCommands.cs
#:include Commands/ReleaseAurCommands.cs
#:include Commands/ReportCommands.cs
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env -S dotnet run --disable-build-servers --file
#:package YamlDotNet@16.3.0
#:include csharp/includes.cs
using Wayscriber.Tools;
if ( args is [CommandNames.Help or CommandLineOptions.ShortHelp or CommandLineOptions.Help] )
{
return await ToolApplication.RunAsync( [CommandNames.Help, CommandAreas.Install] );
}
var explicitCommand = args.Length > 0 && args[0] is CommandNames.App or CommandNames.Configurator;
var command = explicitCommand ? args[0] : CommandNames.App;
var commandArguments = explicitCommand ? args[1..] : args;
return await ToolApplication.RunAsync( [CommandAreas.Install, command, .. commandArguments] );
+44 -2
View File
@@ -15,6 +15,20 @@ run_check() {
run_check bash tools/check-version-consistency.sh
run_check bash tools/test-package-repo-layout.sh
run_check bash tools/test-release-packaging.sh
run_check bash tools/test-aur-desktop-assets.sh
if command -v dotnet >/dev/null 2>&1 && dotnet --version >/dev/null 2>&1; then
run_check dotnet build tools/wayscriber.cs --disable-build-servers --verbosity quiet
run_check dotnet build tools/install.cs --disable-build-servers --verbosity quiet
run_check dotnet build tools/wayscriber.tests.cs --disable-build-servers --verbosity quiet
for file_app in tools/wayscriber.cs tools/install.cs tools/wayscriber.tests.cs; do
run_check dotnet format style "$file_app" --no-restore --verify-no-changes
run_check dotnet format whitespace "$file_app" --no-restore --verify-no-changes
done
run_check dotnet run tools/wayscriber.tests.cs --no-build --verbosity quiet
else
printf '\nSkipping C# repository-tool checks: the SDK selected by global.json is unavailable.\n'
fi
run_check ./tools/check-nixpkgs-recipe.py
run_check ./tools/check-rust-source-coverage.py
run_check ./tools/check-process-sites.py
@@ -23,10 +37,38 @@ run_check ./tools/check-shared-dependencies.py
run_check cargo fmt --all -- --check
run_check cargo clippy --locked --workspace --all-targets --all-features -- -D warnings
run_check cargo build --locked --workspace --all-features --bins
run_check cargo test --locked --workspace --all-features
# Parallel render tests have crashed in the native font stack from context-menu,
# board-picker, and region-capture paths. Serialize the harness while that issue
# remains unresolved; tests that create their own threads still exercise them.
run_check cargo test --locked --workspace --all-features -- --test-threads=1
# Keep these regressions covered in separate processes: the parallel font tests
# can trigger an upstream Cairo/FreeType race. This does not fix that race.
run_isolated_render_test() {
local output status=0
local test_name="$2"
output="$(mktemp)"
run_check cargo test --locked -p wayscriber "$1" --lib "$test_name" -- --exact --ignored --test-threads=1 \
| tee "$output" || status=$?
if [[ "$status" -eq 0 ]] && ! grep -Fq 'test result: ok. 1 passed; 0 failed;' "$output"; then
echo "Expected exactly one passing isolated render test: $test_name ($1)" >&2
status=1
fi
rm -f "$output"
return "$status"
}
isolated_render_tests=(
ui::context_menu::engine_tests::retained_context_menu_owner_preserves_layout_pixels_and_row_hits
ui::board_picker::tests::retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layouts
)
for test_name in "${isolated_render_tests[@]}"; do
run_isolated_render_test --all-features "$test_name"
done
# Linted as strictly as the all-features build: code reachable only behind an
# optional feature leaves its callers dead without it, and building alone does
# not promote that to an error.
run_check cargo clippy --locked --workspace --all-targets --no-default-features -- -D warnings
run_check cargo build --locked --workspace --no-default-features --bins
run_check cargo test --locked --workspace --no-default-features
run_check cargo test --locked --workspace --no-default-features -- --test-threads=1
for test_name in "${isolated_render_tests[@]}"; do
run_isolated_render_test --no-default-features "$test_name"
done
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Run the release binary as a development daemon.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BINARY="${PROJECT_ROOT}/target/release/wayscriber"
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
echo "Usage: $0 [WAYSCRIBER_OPTIONS...]"
echo "Run target/release/wayscriber in daemon mode with RUST_LOG=info by default."
exit 0
fi
if [[ ! -x "${BINARY}" ]]; then
echo "Wayscriber release binary not found: ${BINARY}" >&2
echo "Run ./tools/build.sh first." >&2
exit 1
fi
cd "${PROJECT_ROOT}"
RUST_LOG="${RUST_LOG:-info}" exec "${BINARY}" --daemon "$@"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
bash "$SCRIPT_DIR/aur-desktop-assets.sh" --check
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$WORK_DIR/tools"
cp "$SCRIPT_DIR/aur-desktop-assets.sh" "$SCRIPT_DIR/aur-desktop-assets.py" "$WORK_DIR/tools/"
cp -a "$REPO_ROOT/packaging" "$WORK_DIR/"
# All concurrent standalone callers must receive complete, identical JSON.
pids=()
for run in 1 2 3; do
bash "$WORK_DIR/tools/aur-desktop-assets.sh" "$WORK_DIR" > "$WORK_DIR/$run.json" 2> "$WORK_DIR/$run.log" &
pids+=("$!")
done
for index in "${!pids[@]}"; do
if ! wait "${pids[$index]}"; then
echo "Concurrent cold asset generation failed for caller $((index + 1))" >&2
cat "$WORK_DIR/$((index + 1)).log" >&2
exit 1
fi
done
for run in 1 2 3; do
jq -e '.source.lines | length > 0' "$WORK_DIR/$run.json" >/dev/null
cmp "$WORK_DIR/1.json" "$WORK_DIR/$run.json" || {
echo "Concurrent asset output differs for caller $run" >&2
exit 1
}
done
write_manifest() {
cat > "$WORK_DIR/packaging/package.wayscriber.yaml" <<EOF
contents:
- src: packaging/wayscriber.desktop
dst: /usr/share/applications/wayscriber.desktop
file_info:
mode: $1
- src: packaging/icons/wayscriber.svg
dst: /usr/share/icons/hicolor/scalable/apps/wayscriber.svg
file_info:
mode: 0644
EOF
}
expect_failure() {
if bash "$WORK_DIR/tools/aur-desktop-assets.sh" "$WORK_DIR" > "$WORK_DIR/failure.json" 2> "$WORK_DIR/failure.log"; then
echo "Expected asset generator failure: $1" >&2
exit 1
fi
grep -Fq "$1" "$WORK_DIR/failure.log" || {
echo "Expected diagnostic: $1" >&2
cat "$WORK_DIR/failure.log" >&2
exit 1
}
[[ ! -s "$WORK_DIR/failure.json" ]] || {
echo 'Failed generation wrote partial JSON' >&2
exit 1
}
}
for mode in 0644 420 0o644; do
write_manifest "$mode"
bash "$WORK_DIR/tools/aur-desktop-assets.sh" "$WORK_DIR" > "$WORK_DIR/mode-$mode.json"
cmp "$WORK_DIR/mode-0644.json" "$WORK_DIR/mode-$mode.json" || {
echo "Equivalent YAML integer mode $mode changed the recipe" >&2
exit 1
}
done
for mode in nonsense 0oBAD 999999999999999999999999 '"0644"' '!!str 0644' 644; do
write_manifest "$mode"
expect_failure 'package.wayscriber.yaml: desktop asset /usr/share/applications/wayscriber.desktop must have mode 0644'
done
write_manifest 0644
sed -i '/file_info:/,+1d' "$WORK_DIR/packaging/package.wayscriber.yaml"
expect_failure 'package.wayscriber.yaml: desktop asset /usr/share/applications/wayscriber.desktop has no file_info mapping'
printf '{}\n' > "$WORK_DIR/packaging/package.wayscriber.yaml"
expect_failure 'package.wayscriber.yaml: expected one package contents sequence'
echo 'AUR standalone asset concurrency and parser regression checks passed.'
+288 -30
View File
@@ -219,6 +219,7 @@ cp "${REPO_ROOT}/Cargo.toml" \
"${REPO_ROOT}/Cargo.lock" \
"${REPO_ROOT}/README.md" \
"${REPO_ROOT}/flake.nix" \
"${REPO_ROOT}/global.json" \
"${LIBADWAITA_FLOOR_REPO}/"
cp "${REPO_ROOT}/configurator/Cargo.toml" \
"${LIBADWAITA_FLOOR_REPO}/configurator/Cargo.toml"
@@ -237,6 +238,34 @@ cp "${REPO_ROOT}/tools/update-aur-from-manifest.sh" \
bash tools/check-version-consistency.sh >/dev/null
)
printf '%s\n' '{"sdk":{"version":"11.0.100-rc.1.26425.128","rollForward":"disable","allowPrerelease":true}}' \
> "${LIBADWAITA_FLOOR_REPO}/global.json"
(
cd "${LIBADWAITA_FLOOR_REPO}"
bash tools/check-version-consistency.sh >/dev/null
)
printf '%s\n' '{"sdk":{}}' > "${LIBADWAITA_FLOOR_REPO}/global.json"
expect_version_consistency_failure \
"global.json SDK metadata is invalid" \
"${LIBADWAITA_FLOOR_REPO}"
printf '%s\n' '{"sdk":{"version":"11.0.100-rc.1.26425.127","rollForward":"disable","allowPrerelease":true}}' \
> "${LIBADWAITA_FLOOR_REPO}/global.json"
expect_version_consistency_failure \
"global.json SDK: expected 11.0.100-rc.1.26425.128, got 11.0.100-rc.1.26425.127" \
"${LIBADWAITA_FLOOR_REPO}"
printf '%s\n' '{"sdk":{"version":"11.0.100-rc.1.26425.128","rollForward":"latestPatch","allowPrerelease":false}}' \
> "${LIBADWAITA_FLOOR_REPO}/global.json"
expect_version_consistency_failure \
"global.json SDK rollForward must be disable" \
"${LIBADWAITA_FLOOR_REPO}"
expect_version_consistency_failure \
"global.json SDK allowPrerelease must be true" \
"${LIBADWAITA_FLOOR_REPO}"
cp "${REPO_ROOT}/global.json" "${LIBADWAITA_FLOOR_REPO}/global.json"
sed -i 's/features = \["v1_4"\]/features = ["v1_5"]/' \
"${LIBADWAITA_FLOOR_REPO}/configurator/Cargo.toml"
expect_version_consistency_failure \
@@ -277,21 +306,6 @@ expect_version_consistency_failure \
cp "${REPO_ROOT}/packaging/.SRCINFO" \
"${LIBADWAITA_FLOOR_REPO}/packaging/.SRCINFO"
sed -i '/configurator_deb_depends/ s/libadwaita-1-0 (>= 1.4)/libadwaita-1-0 (>= 1.5)/' \
"${LIBADWAITA_FLOOR_REPO}/.github/workflows/build-packages.yml"
expect_version_consistency_failure \
"release workflow deb libadwaita floor: expected 1.4, got 1.5" \
"${LIBADWAITA_FLOOR_REPO}"
cp "${RELEASE_WORKFLOW}" \
"${LIBADWAITA_FLOOR_REPO}/.github/workflows/build-packages.yml"
sed -i '/configurator_rpm_requires/ s/libadwaita >= 1.4/libadwaita >= 1.5/' \
"${LIBADWAITA_FLOOR_REPO}/.github/workflows/build-packages.yml"
expect_version_consistency_failure \
"release workflow rpm libadwaita floor: expected 1.4, got 1.5" \
"${LIBADWAITA_FLOOR_REPO}"
cp "${RELEASE_WORKFLOW}" \
"${LIBADWAITA_FLOOR_REPO}/.github/workflows/build-packages.yml"
sed -i \
"s/ensure_runtime_dependency 'libadwaita>=1.4'/ensure_runtime_dependency 'libadwaita>=1.5'/" \
@@ -343,24 +357,18 @@ sed -n '/^ package:/,/^ package-repos:/p' "${RELEASE_WORKFLOW}" \
> "${WORK_DIR}/release-package-job.yml"
assert_contains "${WORK_DIR}/release-package-job.yml" "runs-on: ubuntu-24.04"
assert_not_contains "${RELEASE_WORKFLOW}" "runs-on: ubuntu-latest"
assert_contains "${WORK_DIR}/release-package-job.yml" 'dpkg-deb -f dist/wayscriber-amd64.deb Version'
assert_contains "${WORK_DIR}/release-package-job.yml" 'dpkg-deb -f dist/wayscriber-configurator-amd64.deb Version'
assert_contains "${WORK_DIR}/release-package-job.yml" "'%{VERSION}-%{RELEASE}\\n' dist/wayscriber-x86_64.rpm"
assert_contains "${WORK_DIR}/release-package-job.yml" "'%{VERSION}-%{RELEASE}\\n' dist/wayscriber-configurator-x86_64.rpm"
assert_contains "${WORK_DIR}/release-package-job.yml" "grep -Eq '/usr/bin/wayscriber$'"
# Recipe URLs must not become public while GitHub asset upload is still running.
sed -n '/^ aur:/,/^ [a-z][a-z-]*:/p' "${RELEASE_WORKFLOW}" \
> "${WORK_DIR}/release-aur-job.yml"
assert_contains "${WORK_DIR}/release-aur-job.yml" 'needs: [package, release]'
assert_contains "${WORK_DIR}/release-package-job.yml" 'package verify-artifacts --artifact-root dist'
assert_contains "${WORK_DIR}/release-package-job.yml" 'wayscriber-configurator-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz'
assert_contains "${WORK_DIR}/release-package-job.yml" "grep -Eq '/usr/bin/wayscriber-configurator$'"
assert_contains "${WORK_DIR}/release-package-job.yml" \
"grep -Fq 'libadwaita-1-0 (>= 1.4)' <<< \"\$configurator_deb_depends\""
assert_contains "${WORK_DIR}/release-package-job.yml" \
"grep -Fxq 'libadwaita >= 1.4' <<< \"\$configurator_rpm_requires\""
assert_contains "${WORK_DIR}/release-package-job.yml" \
"/dist/wayscriber-configurator-amd64.deb"
'package smoke-ubuntu --artifact-root dist'
assert_contains "${WORK_DIR}/release-package-job.yml" "Check direct Arch installer compatibility"
assert_contains "${WORK_DIR}/release-package-job.yml" "https://wayscriber.com/arch-install.sh"
assert_contains "${WORK_DIR}/release-package-job.yml" "./tools/check-arch-installer-manifest.sh"
assert_contains "${WORK_DIR}/release-package-job.yml" "package check-live-arch-installer"
assert_contains "${WORK_DIR}/release-package-job.yml" 'wayscriber-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz'
assert_not_contains "${WORK_DIR}/release-package-job.yml" 'sh "$RUNNER_TEMP/arch-install.sh"'
assert_not_contains "${RELEASE_WORKFLOW}" 'bash '
assert_contains "${REPO_ROOT}/README.md" "--replace-other"
assert_contains "${REPO_ROOT}/README.md" "--no-restart"
assert_contains "${REPO_ROOT}/README.md" "--remove-unmanaged-usr"
@@ -375,6 +383,7 @@ ARCH_INSTALLER_FIXTURE_ARCHIVE="${WORK_DIR}/arch-installer.tar.gz"
ARCH_INSTALLER_VALID_FIXTURE="${WORK_DIR}/arch-installer-valid.sh"
ARCH_INSTALLER_MALFORMED_FIXTURE="${WORK_DIR}/arch-installer-malformed.sh"
ARCH_INSTALLER_MALFORMED_OUTPUT="${WORK_DIR}/arch-installer-malformed-output"
ARCH_INSTALLER_SERVICE_OUTPUT="${WORK_DIR}/arch-installer-service-output"
mkdir -p \
"${ARCH_INSTALLER_FIXTURE_STAGE}/${ARCH_INSTALLER_FIXTURE_ROOT}/usr/bin" \
"${ARCH_INSTALLER_FIXTURE_STAGE}/${ARCH_INSTALLER_FIXTURE_ROOT}/usr/lib/systemd/user"
@@ -402,6 +411,30 @@ EOF
--installer "${ARCH_INSTALLER_VALID_FIXTURE}" \
--archive "${ARCH_INSTALLER_FIXTURE_ARCHIVE}" >/dev/null
printf '%s\n' 'ExecStartPost=/usr/bin/env \' '# ignored by systemd' ' wayscriber --active' \
>> "${ARCH_INSTALLER_FIXTURE_STAGE}/${ARCH_INSTALLER_FIXTURE_ROOT}/usr/lib/systemd/user/wayscriber.service"
tar -czf "${ARCH_INSTALLER_FIXTURE_ARCHIVE}" \
-C "${ARCH_INSTALLER_FIXTURE_STAGE}" "${ARCH_INSTALLER_FIXTURE_ROOT}"
set +e
"${ARCH_INSTALLER_CHECKER}" \
--installer "${ARCH_INSTALLER_VALID_FIXTURE}" \
--archive "${ARCH_INSTALLER_FIXTURE_ARCHIVE}" \
>"${ARCH_INSTALLER_SERVICE_OUTPUT}" 2>&1
ARCH_INSTALLER_SERVICE_STATUS=$?
set -e
if [[ ${ARCH_INSTALLER_SERVICE_STATUS} -eq 0 ]]; then
echo "Expected an alternate Wayscriber service command to fail" >&2
exit 1
fi
assert_contains "${ARCH_INSTALLER_SERVICE_OUTPUT}" \
"release user service is incompatible"
cat > "${ARCH_INSTALLER_FIXTURE_STAGE}/${ARCH_INSTALLER_FIXTURE_ROOT}/usr/lib/systemd/user/wayscriber.service" <<'EOF'
[Service]
ExecStart="/usr/bin/wayscriber" --daemon
EOF
tar -czf "${ARCH_INSTALLER_FIXTURE_ARCHIVE}" \
-C "${ARCH_INSTALLER_FIXTURE_STAGE}" "${ARCH_INSTALLER_FIXTURE_ROOT}"
cat > "${ARCH_INSTALLER_MALFORMED_FIXTURE}" <<'EOF'
#!/bin/sh
# ARCH_INSTALL_MANIFEST_BEGIN
@@ -429,7 +462,7 @@ assert_contains "${ARCH_INSTALLER_MALFORMED_OUTPUT}" \
# Ordinary CI exercises the dynamic source-build path, while a dedicated step
# checks the static release path.
assert_contains "${CI_WORKFLOW}" "GTK4_LAYER_SHELL_LIBRARY_MODE=both"
assert_contains "${CI_WORKFLOW}" "ci prepare-gtk4-layer-shell --library-mode both"
assert_contains "${CI_WORKFLOW}" "Check dynamic gtk4-layer-shell linkage"
assert_contains "${CI_WORKFLOW}" "Check static gtk4-layer-shell linkage"
if grep -Eq 'SYSTEM_DEPS_GTK4_LAYER_SHELL_0_LINK.*GITHUB_ENV|GITHUB_ENV.*SYSTEM_DEPS_GTK4_LAYER_SHELL_0_LINK' \
@@ -1175,6 +1208,7 @@ source=("old.tar.gz::https://example.invalid/old.tar.gz")
sha256sums=('old')
package() {
cd "\$pkgname"
install -Dm755 "target/release/wayscriber" "\$pkgdir/usr/bin/wayscriber"
}
EOF
cat > "${dir}/.SRCINFO" <<EOF
@@ -1516,4 +1550,228 @@ assert_clean_checkout_at_head \
exit 1
}
# Reapply to prove idempotence, then execute both generated package() bodies.
# Compare installed assets with the canonical package manifest, rather than
# only matching the updater's own generated text.
run_aur_updater "${WORK_DIR}" \
--source-dir "${AUR_SOURCE_HOTFIX}" \
--bin-dir "${AUR_BIN_DIR}" \
--no-configurator \
--source-sha256 "${AUR_SOURCE_SHA}" >/dev/null
for recipe in "${AUR_SOURCE_HOTFIX}" "${AUR_BIN_DIR}"; do
[[ "$(grep -Fxc '# Wayscriber desktop integration' "$recipe/PKGBUILD")" == 1 ]] || {
echo "Expected exactly one managed desktop block in $recipe/PKGBUILD" >&2
exit 1
}
done
# A managed block is exact state, so assets removed from the manifest must not
# survive merely because every current line is also present.
STALE_AUR_ASSET_LINE=' install -Dm644 packaging/icons/retired-icon.png "$pkgdir/usr/share/icons/hicolor/obsolete/retired-icon.png"'
STALE_CONFIGURATOR_ASSET_LINE=' install -Dm644 packaging/icons/retired-legacy-icon.png "$pkgdir/usr/share/icons/hicolor/obsolete/retired-legacy-icon.png"'
STALE_AUR_ASSET_LINE="${STALE_AUR_ASSET_LINE}" perl -0pi -e '
s{^# End Wayscriber desktop integration$}{$ENV{STALE_AUR_ASSET_LINE} . "\n" . $&}me
' "${AUR_SOURCE_HOTFIX}/PKGBUILD"
STALE_CONFIGURATOR_ASSET_LINE="${STALE_CONFIGURATOR_ASSET_LINE}" perl -0pi -e '
s{^# End Wayscriber configurator desktop integration$}{$ENV{STALE_CONFIGURATOR_ASSET_LINE} . "\n" . $&}me
' "${AUR_CONFIG_HOTFIX}/PKGBUILD"
run_aur_updater "${WORK_DIR}" \
--source-dir "${AUR_SOURCE_HOTFIX}" --bin-dir missing-bin \
--config-dir "${AUR_CONFIG_HOTFIX}" --source-sha256 "${AUR_SOURCE_SHA}" >/dev/null
assert_not_contains "${AUR_SOURCE_HOTFIX}/PKGBUILD" "${STALE_AUR_ASSET_LINE}"
assert_not_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" "${STALE_CONFIGURATOR_ASSET_LINE}"
# Pre-marker recipes can contain an older manifest subset and retain removed
# assets. Migrate each legacy set to an exact managed block.
AUR_MARKERLESS_SOURCE="${WORK_DIR}/aur-markerless-source"
AUR_MARKERLESS_BIN="${WORK_DIR}/aur-markerless-bin"
AUR_MARKERLESS_CONFIG="${WORK_DIR}/aur-markerless-config"
cp -a "${AUR_SOURCE_HOTFIX}" "${AUR_MARKERLESS_SOURCE}"
cp -a "${AUR_BIN_DIR}" "${AUR_MARKERLESS_BIN}"
cp -a "${AUR_CONFIG_HOTFIX}" "${AUR_MARKERLESS_CONFIG}"
for recipe in "${AUR_MARKERLESS_SOURCE}" "${AUR_MARKERLESS_BIN}"; do
sed -i '/^# \(End \)\?Wayscriber desktop integration$/d' "${recipe}/PKGBUILD"
sed -i '/wayscriber-symbolic\.svg/d' "${recipe}/PKGBUILD"
printf '%s\n' "${STALE_AUR_ASSET_LINE}" >> "${recipe}/PKGBUILD"
done
sed -i '/^# \(End \)\?Wayscriber configurator desktop integration$/d' "${AUR_MARKERLESS_CONFIG}/PKGBUILD"
sed -i '/wayscriber-configurator\.svg/d' "${AUR_MARKERLESS_CONFIG}/PKGBUILD"
printf '%s\n' "${STALE_CONFIGURATOR_ASSET_LINE}" >> "${AUR_MARKERLESS_CONFIG}/PKGBUILD"
run_aur_updater "${WORK_DIR}" \
--source-dir "${AUR_MARKERLESS_SOURCE}" --bin-dir "${AUR_MARKERLESS_BIN}" \
--config-dir "${AUR_MARKERLESS_CONFIG}" --source-sha256 "${AUR_SOURCE_SHA}" >/dev/null
for recipe in "${AUR_MARKERLESS_SOURCE}" "${AUR_MARKERLESS_BIN}"; do
[[ "$(grep -Fxc '# Wayscriber desktop integration' "${recipe}/PKGBUILD")" == 1 ]] || {
echo "Markerless Wayscriber desktop assets were not normalized in ${recipe}" >&2
exit 1
}
assert_not_contains "${recipe}/PKGBUILD" "${STALE_AUR_ASSET_LINE}"
done
[[ "$(grep -Fxc '# Wayscriber configurator desktop integration' "${AUR_MARKERLESS_CONFIG}/PKGBUILD")" == 1 ]] || {
echo "Markerless configurator desktop assets were not normalized" >&2
exit 1
}
assert_not_contains "${AUR_MARKERLESS_CONFIG}/PKGBUILD" "${STALE_CONFIGURATOR_ASSET_LINE}"
AUR_ASSET_SOURCE="${WORK_DIR}/aur-asset-source"
AUR_ASSET_STAGE="${WORK_DIR}/aur-asset-stage"
mkdir -p "${AUR_ASSET_SOURCE}/wayscriber-9.9.9/target/release"
cp -a "${REPO_ROOT}/packaging" "${AUR_ASSET_SOURCE}/wayscriber-9.9.9/"
printf '#!/usr/bin/env bash\nexit 0\n' > "${AUR_ASSET_SOURCE}/wayscriber-9.9.9/target/release/wayscriber"
srcdir="${AUR_ASSET_SOURCE}" pkgdir="${AUR_ASSET_STAGE}" \
bash -c 'source "$1"; cd "$srcdir"; package' -- "${AUR_SOURCE_HOTFIX}/PKGBUILD"
# Complete the mock release tarball's non-desktop payload for the binary recipe.
mkdir -p "${AUR_ASSET_STAGE}/usr/lib/systemd/user" \
"${AUR_ASSET_STAGE}/usr/share/doc/wayscriber" \
"${AUR_ASSET_STAGE}/usr/share/licenses/wayscriber"
cp "${REPO_ROOT}/packaging/wayscriber.service" "${AUR_ASSET_STAGE}/usr/lib/systemd/user/"
cp "${REPO_ROOT}/config.example.toml" "${REPO_ROOT}/README.md" "${REPO_ROOT}/LICENSE" \
"${AUR_ASSET_STAGE}/usr/share/doc/wayscriber/"
cp "${REPO_ROOT}/packaging/licenses/gtk4-layer-shell.LICENSE" \
"${AUR_ASSET_STAGE}/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell"
tar -czf "${AUR_ASSET_SOURCE}/wayscriber-v9.9.9-linux-x86_64.tar.gz" \
-C "${WORK_DIR}" aur-asset-stage
AUR_BIN_STAGE="${WORK_DIR}/aur-bin-stage"
srcdir="${AUR_ASSET_SOURCE}" pkgdir="${AUR_BIN_STAGE}" CARCH=x86_64 \
bash -c 'source "$1"; cd "$srcdir"; package' -- "${AUR_BIN_DIR}/PKGBUILD"
while read -r asset; do
for stage in "${AUR_ASSET_STAGE}" "${AUR_BIN_STAGE}"; do
[[ -f "$stage$asset" && "$(stat -c '%a' "$stage$asset")" == 644 ]] || {
echo "Expected installed desktop asset with mode 644: $stage$asset" >&2
exit 1
}
done
cmp "${AUR_ASSET_STAGE}${asset}" "${AUR_BIN_STAGE}${asset}" || {
echo "Source and binary package assets differ: $asset" >&2
exit 1
}
done < <(awk '/^ dst: \/usr\/share\/(applications|icons|pixmaps)\// { print $2 }' "${PACKAGE_CONFIG}")
[[ ! -e "${AUR_BIN_STAGE}/usr/bin/wayscriber-configurator" ]] || {
echo 'The binary package unexpectedly includes the configurator' >&2
exit 1
}
# Repair missing assets in both current and legacy managed blocks.
SOURCE_CONFIGURATOR_BLOCK_BEFORE="$(awk '
/^# Wayscriber configurator desktop integration$/ { inside = 1 }
inside { print }
/^# End Wayscriber configurator desktop integration$/ { exit }
' "${AUR_SOURCE_HOTFIX}/PKGBUILD")"
sed -i '/^# End Wayscriber configurator desktop integration$/d; /install .*scalable\/apps\/wayscriber-configurator.svg/d' "${AUR_CONFIG_HOTFIX}/PKGBUILD"
sed -i '/install .*symbolic\/apps\/wayscriber-symbolic.svg/d' "${AUR_BIN_DIR}/PKGBUILD"
sed -i '/^# End Wayscriber desktop integration$/d; /install .*symbolic\/apps\/wayscriber-symbolic.svg/d' "${AUR_SOURCE_HOTFIX}/PKGBUILD"
run_aur_updater "${WORK_DIR}" \
--source-dir "${AUR_SOURCE_HOTFIX}" --bin-dir "${AUR_BIN_DIR}" \
--config-dir "${AUR_CONFIG_HOTFIX}" --source-sha256 "${AUR_SOURCE_SHA}" >/dev/null
for recipe in "${AUR_SOURCE_HOTFIX}" "${AUR_BIN_DIR}"; do
assert_contains "$recipe/PKGBUILD" 'symbolic/apps/wayscriber-symbolic.svg'
assert_contains "$recipe/PKGBUILD" '# End Wayscriber desktop integration'
done
SOURCE_CONFIGURATOR_BLOCK_AFTER="$(awk '
/^# Wayscriber configurator desktop integration$/ { inside = 1 }
inside { print }
/^# End Wayscriber configurator desktop integration$/ { exit }
' "${AUR_SOURCE_HOTFIX}/PKGBUILD")"
[[ "${SOURCE_CONFIGURATOR_BLOCK_AFTER}" == "${SOURCE_CONFIGURATOR_BLOCK_BEFORE}" ]] || {
echo 'Repairing the source desktop block changed configurator assets' >&2
exit 1
}
assert_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" 'scalable/apps/wayscriber-configurator.svg'
assert_contains "${AUR_CONFIG_HOTFIX}/PKGBUILD" '# End Wayscriber configurator desktop integration'
# The interactive updater installs the canonical template, which already has
# desktop assets. The manifest updater must accept that route without duplicates.
AUR_TEMPLATE_SOURCE="${WORK_DIR}/aur-template-source"
mkdir -p "${AUR_TEMPLATE_SOURCE}"
cp "${REPO_ROOT}/packaging/PKGBUILD" "${REPO_ROOT}/packaging/.SRCINFO" "${AUR_TEMPLATE_SOURCE}/"
git -C "${AUR_TEMPLATE_SOURCE}" init -q
run_aur_updater "${WORK_DIR}" \
--source-dir "${AUR_TEMPLATE_SOURCE}" --bin-dir missing-bin \
--no-configurator --source-sha256 "${AUR_SOURCE_SHA}" >/dev/null
[[ "$(grep -Fc 'packaging/wayscriber.desktop' "${AUR_TEMPLATE_SOURCE}/PKGBUILD")" == 1 ]] || {
echo 'Canonical template received duplicate desktop integration' >&2
exit 1
}
# A version bump must retain a locked registry dependency even when a newer
# compatible release exists. A local directory source makes this fully offline.
BUMP_REPO="${WORK_DIR}/bump-repo"
BUMP_FAKE_BIN="${WORK_DIR}/bump-fake-bin"
mkdir -p "${BUMP_REPO}/tools" "${BUMP_REPO}/src" \
"${BUMP_REPO}/configurator/src" "${BUMP_REPO}/packaging" \
"${BUMP_REPO}/.cargo" "${BUMP_FAKE_BIN}"
cp "${REPO_ROOT}/tools/bump-version.sh" "${BUMP_REPO}/tools/"
# This test isolates the bump's Cargo operation; real metadata consistency is
# checked separately by the canonical gate on this repository.
printf '#!/usr/bin/env bash\nexit 0\n' > "${BUMP_REPO}/tools/check-version-consistency.sh"
printf '#!/usr/bin/env bash\nprintf "pkgbase = wayscriber\\n"\n' > "${BUMP_FAKE_BIN}/makepkg"
chmod +x "${BUMP_FAKE_BIN}/makepkg"
cat > "${BUMP_REPO}/Cargo.toml" <<'EOF'
[package]
name = "wayscriber"
version = "1.0.0"
edition = "2024"
[workspace]
members = ["configurator"]
[dependencies]
release-fixture = "1"
EOF
cat > "${BUMP_REPO}/configurator/Cargo.toml" <<'EOF'
[package]
name = "wayscriber-configurator"
version = "1.0.0"
edition = "2024"
[dependencies]
wayscriber = { path = ".." }
EOF
touch "${BUMP_REPO}/src/lib.rs" "${BUMP_REPO}/configurator/src/lib.rs"
printf "pkgver=1.0.0\nsha256sums=('SKIP')\n" > "${BUMP_REPO}/packaging/PKGBUILD"
cat > "${BUMP_REPO}/.cargo/config.toml" <<EOF
[source.crates-io]
replace-with = "fixture"
[source.fixture]
directory = "${BUMP_REPO}/vendor"
EOF
for fixture_version in 1.0.0 1.1.0; do
fixture_dir="${BUMP_REPO}/vendor/release-fixture-${fixture_version}"
mkdir -p "${fixture_dir}/src"
printf '[package]\nname = "release-fixture"\nversion = "%s"\nedition = "2024"\n' \
"${fixture_version}" > "${fixture_dir}/Cargo.toml"
touch "${fixture_dir}/src/lib.rs"
printf '{"files":{},"package":"%s"}\n' "${AUR_SOURCE_SHA}" > "${fixture_dir}/.cargo-checksum.json"
if [[ "$fixture_version" == 1.0.0 ]]; then
(cd "${BUMP_REPO}" && cargo generate-lockfile --offline)
fi
done
BUMP_FAIL_BIN="${WORK_DIR}/bump-fail-bin"
BUMP_DRY_RUN_OUTPUT="${WORK_DIR}/bump-dry-run-output"
mkdir -p "${BUMP_FAIL_BIN}"
cat > "${BUMP_FAIL_BIN}/cargo" <<'EOF'
#!/usr/bin/env bash
if [[ "$*" == "update --workspace --offline --dry-run" ]]; then
exit 1
fi
exec "${BUMP_REAL_CARGO:?}" "$@"
EOF
chmod +x "${BUMP_FAIL_BIN}/cargo"
if BUMP_REAL_CARGO="$(command -v cargo)" PATH="${BUMP_FAIL_BIN}:${BUMP_FAKE_BIN}:${PATH}" \
bash "${BUMP_REPO}/tools/bump-version.sh" --dry-run 1.0.1 >"${BUMP_DRY_RUN_OUTPUT}" 2>&1; then
echo 'Expected the version-bump dry run to fail when offline resolution fails' >&2
exit 1
fi
assert_contains "${BUMP_DRY_RUN_OUTPUT}" 'cannot resolve locked dependencies offline'
cp "${BUMP_REPO}/Cargo.lock" "${WORK_DIR}/before-bump.lock"
for version in 1.0.1 1.0.1.1; do
PATH="${BUMP_FAKE_BIN}:${PATH}" bash "${BUMP_REPO}/tools/bump-version.sh" "$version" >/dev/null
# Both workspace packages change, but all remaining bytes must stay put.
sed 's/version = "1.0.1"/version = "1.0.0"/g' "${BUMP_REPO}/Cargo.lock" \
> "${WORK_DIR}/normalized-bump.lock"
cmp "${WORK_DIR}/before-bump.lock" "${WORK_DIR}/normalized-bump.lock"
done
# Prove the fixture exposes the original bug: resolving afresh selects 1.1.0.
(cd "${BUMP_REPO}" && cargo generate-lockfile --offline)
assert_contains "${BUMP_REPO}/Cargo.lock" 'version = "1.1.0"'
echo "Release packaging contract checks passed."
+195 -30
View File
@@ -297,15 +297,99 @@ rewrite_packaging_asset_paths() {
' PKGBUILD
}
remove_complete_desktop_asset_block() {
local marker="$1" end_marker="$2"
DESKTOP_ASSET_MARKER="$marker" DESKTOP_ASSET_END_MARKER="$end_marker" perl -0pi -e '
s{^\Q$ENV{DESKTOP_ASSET_MARKER}\E\n.*?^\Q$ENV{DESKTOP_ASSET_END_MARKER}\E\n?}{}ms
or die "Failed to remove managed desktop asset block\n";
' PKGBUILD
}
remove_markerless_desktop_assets() {
local package="$1"
DESKTOP_ASSET_PACKAGE="${package}" perl -ni -e '
my $is_asset = /^\s*install\s+.*\$pkgdir\/usr\/share\/(?:applications|icons|pixmaps)\//;
my $is_configurator = index($_, "wayscriber-configurator") >= 0;
$is_configurator ||= index($_, "configurator") >= 0;
my $matches_package = $ENV{DESKTOP_ASSET_PACKAGE} eq "wayscriber-configurator" || !$is_configurator;
print unless $is_asset && $matches_package;
' PKGBUILD
}
has_unmanaged_desktop_assets() {
local package="$1" line
while IFS= read -r line; do
if [[ "${package}" == wayscriber-configurator ]] \
|| [[ "${package}" == wayscriber && "${line}" != *configurator* ]]; then
return 0
fi
done < <(grep -E '^[[:space:]]*install[[:space:]].*\$pkgdir/usr/share/(applications|icons|pixmaps)/' PKGBUILD || true)
return 1
}
# shellcheck disable=SC2016
configurator_desktop_install_lines() {
printf '%s\n' \
' install -Dm644 packaging/wayscriber-configurator.desktop "$pkgdir/usr/share/applications/wayscriber-configurator.desktop"' \
' install -Dm644 packaging/icons/wayscriber-configurator-16.png "$pkgdir/usr/share/icons/hicolor/16x16/apps/wayscriber-configurator.png"' \
' install -Dm644 packaging/icons/wayscriber-configurator-19.png "$pkgdir/usr/share/icons/hicolor/19x19/apps/wayscriber-configurator.png"' \
' install -Dm644 packaging/icons/wayscriber-configurator-22.png "$pkgdir/usr/share/icons/hicolor/22x22/apps/wayscriber-configurator.png"' \
' install -Dm644 packaging/icons/wayscriber-configurator-24.png "$pkgdir/usr/share/icons/hicolor/24x24/apps/wayscriber-configurator.png"' \
' install -Dm644 packaging/icons/wayscriber-configurator-38.png "$pkgdir/usr/share/icons/hicolor/38x38/apps/wayscriber-configurator.png"' \
' install -Dm644 packaging/icons/wayscriber-configurator-64.png "$pkgdir/usr/share/icons/hicolor/64x64/apps/wayscriber-configurator.png"' \
' install -Dm644 packaging/icons/wayscriber-configurator-128.png "$pkgdir/usr/share/icons/hicolor/128x128/apps/wayscriber-configurator.png"' \
' install -Dm644 packaging/icons/wayscriber-configurator.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/wayscriber-configurator.svg"' \
' install -Dm644 packaging/icons/wayscriber-configurator-128.png "$pkgdir/usr/share/pixmaps/wayscriber-configurator.png"'
}
validate_configurator_desktop_assets() {
local dir="$1"
local marker='# Wayscriber configurator desktop integration'
local end_marker='# End Wayscriber configurator desktop integration'
local expected_block actual_block marker_count end_count
expected_block="$(configurator_desktop_install_lines)"
marker_count="$(grep -Fxc "$marker" "$dir/PKGBUILD" || true)"
end_count="$(grep -Fxc "$end_marker" "$dir/PKGBUILD" || true)"
[[ "$marker_count" == 1 && "$end_count" == 1 ]] || {
echo "wayscriber-configurator PKGBUILD has a missing or malformed desktop asset block" >&2
return 1
}
actual_block="$(awk -v marker="$marker" -v end_marker="$end_marker" '
$0 == marker { inside = 1; next }
$0 == end_marker { inside = 0; found_end = 1; next }
inside { print }
END { if (inside || !found_end) exit 1 }
' "$dir/PKGBUILD")" || {
echo "wayscriber-configurator PKGBUILD has a malformed desktop asset block" >&2
return 1
}
[[ "$actual_block" == "$expected_block" ]] || {
echo "wayscriber-configurator PKGBUILD has a stale desktop asset block" >&2
return 1
}
}
ensure_configurator_desktop_assets() {
local marker='# Wayscriber configurator desktop integration'
local end_marker='# End Wayscriber configurator desktop integration'
local binary_install=' install -Dm755 "target/release/wayscriber-configurator" "$pkgdir/usr/bin/wayscriber-configurator"'
local asset_block
local asset_block expected_lines
expected_lines="$(configurator_desktop_install_lines)"
if grep -Fq "$marker" PKGBUILD; then
return
if grep -Fxq "$end_marker" PKGBUILD; then
remove_complete_desktop_asset_block "$marker" "$end_marker"
else
sed -E -i \
-e "\\|^${marker}$|d" \
-e '/install .*\$pkgdir\/usr\/share\/(applications|icons|pixmaps)\/.*wayscriber-configurator/d' \
PKGBUILD
fi
if grep -Fq 'packaging/wayscriber-configurator.desktop' PKGBUILD; then
elif has_unmanaged_desktop_assets wayscriber-configurator; then
remove_markerless_desktop_assets wayscriber-configurator
fi
if has_unmanaged_desktop_assets wayscriber-configurator; then
echo "wayscriber-configurator PKGBUILD has unmanaged desktop integration" >&2
return 1
fi
@@ -314,17 +398,7 @@ ensure_configurator_desktop_assets() {
return 1
}
asset_block="${marker}
install -Dm644 packaging/wayscriber-configurator.desktop \"\$pkgdir/usr/share/applications/wayscriber-configurator.desktop\"
install -Dm644 packaging/icons/wayscriber-configurator-16.png \"\$pkgdir/usr/share/icons/hicolor/16x16/apps/wayscriber-configurator.png\"
install -Dm644 packaging/icons/wayscriber-configurator-19.png \"\$pkgdir/usr/share/icons/hicolor/19x19/apps/wayscriber-configurator.png\"
install -Dm644 packaging/icons/wayscriber-configurator-22.png \"\$pkgdir/usr/share/icons/hicolor/22x22/apps/wayscriber-configurator.png\"
install -Dm644 packaging/icons/wayscriber-configurator-24.png \"\$pkgdir/usr/share/icons/hicolor/24x24/apps/wayscriber-configurator.png\"
install -Dm644 packaging/icons/wayscriber-configurator-38.png \"\$pkgdir/usr/share/icons/hicolor/38x38/apps/wayscriber-configurator.png\"
install -Dm644 packaging/icons/wayscriber-configurator-64.png \"\$pkgdir/usr/share/icons/hicolor/64x64/apps/wayscriber-configurator.png\"
install -Dm644 packaging/icons/wayscriber-configurator-128.png \"\$pkgdir/usr/share/icons/hicolor/128x128/apps/wayscriber-configurator.png\"
install -Dm644 packaging/icons/wayscriber-configurator.svg \"\$pkgdir/usr/share/icons/hicolor/scalable/apps/wayscriber-configurator.svg\"
install -Dm644 packaging/icons/wayscriber-configurator-128.png \"\$pkgdir/usr/share/pixmaps/wayscriber-configurator.png\""
asset_block="${marker}"$'\n'"${expected_lines}"$'\n'"${end_marker}"
CONFIGURATOR_ASSET_BLOCK="$asset_block" perl -0pi -e '
my $anchor = q{ install -Dm755 "target/release/wayscriber-configurator" "$pkgdir/usr/bin/wayscriber-configurator"};
s{^\Q$anchor\E$}{$anchor . "\n\n" . $ENV{CONFIGURATOR_ASSET_BLOCK}}me
@@ -332,6 +406,108 @@ ensure_configurator_desktop_assets() {
' PKGBUILD
}
wayscriber_desktop_files() {
printf '%s\n' 'packaging/wayscriber.desktop usr/share/applications/wayscriber.desktop'
local size kind
for size in 16 19 22 24 38 64 128; do
for kind in apps status; do
printf 'packaging/icons/wayscriber-%s.png usr/share/icons/hicolor/%sx%s/%s/wayscriber.png\n' \
"$size" "$size" "$size" "$kind"
done
done
printf '%s\n' \
'packaging/icons/wayscriber.svg usr/share/icons/hicolor/scalable/apps/wayscriber.svg' \
'packaging/icons/wayscriber-symbolic.svg usr/share/icons/hicolor/symbolic/apps/wayscriber-symbolic.svg' \
'packaging/icons/wayscriber-128.png usr/share/pixmaps/wayscriber.png'
}
has_unmanaged_wayscriber_desktop_assets() {
has_unmanaged_desktop_assets wayscriber
}
# Emit package-time variables literally, just like the existing recipe templates.
# shellcheck disable=SC2016
wayscriber_desktop_install_lines() {
local channel="$1" source destination
while read -r source destination; do
if [[ "$channel" == bin ]]; then
source='${srcdir_tmp}/'"$destination"
printf ' install -Dm644 "%s" "$pkgdir/%s"\n' "$source" "$destination"
else
printf ' install -Dm644 %s "$pkgdir/%s"\n' "$source" "$destination"
fi
done < <(wayscriber_desktop_files)
}
# shellcheck disable=SC2016
ensure_wayscriber_desktop_assets() {
local channel="$1" marker='# Wayscriber desktop integration' end_marker='# End Wayscriber desktop integration' anchor asset_block
# The interactive updater copies the complete packaging/PKGBUILD template.
# Accept its existing assets without inserting a second block.
if validate_wayscriber_desktop_assets "$channel" . 2>/dev/null; then
return
fi
if grep -Fxq "$marker" PKGBUILD; then
if grep -Fxq "$end_marker" PKGBUILD; then
remove_complete_desktop_asset_block "$marker" "$end_marker"
else
sed -i "\\|^${marker}$|d" PKGBUILD
remove_markerless_desktop_assets wayscriber
fi
elif has_unmanaged_wayscriber_desktop_assets; then
remove_markerless_desktop_assets wayscriber
fi
if has_unmanaged_wayscriber_desktop_assets; then
echo "wayscriber ${channel} PKGBUILD has unmanaged desktop integration" >&2
return 1
fi
if [[ "$channel" == bin ]]; then
anchor=' install -Dm755 "${srcdir_tmp}/usr/bin/wayscriber" "$pkgdir/usr/bin/wayscriber"'
else
anchor=' install -Dm755 "target/release/wayscriber" "$pkgdir/usr/bin/wayscriber"'
fi
asset_block="$(wayscriber_desktop_install_lines "$channel")"
WAYSCRIBER_ASSET_ANCHOR="$anchor" WAYSCRIBER_ASSET_BLOCK="$marker"$'\n'"$asset_block"$'\n'"$end_marker" \
perl -0pi -e '
my $anchor = $ENV{WAYSCRIBER_ASSET_ANCHOR};
s{^\Q$anchor\E$}{$anchor . "\n\n" . $ENV{WAYSCRIBER_ASSET_BLOCK}}me
or die "wayscriber PKGBUILD has no binary install line to extend\n";
' PKGBUILD
}
validate_wayscriber_desktop_assets() {
local channel="$1" dir="$2" line
local marker='# Wayscriber desktop integration'
local end_marker='# End Wayscriber desktop integration'
local expected_block actual_block marker_count end_count
expected_block="$(wayscriber_desktop_install_lines "$channel")"
marker_count="$(grep -Fxc "$marker" "$dir/PKGBUILD" || true)"
end_count="$(grep -Fxc "$end_marker" "$dir/PKGBUILD" || true)"
if [[ "$marker_count" != 0 || "$end_count" != 0 ]]; then
[[ "$marker_count" == 1 && "$end_count" == 1 ]] || {
echo "wayscriber ${channel} PKGBUILD has a malformed desktop asset block" >&2
return 1
}
actual_block="$(awk -v marker="$marker" -v end_marker="$end_marker" '
$0 == marker { inside = 1; next }
$0 == end_marker { inside = 0; found_end = 1; next }
inside { print }
END { if (inside || !found_end) exit 1 }
' "$dir/PKGBUILD")" || {
echo "wayscriber ${channel} PKGBUILD has a malformed desktop asset block" >&2
return 1
}
[[ "$actual_block" == "$expected_block" ]] || {
echo "wayscriber ${channel} PKGBUILD has a stale desktop asset block" >&2
return 1
}
return 0
fi
echo "wayscriber ${channel} PKGBUILD lacks a managed desktop asset block" >&2
return 1
}
ensure_libxkbcommon_dependency() {
if ! grep -Eq "^[[:space:]]*'libxkbcommon'[[:space:]]*$" PKGBUILD; then
sed -i "/^[[:space:]]*'gcc-libs'/i\\ 'libxkbcommon'" PKGBUILD
@@ -459,22 +635,7 @@ validate_configurator_recipe() {
echo "wayscriber-configurator recipe lacks GTK4/libadwaita metadata" >&2
return 1
}
grep -Fq 'packaging/wayscriber-configurator.desktop' PKGBUILD \
&& grep -Fq 'packaging/icons/wayscriber-configurator.svg' PKGBUILD \
&& grep -Fq '$pkgdir/usr/share/applications/wayscriber-configurator.desktop' PKGBUILD \
&& grep -Fq '$pkgdir/usr/share/icons/hicolor/scalable/apps/wayscriber-configurator.svg' PKGBUILD \
&& grep -Fq '$pkgdir/usr/share/pixmaps/wayscriber-configurator.png' PKGBUILD || {
echo "wayscriber-configurator recipe lacks desktop launcher assets" >&2
return 1
}
local size
for size in 16 19 22 24 38 64 128; do
grep -Fq "packaging/icons/wayscriber-configurator-${size}.png" PKGBUILD \
&& grep -Fq "\$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/wayscriber-configurator.png" PKGBUILD || {
echo "wayscriber-configurator recipe lacks the ${size}x${size} launcher icon" >&2
return 1
}
done
validate_configurator_desktop_assets .
popd >/dev/null
}
@@ -517,6 +678,7 @@ update_bin() {
# aligned with the binary instead of inheriting source-build dependencies.
remove_runtime_dependency gtk4-layer-shell
ensure_bin_layer_shell_license
ensure_wayscriber_desktop_assets bin
replace_line PKGBUILD '^pkgver=.*' "pkgver=${VERSION}"
replace_line PKGBUILD '^pkgrel=.*' "pkgrel=${pkgrel}"
replace_pkgbuild_array PKGBUILD source_x86_64 "source_x86_64=(\"wayscriber-v${VERSION}-linux-x86_64.tar.gz::https://github.com/devmobasa/wayscriber/releases/download/v${VERSION}/wayscriber-v${VERSION}-linux-x86_64.tar.gz\")"
@@ -553,6 +715,7 @@ update_source() {
replace_pkgbuild_array PKGBUILD sha256sums "sha256sums=('${source_sha}')"
replace_line PKGBUILD 'cd "\$pkgname"' 'cd "$pkgname-$pkgver"'
rewrite_packaging_asset_paths
ensure_wayscriber_desktop_assets source
set_srcinfo_field .SRCINFO pkgver "${VERSION}"
set_srcinfo_field .SRCINFO pkgrel "${pkgrel}"
@@ -625,10 +788,12 @@ validate_selected_recipes() {
local source_dir="$1" bin_dir="$2" config_dir="$3"
if [[ "$SOURCE_SELECTED" -eq 1 ]]; then
validate_recipe_pair "wayscriber" "$source_dir" sha256sums "$SOURCE_ARCHIVE_SHA"
validate_wayscriber_desktop_assets source "$source_dir"
fi
if [[ "$BIN_SELECTED" -eq 1 ]]; then
validate_recipe_pair \
"wayscriber-bin" "$bin_dir" sha256sums_x86_64 "$BIN_ARCHIVE_SHA"
validate_wayscriber_desktop_assets bin "$bin_dir"
fi
if [[ "$CONFIGURATOR_SELECTED" -eq 1 ]]; then
validate_configurator_recipe "$config_dir"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env dotnet
#:package YamlDotNet@16.3.0
#:include csharp/includes.cs
using Wayscriber.Tools;
if ( Environment.GetEnvironmentVariable( EnvironmentVariables.WayscriberSshAskpass ) == EnvironmentVariables.Enabled )
{
Console.WriteLine( Environment.GetEnvironmentVariable( EnvironmentVariables.AurSshPassphrase ) ?? string.Empty );
return ExitCodes.Success;
}
return await ToolApplication.RunAsync( args );
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env dotnet
#:property OutputType=Exe
#:property UseAppHost=true
#:package YamlDotNet@16.3.0
#:package xunit.v3@3.2.2
#:include csharp/includes.cs
#:include csharp-tests/includes.cs