diff --git a/.github/workflows/distribution-validation.yaml b/.github/workflows/distribution-validation.yaml new file mode 100644 index 0000000..43880df --- /dev/null +++ b/.github/workflows/distribution-validation.yaml @@ -0,0 +1,53 @@ +name: distribution-validation +on: + workflow_dispatch: + inputs: + configuration: + description: Build configuration + required: true + default: Release + type: choice + options: [Debug, Staging, Release] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +env: + DOTNET_VERSION: 10.0.x +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + has_solution: ${{ steps.repository.outputs.has_solution }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - id: repository + shell: pwsh + run: ./packaging/Get-RepositoryMetadata.ps1 -Configuration '${{ inputs.configuration }}' -GitHubOutputPath $env:GITHUB_OUTPUT + validate: + needs: metadata + if: needs.metadata.outputs.has_solution == 'true' + strategy: + fail-fast: false + matrix: + include: + - { os: windows-latest, name: Windows x64 } + - { os: windows-11-arm, name: Windows ARM64 } + - { os: ubuntu-24.04, name: Linux x64 } + - { os: ubuntu-24.04-arm, name: Linux ARM64 } + - { os: macos-15-intel, name: macOS x64 } + - { os: macos-15, name: macOS ARM64 } + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Verify distribution + shell: pwsh + run: ./packaging/VerifyDistribution.ps1 -Configuration '${{ inputs.configuration }}' diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..4655265 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,48 @@ +name: main +on: + push: + branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +env: + DOTNET_VERSION: 10.0.x + CONFIGURATION: Release +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + has_solution: ${{ steps.repository.outputs.has_solution }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - id: repository + shell: pwsh + run: ./packaging/Get-RepositoryMetadata.ps1 -Configuration '${{ env.CONFIGURATION }}' -GitHubOutputPath $env:GITHUB_OUTPUT + validate: + needs: metadata + if: needs.metadata.outputs.has_solution == 'true' + strategy: + fail-fast: false + matrix: + include: + - { os: windows-latest, name: Windows x64 } + - { os: windows-11-arm, name: Windows ARM64 } + - { os: ubuntu-24.04, name: Linux x64 } + - { os: ubuntu-24.04-arm, name: Linux ARM64 } + - { os: macos-15-intel, name: macOS x64 } + - { os: macos-15, name: macOS ARM64 } + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Verify Release distribution + shell: pwsh + run: ./packaging/VerifyDistribution.ps1 -Configuration '${{ env.CONFIGURATION }}' diff --git a/.github/workflows/pr-build-and-test.yaml b/.github/workflows/pr-build-and-test.yaml deleted file mode 100644 index f3a5e1f..0000000 --- a/.github/workflows/pr-build-and-test.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: pr-build-and-test - -on: - pull_request: - -jobs: - build-and-test: - strategy: - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - run: dotnet clean Icod.UtilLinux.sln -c Debug - - run: dotnet restore Icod.UtilLinux.sln - - run: dotnet build Icod.UtilLinux.sln -c Debug --no-restore - - run: dotnet test Icod.UtilLinux.sln -c Debug --no-build --logger trx - - name: Validate isolated UtilLinux boundary - shell: pwsh - run: ./eng/validate-g3n-UtilLinux.ps1 -Configuration Debug diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml new file mode 100644 index 0000000..0edccb9 --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,90 @@ +name: pull-request + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_VERSION: 10.0.x + CONFIGURATION: Staging + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + has_solution: ${{ steps.repository.outputs.has_solution }} + solution_path: ${{ steps.repository.outputs.solution_path }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - id: repository + shell: pwsh + run: >- + ./packaging/Get-RepositoryMetadata.ps1 + -Configuration '${{ env.CONFIGURATION }}' + -GitHubOutputPath $env:GITHUB_OUTPUT + + validate: + needs: metadata + if: needs.metadata.outputs.has_solution == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows + verify_packages: false + - os: ubuntu-latest + name: Linux + verify_packages: true + - os: macos-latest + name: macOS + verify_packages: false + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore + run: dotnet restore '${{ needs.metadata.outputs.solution_path }}' + - name: Build + run: >- + dotnet build '${{ needs.metadata.outputs.solution_path }}' + -c ${{ env.CONFIGURATION }} + --no-restore + -p:ContinuousIntegrationBuild=true + - name: Test + run: >- + dotnet test '${{ needs.metadata.outputs.solution_path }}' + -c ${{ env.CONFIGURATION }} + --no-build + --no-restore + --logger trx + - name: Pack Staging artifacts + if: matrix.verify_packages + run: >- + dotnet pack '${{ needs.metadata.outputs.solution_path }}' + -c ${{ env.CONFIGURATION }} + --no-build + --no-restore + -o artifacts + -p:ContinuousIntegrationBuild=true + - name: Verify exact Staging package artifacts + if: matrix.verify_packages + shell: pwsh + run: >- + ./packaging/VerifyPackageArtifact.ps1 + -ArtifactDirectory artifacts + -Configuration '${{ env.CONFIGURATION }}' + -ExpectedPackageId 'Icod.UtilLinux.Tools' + -ExpectedPackageCount 1 diff --git a/.github/workflows/push-main.yaml b/.github/workflows/push-main.yaml deleted file mode 100644 index 0d5d4d5..0000000 --- a/.github/workflows/push-main.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: build and test - -on: - push: - branches: - - main - -permissions: - contents: read - -jobs: - # ---------------------------------------------------- - # STAGE 1: Cross-Platform Build & Test (All 3 OSs) - # ---------------------------------------------------- - build-and-test: - strategy: - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - - run: dotnet clean Icod.UtilLinux.sln -c Release - - run: dotnet restore Icod.UtilLinux.sln - - run: dotnet build Icod.UtilLinux.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true - - run: dotnet test Icod.UtilLinux.sln -c Release --no-build --logger trx diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..7823bdf --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,261 @@ +name: release + +on: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + DOTNET_VERSION: 10.0.x + CONFIGURATION: Release + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + solution_path: ${{ steps.repository.outputs.solution_path }} + has_executables: ${{ steps.repository.outputs.has_executables }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - id: repository + shell: pwsh + run: >- + ./packaging/Get-RepositoryMetadata.ps1 + -Configuration '${{ env.CONFIGURATION }}' + -GitHubOutputPath $env:GITHUB_OUTPUT + - name: Require tagged commit on default branch + shell: pwsh + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + git fetch origin $env:DEFAULT_BRANCH --no-tags + git merge-base --is-ancestor $env:GITHUB_SHA "origin/$env:DEFAULT_BRANCH" + if (0 -ne $LASTEXITCODE) { + throw "Release tag is not contained in '$env:DEFAULT_BRANCH'." + } + - id: version + shell: pwsh + run: | + if ($env:GITHUB_REF_NAME -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)$') { + throw 'Unsupported release tag.' + } + $version = $Matches.version + "version=$version" >> $env:GITHUB_OUTPUT + "prerelease=$($version.Contains('-').ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + + package: + needs: metadata + runs-on: ubuntu-latest + outputs: + has_packages: ${{ steps.select.outputs.has_packages }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - run: dotnet restore '${{ needs.metadata.outputs.solution_path }}' + - run: >- + dotnet build '${{ needs.metadata.outputs.solution_path }}' + -c Release + --no-restore + -p:ContinuousIntegrationBuild=true + - run: >- + dotnet pack '${{ needs.metadata.outputs.solution_path }}' + -c Release + --no-build + --no-restore + -o artifacts/release + -p:ContinuousIntegrationBuild=true + - id: select + shell: pwsh + run: >- + ./packaging/SelectReleasePackages.ps1 + -SourceDirectory artifacts/release + -DestinationDirectory artifacts/release-packages + -ExpectedVersion '${{ needs.metadata.outputs.version }}' + -GitHubOutputPath $env:GITHUB_OUTPUT + - name: Verify packages + if: steps.select.outputs.has_packages == 'true' + shell: pwsh + run: >- + ./packaging/VerifyPackageArtifact.ps1 + -ArtifactDirectory artifacts/release-packages + -Configuration Release + -ExpectedVersion '${{ needs.metadata.outputs.version }}' + -ExpectedPackageId 'Icod.UtilLinux.Tools' + -ExpectedPackageCount 1 + - name: Upload packages + if: steps.select.outputs.has_packages == 'true' + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: artifacts/release-packages/*.nupkg + + archives: + needs: metadata + if: needs.metadata.outputs.has_executables == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + - os: windows-11-arm + rid: win-arm64 + - os: ubuntu-24.04 + rid: linux-x64 + - os: ubuntu-24.04-arm + rid: linux-arm64 + - os: macos-15-intel + rid: osx-x64 + - os: macos-15 + rid: osx-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - shell: pwsh + run: >- + ./packaging/BuildReleaseArchive.ps1 + -RuntimeIdentifier '${{ matrix.rid }}' + -Version '${{ needs.metadata.outputs.version }}' + -ArchiveBaseName '${{ github.event.repository.name }}' + - uses: actions/upload-artifact@v4 + with: + name: release-archive-${{ matrix.rid }} + path: artifacts/release/${{ github.event.repository.name }}-${{ needs.metadata.outputs.version }}-${{ matrix.rid }}.zip + + publish-nuget: + needs: [metadata, package] + if: needs.package.outputs.has_packages == 'true' + runs-on: ubuntu-latest + environment: Release + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: artifacts/package + - id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + - shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: | + Get-ChildItem artifacts/package -Filter '*.nupkg' | ForEach-Object { + dotnet nuget push $_.FullName ` + --api-key $env:NUGET_API_KEY ` + --source https://api.nuget.org/v3/index.json ` + --skip-duplicate + if (0 -ne $LASTEXITCODE) { + throw "NuGet publication failed for '$($_.Name)'." + } + } + + publish-github-packages: + needs: [metadata, package] + if: needs.package.outputs.has_packages == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: artifacts/package + - shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + Get-ChildItem artifacts/package -Filter '*.nupkg' | ForEach-Object { + dotnet nuget push $_.FullName ` + --api-key $env:GITHUB_TOKEN ` + --source 'https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json' ` + --skip-duplicate + if (0 -ne $LASTEXITCODE) { + throw "GitHub Packages publication failed for '$($_.Name)'." + } + } + + github-release: + needs: [metadata, package, archives, publish-nuget, publish-github-packages] + if: >- + always() && + needs.metadata.result == 'success' && + needs.package.result == 'success' && + (needs.archives.result == 'success' || needs.archives.result == 'skipped') && + (needs.publish-nuget.result == 'success' || needs.publish-nuget.result == 'skipped') && + (needs.publish-github-packages.result == 'success' || needs.publish-github-packages.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download packages + if: needs.package.outputs.has_packages == 'true' + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: artifacts/assets + - name: Download archives + if: needs.metadata.outputs.has_executables == 'true' + uses: actions/download-artifact@v4 + with: + pattern: release-archive-* + path: artifacts/assets + merge-multiple: true + - name: Create checksums and release + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $files = @(Get-ChildItem artifacts/assets -File | Sort-Object Name) + $lines = $files | ForEach-Object { + "$((Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()) $($_.Name)" + } + [IO.File]::WriteAllLines( + (Join-Path (Resolve-Path artifacts/assets) 'SHA256SUMS.txt'), + $lines, + [Text.UTF8Encoding]::new($false) + ) + $assets = @(Get-ChildItem artifacts/assets -File | ForEach-Object { $_.FullName }) + $arguments = @( + 'release', 'create', $env:GITHUB_REF_NAME, + '--verify-tag', + '--title', '${{ github.event.repository.name }} ${{ needs.metadata.outputs.version }}', + '--generate-notes' + ) + if ('true' -eq '${{ needs.metadata.outputs.prerelease }}') { + $arguments += @('--prerelease', '--latest=false') + } + $arguments += $assets + & gh @arguments + if (0 -ne $LASTEXITCODE) { + throw 'GitHub Release creation failed.' + } diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..56ed4cd --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + 1.0.1 + $(VersionPrefix) + $(VersionPrefix) + $(VersionPrefix).0 + $(VersionPrefix).0 + + diff --git a/Icod.UtilLinux.sln b/Icod.UtilLinux.sln index 1711015..cb34c26 100644 --- a/Icod.UtilLinux.sln +++ b/Icod.UtilLinux.sln @@ -6,10 +6,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.UtilLinux.Kill", "kill EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.UtilLinux.Renice", "renice\Icod.UtilLinux.Renice.csproj", "{9A73B8A1-A66A-4B6C-BD8F-0468751D6240}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icod.UtilLinux.Router", "utillinux\Icod.UtilLinux.Router.csproj", "{6E7A0D56-E8B0-4A7C-AE6B-9D7BDA5B3B5F}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.UtilLinux.Kill.Tests", "tests\Kill.Tests\Icod.UtilLinux.Kill.Tests.csproj", "{BC50FB1A-C7E6-4A29-83C4-9C8E38B39008}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.UtilLinux.Renice.Tests", "tests\Renice.Tests\Icod.UtilLinux.Renice.Tests.csproj", "{F3E27F13-2A53-4C79-ADCF-A666C3FE2357}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.UtilLinux.Router.Tests", "tests\Router.Tests\Icod.UtilLinux.Router.Tests.csproj", "{A1E08B21-7D77-4555-BF9D-9D54A453D1C2}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" EndProject Global @@ -31,6 +35,12 @@ Global {9A73B8A1-A66A-4B6C-BD8F-0468751D6240}.Release|Any CPU.Build.0 = Release|Any CPU {9A73B8A1-A66A-4B6C-BD8F-0468751D6240}.Staging|Any CPU.ActiveCfg = Staging|Any CPU {9A73B8A1-A66A-4B6C-BD8F-0468751D6240}.Staging|Any CPU.Build.0 = Staging|Any CPU + {6E7A0D56-E8B0-4A7C-AE6B-9D7BDA5B3B5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E7A0D56-E8B0-4A7C-AE6B-9D7BDA5B3B5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E7A0D56-E8B0-4A7C-AE6B-9D7BDA5B3B5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E7A0D56-E8B0-4A7C-AE6B-9D7BDA5B3B5F}.Release|Any CPU.Build.0 = Release|Any CPU + {6E7A0D56-E8B0-4A7C-AE6B-9D7BDA5B3B5F}.Staging|Any CPU.ActiveCfg = Staging|Any CPU + {6E7A0D56-E8B0-4A7C-AE6B-9D7BDA5B3B5F}.Staging|Any CPU.Build.0 = Staging|Any CPU {BC50FB1A-C7E6-4A29-83C4-9C8E38B39008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BC50FB1A-C7E6-4A29-83C4-9C8E38B39008}.Debug|Any CPU.Build.0 = Debug|Any CPU {BC50FB1A-C7E6-4A29-83C4-9C8E38B39008}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -43,6 +53,12 @@ Global {F3E27F13-2A53-4C79-ADCF-A666C3FE2357}.Release|Any CPU.Build.0 = Release|Any CPU {F3E27F13-2A53-4C79-ADCF-A666C3FE2357}.Staging|Any CPU.ActiveCfg = Staging|Any CPU {F3E27F13-2A53-4C79-ADCF-A666C3FE2357}.Staging|Any CPU.Build.0 = Staging|Any CPU + {A1E08B21-7D77-4555-BF9D-9D54A453D1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1E08B21-7D77-4555-BF9D-9D54A453D1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1E08B21-7D77-4555-BF9D-9D54A453D1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1E08B21-7D77-4555-BF9D-9D54A453D1C2}.Release|Any CPU.Build.0 = Release|Any CPU + {A1E08B21-7D77-4555-BF9D-9D54A453D1C2}.Staging|Any CPU.ActiveCfg = Staging|Any CPU + {A1E08B21-7D77-4555-BF9D-9D54A453D1C2}.Staging|Any CPU.Build.0 = Staging|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -50,5 +66,6 @@ Global GlobalSection(NestedProjects) = preSolution {BC50FB1A-C7E6-4A29-83C4-9C8E38B39008} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {F3E27F13-2A53-4C79-ADCF-A666C3FE2357} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {A1E08B21-7D77-4555-BF9D-9D54A453D1C2} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 4efb4c7..38f5ecf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Icod.UtilLinux +[![PR Staging build](https://github.com/uniblab/Icod.UtilLinux/actions/workflows/pull-request.yaml/badge.svg?event=pull_request)](https://github.com/uniblab/Icod.UtilLinux/actions/workflows/pull-request.yaml) +[![Main Release validation](https://github.com/uniblab/Icod.UtilLinux/actions/workflows/main.yaml/badge.svg?branch=main)](https://github.com/uniblab/Icod.UtilLinux/actions/workflows/main.yaml) + `Icod.UtilLinux` contains .NET implementations of selected util-linux commands extracted from the former multi-suite `Icod.CoreUtils` development repository. Current commands: @@ -9,14 +12,62 @@ Current commands: The projects target `net10.0` with C# 13 and depend on the published `Icod.CommandFramework` package for neutral process-control and platform identity contracts. This repository does not depend on `Icod.CoreUtils.Shared`. +## Router and .NET tool package + +The repository includes the `utillinux` command router: + +```text +utillinux kill [args...] +utillinux renice [args...] +``` + +Dispatch is performed in-process to the managed command implementations; the router does not spawn the standalone executables. + +The repository produces one NuGet/.NET tool package: + +```text +Icod.UtilLinux.Tools +``` + +Install the current version with: + +```text +dotnet tool install --global Icod.UtilLinux.Tools --version 1.0.1 +``` + +The installed tool command is `utillinux`. The standalone `kill` and `renice` executables remain first-class release archive outputs for each supported RID. + +Router-level options are: + +```text +utillinux --help +utillinux --version +``` + +Each routed command retains its own command-line contract, including its own `--help` and version options. + ## Build and test +On Windows: + ```text -dotnet restore Icod.UtilLinux.sln -dotnet build Icod.UtilLinux.sln -c Release --no-restore -dotnet test Icod.UtilLinux.sln -c Release --no-build --logger trx +build.cmd ``` -CI runs on Windows, Ubuntu, and macOS. +On Unix-like hosts: + +```text +./build.sh +``` + +The wrappers use `Debug` by default and run clean → restore → build → test → pack → validate. Individual `clean`, `restore`, `build`, `test`, `pack`, and `validate` stages may also be requested. + +The CI/CD lifecycle follows the canonical `uniblab/.github` pattern: pull requests use `Staging` on Windows/Linux/macOS, pushes to `main` use six-runner `Release` distribution validation, and `v` tags drive package/archive publication. + +## Versioning + +The repository version is centralized in [`Directory.Build.props`](Directory.Build.props). The current version is `1.0.1`; production projects inherit `Version`, `PackageVersion`, `AssemblyVersion`, and `FileVersion` from that single source. + +See [`packaging/README.md`](packaging/README.md) for build, validation, packaging, and release details. -The executable assembly names remain lowercase (`kill` and `renice`) to preserve command identity. +The executable assembly names remain lowercase (`utillinux`, `kill`, and `renice`) to preserve command identity. diff --git a/build.cmd b/build.cmd index e3e4e0b..5cd5b0b 100644 --- a/build.cmd +++ b/build.cmd @@ -1,69 +1,8 @@ @echo off setlocal -if "%~1"=="" goto all +set "SECTION=%~1" +if "%SECTION%"=="" set "SECTION=all" -if /I "%~1"=="clean" goto run-clean -if /I "%~1"=="restore" goto run-restore -if /I "%~1"=="build" goto run-build -if /I "%~1"=="test" goto run-test - -echo Invalid section: "%~1" -echo Usage: %~nx0 [clean^|restore^|build^|test] -exit /b 1 - - -:all -call :clean || exit /b 1 -call :restore || exit /b 1 -call :build || exit /b 1 -call :test || exit /b 1 -exit /b 0 - - -:run-clean -call :clean -exit /b %errorlevel% - - -:run-restore -call :restore -exit /b %errorlevel% - - -:run-build -call :build -exit /b %errorlevel% - - -:run-test -call :test -exit /b %errorlevel% - - -:clean -echo. -echo === Clean === -dotnet clean Icod.UtilLinux.sln -c Debug -exit /b %errorlevel% - - -:restore -echo. -echo === Restore === -dotnet restore Icod.UtilLinux.sln -exit /b %errorlevel% - - -:build -echo. -echo === Build === -dotnet build Icod.UtilLinux.sln -c Debug --no-restore -exit /b %errorlevel% - - -:test -echo. -echo === Test === -dotnet test Icod.UtilLinux.sln -c Debug --no-build +powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File packaging\Invoke-Build.ps1 -Section "%SECTION%" -Configuration Debug exit /b %errorlevel% diff --git a/build.sh b/build.sh index 061913c..5bf3912 100644 --- a/build.sh +++ b/build.sh @@ -1,59 +1,7 @@ #!/usr/bin/env sh set -eu -clean() -{ - printf '\n=== Clean ===\n' - dotnet clean Icod.UtilLinux.sln -c Debug -} - -restore() -{ - printf '\n=== Restore ===\n' - dotnet restore Icod.UtilLinux.sln -} - -build() -{ - printf '\n=== Build ===\n' - dotnet build Icod.UtilLinux.sln -c Debug --no-restore -} - -test() -{ - printf '\n=== Test ===\n' - dotnet test Icod.UtilLinux.sln \ - -c Debug \ - --no-build -} - -case "${1-}" in - "") - clean - restore - build - test - ;; - - clean) - clean - ;; - - restore) - restore - ;; - - build) - build - ;; - - test) - test - ;; - - *) - printf 'Invalid section: %s\n' "$1" >&2 - printf 'Usage: %s [clean|restore|build|test]\n' "$0" >&2 - exit 1 - ;; -esac +section=${1-all} +pwsh -NoLogo -NoProfile -File ./packaging/Invoke-Build.ps1 \ + -Section "$section" \ + -Configuration Debug diff --git a/kill/Icod.UtilLinux.Kill.csproj b/kill/Icod.UtilLinux.Kill.csproj index b0b9c7c..0c619dc 100644 --- a/kill/Icod.UtilLinux.Kill.csproj +++ b/kill/Icod.UtilLinux.Kill.csproj @@ -10,7 +10,7 @@ ..\bin\$(Configuration)\ kill Icod.UtilLinux.Kill - 1.0.1 + false AnyCPU @@ -47,7 +47,6 @@ true CS1591 - PreserveNewest @@ -55,7 +54,6 @@ true \ - PreserveNewest $(AssemblyName).LICENSE.txt diff --git a/packaging/BuildReleaseArchive.ps1 b/packaging/BuildReleaseArchive.ps1 new file mode 100644 index 0000000..c28a58e --- /dev/null +++ b/packaging/BuildReleaseArchive.ps1 @@ -0,0 +1,38 @@ +param( + [Parameter(Mandatory=$true)][string]$RuntimeIdentifier, + [Parameter(Mandatory=$true)][string]$Version, + [ValidateSet('Debug','Staging','Release')][string]$Configuration='Release', + [string]$ArchiveBaseName='' +) +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$repositoryRoot=[System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +$solutionPath=Get-RepositorySolution -RepositoryRoot $repositoryRoot +$projects=@(Get-SolutionProjects -SolutionPath $solutionPath -RepositoryRoot $repositoryRoot) +$executables=@(Get-ExecutableProjects -ProjectPaths $projects -Configuration $Configuration) +if(0 -eq $executables.Count){ throw 'The solution contains no executable projects to archive.' } +if([string]::IsNullOrWhiteSpace($ArchiveBaseName)){ $ArchiveBaseName=Split-Path $repositoryRoot -Leaf } +$releaseRoot=Join-Path $repositoryRoot 'artifacts/release' +$stageName="$ArchiveBaseName-$Version-$RuntimeIdentifier" +$stageParent=Join-Path $releaseRoot 'stage' +$stage=Join-Path $stageParent $stageName +$archive=Join-Path $releaseRoot "$stageName.zip" +if(Test-Path -LiteralPath $stage){ Remove-Item -LiteralPath $stage -Recurse -Force } +New-Item -ItemType Directory -Path $stage -Force | Out-Null +Push-Location $repositoryRoot +try { + Invoke-DotNet -Arguments @('restore',$solutionPath,'-r',$RuntimeIdentifier) + foreach($executable in $executables){ + $publish=Join-Path $releaseRoot "publish/$RuntimeIdentifier/$($executable.AssemblyName)" + Invoke-DotNet -Arguments @('publish',$executable.ProjectPath,'-c',$Configuration,'-r',$RuntimeIdentifier,'--no-restore','--self-contained','false','-p:PublishSingleFile=true','-p:PublishTrimmed=false','-p:DebugType=None','-p:DebugSymbols=false','-p:ContinuousIntegrationBuild=true','-o',$publish) + $file=if($RuntimeIdentifier.StartsWith('win-')){"$($executable.AssemblyName).exe"}else{$executable.AssemblyName} + Copy-Item -LiteralPath (Join-Path $publish $file) -Destination (Join-Path $stage $file) + } + foreach($support in @('README.md','LICENSE')){ $source=Join-Path $repositoryRoot $support; if(Test-Path -LiteralPath $source){ Copy-Item $source -Destination (Join-Path $stage $support) } } + if($RuntimeIdentifier.StartsWith('win-')){ Compress-Archive -LiteralPath $stage -DestinationPath $archive -CompressionLevel Optimal } else { + Get-ChildItem -LiteralPath $stage -File | Where-Object { $_.Name -in @('kill','renice') } | ForEach-Object { & chmod +x $_.FullName } + Push-Location $stageParent; try { & zip -r -q $archive $stageName; if(0 -ne $LASTEXITCODE){ throw 'zip failed' } } finally { Pop-Location } + } + Write-Host "Created release archive: $archive" +} finally { Pop-Location } diff --git a/packaging/Get-RepositoryMetadata.ps1 b/packaging/Get-RepositoryMetadata.ps1 new file mode 100644 index 0000000..039c75f --- /dev/null +++ b/packaging/Get-RepositoryMetadata.ps1 @@ -0,0 +1,44 @@ +param( + [ValidateSet('Debug', 'Staging', 'Release')] + [string]$Configuration = 'Release', + + [string]$GitHubOutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force + +$solutionPath = Get-RepositorySolution -RepositoryRoot $repositoryRoot -AllowMissing +$hasSolution = $null -ne $solutionPath +$hasExecutables = $false +$portableSolutionPath = '' + +if ($hasSolution) { + $projects = @(Get-SolutionProjects -SolutionPath $solutionPath -RepositoryRoot $repositoryRoot) + $hasExecutables = 0 -lt @(Get-ExecutableProjects -ProjectPaths $projects -Configuration $Configuration).Count + $portableSolutionPath = [System.IO.Path]::GetRelativePath( + $repositoryRoot, + $solutionPath + ).Replace( + [System.IO.Path]::DirectorySeparatorChar, + '/' + ) +} + +$result = [ordered]@{ + RepositoryRoot = $repositoryRoot + HasSolution = $hasSolution + SolutionPath = $portableSolutionPath + HasExecutables = $hasExecutables +} + +if (-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)) { + "has_solution=$($hasSolution.ToString().ToLowerInvariant())" >> $GitHubOutputPath + "solution_path=$portableSolutionPath" >> $GitHubOutputPath + "has_executables=$($hasExecutables.ToString().ToLowerInvariant())" >> $GitHubOutputPath +} + +[pscustomobject]$result diff --git a/packaging/Invoke-Build.ps1 b/packaging/Invoke-Build.ps1 new file mode 100644 index 0000000..3e5a1f0 --- /dev/null +++ b/packaging/Invoke-Build.ps1 @@ -0,0 +1,79 @@ +param( + [ValidateSet('all', 'clean', 'restore', 'build', 'test', 'pack', 'validate')] + [string]$Section = 'all', + + [ValidateSet('Debug', 'Staging', 'Release')] + [string]$Configuration = 'Debug' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +$solutionPath = Get-RepositorySolution -RepositoryRoot $repositoryRoot +$artifactDirectory = Join-Path $repositoryRoot 'artifacts' + +function Invoke-Clean { + Invoke-DotNet -Arguments @('clean', $solutionPath, '-c', $Configuration) +} + +function Invoke-Restore { + Invoke-DotNet -Arguments @('restore', $solutionPath) +} + +function Invoke-Build { + Invoke-DotNet -Arguments @('build', $solutionPath, '-c', $Configuration, '--no-restore') +} + +function Invoke-Test { + Invoke-DotNet -Arguments @( + 'test', + $solutionPath, + '-c', $Configuration, + '--no-build', + '--no-restore' + ) +} + +function Invoke-Pack { + New-Item -ItemType Directory -Path $artifactDirectory -Force | Out-Null + Invoke-DotNet -Arguments @( + 'pack', + $solutionPath, + '-c', $Configuration, + '--no-build', + '--no-restore', + '-o', $artifactDirectory + ) +} + +function Invoke-Validate { + & (Join-Path $PSScriptRoot 'VerifyPackageArtifact.ps1') ` + -ArtifactDirectory $artifactDirectory ` + -Configuration $Configuration ` + -ExpectedPackageId 'Icod.UtilLinux.Tools' ` + -ExpectedPackageCount 1 +} + +Push-Location $repositoryRoot +try { + switch ($Section) { + 'all' { + Invoke-Clean + Invoke-Restore + Invoke-Build + Invoke-Test + Invoke-Pack + Invoke-Validate + } + 'clean' { Invoke-Clean } + 'restore' { Invoke-Restore } + 'build' { Invoke-Build } + 'test' { Invoke-Test } + 'pack' { Invoke-Pack } + 'validate' { Invoke-Validate } + } +} finally { + Pop-Location +} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..8701258 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,13 @@ +# Icod.UtilLinux build and packaging + +This repository follows the canonical `uniblab/.github` .NET lifecycle. + +- Local `build.cmd` / `build.sh`: `Debug`, running clean → restore → build → test → pack → validate. +- Pull requests: `Staging` on Windows, Linux, and macOS; Linux additionally validates generated packages. +- `main`: `Release` distribution validation on Windows/Linux/macOS, x64 and ARM64. +- Manual distribution validation: selectable Debug/Staging/Release across the same six runners. +- `v` tags: Release package selection/publication plus RID archives and checksums. + +Repository versioning is centralized in `/Directory.Build.props`; the current version is `1.0.1`. Production projects must not duplicate the repository version locally. + +The release archive builder discovers executable projects from the solution and currently stages `kill` and `renice` together with the root README and LICENSE. diff --git a/packaging/RepositoryTools.psm1 b/packaging/RepositoryTools.psm1 new file mode 100644 index 0000000..98874dd --- /dev/null +++ b/packaging/RepositoryTools.psm1 @@ -0,0 +1,66 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-DotNet { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + Write-Host "> dotnet $($Arguments -join ' ')" + & dotnet @Arguments + if (0 -ne $LASTEXITCODE) { throw "dotnet exited with status $LASTEXITCODE." } +} +function Get-RepositorySolution { + param([Parameter(Mandatory = $true)][string]$RepositoryRoot,[switch]$AllowMissing) + $solutions = @(Get-ChildItem -LiteralPath $RepositoryRoot -File | Where-Object { $_.Extension -in @('.sln', '.slnx') }) + if (0 -eq $solutions.Count -and $AllowMissing) { return $null } + if (1 -ne $solutions.Count) { throw "Expected exactly one root .sln or .slnx file; found $($solutions.Count)." } + return $solutions[0].FullName +} +function Get-SolutionProjects { + param([Parameter(Mandatory = $true)][string]$SolutionPath,[Parameter(Mandatory = $true)][string]$RepositoryRoot) + $output = @(& dotnet sln $SolutionPath list) + if (0 -ne $LASTEXITCODE) { throw "Unable to list projects in '$SolutionPath'." } + $projects = @() + foreach ($line in $output) { + $candidate = $line.Trim() + if (-not $candidate.EndsWith('.csproj', [System.StringComparison]::OrdinalIgnoreCase)) { continue } + $fullPath = if ([System.IO.Path]::IsPathRooted($candidate)) { $candidate } else { Join-Path $RepositoryRoot $candidate } + $projects += [System.IO.Path]::GetFullPath($fullPath) + } + return $projects +} +function Get-MSBuildProperty { + param([Parameter(Mandatory = $true)][string]$ProjectPath,[Parameter(Mandatory = $true)][string]$Name,[string]$Configuration = 'Release') + $value = @(& dotnet msbuild $ProjectPath -nologo "-property:Configuration=$Configuration" "-getProperty:$Name") -join "`n" + if (0 -ne $LASTEXITCODE) { throw "Unable to read MSBuild property '$Name' from '$ProjectPath'." } + return $value.Trim() +} +function Get-ExecutableProjects { + param([Parameter(Mandatory = $true)][string[]]$ProjectPaths,[string]$Configuration = 'Release') + $result = @() + foreach ($projectPath in $ProjectPaths) { + $outputType = Get-MSBuildProperty -ProjectPath $projectPath -Name 'OutputType' -Configuration $Configuration + if ($outputType -in @('Exe','WinExe')) { + $assemblyName = Get-MSBuildProperty -ProjectPath $projectPath -Name 'AssemblyName' -Configuration $Configuration + $result += [pscustomobject]@{ ProjectPath=$projectPath; AssemblyName=$assemblyName } + } + } + return $result +} +function Get-PackageMetadata { + param([Parameter(Mandatory = $true)][string]$PackagePath) + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $entry = @($archive.Entries | Where-Object { $_.FullName.EndsWith('.nuspec',[System.StringComparison]::OrdinalIgnoreCase) }) + if (1 -ne $entry.Count) { throw "Package '$PackagePath' must contain exactly one nuspec." } + $reader = [System.IO.StreamReader]::new($entry[0].Open()) + try { [xml]$xml = $reader.ReadToEnd() } finally { $reader.Dispose() } + $metadata = $xml.SelectSingleNode("/*[local-name()='package']/*[local-name()='metadata']") + $readme = $metadata.SelectSingleNode("*[local-name()='readme']") + return [pscustomobject]@{ + Id=$metadata.SelectSingleNode("*[local-name()='id']").InnerText.Trim() + Version=$metadata.SelectSingleNode("*[local-name()='version']").InnerText.Trim() + Readme=if($null -eq $readme){''}else{$readme.InnerText.Trim().Replace('\\','/')} + } + } finally { $archive.Dispose() } +} +Export-ModuleMember -Function @('Invoke-DotNet','Get-RepositorySolution','Get-SolutionProjects','Get-MSBuildProperty','Get-ExecutableProjects','Get-PackageMetadata') diff --git a/packaging/SelectReleasePackages.ps1 b/packaging/SelectReleasePackages.ps1 new file mode 100644 index 0000000..de377d2 --- /dev/null +++ b/packaging/SelectReleasePackages.ps1 @@ -0,0 +1,24 @@ +param( + [Parameter(Mandatory=$true)][string]$SourceDirectory, + [Parameter(Mandatory=$true)][string]$DestinationDirectory, + [Parameter(Mandatory=$true)][string]$ExpectedVersion, + [string]$GitHubOutputPath='' +) +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$repositoryRoot=[System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +foreach($name in @('SourceDirectory','DestinationDirectory')){ $value=Get-Variable -Name $name -ValueOnly; if(-not [System.IO.Path]::IsPathRooted($value)){ $value=Join-Path $repositoryRoot $value }; Set-Variable -Name $name -Value ([System.IO.Path]::GetFullPath($value)) } +if(Test-Path -LiteralPath $DestinationDirectory){ Remove-Item -LiteralPath $DestinationDirectory -Recurse -Force } +New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null +$selected=@() +foreach($package in @(Get-ChildItem -LiteralPath $SourceDirectory -Filter '*.nupkg' -File | Where-Object { -not $_.Name.EndsWith('.symbols.nupkg',[System.StringComparison]::OrdinalIgnoreCase) } | Sort-Object Name)){ + $metadata=Get-PackageMetadata -PackagePath $package.FullName + if($metadata.Version -ne $ExpectedVersion){ continue } + $destination=Join-Path $DestinationDirectory $package.Name + Copy-Item -LiteralPath $package.FullName -Destination $destination + $selected += $destination +} +$hasPackages=0 -lt $selected.Count +if(-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)){ "has_packages=$($hasPackages.ToString().ToLowerInvariant())" >> $GitHubOutputPath; "package_count=$($selected.Count)" >> $GitHubOutputPath } +Write-Host "Selected $($selected.Count) NuGet package(s) for release $ExpectedVersion." diff --git a/packaging/VerifyDistribution.ps1 b/packaging/VerifyDistribution.ps1 new file mode 100644 index 0000000..f66163c --- /dev/null +++ b/packaging/VerifyDistribution.ps1 @@ -0,0 +1,61 @@ +param( + [ValidateSet('Debug', 'Staging', 'Release')] + [string]$Configuration = 'Release', + + [switch]$SkipPackageValidation +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force +$solutionPath = Get-RepositorySolution -RepositoryRoot $repositoryRoot +$validationRoot = Join-Path $repositoryRoot 'artifacts/distribution-validation' +$packageDirectory = Join-Path $validationRoot 'packages' + +if (Test-Path -LiteralPath $validationRoot) { + Remove-Item -LiteralPath $validationRoot -Recurse -Force +} +New-Item -ItemType Directory -Path $packageDirectory -Force | Out-Null + +Push-Location $repositoryRoot +try { + Invoke-DotNet -Arguments @('restore', $solutionPath) + Invoke-DotNet -Arguments @( + 'build', + $solutionPath, + '-c', $Configuration, + '--no-restore', + '-p:ContinuousIntegrationBuild=true' + ) + Invoke-DotNet -Arguments @( + 'test', + $solutionPath, + '-c', $Configuration, + '--no-build', + '--no-restore', + '--logger', 'trx' + ) + + if (-not $SkipPackageValidation) { + Invoke-DotNet -Arguments @( + 'pack', + $solutionPath, + '-c', $Configuration, + '--no-build', + '--no-restore', + '-o', $packageDirectory, + '-p:ContinuousIntegrationBuild=true' + ) + & (Join-Path $PSScriptRoot 'VerifyPackageArtifact.ps1') ` + -ArtifactDirectory $packageDirectory ` + -Configuration $Configuration ` + -ExpectedPackageId 'Icod.UtilLinux.Tools' ` + -ExpectedPackageCount 1 + } + + Write-Host "Distribution verification completed successfully ($Configuration)." +} finally { + Pop-Location +} diff --git a/packaging/VerifyPackageArtifact.ps1 b/packaging/VerifyPackageArtifact.ps1 new file mode 100644 index 0000000..b302129 --- /dev/null +++ b/packaging/VerifyPackageArtifact.ps1 @@ -0,0 +1,92 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDirectory, + + [ValidateSet('Debug', 'Staging', 'Release')] + [string]$Configuration = 'Release', + + [string]$ExpectedVersion = '', + + [string]$ExpectedPackageId = '', + + [int]$ExpectedPackageCount = -1, + + [switch]$AllowNoPackages, + + [string]$GitHubOutputPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force + +if (-not [System.IO.Path]::IsPathRooted($ArtifactDirectory)) { + $ArtifactDirectory = Join-Path $repositoryRoot $ArtifactDirectory +} +$ArtifactDirectory = [System.IO.Path]::GetFullPath($ArtifactDirectory) +if (-not (Test-Path -LiteralPath $ArtifactDirectory -PathType Container)) { + throw "Artifact directory '$ArtifactDirectory' does not exist." +} + +$packages = @( + Get-ChildItem -LiteralPath $ArtifactDirectory -Filter '*.nupkg' -File | + Where-Object { + -not $_.Name.EndsWith( + '.symbols.nupkg', + [System.StringComparison]::OrdinalIgnoreCase + ) + } | + Sort-Object Name +) + +if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + $packages = @( + $packages | + Where-Object { + (Get-PackageMetadata -PackagePath $_.FullName).Version -eq $ExpectedVersion + } + ) +} + +if (0 -eq $packages.Count -and -not $AllowNoPackages) { + throw "No NuGet packages were found in '$ArtifactDirectory'." +} +if (0 -le $ExpectedPackageCount -and $ExpectedPackageCount -ne $packages.Count) { + throw "Expected $ExpectedPackageCount NuGet package(s) in '$ArtifactDirectory'; found $($packages.Count)." +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +foreach ($package in $packages) { + $metadata = Get-PackageMetadata -PackagePath $package.FullName + $hasExpectedPackageId = -not [string]::IsNullOrWhiteSpace($ExpectedPackageId) + if ($hasExpectedPackageId -and $metadata.Id -ne $ExpectedPackageId) { + throw "Package '$($package.Name)' has ID '$($metadata.Id)'; expected '$ExpectedPackageId'." + } + + Write-Host "Verifying $($metadata.Id) $($metadata.Version): $($package.FullName)" + $archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) + try { + if (0 -eq $archive.Entries.Count) { + throw "Package '$($package.FullName)' is empty." + } + if (-not [string]::IsNullOrWhiteSpace($metadata.Readme)) { + $readmeEntry = $archive.Entries | + Where-Object { $_.FullName -eq $metadata.Readme } | + Select-Object -First 1 + if ($null -eq $readmeEntry) { + throw "Package '$($package.FullName)' declares missing readme '$($metadata.Readme)'." + } + } + } finally { + $archive.Dispose() + } +} + +if (-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)) { + "package_count=$($packages.Count)" >> $GitHubOutputPath + "has_packages=$((0 -lt $packages.Count).ToString().ToLowerInvariant())" >> $GitHubOutputPath +} + +Write-Host "Exact package verification completed successfully for $($packages.Count) package(s) ($Configuration)." diff --git a/renice/Icod.UtilLinux.Renice.csproj b/renice/Icod.UtilLinux.Renice.csproj index f7cba36..2a80588 100644 --- a/renice/Icod.UtilLinux.Renice.csproj +++ b/renice/Icod.UtilLinux.Renice.csproj @@ -10,7 +10,7 @@ ..\bin\$(Configuration)\ renice Icod.UtilLinux.Renice - 1.0.1 + false AnyCPU @@ -47,7 +47,6 @@ true CS1591 - PreserveNewest @@ -55,7 +54,6 @@ true \ - PreserveNewest $(AssemblyName).LICENSE.txt diff --git a/tests/Router.Tests/Icod.UtilLinux.Router.Tests.csproj b/tests/Router.Tests/Icod.UtilLinux.Router.Tests.csproj new file mode 100644 index 0000000..da7fd9d --- /dev/null +++ b/tests/Router.Tests/Icod.UtilLinux.Router.Tests.csproj @@ -0,0 +1,47 @@ + + + + net10.0 + 13.0 + enable + enable + false + true + Icod.UtilLinux.Router.Tests + Icod.UtilLinux.Router.Tests + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + 2 + true + full + false + DEBUG;TRACE + false + + + 3 + true + full + false + TRACE + false + + + 4 + portable + true + true + CS1591 + + diff --git a/tests/Router.Tests/src/CommandTests.cs b/tests/Router.Tests/src/CommandTests.cs new file mode 100644 index 0000000..3a5cfd8 --- /dev/null +++ b/tests/Router.Tests/src/CommandTests.cs @@ -0,0 +1,88 @@ +namespace Icod.UtilLinux.Router.Tests; + +using Icod.UtilLinux.Router; +using Xunit; + +public sealed class CommandTests { + [Fact] + public async Task HelpListsCommands() { + var output = new StringWriter(); + var error = new StringWriter(); + + var status = await Command.RunAsync( + [ "--help" ], + output, + error + ); + + Assert.Equal( 0, status ); + Assert.Contains( "kill", output.ToString(), StringComparison.Ordinal ); + Assert.Contains( "renice", output.ToString(), StringComparison.Ordinal ); + Assert.Equal( string.Empty, error.ToString() ); + } + + [Fact] + public async Task MissingCommandIsUsageError() { + var output = new StringWriter(); + var error = new StringWriter(); + + var status = await Command.RunAsync( + [], + output, + error + ); + + Assert.Equal( 2, status ); + Assert.Contains( "missing command", error.ToString(), StringComparison.Ordinal ); + } + + [Fact] + public async Task UnknownCommandIsUsageError() { + var output = new StringWriter(); + var error = new StringWriter(); + + var status = await Command.RunAsync( + [ "unknown" ], + output, + error + ); + + Assert.Equal( 2, status ); + Assert.Contains( "unknown command", error.ToString(), StringComparison.Ordinal ); + } + + [Fact] + public async Task VersionUsesCentralizedRepositoryVersion() { + var output = new StringWriter(); + + var status = await Command.RunAsync( + [ "--version" ], + output, + new StringWriter() + ); + + Assert.Equal( 0, status ); + Assert.Contains( "utillinux 1.0.1", output.ToString(), StringComparison.Ordinal ); + } + + [Theory] + [InlineData( "kill", "kill from util-linux" )] + [InlineData( "renice", "renice from util-linux" )] + public async Task DispatchesVersionToCommand( + string command, + string expected + ) { + var output = new StringWriter(); + var error = new StringWriter(); + + var status = await Command.RunAsync( + [ command, "-V" ], + output, + error + ); + + Assert.Equal( 0, status ); + Assert.Contains( expected, output.ToString(), StringComparison.Ordinal ); + Assert.Equal( string.Empty, error.ToString() ); + } +} diff --git a/utillinux/Command.cs b/utillinux/Command.cs new file mode 100644 index 0000000..53e4e66 --- /dev/null +++ b/utillinux/Command.cs @@ -0,0 +1,103 @@ +namespace Icod.UtilLinux.Router; + +using System.Reflection; +using KillCommand = Icod.UtilLinux.Kill.Command; +using ReniceCommand = Icod.UtilLinux.Renice.Command; + +/// Routes utillinux subcommands to their managed implementations. +public static class Command { + private const int Success = 0; + private const int UsageError = 2; + + private const string HelpText = """ +Usage: utillinux COMMAND [ARG]... + +Commands: + kill send signals to processes + renice alter priority of running processes + +Options: + -h, --help display this help and exit + -V, --version output version information and exit +"""; + + /// Runs the router. + public static async Task RunAsync( + string[] args, + TextWriter? stdout = null, + TextWriter? stderr = null, + CancellationToken cancellationToken = default + ) { + ArgumentNullException.ThrowIfNull( args ); + stdout ??= Console.Out; + stderr ??= Console.Error; + cancellationToken.ThrowIfCancellationRequested(); + + if ( 0 == args.Length ) { + await stderr.WriteLineAsync( "utillinux: missing command" ).ConfigureAwait( false ); + await stderr.WriteLineAsync( "Try 'utillinux --help' for more information." ).ConfigureAwait( false ); + return UsageError; + } + + if ( 1 == args.Length && args[ 0 ] is "-h" or "--help" ) { + await stdout.WriteLineAsync( HelpText ).ConfigureAwait( false ); + return Success; + } + + if ( 1 == args.Length && args[ 0 ] is "-V" or "--version" ) { + await stdout.WriteLineAsync( string.Concat( "utillinux ", GetVersion() ) ).ConfigureAwait( false ); + return Success; + } + + var commandArgs = args[ 1.. ]; + return args[ 0 ] switch { + "kill" => await KillCommand.RunAsync( + commandArgs, + stdout, + stderr, + cancellationToken: cancellationToken + ).ConfigureAwait( false ), + "renice" => RunRenice( commandArgs, stdout, stderr, cancellationToken ), + _ => await UnknownCommandAsync( args[ 0 ], stderr ).ConfigureAwait( false ) + }; + } + + private static int RunRenice( + string[] args, + TextWriter stdout, + TextWriter stderr, + CancellationToken cancellationToken + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ReniceCommand.Run( + args, + stdout: stdout, + stderr: stderr + ); + } + + private static async Task UnknownCommandAsync( + string command, + TextWriter stderr + ) { + await stderr.WriteLineAsync( + string.Concat( "utillinux: unknown command '", command, "'" ) + ).ConfigureAwait( false ); + await stderr.WriteLineAsync( "Try 'utillinux --help' for more information." ).ConfigureAwait( false ); + return UsageError; + } + + private static string GetVersion() { + var assembly = typeof( Command ).Assembly; + var informational = assembly + .GetCustomAttribute()? + .InformationalVersion; + if ( !string.IsNullOrWhiteSpace( informational ) ) { + var metadataIndex = informational.IndexOf( '+' ); + return 0 <= metadataIndex + ? informational[ ..metadataIndex ] + : informational; + } + return assembly.GetName().Version?.ToString( 3 ) ?? "unknown"; + } +} diff --git a/utillinux/Icod.UtilLinux.Router.csproj b/utillinux/Icod.UtilLinux.Router.csproj new file mode 100644 index 0000000..07319b8 --- /dev/null +++ b/utillinux/Icod.UtilLinux.Router.csproj @@ -0,0 +1,53 @@ + + + + Exe + net10.0 + 13.0 + enable + enable + true + ..\bin\$(Configuration)\ + utillinux + Icod.UtilLinux.Router + true + utillinux + Icod.UtilLinux.Tools + Timothy J. Bruce + Managed command router for the Icod.UtilLinux kill and renice tools. + README.md + GPL-3.0-or-later + dotnet;csharp;util-linux;linux;unix;command-line;cli;terminal;kill;renice;process;process-control;cross-platform;windows;macos + + + + + + + + + + + 2 + true + full + false + DEBUG;TRACE + false + + + 3 + true + full + false + TRACE + false + + + 4 + portable + true + true + CS1591 + + diff --git a/utillinux/Program.cs b/utillinux/Program.cs new file mode 100644 index 0000000..7964ce6 --- /dev/null +++ b/utillinux/Program.cs @@ -0,0 +1,13 @@ +namespace Icod.UtilLinux.Router; + +/// Hosts the utillinux command router. +public static class Program { + /// Runs the router. + public static Task Main( + string[] args + ) => Command.RunAsync( + args, + Console.Out, + Console.Error + ); +}