From 6d3dccfab8ed5936f7a50e9bd41a5dac827b439f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:51:27 +0200 Subject: [PATCH] Migrate repository automation to C# --- .github/workflows/build-packages.yml | 301 ++--- .github/workflows/ci.yml | 159 +-- .gitignore | 1 + CONTRIBUTING.md | 35 +- configurator/README.md | 2 +- docs/AGENTS.md | 1 + docs/RELEASING.md | 51 + flake.nix | 24 +- global.json | 7 + packaging/AGENTS.md | 1 + packaging/PKGBUILD | 8 +- src/ui/AGENTS.md | 1 + src/ui/board_picker/tests.rs | 4 + src/ui/context_menu.rs | 12 +- tools/.editorconfig | 49 + tools/AGENTS.md | 16 + tools/CodeMetricsConfig.txt | 1 + tools/Directory.Build.props | 15 + tools/README.md | 69 ++ tools/aur-desktop-assets.py | 177 +++ tools/aur-desktop-assets.sh | 19 + tools/build-package-repos.sh | 49 +- tools/bump-version.sh | 15 +- tools/check-arch-installer-manifest.sh | 34 +- tools/check-version-consistency.sh | 32 +- tools/csharp-tests/CoreTests.cs | 89 ++ .../ReleaseParityRegressionTests.cs | 400 +++++++ tools/csharp-tests/ReleaseRegressionTests.cs | 471 ++++++++ tools/csharp-tests/RepositoryContractTests.cs | 386 +++++++ .../SignedRepositoryIntegrationTests.cs | 130 +++ tools/csharp-tests/includes.cs | 5 + tools/csharp/Application/ToolApplication.cs | 96 ++ tools/csharp/Commands/AssetsCommand.cs | 172 +++ tools/csharp/Commands/ChecksCommand.cs | 713 ++++++++++++ tools/csharp/Commands/DevelopmentCommands.cs | 317 ++++++ .../csharp/Commands/NativeDesktopCommands.cs | 548 +++++++++ tools/csharp/Commands/PackagingCommands.cs | 935 +++++++++++++++ tools/csharp/Commands/ReleaseAurCommands.cs | 1010 +++++++++++++++++ tools/csharp/Commands/ReportCommands.cs | 224 ++++ tools/csharp/Commands/VersionCommands.cs | 351 ++++++ tools/csharp/Infrastructure/Core.cs | 134 +++ tools/csharp/Infrastructure/Files.cs | 161 +++ tools/csharp/Infrastructure/ProcessRunner.cs | 156 +++ .../csharp/Infrastructure/RepositoryNames.cs | 14 + tools/csharp/Infrastructure/ToolConstants.cs | 284 +++++ tools/csharp/includes.cs | 14 + tools/install.cs | 15 + tools/lint-and-test.sh | 46 +- tools/run.sh | 22 + tools/test-aur-desktop-assets.sh | 81 ++ tools/test-release-packaging.sh | 318 +++++- tools/update-aur-from-manifest.sh | 225 +++- tools/wayscriber.cs | 13 + tools/wayscriber.tests.cs | 7 + 54 files changed, 7962 insertions(+), 458 deletions(-) create mode 100644 docs/RELEASING.md create mode 100644 global.json create mode 100644 tools/.editorconfig create mode 100644 tools/CodeMetricsConfig.txt create mode 100644 tools/Directory.Build.props create mode 100755 tools/aur-desktop-assets.py create mode 100755 tools/aur-desktop-assets.sh create mode 100644 tools/csharp-tests/CoreTests.cs create mode 100644 tools/csharp-tests/ReleaseParityRegressionTests.cs create mode 100644 tools/csharp-tests/ReleaseRegressionTests.cs create mode 100644 tools/csharp-tests/RepositoryContractTests.cs create mode 100644 tools/csharp-tests/SignedRepositoryIntegrationTests.cs create mode 100644 tools/csharp-tests/includes.cs create mode 100644 tools/csharp/Application/ToolApplication.cs create mode 100644 tools/csharp/Commands/AssetsCommand.cs create mode 100644 tools/csharp/Commands/ChecksCommand.cs create mode 100644 tools/csharp/Commands/DevelopmentCommands.cs create mode 100644 tools/csharp/Commands/NativeDesktopCommands.cs create mode 100644 tools/csharp/Commands/PackagingCommands.cs create mode 100644 tools/csharp/Commands/ReleaseAurCommands.cs create mode 100644 tools/csharp/Commands/ReportCommands.cs create mode 100644 tools/csharp/Commands/VersionCommands.cs create mode 100644 tools/csharp/Infrastructure/Core.cs create mode 100644 tools/csharp/Infrastructure/Files.cs create mode 100644 tools/csharp/Infrastructure/ProcessRunner.cs create mode 100644 tools/csharp/Infrastructure/RepositoryNames.cs create mode 100644 tools/csharp/Infrastructure/ToolConstants.cs create mode 100644 tools/csharp/includes.cs create mode 100755 tools/install.cs create mode 100755 tools/run.sh create mode 100755 tools/test-aur-desktop-assets.sh create mode 100755 tools/wayscriber.cs create mode 100755 tools/wayscriber.tests.cs diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index ceaafebe..429fc41a 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -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 > "$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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a4f8211..0cb828ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index a1001c7c..fcb13814 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ CLAUDE.md # Test scripts test_run.sh run.sh +!/tools/run.sh # Local release helper tools/release.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f603ba77..2bf1e125 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/configurator/README.md b/configurator/README.md index fe97a046..bc21255a 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -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. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index dd7223a4..1917e96d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..bccbcbaf --- /dev/null +++ b/docs/RELEASING.md @@ -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. diff --git a/flake.nix b/flake.nix index 61a9e08a..43b179a8 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; }); } diff --git a/global.json b/global.json new file mode 100644 index 00000000..b53c3000 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "11.0.100-rc.1.26425.128", + "rollForward": "disable", + "allowPrerelease": true + } +} diff --git a/packaging/AGENTS.md b/packaging/AGENTS.md index a70e913c..7bf77111 100644 --- a/packaging/AGENTS.md +++ b/packaging/AGENTS.md @@ -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. diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 38e28024..63cefdc4 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -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" diff --git a/src/ui/AGENTS.md b/src/ui/AGENTS.md index 1b81a246..b8cb624a 100644 --- a/src/ui/AGENTS.md +++ b/src/ui/AGENTS.md @@ -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. diff --git a/src/ui/board_picker/tests.rs b/src/ui/board_picker/tests.rs index 3797fdd3..72983699 100644 --- a/src/ui/board_picker/tests.rs +++ b/src/ui/board_picker/tests.rs @@ -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(); diff --git a/src/ui/context_menu.rs b/src/ui/context_menu.rs index 359da427..f8370f14 100644 --- a/src/ui/context_menu.rs +++ b/src/ui/context_menu.rs @@ -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(); diff --git a/tools/.editorconfig b/tools/.editorconfig new file mode 100644 index 00000000..193abfa6 --- /dev/null +++ b/tools/.editorconfig @@ -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 diff --git a/tools/AGENTS.md b/tools/AGENTS.md index 4c9d33f9..af1bcd5b 100644 --- a/tools/AGENTS.md +++ b/tools/AGENTS.md @@ -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/.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. diff --git a/tools/CodeMetricsConfig.txt b/tools/CodeMetricsConfig.txt new file mode 100644 index 00000000..3934c61f --- /dev/null +++ b/tools/CodeMetricsConfig.txt @@ -0,0 +1 @@ +CA1502: 20 diff --git a/tools/Directory.Build.props b/tools/Directory.Build.props new file mode 100644 index 00000000..126dee75 --- /dev/null +++ b/tools/Directory.Build.props @@ -0,0 +1,15 @@ + + + enable + enable + true + true + false + false + + + + + + + diff --git a/tools/README.md b/tools/README.md index 40f30985..270a19d8 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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 diff --git a/tools/aur-desktop-assets.py b/tools/aur-desktop-assets.py new file mode 100755 index 00000000..34e64cbc --- /dev/null +++ b/tools/aur-desktop-assets.py @@ -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) diff --git a/tools/aur-desktop-assets.sh b/tools/aur-desktop-assets.sh new file mode 100755 index 00000000..9d58dfa2 --- /dev/null +++ b/tools/aur-desktop-assets.sh @@ -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" diff --git a/tools/build-package-repos.sh b/tools/build-package-repos.sh index 4bae10d8..5f9b3ac6 100644 --- a/tools/build-package-repos.sh +++ b/tools/build-package-repos.sh @@ -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" <= 6)); then + cat >> "${RPM_MACRO_HOME}/.rpmmacros" <> "${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" </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 diff --git a/tools/check-arch-installer-manifest.sh b/tools/check-arch-installer-manifest.sh index 8e587dac..61223f4e 100755 --- a/tools/check-arch-installer-manifest.sh +++ b/tools/check-arch-installer-manifest.sh @@ -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" { - matches++ - next + 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 + } + command = command line + if (sub(/\\[[:space:]]*$/, " ", command)) { + next + } + check_command(command) + command = "" + } + END { + if (command != "") check_command(command) + exit !(matches == 1 && !unexpected) } - index($0, "/usr/bin/wayscriber") { unexpected = 1 } - END { exit !(matches == 1 && !unexpected) } ' "$SERVICE_FILE" \ || die "release user service is incompatible with the direct installer rewrite" diff --git a/tools/check-version-consistency.sh b/tools/check-version-consistency.sh index d82e9715..52e77125 100755 --- a/tools/check-version-consistency.sh +++ b/tools/check-version-consistency.sh @@ -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", diff --git a/tools/csharp-tests/CoreTests.cs b/tools/csharp-tests/CoreTests.cs new file mode 100644 index 00000000..82a835a5 --- /dev/null +++ b/tools/csharp-tests/CoreTests.cs @@ -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( ( ) => 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( ) ); + } + + [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( ( ) => 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, ".." ) ); + } +} diff --git a/tools/csharp-tests/ReleaseParityRegressionTests.cs b/tools/csharp-tests/ReleaseParityRegressionTests.cs new file mode 100644 index 00000000..9b2b45a9 --- /dev/null +++ b/tools/csharp-tests/ReleaseParityRegressionTests.cs @@ -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( 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( 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( ); + var end = recipe["end_marker"]!.GetValue( ); + var anchor = recipe["anchor"]!.GetValue( ); + var expectedLines = recipe["lines"]!.AsArray( ).Select( item => item!.GetValue( ) ).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( ); + var expectedLines = recipe["lines"]!.AsArray( ).Select( item => item!.GetValue( ) ).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( ); + var previousManifestLines = recipe["lines"]!.AsArray( ).Select( item => item!.GetValue( ) ).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 { 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 { 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? 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 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 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( ( ) => command.Handler( + context, + ["--installer", Path.Combine( directory, "installer.sh" ), "--archive", archive] ) ); + } + + private static async Task RunProcess( string directory, string fileName, IReadOnlyList 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 RunStandaloneGate( string repository, string dotnetLog, IReadOnlySet allowedExitCodes ) + { + var environment = new Dictionary + { + [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 Requests { get; } = []; + + public Task 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 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 ) ); + } + } +} diff --git a/tools/csharp-tests/ReleaseRegressionTests.cs b/tools/csharp-tests/ReleaseRegressionTests.cs new file mode 100644 index 00000000..2ab0265d --- /dev/null +++ b/tools/csharp-tests/ReleaseRegressionTests.cs @@ -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( 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( ( ) => 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( ); + var endMarker = recipe["end_marker"]!.GetValue( ); + var anchor = recipe["anchor"]!.GetValue( ); + var assetLines = recipe["lines"]!.AsArray( ).Select( item => item!.GetValue( ) ).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( ); + var sourceMarker = sourceRecipe["marker"]!.GetValue( ); + var existingSourceAsset = sourceRecipe["lines"]!.AsArray( )[0]!.GetValue( ); + var configuratorAnchor = configuratorRecipe["anchor"]!.GetValue( ); + var configuratorMarker = configuratorRecipe["marker"]!.GetValue( ); + var configuratorEndMarker = configuratorRecipe["end_marker"]!.GetValue( ); + var configuratorAssets = configuratorRecipe["lines"]!.AsArray( ).Select( item => item!.GetValue( ) ); + 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( 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( ) ); + + 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( ) ); + + 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( 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( ( ) => 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( 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( ( ) => 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? 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 Requests { get; } = []; + + public Task 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 Requests { get; } = []; + + public Task RunAsync( ProcessRequest request, CancellationToken cancellationToken ) + { + Requests.Add( request ); + throw new ToolException( "Synthetic build failure." ); + } + } + + private sealed class CheckoutOnlyRunner : IProcessRunner + { + public Task 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 Requests { get; } = []; + + public Task 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 Requests { get; } = []; + + public Task 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 )}" ); + } + } +} diff --git a/tools/csharp-tests/RepositoryContractTests.cs b/tools/csharp-tests/RepositoryContractTests.cs new file mode 100644 index 00000000..e8668c33 --- /dev/null +++ b/tools/csharp-tests/RepositoryContractTests.cs @@ -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 { 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( ) ); + 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( ( ) => 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( ( ) => 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( ( ) => 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( 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 Requests { get; } = []; + + public Task 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 Requests { get; } = []; + + public Task 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" ), "" ); + return Task.FromResult( new ProcessResult( ExitCodes.Success, string.Empty, string.Empty ) ); + } + throw new Xunit.Sdk.XunitException( $"Unexpected process: {request.FileName} {string.Join( ' ', request.Arguments )}" ); + } + } +} diff --git a/tools/csharp-tests/SignedRepositoryIntegrationTests.cs b/tools/csharp-tests/SignedRepositoryIntegrationTests.cs new file mode 100644 index 00000000..1459eb15 --- /dev/null +++ b/tools/csharp-tests/SignedRepositoryIntegrationTests.cs @@ -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 "; + 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( 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 { 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 +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 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 { [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 Run( string workingDirectory, string fileName, IReadOnlyList arguments, + IReadOnlyDictionary? environment = null, IReadOnlySet? 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, ".." ) ); + } +} diff --git a/tools/csharp-tests/includes.cs b/tools/csharp-tests/includes.cs new file mode 100644 index 00000000..c20c7172 --- /dev/null +++ b/tools/csharp-tests/includes.cs @@ -0,0 +1,5 @@ +#:include CoreTests.cs +#:include RepositoryContractTests.cs +#:include ReleaseParityRegressionTests.cs +#:include ReleaseRegressionTests.cs +#:include SignedRepositoryIntegrationTests.cs diff --git a/tools/csharp/Application/ToolApplication.cs b/tools/csharp/Application/ToolApplication.cs new file mode 100644 index 00000000..63a5c539 --- /dev/null +++ b/tools/csharp/Application/ToolApplication.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 Commands = + [ + .. AssetsCommand.Commands, + .. DevelopmentCommands.Commands, + .. ChecksCommand.Commands, + .. VersionCommands.Commands, + .. NativeDesktopCommands.Commands, + .. PackagingCommands.Commands, + .. ReleaseAurCommands.Commands, + .. ReportCommands.Commands, + ]; + + public static async Task 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 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}]" ); + } + } +} diff --git a/tools/csharp/Commands/AssetsCommand.cs b/tools/csharp/Commands/AssetsCommand.cs new file mode 100644 index 00000000..0f06e0a7 --- /dev/null +++ b/tools/csharp/Commands/AssetsCommand.cs @@ -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 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 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 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( 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/"]; +} diff --git a/tools/csharp/Commands/ChecksCommand.cs b/tools/csharp/Commands/ChecksCommand.cs new file mode 100644 index 00000000..2420192f --- /dev/null +++ b/tools/csharp/Commands/ChecksCommand.cs @@ -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 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 SharedDependencies( ToolContext context, string[] args ) + { + new Arguments( args ).RequireEmpty( "check shared-dependencies" ); + var errors = new List( ); + 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 ProcessSites( ToolContext context, string[] args ) + { + new Arguments( args ).RequireEmpty( "check process-sites" ); + var errors = new List( ); + var allow = new HashSet( 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 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> CollectRustDepInfos( ToolContext context, HashSet packageIds ) + { + var depInfos = new HashSet( 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 CollectCoveredRustSources( ToolContext context, HashSet depInfos ) + { + var covered = new HashSet( 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 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 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( ); + 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 NixList( string text, string attribute, int start = 0 ) + { + var match = Regex.Match( text[start..], $@"(? item.Value ).ToHashSet( ); + } + + private static void RequireNixInput( List errors, string attribute, HashSet recipe, HashSet arguments, + HashSet 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 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 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 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 SystemLibraries = new Dictionary( 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 Run( string root ) + { + var errors = new List( ); + 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( 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( 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 sources, HashSet owners, + IEnumerable primitives, IEnumerable writers, Dictionary> callers, List 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 writers, string expectedCaller, + Dictionary> callers, List 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 primitives, IEnumerable writers, List 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; + } + } + } +} diff --git a/tools/csharp/Commands/DevelopmentCommands.cs b/tools/csharp/Commands/DevelopmentCommands.cs new file mode 100644 index 00000000..a1149fd5 --- /dev/null +++ b/tools/csharp/Commands/DevelopmentCommands.cs @@ -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 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 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 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 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 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 FetchManifest( ToolContext context, string manifest, string? target ) + { + var arguments = new List { "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 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 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 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 + { + [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 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? 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 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 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? environment = mode == NativeLibraryModes.Static + ? new Dictionary { [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 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 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 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; + } +} diff --git a/tools/csharp/Commands/NativeDesktopCommands.cs b/tools/csharp/Commands/NativeDesktopCommands.cs new file mode 100644 index 00000000..0490bd1a --- /dev/null +++ b/tools/csharp/Commands/NativeDesktopCommands.cs @@ -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 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 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 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 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 + { + [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 { 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 GtkArtifactsExist( ToolContext context, string prefix, string systemPrefix, string mode, string stamp, string pin, + IReadOnlyDictionary 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 { 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 SetPortalShortcut( ToolContext context, string[] args ) + { + var shortcut = args.Length > 0 ? args[0] : "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 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 { 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 { 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 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 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 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 { 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( ); + 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 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 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 { ExitCodes.Success, ExitCodes.Failure } ); + return result.ExitCode == ExitCodes.Success; + } + + private static async Task CommandExists( ToolContext context, string command ) => + (await context.Run( Programs.Which, [command], capture: true, trace: false, allowedExitCodes: new HashSet { 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 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 RunPossiblyRoot( ToolContext context, bool requiresPrivilege, string program, IReadOnlyList arguments, + IReadOnlySet? allowedExitCodes = null ) => ShouldUseSudo( requiresPrivilege, GetEffectiveUserId( ) ) + ? context.Run( Programs.Sudo, [program, .. arguments], allowedExitCodes: allowedExitCodes ) + : context.Run( program, arguments, allowedExitCodes: allowedExitCodes ); +} diff --git a/tools/csharp/Commands/PackagingCommands.cs b/tools/csharp/Commands/PackagingCommands.cs new file mode 100644 index 00000000..ae6c7476 --- /dev/null +++ b/tools/csharp/Commands/PackagingCommands.cs @@ -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 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 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 + { + [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 { 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> BuildArtifacts( ToolContext context, IEnumerable formats, string artifactRoot, + string version, bool includeConfigurator, IReadOnlyDictionary environment ) + { + var artifacts = new List( ); + 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 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 BuildNfpm( ToolContext context, string output, string format, bool configurator, + IReadOnlyDictionary 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 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 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( ); + 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 ?? ""}." ); + } + + 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 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( StringComparer.Ordinal ); + var entries = body[2..^1]; + for ( var index = 0; index < entries.Length; index++ ) + { + var match = Regex.Match( entries[index], @"^\s*'(?0[0-7]{3}) (?[-A-Za-z0-9._/+]+)'\s*(?\\)?\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 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 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 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 { ExitCodes.Success, ExitCodes.Failure } ); + } + return ExitCodes.Success; + } + + private static async Task 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 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 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 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 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 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 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 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? _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 { [EnvironmentVariables.GnuPgHome] = home.Path, [EnvironmentVariables.Home] = home.Path }; + } + + public static async Task 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 { [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(?[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 { "--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( ); + } +} diff --git a/tools/csharp/Commands/ReleaseAurCommands.cs b/tools/csharp/Commands/ReleaseAurCommands.cs new file mode 100644 index 00000000..5a3143c9 --- /dev/null +++ b/tools/csharp/Commands/ReleaseAurCommands.cs @@ -0,0 +1,1010 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Wayscriber.Tools; + +internal static class ReleaseAurCommands +{ + private const int SourceDownloadTimeoutMinutes = 2; + private const string AurHost = "aur.archlinux.org"; + private const string AurKeyFileName = "aur"; + private const string AurKnownHostsFileName = "known_hosts.wayscriber-aur"; + + private sealed record AurUpdateOptions( string ManifestPath, string? Version, string SourceDirectory, string BinaryDirectory, + string ConfiguratorDirectory, string? SourceChecksum, bool NoConfigurator, bool Push, bool Interactive ); + + public static IReadOnlyList Commands + { + get; + } = + [ + new( CommandAreas.Release, CommandNames.ResolveVersion, "Resolve a release version and optional GitHub output.", SideEffect.ReadOnly, ResolveVersion ), + new( CommandAreas.Release, CommandNames.CreateTag, "Create a local annotated release tag.", SideEffect.FixtureMutating, CreateTag ), + new( CommandAreas.Release, CommandNames.PublishTag, "Create and push an annotated release tag.", SideEffect.RemoteMutating, PublishTag ), + new( CommandAreas.Aur, CommandNames.SourceChecksum, "Resolve a tagged source archive checksum.", SideEffect.ReadOnly, SourceChecksum ), + new( CommandAreas.Aur, CommandNames.Update, "Update AUR package recipes from a manifest.", SideEffect.RemoteMutating, UpdateAur ), + new( CommandAreas.Aur, CommandNames.PrepareSsh, "Install AUR SSH credentials from the environment.", SideEffect.MachineMutating, PrepareSsh ), + new( CommandAreas.Aur, CommandNames.ConfigureGit, "Configure the AUR commit identity.", SideEffect.MachineMutating, ConfigureGit ), + new( CommandAreas.Aur, CommandNames.Clone, "Clone the three Wayscriber AUR repositories.", SideEffect.RemoteMutating, CloneAur ), + new( CommandAreas.Release, CommandNames.DeployPackageRepositories, "Deploy apt/rpm repositories over SSH.", SideEffect.RemoteMutating, DeployRepositories ), + ]; + + private static async Task ResolveVersion( ToolContext context, string[] args ) + { + var parsed = new Arguments( args ); + var reference = parsed.TakeOption( "--ref-name" ) ?? context.Environment( EnvironmentVariables.GitHubRefName ); + var githubOutput = parsed.TakeOption( "--github-output" ) ?? context.Environment( EnvironmentVariables.GitHubOutput ); + parsed.RequireEmpty( "release resolve-version [--ref-name REF] [--github-output FILE]" ); + var version = reference is { Length: > 1 } && reference.StartsWith( 'v' ) ? reference[1..] : VersionCommands.ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) ); + _ = ReleaseVersion.Parse( version ); + if ( githubOutput is not null ) + { + await File.AppendAllTextAsync( githubOutput, $"version={version}\n", context.CancellationToken ); + } + await context.Output.WriteLineAsync( $"Release version: {version}" ); + return ExitCodes.Success; + } + + private static async Task CreateTag( ToolContext context, string[] args ) + { + var version = new Arguments( args ).SinglePositional( "release create-tag VERSION" ); + _ = ReleaseVersion.Parse( version ); + await EnsureVersionAndCleanTree( context, version ); + var tag = "v" + version; + var exists = await context.Run( Programs.Git, ["rev-parse", "-q", "--verify", $"refs/tags/{tag}"], capture: true, + allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); + if ( exists.ExitCode == ExitCodes.Success ) + { + throw new ToolException( $"tag {tag} already exists" ); + } + await context.Run( Programs.Git, ["tag", "-a", tag, "-m", $"Release {tag}"] ); + await context.Output.WriteLineAsync( $"Created tag {tag}" ); + return ExitCodes.Success; + } + + private static async Task PublishTag( ToolContext context, string[] args ) + { + var parsed = new Arguments( args ); + var version = parsed.TakeOption( CommandLineOptions.Version ) ?? VersionCommands.ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) ); + var dryRun = parsed.TakeFlag( "--dry-run" ); + parsed.RequireEmpty( "release publish-tag [--version VERSION] [--dry-run]" ); + _ = ReleaseVersion.Parse( version ); + await EnsureVersionAndCleanTree( context, version ); + var tag = "v" + version; + var local = await context.Run( Programs.Git, ["rev-parse", "-q", "--verify", $"refs/tags/{tag}"], capture: true, allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); + if ( local.ExitCode == ExitCodes.Success ) + { + throw new ToolException( $"Tag {tag} already exists locally; aborting." ); + } + var remote = await context.Run( Programs.Git, ["ls-remote", "--tags", "origin", tag], capture: true ); + if ( remote.StandardOutput.Contains( tag, StringComparison.Ordinal ) ) + { + throw new ToolException( $"Tag {tag} already exists on origin; aborting." ); + } + if ( dryRun ) + { + await context.Output.WriteLineAsync( $"[dry-run] git tag -a {tag} -m 'Release {tag}'\n[dry-run] git push origin {tag}" ); + return ExitCodes.Success; + } + await context.Run( Programs.Git, ["tag", "-a", tag, "-m", $"Release {tag}"] ); + try + { + await context.Run( Programs.Git, ["push", "origin", tag] ); + } + catch { await context.Run( Programs.Git, ["tag", "-d", tag], allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); throw; } + return ExitCodes.Success; + } + + private static async Task EnsureVersionAndCleanTree( ToolContext context, string version ) + { + var errors = VersionCommands.Validate( context.RepositoryRoot, version ); + if ( errors.Count > 0 ) + { + throw new ToolException( string.Join( '\n', errors ) ); + } + var status = await context.Run( Programs.Git, ["status", "--porcelain", "--untracked-files=all"], capture: true ); + if ( status.StandardOutput.Length > 0 ) + { + throw new ToolException( "Working tree is not clean; commit or stash changes before tagging." ); + } + } + + private static async Task SourceChecksum( ToolContext context, string[] args ) + { + var parsed = new Arguments( args ); + var version = parsed.TakeOption( CommandLineOptions.Version, required: true )!; + var output = parsed.TakeOption( "--github-output" ); + parsed.RequireEmpty( "aur source-checksum --version VERSION [--github-output FILE]" ); + var checksum = await ResolveSourceChecksum( context, version, null ); + await context.Output.WriteLineAsync( checksum ); + if ( output is not null ) + { + await File.AppendAllTextAsync( output, $"source_sha256={checksum}\n", context.CancellationToken ); + } + return ExitCodes.Success; + } + + private static async Task ResolveSourceChecksum( ToolContext context, string version, string? supplied ) + { + _ = ReleaseVersion.Parse( version ); + if ( supplied is not null ) + { + if ( !Regex.IsMatch( supplied, HashingConstants.Sha256HexPattern ) ) + { + throw new ToolException( $"Source archive checksum is not a sha256 digest: '{supplied}'" ); + } + return supplied.ToLowerInvariant( ); + } + using var temporary = new TemporaryDirectory( "wayscriber-source" ); + var archive = Path.Combine( temporary.Path, "source.tar.gz" ); + using var client = new HttpClient { Timeout = TimeSpan.FromMinutes( SourceDownloadTimeoutMinutes ) }; + await using ( var input = await client.GetStreamAsync( $"https://github.com/devmobasa/wayscriber/archive/refs/tags/v{version}.tar.gz", context.CancellationToken ) ) + await using ( var output = File.Create( archive ) ) + { + await input.CopyToAsync( output, context.CancellationToken ); + } + if ( new FileInfo( archive ).Length == 0 ) + { + throw new ToolException( "Downloaded source archive is empty." ); + } + return Files.Sha256( archive ); + } + + private static async Task UpdateAur( ToolContext context, string[] args ) + { + if ( args.Contains( "--interactive", StringComparer.Ordinal ) && !args.Contains( "--manifest", StringComparer.Ordinal ) ) + { + return await UpdateAurInteractive( context, args.Where( argument => argument != "--interactive" ).ToArray( ) ); + } + var options = ParseAurUpdateOptions( context, args ); + if ( !File.Exists( options.ManifestPath ) ) + { + throw new ToolException( $"Manifest not found: {options.ManifestPath}" ); + } + using var manifest = JsonDocument.Parse( Files.Read( options.ManifestPath ) ); + var manifestVersion = manifest.RootElement.TryGetProperty( "version", out var versionValue ) && versionValue.ValueKind == JsonValueKind.String ? versionValue.GetString( ) : null; + var version = options.Version ?? manifestVersion; + if ( version is null ) + { + throw new ToolException( $"Manifest version must be a string: {options.ManifestPath}" ); + } + + _ = ReleaseVersion.Parse( version ); + var selections = SelectAurCheckouts( options.SourceDirectory, options.BinaryDirectory, options.ConfiguratorDirectory, options.NoConfigurator ); + await ValidateAurCheckouts( context, selections ); + + var binarySha = selections.Any( item => item.Channel == PackageChannels.Binary ) ? ArtifactSha( manifest.RootElement, $"wayscriber-v{version}-linux-x86_64.tar.gz" ) : string.Empty; + var sourceSha = selections.Any( item => item.Channel is PackageChannels.Source or PackageChannels.Configurator ) ? await ResolveSourceChecksum( context, version, options.SourceChecksum ) : string.Empty; + var recipe = AssetsCommand.CreateRecipe( context.RepositoryRoot ); + await PreflightAurUpdates( context, selections, version, sourceSha, binarySha, recipe ); + await ApplyAurUpdates( context, selections, version, sourceSha, binarySha, recipe ); + + var push = options.Push; + if ( options.Interactive && !push ) + { + await context.Output.WriteAsync( "Push updated AUR recipes? [y/N] " ); + var answer = Console.ReadLine( ); + push = string.Equals( answer, "y", StringComparison.OrdinalIgnoreCase ) || string.Equals( answer, "yes", StringComparison.OrdinalIgnoreCase ); + } + if ( push ) + { + await PublishAurUpdates( context, selections, version ); + } + + await context.Output.WriteLineAsync( "AUR recipes updated" ); + return ExitCodes.Success; + } + + private static AurUpdateOptions ParseAurUpdateOptions( ToolContext context, string[] args ) + { + var parsed = new Arguments( args ); + var manifestPath = Path.GetFullPath( parsed.TakeOption( "--manifest" ) ?? context.Path( "dist", "manifest.json" ), Environment.CurrentDirectory ); + var version = parsed.TakeOption( CommandLineOptions.Version ); + var sourceDirectory = Path.GetFullPath( parsed.TakeOption( "--source-dir" ) ?? context.Environment( EnvironmentVariables.AurSourceDirectory ) ?? Path.Combine( context.RepositoryRoot, "..", "aur-wayscriber" ) ); + var binaryDirectory = Path.GetFullPath( parsed.TakeOption( "--bin-dir" ) ?? context.Environment( EnvironmentVariables.AurBinaryDirectory ) ?? Path.Combine( context.RepositoryRoot, "..", "aur-wayscriber-bin" ) ); + var configuratorDirectory = Path.GetFullPath( parsed.TakeOption( "--config-dir" ) ?? context.Environment( EnvironmentVariables.AurConfiguratorDirectory ) ?? Path.Combine( context.RepositoryRoot, "..", "aur-wayscriber-configurator" ) ); + var sourceChecksum = parsed.TakeOption( "--source-sha256" ) ?? context.Environment( EnvironmentVariables.AurSourceArchiveSha256 ); + var noConfigurator = parsed.TakeFlag( "--no-configurator" ); + var push = parsed.TakeFlag( "--push" ); + var interactive = parsed.TakeFlag( "--interactive" ); + parsed.RequireEmpty( "aur update [--version VERSION] [--manifest FILE] [--source-dir DIR] [--bin-dir DIR] [--config-dir DIR] [--no-configurator] [--source-sha256 SHA] [--push] [--interactive]" ); + return new AurUpdateOptions( manifestPath, version, sourceDirectory, binaryDirectory, configuratorDirectory, sourceChecksum, noConfigurator, push, interactive ); + } + + private static List<(string Channel, string Directory)> SelectAurCheckouts( string sourceDir, string binDir, string configDir, bool noConfigurator ) + { + var selections = new List<(string Channel, string Directory)>( ); + if ( Directory.Exists( sourceDir ) ) + { + selections.Add( (PackageChannels.Source, sourceDir) ); + } + if ( Directory.Exists( binDir ) ) + { + selections.Add( (PackageChannels.Binary, binDir) ); + } + if ( !noConfigurator ) + { + if ( !Directory.Exists( configDir ) ) + { + throw new ToolException( $"wayscriber-configurator AUR clone not found: {configDir}" ); + } + selections.Add( (PackageChannels.Configurator, configDir) ); + } + + if ( selections.Count == 0 ) + { + throw new ToolException( "No AUR checkouts were found." ); + } + return selections; + } + + private static async Task ValidateAurCheckouts( ToolContext context, IEnumerable<(string Channel, string Directory)> selections ) + { + foreach ( var selection in selections ) + { + await ValidateAurCheckout( context, selection.Channel, selection.Directory ); + } + } + + private static async Task PreflightAurUpdates( ToolContext context, IEnumerable<(string Channel, string Directory)> selections, + string version, string sourceSha, string binarySha, JsonObject recipe ) + { + using var preflight = new TemporaryDirectory( "wayscriber-aur-preflight" ); + foreach ( var selection in selections ) + { + var copy = Path.Combine( preflight.Path, selection.Channel ); + CopyTree( selection.Directory, copy, includeGit: false ); + await TransformAur( context, selection.Channel, copy, version, sourceSha, binarySha, recipe ); + ValidateAurRecipe( selection.Channel, copy, version, ChecksumFor( selection.Channel, sourceSha, binarySha ), recipe ); + } + } + + private static async Task ApplyAurUpdates( ToolContext context, IReadOnlyCollection<(string Channel, string Directory)> selections, + string version, string sourceSha, string binarySha, JsonObject recipe ) + { + var snapshots = AurMutationPaths( selections ).ToDictionary( path => path, + path => File.Exists( path ) ? File.ReadAllBytes( path ) : null, StringComparer.Ordinal ); + try + { + foreach ( var selection in selections ) + { + await TransformAur( context, selection.Channel, selection.Directory, version, sourceSha, binarySha, recipe ); + ValidateAurRecipe( selection.Channel, selection.Directory, version, ChecksumFor( selection.Channel, sourceSha, binarySha ), recipe ); + } + } + catch + { + foreach ( var snapshot in snapshots ) + { + if ( snapshot.Value is null ) + { + File.Delete( snapshot.Key ); + } + else + { + File.WriteAllBytes( snapshot.Key, snapshot.Value ); + } + } + throw; + } + } + + private static IEnumerable AurMutationPaths( IEnumerable<(string Channel, string Directory)> selections ) + { + foreach ( var selection in selections ) + { + yield return Path.Combine( selection.Directory, RepositoryNames.PackageBuildFile ); + yield return Path.Combine( selection.Directory, RepositoryNames.SourceInfoFile ); + if ( selection.Channel == PackageChannels.Source ) + { + yield return Path.Combine( selection.Directory, "wayscriber.install" ); + } + if ( selection.Channel == PackageChannels.Binary ) + { + yield return Path.Combine( selection.Directory, "wayscriber-bin.install" ); + } + } + } + + private static string ChecksumFor( string channel, string sourceSha, string binarySha ) => channel == PackageChannels.Binary ? binarySha : sourceSha; + + private static async Task PublishAurUpdates( ToolContext context, IEnumerable<(string Channel, string Directory)> selections, string version ) + { + var gitEnvironment = AurGitEnvironment( context ); + foreach ( var selection in selections ) + { + var paths = new List { RepositoryNames.PackageBuildFile, RepositoryNames.SourceInfoFile }; + var obsolete = selection.Channel == PackageChannels.Source ? "wayscriber.install" : selection.Channel == PackageChannels.Binary ? "wayscriber-bin.install" : null; + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, selection.Directory, "add", RepositoryNames.PackageBuildFile, RepositoryNames.SourceInfoFile] ); + if ( obsolete is not null && await RemoveTrackedFile( context, selection.Directory, obsolete ) ) + { + paths.Add( obsolete ); + } + + var status = await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, selection.Directory, "status", "--porcelain", CommandLineOptions.EndOfOptions, .. paths], capture: true ); + if ( status.StandardOutput.Length > 0 ) + { + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, selection.Directory, "commit", "-m", $"{PackageName( selection.Channel )} {version}", CommandLineOptions.EndOfOptions, .. paths] ); + } + } + + foreach ( var selection in selections ) + { + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, selection.Directory, "push"], environment: gitEnvironment ); + } + } + + private static async Task RemoveTrackedFile( ToolContext context, string directory, string path ) + { + var tracked = await context.Run( + Programs.Git, + [CommandLineOptions.ChangeDirectory, directory, "ls-files", "--error-unmatch", path], + capture: true, + allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); + if ( tracked.ExitCode != ExitCodes.Success ) + { + return false; + } + + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "rm", "-f", "--ignore-unmatch", path] ); + return true; + } + + private static async Task UpdateAurInteractive( ToolContext context, string[] args ) + { + if ( Console.IsInputRedirected ) + { + throw new ToolException( "aur update --interactive requires a terminal." ); + } + var parsed = new Arguments( args ); + var cargoVersion = VersionCommands.ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) ); + var version = parsed.TakeOption( CommandLineOptions.Version ) ?? cargoVersion; + var home = context.Environment( EnvironmentVariables.Home ); + if ( home is null ) + { + throw new ToolException( ToolMessages.HomeNotSet ); + } + + var directory = Path.GetFullPath( parsed.TakeOption( "--source-dir" ) ?? Path.Combine( home, "aur-packages", RepositoryNames.MainPackage ) ); + parsed.RequireEmpty( "aur update --interactive [--version VERSION] [--source-dir DIR]" ); + var release = ReleaseVersion.Parse( version ); + if ( release.CargoVersion != cargoVersion ) + { + throw new ToolException( $"Release version {version} is not a release or hotfix of Cargo version {cargoVersion}." ); + } + + var tag = "v" + version; + var tagExists = await context.Run( Programs.Git, ["rev-parse", "-q", "--verify", $"refs/tags/{tag}"], capture: true, + allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); + if ( tagExists.ExitCode != ExitCodes.Success ) + { + if ( !Confirm( $"Create and push tag {tag}?" ) ) + { + throw new ToolException( "Aborted: a release tag is required for AUR." ); + } + await EnsureVersionAndCleanTree( context, version ); + await context.Run( Programs.Git, ["tag", "-a", tag, "-m", $"Release {tag}"] ); + try + { + await context.Run( Programs.Git, ["push", "origin", tag] ); + } + catch { await context.Run( Programs.Git, ["tag", "-d", tag], allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); throw; } + } + + if ( !Directory.Exists( directory ) ) + { + Directory.CreateDirectory( Path.GetDirectoryName( directory )! ); + await context.Run( Programs.Git, ["clone", $"ssh://aur@{AurHost}/{RepositoryNames.MainPackage}.git", directory], environment: AurGitEnvironment( context ) ); + } + await ValidateAurCheckout( context, PackageChannels.Source, directory ); + var aurGitEnvironment = AurGitEnvironment( context ); + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "fetch", "origin"], environment: aurGitEnvironment, allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); + var checkout = await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "checkout", "master"], allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); + if ( checkout.ExitCode != ExitCodes.Success ) + { + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "checkout", "-b", "master"] ); + } + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "pull", "--rebase", "origin", "master"], environment: aurGitEnvironment, allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); + + var sourceChecksum = await ResolveSourceChecksum( context, version, null ); + var packageBuild = Files.Read( context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.PackageBuildFile ) ); + packageBuild = Replace( packageBuild, @"(?m)^pkgver=.*$", $"pkgver={version}", "pkgver" ); + packageBuild = Replace( packageBuild, @"(?m)^pkgrel=.*$", "pkgrel=1", "pkgrel" ); + packageBuild = ReplaceArray( packageBuild, "sha256sums", $"sha256sums=('{sourceChecksum}')" ); + Files.WriteAtomic( Path.Combine( directory, RepositoryNames.PackageBuildFile ), packageBuild ); + File.Delete( Path.Combine( directory, "wayscriber.install" ) ); + var sourceInfo = await context.Run( Programs.Makepkg, ["--printsrcinfo"], directory, capture: true ); + Files.WriteAtomic( Path.Combine( directory, RepositoryNames.SourceInfoFile ), sourceInfo.StandardOutput ); + + if ( Confirm( "Test build locally?" ) ) + { + await context.Run( Programs.Makepkg, ["-f"], directory ); + if ( Confirm( "Install locally to test?" ) ) + { + await context.Run( Programs.Makepkg, ["-i"], directory ); + } + } + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "status", "--short"] ); + if ( !Confirm( "Push to AUR?" ) ) + { + await context.Output.WriteLineAsync( $"AUR changes remain in {directory}." ); + return ExitCodes.Success; + } + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "add", RepositoryNames.PackageBuildFile, RepositoryNames.SourceInfoFile] ); + var obsoleteRemoved = await RemoveTrackedFile( context, directory, "wayscriber.install" ); + var commitPaths = obsoleteRemoved + ? new[] { RepositoryNames.PackageBuildFile, RepositoryNames.SourceInfoFile, "wayscriber.install" } + : [RepositoryNames.PackageBuildFile, RepositoryNames.SourceInfoFile]; + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "commit", "-m", $"Update to {tag}", CommandLineOptions.EndOfOptions, .. commitPaths] ); + var push = await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "push", "origin", "master"], environment: aurGitEnvironment, allowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ); + if ( push.ExitCode != ExitCodes.Success ) + { + await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "push", "-u", "origin", "master"], environment: aurGitEnvironment ); + } + await context.Output.WriteLineAsync( $"Published {RepositoryNames.MainPackage} {version} to AUR." ); + return ExitCodes.Success; + } + + private static bool Confirm( string prompt ) + { + Console.Write( $"{prompt} [y/N] " ); + var response = Console.ReadLine( ); + return response is not null && (response.Equals( "y", StringComparison.OrdinalIgnoreCase ) || response.Equals( "yes", StringComparison.OrdinalIgnoreCase )); + } + + private static async Task ValidateAurCheckout( ToolContext context, string channel, string directory ) + { + if ( !File.Exists( Path.Combine( directory, RepositoryNames.PackageBuildFile ) ) || !File.Exists( Path.Combine( directory, RepositoryNames.SourceInfoFile ) ) ) + { + throw new ToolException( $"{channel} AUR clone is missing {RepositoryNames.PackageBuildFile} or {RepositoryNames.SourceInfoFile}: {directory}" ); + } + var root = await context.Run( Programs.Git, [CommandLineOptions.ChangeDirectory, directory, "rev-parse", "--show-toplevel"], capture: true ); + if ( Path.GetFullPath( root.StandardOutput.Trim( ) ) != Path.GetFullPath( directory ) ) + { + throw new ToolException( $"{channel} AUR path is not the root of its Git worktree: {directory}" ); + } + } + + private static string ArtifactSha( JsonElement manifest, string name ) + { + var matches = manifest.GetProperty( "artifacts" ).EnumerateArray( ).Where( item => item.GetProperty( "name" ).GetString( ) == name ).ToArray( ); + if ( matches.Length != 1 ) + { + throw new ToolException( $"Expected exactly one checksum for artifact {name}, found {matches.Length}" ); + } + var value = matches[0].GetProperty( "sha256" ).GetString( ) ?? string.Empty; + if ( !Regex.IsMatch( value, HashingConstants.Sha256HexPattern ) ) + { + throw new ToolException( $"Artifact checksum for {name} is invalid." ); + } + return value.ToLowerInvariant( ); + } + + private static async Task TransformAur( ToolContext context, string channel, string directory, string version, string sourceSha, string binarySha, JsonObject recipes ) + { + var path = Path.Combine( directory, RepositoryNames.PackageBuildFile ); + var text = Files.Read( path ); + var currentVersion = Regex.Match( text, @"(?m)^pkgver=(.*)$" ).Groups[1].Value.Trim( ); + var currentReleaseText = Regex.Match( text, @"(?m)^pkgrel=(\d+)$" ).Groups[1].Value; + var nextRelease = currentVersion == version && int.TryParse( currentReleaseText, out var currentRelease ) ? currentRelease + 1 : 1; + text = Replace( text, @"(?m)^pkgver=.*$", $"pkgver={version}", "pkgver" ); + text = Replace( text, @"(?m)^pkgrel=.*$", $"pkgrel={nextRelease}", "pkgrel" ); + text = Regex.Replace( text, @"(?m)^install=.*\n?", string.Empty ); + text = Regex.Replace( text, @"(?m)^\s*'git'\s*\n", string.Empty ); + text = EnsureDependency( text, "libxkbcommon", "gcc-libs" ); + if ( channel == PackageChannels.Binary ) + { + text = EnsureDependency( text, "gtk4", "wl-clipboard" ); + text = RemoveDependency( text, RepositoryNames.Gtk4LayerShell ); + text = ReplaceArray( text, "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\")" ); + text = ReplaceArray( text, "sha256sums_x86_64", $"sha256sums_x86_64=('{binarySha}')" ); + if ( !text.Contains( "usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell", StringComparison.Ordinal ) ) + { + var license = " install -Dm644 \"${srcdir_tmp}/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell\" \"$pkgdir/usr/share/licenses/$pkgname/LICENSE.gtk4-layer-shell\""; + text = new Regex( @"(?m)^(.*usr/share/doc/wayscriber/LICENSE.*)$" ).Replace( text, $"$1\n{license}", 1 ); + } + } + else + { + text = EnsureDependency( text, "gtk4", channel == PackageChannels.Configurator ? "gcc-libs" : "wl-clipboard" ); + if ( channel == PackageChannels.Source ) + { + text = EnsureDependency( text, RepositoryNames.Gtk4LayerShell, "wl-clipboard" ); + } + + if ( channel == PackageChannels.Configurator ) + { + text = EnsureDependency( text, $"libadwaita>={VersionCommands.SupportedLibadwaitaFloor}", "gcc-libs" ); + text = Replace( text, @"(?m)^pkgdesc=.*$", "pkgdesc='GUI configurator for wayscriber (GTK4/libadwaita)'", "pkgdesc" ); + } + text = ReplaceArray( text, "source", "source=(\"wayscriber-$pkgver.tar.gz::https://github.com/devmobasa/wayscriber/archive/refs/tags/v$pkgver.tar.gz\")" ); + text = ReplaceArray( text, "sha256sums", $"sha256sums=('{sourceSha}')" ); + text = Regex.Replace( text, """(?m)^\s*cd (?:"\$pkgname"|wayscriber)$""", " cd \"wayscriber-$pkgver\"" ); + text = Regex.Replace( text, "\"\\$srcdir/(wayscriber(?:-configurator)?\\.desktop)\"", "packaging/$1" ); + text = Regex.Replace( text, "\"\\$srcdir/(wayscriber(?:-configurator)?-[0-9]+\\.png)\"", "packaging/icons/$1" ); + } + text = ApplyDesktopAssets( text, recipes[channel]!.AsObject( ) ); + Files.WriteAtomic( path, text ); + var srcinfoPath = Path.Combine( directory, RepositoryNames.SourceInfoFile ); + var srcinfo = TransformSrcInfo( Files.Read( srcinfoPath ), channel, version, nextRelease, sourceSha, binarySha ); + Files.WriteAtomic( srcinfoPath, srcinfo ); + if ( channel == PackageChannels.Source ) + { + File.Delete( Path.Combine( directory, "wayscriber.install" ) ); + } + if ( channel == PackageChannels.Binary ) + { + File.Delete( Path.Combine( directory, "wayscriber-bin.install" ) ); + } + } + + private static string TransformSrcInfo( string text, string channel, string version, int release, string sourceSha, string binarySha ) + { + text = SetSrcInfoField( text, "pkgver", version ); + text = SetSrcInfoField( text, "pkgrel", release.ToString( System.Globalization.CultureInfo.InvariantCulture ) ); + text = RemoveSrcInfoField( text, "install" ); + text = RemoveSrcInfoValue( text, "makedepends", Programs.Git ); + text = EnsureSrcInfoDependency( text, "libxkbcommon", "gcc-libs" ); + if ( channel == PackageChannels.Binary ) + { + text = EnsureSrcInfoDependency( text, "gtk4", "wl-clipboard" ); + text = RemoveSrcInfoValue( text, "depends", RepositoryNames.Gtk4LayerShell ); + text = SetSrcInfoField( text, "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" ); + text = SetSrcInfoField( text, "sha256sums_x86_64", binarySha ); + } + else + { + text = EnsureSrcInfoDependency( text, "gtk4", channel == PackageChannels.Configurator ? "gcc-libs" : "wl-clipboard" ); + if ( channel == PackageChannels.Source ) + { + text = EnsureSrcInfoDependency( text, RepositoryNames.Gtk4LayerShell, "wl-clipboard" ); + } + if ( channel == PackageChannels.Configurator ) + { + text = EnsureSrcInfoDependency( text, $"libadwaita>={VersionCommands.SupportedLibadwaitaFloor}", "gcc-libs" ); + text = SetSrcInfoField( text, "pkgdesc", "GUI configurator for wayscriber (GTK4/libadwaita)" ); + } + text = SetSrcInfoField( text, "source", $"wayscriber-{version}.tar.gz::https://github.com/devmobasa/wayscriber/archive/refs/tags/v{version}.tar.gz" ); + text = SetSrcInfoField( text, "sha256sums", sourceSha ); + } + return text; + } + + private static string SetSrcInfoField( string text, string field, string value ) + { + var lines = text.Split( '\n' ).ToList( ); + var found = false; + for ( var index = 0; index < lines.Count; index++ ) + { + if ( !Regex.IsMatch( lines[index], $@"^\s*{Regex.Escape( field )} = " ) ) + { + continue; + } + if ( !found ) + { + lines[index] = $"\t{field} = {value}"; + found = true; + } + else + { + lines.RemoveAt( index-- ); + } + } + if ( !found ) + { + var packageIndex = lines.FindIndex( line => Regex.IsMatch( line, @"^\s*pkgname = " ) ); + lines.Insert( packageIndex >= 0 ? packageIndex : lines.Count, $"\t{field} = {value}" ); + } + return string.Join( '\n', lines ); + } + + private static string RemoveSrcInfoField( string text, string field ) => + string.Join( '\n', text.Split( '\n' ).Where( line => !Regex.IsMatch( line, $@"^\s*{Regex.Escape( field )} = " ) ) ); + + private static string RemoveSrcInfoValue( string text, string field, string value ) => + string.Join( '\n', text.Split( '\n' ).Where( line => !Regex.IsMatch( line, $@"^\s*{Regex.Escape( field )} = {Regex.Escape( value )}\s*$" ) ) ); + + private static string EnsureSrcInfoDependency( string text, string dependency, string anchor ) + { + if ( Regex.IsMatch( text, $@"(?m)^\s*depends = {Regex.Escape( dependency )}\s*$" ) ) + { + return text; + } + var pattern = $@"(?m)^(\s*)depends = {Regex.Escape( anchor )}\s*$"; + if ( !Regex.IsMatch( text, pattern ) ) + { + throw new ToolException( $".SRCINFO lacks dependency anchor '{anchor}' needed to add '{dependency}'." ); + } + return new Regex( pattern ).Replace( text, $"$1depends = {dependency}\n$0", 1 ); + } + + internal static string ApplyDesktopAssets( string text, JsonObject recipe ) + { + var marker = recipe["marker"]!.GetValue( ); + var end = recipe["end_marker"]!.GetValue( ); + var anchor = recipe["anchor"]!.GetValue( ); + var block = DesktopAssetBlock( recipe ); + var markerCount = text.Split( '\n' ).Count( line => line == marker ); + var endCount = text.Split( '\n' ).Count( line => line == end ); + if ( markerCount > 1 || endCount > 1 || markerCount == 0 && endCount != 0 ) + { + throw new ToolException( $"Malformed desktop asset block: {marker}" ); + } + + if ( markerCount == 1 && endCount == 1 ) + { + var match = Regex.Match( text, DesktopAssetBlockPattern( marker, end ) ); + if ( !match.Success ) + { + throw new ToolException( $"Malformed desktop asset block: {marker}" ); + } + + return match.Value == block + ? text + : text[..match.Index] + block + text[(match.Index + match.Length)..]; + } + + if ( markerCount == 1 ) + { + text = RemoveIncompleteDesktopAssetBlock( text, marker, end, recipe ); + } + else if ( HasMarkerlessDesktopAssets( text, recipe ) ) + { + text = RemoveMarkerlessDesktopAssets( text, recipe ); + } + + var destinations = recipe["destinations"]!.AsArray( ).Select( item => item!.GetValue( ) ).ToArray( ); + if ( destinations.Any( destination => text.Contains( destination, StringComparison.Ordinal ) ) ) + { + throw new ToolException( $"{recipe["label"]!.GetValue( )} PKGBUILD has unmanaged desktop integration." ); + } + if ( !text.Contains( anchor, StringComparison.Ordinal ) ) + { + throw new ToolException( $"PKGBUILD has no binary install line to extend: {anchor}" ); + } + + return text.Replace( anchor, anchor + "\n\n" + block, StringComparison.Ordinal ); + } + + private static string RemoveIncompleteDesktopAssetBlock( string text, string marker, string end, JsonObject recipe ) + { + var anchor = recipe["anchor"]!.GetValue( ); + var isConfigurator = anchor.Contains( RepositoryNames.ConfiguratorPackage, StringComparison.Ordinal ); + var lines = text.Split( '\n' ).Where( line => line != marker && line != end && + !IsDesktopAssetInstallForPackage( line, isConfigurator ) ); + return string.Join( '\n', lines ); + } + + private static string RemoveMarkerlessDesktopAssets( string text, JsonObject recipe ) + { + var anchor = recipe["anchor"]!.GetValue( ); + var isConfigurator = anchor.Contains( RepositoryNames.ConfiguratorPackage, StringComparison.Ordinal ); + var lines = text.Split( '\n' ).Where( line => !IsDesktopAssetInstallForPackage( line, isConfigurator ) ); + return string.Join( '\n', lines ); + } + + private static bool HasMarkerlessDesktopAssets( string text, JsonObject recipe ) + { + var anchor = recipe["anchor"]!.GetValue( ); + var isConfigurator = anchor.Contains( RepositoryNames.ConfiguratorPackage, StringComparison.Ordinal ); + return text.Split( '\n' ).Any( line => IsDesktopAssetInstallForPackage( line, isConfigurator ) ); + } + + private static bool IsDesktopAssetInstallForPackage( string line, bool isConfigurator ) + { + if ( !Regex.IsMatch( line, @"^\s*install\s+.*\$pkgdir/usr/share/(?:applications|icons|pixmaps)/" ) ) + { + return false; + } + + var containsConfigurator = line.Contains( CommandNames.Configurator, StringComparison.Ordinal ); + return isConfigurator || !containsConfigurator; + } + + private static void ValidateAurRecipe( string channel, string directory, string version, string sha, JsonObject recipes ) + { + var pkgbuild = Files.Read( Path.Combine( directory, RepositoryNames.PackageBuildFile ) ); + var srcinfo = Files.Read( Path.Combine( directory, RepositoryNames.SourceInfoFile ) ); + var packageRelease = Regex.Match( pkgbuild, @"(?m)^pkgrel=([1-9][0-9]*)$" ).Groups[1].Value; + if ( !Regex.IsMatch( pkgbuild, $@"(?m)^pkgver={Regex.Escape( version )}$" ) || !Regex.IsMatch( pkgbuild, $@"'{Regex.Escape( sha )}'" ) || + packageRelease.Length == 0 || !Regex.IsMatch( srcinfo, $@"(?m)^\s*pkgver = {Regex.Escape( version )}$" ) || + !Regex.IsMatch( srcinfo, $@"(?m)^\s*pkgrel = {Regex.Escape( packageRelease )}$" ) || !srcinfo.Contains( sha, StringComparison.Ordinal ) ) + { + throw new ToolException( $"{channel} PKGBUILD and .SRCINFO metadata disagree." ); + } + IReadOnlyList? dependencies = channel switch + { + PackageChannels.Source => new[] { "libxkbcommon", "gtk4", RepositoryNames.Gtk4LayerShell }, + PackageChannels.Binary => new[] { "libxkbcommon", "gtk4" }, + PackageChannels.Configurator => new[] { "libxkbcommon", "gtk4", $"libadwaita>={VersionCommands.SupportedLibadwaitaFloor}" }, + _ => null, + }; + if ( dependencies is null ) + { + throw new ToolException( $"Unknown AUR channel: {channel}" ); + } + + foreach ( var dependency in dependencies ) + { + if ( !Regex.IsMatch( pkgbuild, $@"(?m)^\s*'{Regex.Escape( dependency )}'\s*$" ) || + !Regex.IsMatch( srcinfo, $@"(?m)^\s*depends = {Regex.Escape( dependency )}\s*$" ) ) + { + throw new ToolException( $"{channel} recipe lacks dependency {dependency}." ); + } + } + if ( channel == PackageChannels.Binary && (pkgbuild.Contains( "'gtk4-layer-shell'", StringComparison.Ordinal ) || Regex.IsMatch( srcinfo, @"(?m)^\s*depends = gtk4-layer-shell\s*$" )) ) + { + throw new ToolException( "wayscriber-bin retains a dynamic gtk4-layer-shell dependency." ); + } + if ( Regex.IsMatch( srcinfo, @"(?m)^\s*(?:install|makedepends) = (?:wayscriber(?:-bin)?\.install|git)\s*$" ) ) + { + throw new ToolException( $"{channel} .SRCINFO retains obsolete install or git metadata." ); + } + var recipe = recipes[channel]!.AsObject( ); + if ( !HasExactDesktopAssetBlock( pkgbuild, recipe ) ) + { + throw new ToolException( $"{channel} PKGBUILD has a missing, stale, or malformed desktop asset block." ); + } + } + + private static string DesktopAssetBlock( JsonObject recipe ) => + recipe["marker"]!.GetValue( ) + "\n" + + string.Join( '\n', recipe["lines"]!.AsArray( ).Select( item => item!.GetValue( ) ) ) + "\n" + + recipe["end_marker"]!.GetValue( ); + + private static string DesktopAssetBlockPattern( string marker, string end ) => + $@"(?ms)^{Regex.Escape( marker )}\n.*?^{Regex.Escape( end )}$"; + + internal static bool HasExactDesktopAssetBlock( string text, JsonObject recipe ) + { + var marker = recipe["marker"]!.GetValue( ); + var end = recipe["end_marker"]!.GetValue( ); + var matches = Regex.Matches( text, DesktopAssetBlockPattern( marker, end ) ); + return matches.Count == 1 && matches[0].Value == DesktopAssetBlock( recipe ); + } + + private static string Replace( string text, string pattern, string value, string label ) + { + if ( Regex.Matches( text, pattern ).Count != 1 ) + { + throw new ToolException( $"Expected one {label} field." ); + } + return Regex.Replace( text, pattern, value ); + } + private static string ReplaceArray( string text, string name, string value ) => Replace( text, $@"(?ms)^{Regex.Escape( name )}=\([^)]*\)", value, name ); + private static string EnsureDependency( string text, string dependency, string anchor ) + { + if ( Regex.IsMatch( text, $@"(?m)^\s*'{Regex.Escape( dependency )}'\s*$" ) ) + { + return text; + } + + var pattern = $@"(?m)^(\s*'{Regex.Escape( anchor )}'\s*)$"; + if ( !Regex.IsMatch( text, pattern ) ) + { + throw new ToolException( $"PKGBUILD lacks dependency anchor '{anchor}' needed to add '{dependency}'." ); + } + + return new Regex( pattern ).Replace( text, $" '{dependency}'\n$1", 1 ); + } + private static string RemoveDependency( string text, string dependency ) => Regex.Replace( text, $@"(?m)^\s*'{Regex.Escape( dependency )}'\s*\n", string.Empty ); + private static string PackageName( string channel ) + { + var packageName = channel switch + { + PackageChannels.Source => RepositoryNames.MainPackage, + PackageChannels.Binary => RepositoryNames.BinaryPackage, + PackageChannels.Configurator => RepositoryNames.ConfiguratorPackage, + _ => null, + }; + if ( packageName is null ) + { + throw new ToolException( $"Unknown AUR channel: {channel}" ); + } + + return packageName; + } + + private static void CopyTree( string source, string destination, bool includeGit ) + { + Directory.CreateDirectory( destination ); + foreach ( var file in Directory.EnumerateFiles( source ) ) + { + File.Copy( file, Path.Combine( destination, Path.GetFileName( file ) ), true ); + } + + foreach ( var directory in Directory.EnumerateDirectories( source ) ) + { + if ( includeGit || Path.GetFileName( directory ) != ".git" ) + { + CopyTree( directory, Path.Combine( destination, Path.GetFileName( directory ) ), includeGit ); + } + } + } + + private static async Task PrepareSsh( ToolContext context, string[] args ) + { + if ( OperatingSystem.IsWindows( ) ) + { + throw new ToolException( "AUR SSH setup requires Unix." ); + } + new Arguments( args ).RequireEmpty( "aur prepare-ssh" ); + var key = context.Environment( EnvironmentVariables.AurSshPrivateKey ); + if ( key is null ) + { + throw new ToolException( "AUR_SSH_PRIVATE_KEY is missing." ); + } + + var home = context.Environment( EnvironmentVariables.Home ); + if ( home is null ) + { + throw new ToolException( "HOME is missing." ); + } + + var directory = Path.Combine( home, ".ssh" ); + Directory.CreateDirectory( directory ); + var privateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + var keyPath = Path.Combine( directory, AurKeyFileName ); + Files.WriteAtomic( keyPath, EnsureTrailingNewline( key ), privateMode ); + + var knownHostsPath = Path.Combine( directory, AurKnownHostsFileName ); + var knownHosts = context.Environment( EnvironmentVariables.AurSshKnownHosts ); + if ( knownHosts is null ) + { + var scan = await context.Run( Programs.SshKeyScan, ["-H", AurHost], capture: true ); + knownHosts = scan.StandardOutput; + } + + Files.WriteAtomic( knownHostsPath, knownHosts, privateMode ); + + var githubEnvironment = context.Environment( EnvironmentVariables.GitHubEnvironment ); + var processEnvironment = new Dictionary( AurGitEnvironment( context ) ); + if ( context.Environment( EnvironmentVariables.AurSshPassphrase ) is { Length: > 0 } passphrase ) + { + var agent = await context.Run( Programs.SshAgent, ["-s"], capture: true ); + foreach ( var name in new[] { EnvironmentVariables.SshAuthSocket, EnvironmentVariables.SshAgentProcessId } ) + { + var match = Regex.Match( agent.StandardOutput, $@"(?:^|\n){name}=([^;\n]+);" ); + if ( !match.Success ) + { + throw new ToolException( $"ssh-agent did not report {name}." ); + } + processEnvironment[name] = match.Groups[1].Value; + } + + var askpassEnvironment = new Dictionary( processEnvironment ) + { + [EnvironmentVariables.SshAskpass] = context.Path( RepositoryPaths.ToolsDirectory, "wayscriber.cs" ), + [EnvironmentVariables.SshAskpassRequire] = EnvironmentVariables.Force, + [EnvironmentVariables.Display] = ":0", + [EnvironmentVariables.WayscriberSshAskpass] = EnvironmentVariables.Enabled, + [EnvironmentVariables.AurSshPassphrase] = passphrase, + }; + await context.Run( Programs.Setsid, ["-w", Programs.SshAdd, keyPath], environment: askpassEnvironment, input: string.Empty, capture: true ); + } + + if ( githubEnvironment is not null ) + { + var values = processEnvironment.Where( pair => pair.Key is EnvironmentVariables.SshAuthSocket or EnvironmentVariables.SshAgentProcessId ) + .Select( pair => $"{pair.Key}={pair.Value}\n" ); + await File.AppendAllTextAsync( githubEnvironment, string.Concat( values ) + $"GIT_SSH_COMMAND={processEnvironment[EnvironmentVariables.GitSshCommand]}\n", context.CancellationToken ); + } + + await context.Run( Programs.Git, ["ls-remote", $"ssh://aur@{AurHost}/wayscriber.git", "HEAD"], environment: processEnvironment ); + await context.Output.WriteLineAsync( $"AUR SSH key prepared at {keyPath}" ); + return ExitCodes.Success; + } + + private static async Task ConfigureGit( ToolContext context, string[] args ) + { + new Arguments( args ).RequireEmpty( "aur configure-git" ); + var name = context.Environment( EnvironmentVariables.AurGitUserName ); + if ( name is null ) + { + throw new ToolException( "AUR_GIT_USERNAME is missing." ); + } + + var email = context.Environment( EnvironmentVariables.AurGitEmail ); + if ( email is null ) + { + throw new ToolException( "AUR_GIT_EMAIL is missing." ); + } + + await context.Run( Programs.Git, ["config", "--global", "user.name", name] ); + await context.Run( Programs.Git, ["config", "--global", "user.email", email] ); + return ExitCodes.Success; + } + + private static async Task CloneAur( ToolContext context, string[] args ) + { + var parsed = new Arguments( args ); + var root = Path.GetFullPath( parsed.TakeOption( "--directory" ) ?? context.RepositoryRoot, Environment.CurrentDirectory ); + parsed.RequireEmpty( "aur clone [--directory PATH]" ); + var gitEnvironment = AurGitEnvironment( context ); + foreach ( var (repository, directory) in new[] + { + (RepositoryNames.MainPackage, "aur-wayscriber"), + ("wayscriber-bin", "aur-wayscriber-bin"), + (RepositoryNames.ConfiguratorPackage, "aur-wayscriber-configurator"), + } ) + { + await context.Run( Programs.Git, ["clone", $"ssh://aur@{AurHost}/{repository}.git", Path.Combine( root, directory )], environment: gitEnvironment ); + } + return ExitCodes.Success; + } + + private static IReadOnlyDictionary AurGitEnvironment( ToolContext context ) + { + var home = context.Environment( EnvironmentVariables.Home ); + if ( home is null ) + { + return new Dictionary( ); + } + + var directory = Path.Combine( home, ".ssh" ); + var keyPath = Path.Combine( directory, AurKeyFileName ); + var knownHostsPath = Path.Combine( directory, AurKnownHostsFileName ); + if ( !File.Exists( keyPath ) || !File.Exists( knownHostsPath ) ) + { + return new Dictionary( ); + } + + return new Dictionary + { + [EnvironmentVariables.GitSshCommand] = ProcessRunner.FormatCommand( + Programs.Ssh, + ["-i", keyPath, "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=yes", "-o", $"UserKnownHostsFile={knownHostsPath}"] ), + }; + } + + private static async Task DeployRepositories( ToolContext context, string[] args ) + { + if ( OperatingSystem.IsWindows( ) ) + { + throw new ToolException( "Repository deployment requires Unix." ); + } + new Arguments( args ).RequireEmpty( "release deploy-package-repositories" ); + var host = context.Environment( EnvironmentVariables.DeployHost ); + if ( host is null ) + { + throw new ToolException( "DEPLOY_HOST is missing." ); + } + + var path = context.Environment( EnvironmentVariables.DeployPath ); + if ( path is null ) + { + throw new ToolException( "DEPLOY_PATH is missing." ); + } + + var user = context.Environment( EnvironmentVariables.DeployUser ) ?? "root"; + var key = context.Environment( EnvironmentVariables.PackageRepositorySshKey ); + if ( key is null ) + { + throw new ToolException( "PACKAGE_REPO_SSH_KEY is missing." ); + } + + using var temporary = new TemporaryDirectory( "wayscriber-deploy" ); + var keyPath = Path.Combine( temporary.Path, "key" ); + Files.WriteAtomic( keyPath, EnsureTrailingNewline( key ), UnixFileMode.UserRead | UnixFileMode.UserWrite ); + var knownHostsPath = Path.Combine( temporary.Path, "known_hosts" ); + var knownHosts = context.Environment( EnvironmentVariables.PackageRepositorySshKnownHosts ); + if ( string.IsNullOrWhiteSpace( knownHosts ) ) + { + var scan = await context.Run( Programs.SshKeyScan, ["-H", host], capture: true ); + knownHosts = scan.StandardOutput; + } + Files.WriteAtomic( knownHostsPath, knownHosts ); + var target = $"{user}@{host}"; + await context.Run( Programs.Ssh, ["-i", keyPath, "-o", "StrictHostKeyChecking=yes", "-o", $"UserKnownHostsFile={knownHostsPath}", target, "mkdir", "-p", $"{path}/apt", $"{path}/rpm"] ); + foreach ( var kind in new[] { PackageFormats.Apt, PackageFormats.Rpm } ) + { + await context.Run( Programs.Rsync, ["-rlvz", "--omit-dir-times", "--delete", "--no-perms", "--no-owner", "--no-group", "-e", $"ssh -i {keyPath} -o StrictHostKeyChecking=yes -o UserKnownHostsFile={knownHostsPath}", $"repo-out/{kind}/", $"{target}:{path}/{kind}/"] ); + } + + return ExitCodes.Success; + } + + private static string EnsureTrailingNewline( string value ) => + value.EndsWith( '\n' ) ? value : value + "\n"; +} diff --git a/tools/csharp/Commands/ReportCommands.cs b/tools/csharp/Commands/ReportCommands.cs new file mode 100644 index 00000000..1e68382a --- /dev/null +++ b/tools/csharp/Commands/ReportCommands.cs @@ -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 Commands + { + get; + } = + [ + new( CommandAreas.Report, CommandNames.CodeHealth, "Report Rust maintainability metrics.", SideEffect.ReadOnly, CodeHealth ), + ]; + + private static async Task 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( ); + var counts = new Dictionary { ["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, @"(? + { + 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 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; + } +} diff --git a/tools/csharp/Commands/VersionCommands.cs b/tools/csharp/Commands/VersionCommands.cs new file mode 100644 index 00000000..55ed745a --- /dev/null +++ b/tools/csharp/Commands/VersionCommands.cs @@ -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 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 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 Validate( string root, string? releaseText = null ) + { + var errors = new List( ); + 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 read, List 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 read, string cargo, List 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 + { + ["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 read, string cargo, string? releaseText, List 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 read, List 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 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 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 errors, string label, string? actual, string expected ) + { + if ( actual != expected ) + { + errors.Add( $"{label}: expected {expected}, got {actual ?? "missing"}" ); + } + } + + private static void CheckTemplateChecksum( List 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 ))}" ); + } + } +} diff --git a/tools/csharp/Infrastructure/Core.cs b/tools/csharp/Infrastructure/Core.cs new file mode 100644 index 00000000..77530314 --- /dev/null +++ b/tools/csharp/Infrastructure/Core.cs @@ -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> Handler ); + +internal sealed record ToolContext( + string RepositoryRoot, + TextWriter Output, + TextWriter Error, + IProcessRunner Processes, + CancellationToken CancellationToken, + Func? 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 values ) +{ + private const int OptionTokenCount = 2; + private readonly List _values = [.. values]; + + public IReadOnlyList 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; + } +} diff --git a/tools/csharp/Infrastructure/Files.cs b/tools/csharp/Infrastructure/Files.cs new file mode 100644 index 00000000..b2b2ea20 --- /dev/null +++ b/tools/csharp/Infrastructure/Files.cs @@ -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 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 _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( ); + 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; + } + } +} diff --git a/tools/csharp/Infrastructure/ProcessRunner.cs b/tools/csharp/Infrastructure/ProcessRunner.cs new file mode 100644 index 00000000..4047b270 --- /dev/null +++ b/tools/csharp/Infrastructure/ProcessRunner.cs @@ -0,0 +1,156 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace Wayscriber.Tools; + +internal sealed record ProcessRequest( + string FileName, + IReadOnlyList Arguments, + string WorkingDirectory, + IReadOnlyDictionary? Environment = null, + bool CaptureOutput = false, + string? StandardInput = null, + bool Trace = true, + IReadOnlySet? AllowedExitCodes = null ); + +internal sealed record ProcessResult( int ExitCode, string StandardOutput, string StandardError ) +{ + public bool IsSuccess => ExitCode == ExitCodes.Success; +} + +internal interface IProcessRunner +{ + Task RunAsync( ProcessRequest request, CancellationToken cancellationToken ); +} + +internal sealed class ProcessRunner( TextWriter output, TextWriter error ) : IProcessRunner +{ + private const int MaximumDiagnosticCharacters = 32_768; + + public async Task 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? stdout = request.CaptureOutput ? process.StandardOutput.ReadToEndAsync( cancellationToken ) : null; + Task? 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 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 Run( + this ToolContext context, + string fileName, + IReadOnlyList arguments, + string? workingDirectory = null, + IReadOnlyDictionary? environment = null, + bool capture = false, + string? input = null, + bool trace = true, + IReadOnlySet? allowedExitCodes = null ) => + context.Processes.RunAsync( + new ProcessRequest( fileName, arguments, workingDirectory ?? context.RepositoryRoot, environment, capture, input, trace, allowedExitCodes ), + context.CancellationToken ); +} diff --git a/tools/csharp/Infrastructure/RepositoryNames.cs b/tools/csharp/Infrastructure/RepositoryNames.cs new file mode 100644 index 00000000..fcda8ca4 --- /dev/null +++ b/tools/csharp/Infrastructure/RepositoryNames.cs @@ -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"; +} diff --git a/tools/csharp/Infrastructure/ToolConstants.cs b/tools/csharp/Infrastructure/ToolConstants.cs new file mode 100644 index 00000000..03d09b58 --- /dev/null +++ b/tools/csharp/Infrastructure/ToolConstants.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"; +} diff --git a/tools/csharp/includes.cs b/tools/csharp/includes.cs new file mode 100644 index 00000000..bdd12ab7 --- /dev/null +++ b/tools/csharp/includes.cs @@ -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 diff --git a/tools/install.cs b/tools/install.cs new file mode 100755 index 00000000..5bcbdc79 --- /dev/null +++ b/tools/install.cs @@ -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] ); diff --git a/tools/lint-and-test.sh b/tools/lint-and-test.sh index 12381283..abefd2f5 100755 --- a/tools/lint-and-test.sh +++ b/tools/lint-and-test.sh @@ -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 diff --git a/tools/run.sh b/tools/run.sh new file mode 100755 index 00000000..44956034 --- /dev/null +++ b/tools/run.sh @@ -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 "$@" diff --git a/tools/test-aur-desktop-assets.sh b/tools/test-aur-desktop-assets.sh new file mode 100755 index 00000000..fa7bdb5f --- /dev/null +++ b/tools/test-aur-desktop-assets.sh @@ -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" < "$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.' diff --git a/tools/test-release-packaging.sh b/tools/test-release-packaging.sh index 7b005ab2..1beff563 100755 --- a/tools/test-release-packaging.sh +++ b/tools/test-release-packaging.sh @@ -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" </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" < "${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." diff --git a/tools/update-aur-from-manifest.sh b/tools/update-aur-from-manifest.sh index 82dca089..243376b9 100644 --- a/tools/update-aur-from-manifest.sh +++ b/tools/update-aur-from-manifest.sh @@ -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 + elif has_unmanaged_desktop_assets wayscriber-configurator; then + remove_markerless_desktop_assets wayscriber-configurator fi - if grep -Fq 'packaging/wayscriber-configurator.desktop' PKGBUILD; then + 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" diff --git a/tools/wayscriber.cs b/tools/wayscriber.cs new file mode 100755 index 00000000..7856a36c --- /dev/null +++ b/tools/wayscriber.cs @@ -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 ); diff --git a/tools/wayscriber.tests.cs b/tools/wayscriber.tests.cs new file mode 100755 index 00000000..e98bea57 --- /dev/null +++ b/tools/wayscriber.tests.cs @@ -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