mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-11-01 07:36:42 +01:00
Migrate repository automation to C#
This commit is contained in:
1 parent
d3a346ae12
commit
6d3dccfab8
54 files changed
+7960
-456
No files matched your search
@@ -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
@@ -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
|
||||
@@ -22,6 +22,7 @@ CLAUDE.md
|
||||
# Test scripts
|
||||
test_run.sh
|
||||
run.sh
|
||||
!/tools/run.sh
|
||||
|
||||
# Local release helper
|
||||
tools/release.sh
|
||||
|
||||
+27
-8
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "11.0.100-rc.1.26425.128",
|
||||
"rollForward": "disable",
|
||||
"allowPrerelease": true
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CA1502: 20
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Executable
+177
@@ -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)
|
||||
Executable
+19
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, ".." ) );
|
||||
}
|
||||
}
|
||||
@@ -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}]" );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/"];
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ))}" );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
Executable
+15
@@ -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
@@ -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
@@ -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 "$@"
|
||||
Executable
+81
@@ -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
@@ -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."
|
||||
@@ -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"
|
||||
|
||||
Executable
+13
@@ -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 );
|
||||
Executable
+7
@@ -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
|
||||
Reference in new issue
Block a user