diff --git a/.github/scripts/verify-package-artifact.cmd b/.github/scripts/verify-package-artifact.cmd new file mode 100644 index 0000000..5c0fb0d --- /dev/null +++ b/.github/scripts/verify-package-artifact.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal EnableExtensions +if "%~1"=="" goto usage +if "%~2"=="" goto usage +pushd "%~dp0\..\.." >nul || exit /b 1 +powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File packaging\VerifyPackageArtifact.ps1 -ArtifactDirectory "%~1" -Configuration "%~2" +set "RESULT=%errorlevel%" +popd +exit /b %RESULT% +:usage +echo Usage: %~nx0 ^ ^ 1>&2 +exit /b 1 diff --git a/.github/scripts/verify-package-artifact.ps1 b/.github/scripts/verify-package-artifact.ps1 new file mode 100644 index 0000000..1ef7a84 --- /dev/null +++ b/.github/scripts/verify-package-artifact.ps1 @@ -0,0 +1,8 @@ +param( + [Parameter(Mandatory = $true)][string]$ArtifactDirectory, + [ValidateSet('Debug', 'Staging', 'Release')][string]$Configuration = 'Release' +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +& (Join-Path $repositoryRoot 'packaging/VerifyPackageArtifact.ps1') -ArtifactDirectory $ArtifactDirectory -Configuration $Configuration diff --git a/.github/scripts/verify-package-artifact.sh b/.github/scripts/verify-package-artifact.sh new file mode 100755 index 0000000..0489dac --- /dev/null +++ b/.github/scripts/verify-package-artifact.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu +if [ "$#" -ne 2 ]; then + printf 'Usage: %s \n' "$0" >&2 + exit 1 +fi +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repository_root=$(CDPATH= cd -- "$script_dir/../.." && pwd) +cd "$repository_root" +pwsh -NoLogo -NoProfile -File ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory "$1" -Configuration "$2" diff --git a/.github/workflows/distribution-validation.yaml b/.github/workflows/distribution-validation.yaml new file mode 100644 index 0000000..f3e2c58 --- /dev/null +++ b/.github/workflows/distribution-validation.yaml @@ -0,0 +1,47 @@ +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 + +jobs: + validate: + 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: 10.0.x + - 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..1b45f53 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,71 @@ +name: main + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CONFIGURATION: Release + +jobs: + validate: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows x64 + package: false + - os: windows-11-arm + name: Windows ARM64 + package: false + - os: ubuntu-24.04 + name: Linux x64 + package: true + - os: ubuntu-24.04-arm + name: Linux ARM64 + package: false + - os: macos-15-intel + name: macOS x64 + package: false + - os: macos-15 + name: macOS ARM64 + package: false + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore + run: dotnet restore Icod.Processes.sln + - name: Build + run: dotnet build Icod.Processes.sln -c ${{ env.CONFIGURATION }} --no-restore -p:ContinuousIntegrationBuild=true + - name: Test + run: dotnet test Icod.Processes.sln -c ${{ env.CONFIGURATION }} --no-build --no-restore --logger trx + - name: Pack Release package + if: matrix.package + run: dotnet pack Icod.Processes.csproj -c ${{ env.CONFIGURATION }} --no-build --no-restore -o artifacts -p:ContinuousIntegrationBuild=true + - name: Verify exact Release package artifacts + if: matrix.package + shell: pwsh + run: ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory artifacts -Configuration '${{ env.CONFIGURATION }}' + - name: Upload validated Release package artifacts + if: matrix.package + uses: actions/upload-artifact@v4 + with: + name: icod-processes-main-packages + path: | + artifacts/*.nupkg + artifacts/*.snupkg + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/pr-build-and-test.yaml b/.github/workflows/pr-build-and-test.yaml deleted file mode 100644 index 404ae1a..0000000 --- a/.github/workflows/pr-build-and-test.yaml +++ /dev/null @@ -1,21 +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.Processes.sln -c Staging - - run: dotnet restore Icod.Processes.sln - - run: dotnet build Icod.Processes.sln -c Staging --no-restore - - run: dotnet test Icod.Processes.sln -c Staging --no-build --logger trx diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml new file mode 100644 index 0000000..87336a7 --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,60 @@ +name: pull-request + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CONFIGURATION: Staging + +jobs: + validate: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows + package: false + - os: ubuntu-latest + name: Linux + package: true + - os: macos-latest + name: macOS + package: false + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore + run: dotnet restore Icod.Processes.sln + - name: Build + run: dotnet build Icod.Processes.sln -c ${{ env.CONFIGURATION }} --no-restore -p:ContinuousIntegrationBuild=true + - name: Test + run: dotnet test Icod.Processes.sln -c ${{ env.CONFIGURATION }} --no-build --no-restore --logger trx + - name: Pack Staging package + if: matrix.package + run: dotnet pack Icod.Processes.csproj -c ${{ env.CONFIGURATION }} --no-build --no-restore -o artifacts -p:ContinuousIntegrationBuild=true + - name: Verify exact Staging package artifacts + if: matrix.package + shell: pwsh + run: ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory artifacts -Configuration '${{ env.CONFIGURATION }}' + - name: Upload validated Staging package artifacts + if: matrix.package + uses: actions/upload-artifact@v4 + with: + name: icod-processes-pr-packages + path: | + artifacts/*.nupkg + artifacts/*.snupkg + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/push-main.yaml b/.github/workflows/push-main.yaml deleted file mode 100644 index 19b427b..0000000 --- a/.github/workflows/push-main.yaml +++ /dev/null @@ -1,77 +0,0 @@ -name: build and publish - -on: - push: - branches: - - main - -permissions: - id-token: write - contents: read - packages: write - -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.Processes.sln -c Release - - run: dotnet restore Icod.Processes.sln - - run: dotnet build Icod.Processes.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true - - run: dotnet test Icod.Processes.sln -c Release --no-build --logger trx - - - name: Pack NuGet Package - if: matrix.os == 'windows-latest' - run: dotnet pack Icod.Processes.csproj -c Release --no-build -o ./artifacts - - - name: Upload Artifact - if: matrix.os == 'windows-latest' - uses: actions/upload-artifact@v4 - with: - name: nuget-package - path: ./artifacts/*nupkg - - deploy: - needs: build-and-test - runs-on: windows-latest - environment: Release - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - - name: Download Artifact - uses: actions/download-artifact@v4 - with: - name: nuget-package - path: ./artifacts - - - name: NuGet login (OIDC → temp API key) - uses: NuGet/login@v1 - id: login - with: - user: ${{ secrets.NUGET_USER }} - - - name: NuGet push - shell: pwsh - run: | - Get-ChildItem "./artifacts/Icod.Processes.*.nupkg" | ForEach-Object { - dotnet nuget push $_.FullName --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate - } - - - name: Push to GitHub Packages - shell: pwsh - run: | - Get-ChildItem "./artifacts/Icod.Processes.*.nupkg" | ForEach-Object { - dotnet nuget push $_.FullName --api-key "${{ secrets.GITHUB_TOKEN }}" --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --skip-duplicate - } diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..af81d4a --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,192 @@ +name: release + +on: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + CONFIGURATION: Release + +jobs: + package: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Require tagged commit in main + shell: pwsh + run: | + git fetch origin main --no-tags + git merge-base --is-ancestor $env:GITHUB_SHA origin/main + if (0 -ne $LASTEXITCODE) { + throw "Release tag '$env:GITHUB_REF_NAME' does not point to a commit contained in main." + } + - id: version + name: Validate tag and package version + shell: pwsh + run: | + $tag = $env:GITHUB_REF_NAME + if ($tag -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)$') { + throw "Release tag '$tag' is not a supported v tag." + } + $version = $Matches.version + $projectVersion = (dotnet msbuild Icod.Processes.csproj -nologo -getProperty:PackageVersion).Trim() + if (0 -ne $LASTEXITCODE) { + throw "Unable to read Icod.Processes PackageVersion." + } + if ($projectVersion -ne $version) { + throw "Tag version '$version' does not match PackageVersion '$projectVersion'." + } + "version=$version" >> $env:GITHUB_OUTPUT + "prerelease=$($version.Contains('-').ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + - name: Restore + run: dotnet restore Icod.Processes.sln + - name: Build Release + run: dotnet build Icod.Processes.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true + - name: Test Release + run: dotnet test Icod.Processes.sln -c Release --no-build --no-restore --logger trx + - name: Pack Release package + run: dotnet pack Icod.Processes.csproj -c Release --no-build --no-restore -o artifacts/release -p:ContinuousIntegrationBuild=true + - name: Verify exact Release package + shell: pwsh + run: ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory artifacts/release -Configuration Release -ExpectedVersion '${{ steps.version.outputs.version }}' + - name: Select release package and symbols + shell: pwsh + run: ./packaging/SelectReleasePackages.ps1 -SourceDirectory artifacts/release -DestinationDirectory artifacts/release-selected -ExpectedVersion '${{ steps.version.outputs.version }}' + - name: Upload release package artifacts + uses: actions/upload-artifact@v4 + with: + name: icod-processes-release-packages + path: | + artifacts/release-selected/*.nupkg + artifacts/release-selected/*.snupkg + if-no-files-found: error + retention-days: 7 + + publish-nuget: + needs: package + runs-on: ubuntu-latest + environment: Release + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - uses: actions/download-artifact@v4 + with: + name: icod-processes-release-packages + path: artifacts/package + - name: Exchange GitHub OIDC token for NuGet credential + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + - name: Publish to NuGet.org + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + $packages = @(Get-ChildItem -LiteralPath artifacts/package -Filter '*.nupkg' -File | Sort-Object Name) + foreach ($package in $packages) { + dotnet nuget push $package.FullName --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + if (0 -ne $LASTEXITCODE) { + throw "NuGet.org publication failed for '$($package.Name)'." + } + } + + publish-github-packages: + needs: package + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - uses: actions/download-artifact@v4 + with: + name: icod-processes-release-packages + path: artifacts/package + - name: Publish to GitHub Packages + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $source = 'https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json' + $packages = @(Get-ChildItem -LiteralPath artifacts/package -Filter '*.nupkg' -File | Sort-Object Name) + foreach ($package in $packages) { + dotnet nuget push $package.FullName --api-key $env:GITHUB_TOKEN --source $source --skip-duplicate + if (0 -ne $LASTEXITCODE) { + throw "GitHub Packages publication failed for '$($package.Name)'." + } + } + + github-release: + needs: [package, publish-nuget, publish-github-packages] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: icod-processes-release-packages + path: artifacts/release-assets + - name: Create checksum manifest + shell: pwsh + run: | + $files = @(Get-ChildItem -LiteralPath artifacts/release-assets -File | Sort-Object Name) + if (2 -ne $files.Count) { + throw "Expected the .nupkg and .snupkg release assets; found $($files.Count)." + } + $lines = foreach ($file in $files) { + $hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($file.Name)" + } + [System.IO.File]::WriteAllLines( + 'artifacts/release-assets/SHA256SUMS.txt', + $lines, + [System.Text.UTF8Encoding]::new($false) + ) + - name: Create GitHub Release + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + $arguments = @( + 'release', 'create', $env:GITHUB_REF_NAME, + '--verify-tag', + '--title', "Icod.Processes ${{ needs.package.outputs.version }}", + '--generate-notes' + ) + if ('true' -eq '${{ needs.package.outputs.prerelease }}') { + $arguments += '--prerelease' + $arguments += '--latest=false' + } + $arguments += @( + Get-ChildItem -LiteralPath artifacts/release-assets -File | + Sort-Object Name | + ForEach-Object { $_.FullName } + ) + & gh @arguments + if (0 -ne $LASTEXITCODE) { + throw "GitHub Release creation failed with status $LASTEXITCODE." + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64f5b06..eee6569 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,10 @@ boundary between general process mechanisms and suite-specific policy. - Target framework: `net10.0`. - Language version: C# 13. - Nullable reference types and implicit global usings remain enabled. -- Supported CI runners are `windows-latest`, `ubuntu-latest`, and `macos-latest`. +- Pull-request CI runs on Windows, Ubuntu, and macOS. +- Authoritative `main` Release validation runs on Windows/Linux/macOS x64 and + ARM64 runners. +- Debug, Staging, and Release use portable debug information. - Repository text files use UTF-8 with LF line endings. - Public, protected, and internal types and members should have substantive XML documentation; use `` where appropriate. @@ -76,27 +79,31 @@ named and delete only resources owned by the test. ## Build and validation -From the repository root: +The standard local entry points are: ```text -dotnet clean Icod.Processes.sln -c Debug -dotnet restore Icod.Processes.sln -dotnet build Icod.Processes.sln -c Debug --no-restore -dotnet test Icod.Processes.sln -c Debug --no-build +build.cmd +./build.sh ``` -Before merge or release, also validate Release: +With no section argument they run the complete Debug sequence: ```text -dotnet clean Icod.Processes.sln -c Release -dotnet restore Icod.Processes.sln -dotnet build Icod.Processes.sln -c Release --no-restore -dotnet test Icod.Processes.sln -c Release --no-build +clean -> restore -> build -> test -> pack -> validate ``` -`build.cmd` and `build.sh` may be used for the standard local sequence. Pull -requests run the Staging configuration across the three CI operating systems; -pushes to `main` run Release and publish only after the Release matrix succeeds. +Individual sections are `clean`, `restore`, `build`, `test`, `pack`, and +`validate`. + +Pull requests run Staging on Windows, Linux, and macOS. A push to `main` runs the +authoritative validation-only Release matrix on six OS/architecture runners. +Ordinary pushes to `main` never publish packages. + +Publication is performed only by `.github/workflows/release.yaml` for an +immutable `v` tag whose commit is contained in `main` and whose version +matches `Icod.Processes.csproj:PackageVersion`. NuGet.org and GitHub Packages +publish the same verified package in parallel; GitHub Release is the final +rendezvous. ## Pull requests and commits diff --git a/Icod.Processes.csproj b/Icod.Processes.csproj index a519e50..8ab7706 100644 --- a/Icod.Processes.csproj +++ b/Icod.Processes.csproj @@ -13,6 +13,9 @@ Icod.Processes Debug;Release;Staging 1.2.0 + portable + true + false AnyCPU @@ -20,29 +23,21 @@ prompt 2 - true - full false DEBUG;TRACE - false false prompt 3 - true - full false TRACE - false false prompt 4 - portable true - false true CS1591 diff --git a/README.md b/README.md index 702cbe9..6f3ba9b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Icod.Processes +[![PR Staging build](https://github.com/uniblab/Icod.Processes/actions/workflows/pull-request.yaml/badge.svg)](https://github.com/uniblab/Icod.Processes/actions/workflows/pull-request.yaml) +[![Main Release validation](https://github.com/uniblab/Icod.Processes/actions/workflows/main.yaml/badge.svg?branch=main)](https://github.com/uniblab/Icod.Processes/actions/workflows/main.yaml) + `Icod.Processes` is a cross-platform .NET library for safe child-process execution and neutral process-control primitives. It provides reusable process mechanisms without tying callers to a command suite such as CoreUtils or @@ -234,25 +237,44 @@ grammar, metrics, personalities, or command presentation. Those remain in Likewise, command-line parsing, diagnostics, and other command-hosting concerns remain outside this package. -## Building +## Build and CI/CD lifecycle -On Windows: +Local development uses `Debug`: ```text build.cmd ``` -On Unix-like hosts: +or, on Unix-like hosts: ```text ./build.sh ``` -Both scripts support `clean`, `restore`, `build`, `test`, and `pack`. With no -argument they run the complete sequence. +Both scripts delegate to `packaging/Invoke-Build.ps1`. With no argument they run: + +```text +clean -> restore -> build -> test -> pack -> validate +``` + +The repository lifecycle is: + +```text +local development -> Debug +pull request -> Staging on Windows/Linux/macOS +main -> validation-only Release on six OS/architecture runners +v tag -> Release publication, when the tagged commit is contained in main +``` + +`main` never publishes. Tagged releases verify the exact `Icod.Processes` +`.nupkg` and `.snupkg`, including the `Icod.Timing` 1.0.0 dependency and portable +PDB payload, before NuGet.org and GitHub Packages publish the same package in +parallel. The final GitHub Release contains the package, symbols, and a SHA-256 +manifest. -CI builds and tests on Windows, Ubuntu, and macOS. Publishing from `main` packs -and publishes the NuGet package after all three platform jobs succeed. +`Debug`, `Staging`, and `Release` all use portable debug information. Common +configuration properties are declared once in each project instead of being +repeated across configuration-specific property groups. ## Author diff --git a/build.cmd b/build.cmd index 81630be..5cd5b0b 100644 --- a/build.cmd +++ b/build.cmd @@ -1,72 +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 -if /I "%~1"=="pack" goto run-pack - -echo Invalid section: "%~1" -echo Usage: %~nx0 [clean^|restore^|build^|test^|pack] -exit /b 1 - -:all -call :clean || exit /b 1 -call :restore || exit /b 1 -call :build || exit /b 1 -call :test || exit /b 1 -call :pack || 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% - -:run-pack -call :pack -exit /b %errorlevel% - -:clean -echo. -echo === Clean === -dotnet clean Icod.Processes.sln -c Debug -exit /b %errorlevel% - -:restore -echo. -echo === Restore === -dotnet restore Icod.Processes.sln -exit /b %errorlevel% - -:build -echo. -echo === Build === -dotnet build Icod.Processes.sln -c Debug --no-restore -exit /b %errorlevel% - -:test -echo. -echo === Test === -dotnet test Icod.Processes.sln -c Debug --no-build -exit /b %errorlevel% - -:pack -echo. -echo === Pack === -dotnet pack Icod.Processes.csproj -c Debug --include-source --include-symbols --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 old mode 100644 new mode 100755 index 8676b93..5bf3912 --- a/build.sh +++ b/build.sh @@ -1,62 +1,7 @@ #!/usr/bin/env sh set -eu -clean() -{ - printf '\n=== Clean ===\n' - dotnet clean Icod.Processes.sln -c Debug -} - -restore() -{ - printf '\n=== Restore ===\n' - dotnet restore Icod.Processes.sln -} - -build() -{ - printf '\n=== Build ===\n' - dotnet build Icod.Processes.sln -c Debug --no-restore -} - -test() -{ - printf '\n=== Test ===\n' - dotnet test Icod.Processes.sln -c Debug --no-build -} - -pack() -{ - printf '\n=== Pack ===\n' - dotnet pack Icod.Processes.csproj -c Debug --include-source --include-symbols --no-build -} - -case "${1-}" in - "") - clean - restore - build - test - pack - ;; - clean) - clean - ;; - restore) - restore - ;; - build) - build - ;; - test) - test - ;; - pack) - pack - ;; - *) - printf 'Invalid section: %s\n' "$1" >&2 - printf 'Usage: %s [clean|restore|build|test|pack]\n' "$0" >&2 - exit 1 - ;; -esac +section=${1-all} +pwsh -NoLogo -NoProfile -File ./packaging/Invoke-Build.ps1 \ + -Section "$section" \ + -Configuration Debug diff --git a/packaging/Invoke-Build.ps1 b/packaging/Invoke-Build.ps1 new file mode 100644 index 0000000..58dbdbe --- /dev/null +++ b/packaging/Invoke-Build.ps1 @@ -0,0 +1,83 @@ +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 +$projectPath = Join-Path $repositoryRoot 'Icod.Processes.csproj' +$artifactDirectory = Join-Path $repositoryRoot 'artifacts' + +function Invoke-Clean { + Write-Host '' + Write-Host "=== Clean ($Configuration) ===" + Invoke-DotNet -Arguments @('clean', $solutionPath, '-c', $Configuration) +} + +function Invoke-Restore { + Write-Host '' + Write-Host '=== Restore ===' + Invoke-DotNet -Arguments @('restore', $solutionPath) +} + +function Invoke-Build { + Write-Host '' + Write-Host "=== Build ($Configuration) ===" + Invoke-DotNet -Arguments @('build', $solutionPath, '-c', $Configuration, '--no-restore') +} + +function Invoke-Test { + Write-Host '' + Write-Host "=== Test ($Configuration) ===" + Invoke-DotNet -Arguments @('test', $solutionPath, '-c', $Configuration, '--no-build', '--no-restore') +} + +function Invoke-Pack { + Write-Host '' + Write-Host "=== Pack ($Configuration) ===" + New-Item -ItemType Directory -Path $artifactDirectory -Force | Out-Null + Invoke-DotNet -Arguments @( + 'pack', $projectPath, + '-c', $Configuration, + '--no-build', + '--no-restore', + '-o', $artifactDirectory + ) +} + +function Invoke-Validate { + Write-Host '' + Write-Host "=== Validate ($Configuration) ===" + & (Join-Path $PSScriptRoot 'VerifyPackageArtifact.ps1') ` + -ArtifactDirectory $artifactDirectory ` + -Configuration $Configuration +} + +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..71725d1 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,17 @@ +# Icod.Processes build and distribution tooling + +`Icod.Processes` follows the canonical Icod C#/.NET lifecycle while preserving its .NET 10 process-control package contract. + +| Lifecycle | Configuration | Entry point | +| --- | --- | --- | +| local `build.cmd` / `build.sh` | `Debug` | `packaging/Invoke-Build.ps1` | +| pull request | `Staging` | `.github/workflows/pull-request.yaml` | +| push to `main` | `Release` | `.github/workflows/main.yaml` | +| manual diagnostic | selected | `.github/workflows/distribution-validation.yaml` | +| `v*` tag contained in `main` | `Release` | `.github/workflows/release.yaml` | + +The package verifier requires the exact generated `Icod.Processes` `.nupkg` and matching `.snupkg`. It checks package identity/version, README, LICENSE, icon, the `net10.0` DLL/XML payload, the `Icod.Timing` 1.0.0 dependency, and the portable-PDB signature of the symbol payload. + +`DebugType`, `DebugSymbols`, and signing policy are shared across configurations in the project files. Debug, Staging, and Release all use portable debug information. + +Ordinary pushes to `main` validate but never publish. Tagged releases publish the same exact validated package to NuGet.org and GitHub Packages in parallel, then create the GitHub Release. diff --git a/packaging/RepositoryTools.psm1 b/packaging/RepositoryTools.psm1 new file mode 100644 index 0000000..118baa1 --- /dev/null +++ b/packaging/RepositoryTools.psm1 @@ -0,0 +1,93 @@ +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 + ) + + $solutions = @( + Get-ChildItem -LiteralPath $RepositoryRoot -File | + Where-Object { $_.Extension -in @('.sln', '.slnx') } + ) + if (1 -ne $solutions.Count) { + throw "Expected exactly one root .sln or .slnx file; found $($solutions.Count)." + } + return $solutions[0].FullName +} + +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-PackageMetadata { + param( + [Parameter(Mandatory = $true)][string]$PackagePath + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $nuspecEntries = @( + $archive.Entries | + Where-Object { $_.FullName.EndsWith('.nuspec', [System.StringComparison]::OrdinalIgnoreCase) } + ) + if (1 -ne $nuspecEntries.Count) { + throw "Package '$PackagePath' contains $($nuspecEntries.Count) nuspec files; expected exactly one." + } + $reader = [System.IO.StreamReader]::new($nuspecEntries[0].Open()) + try { + [xml]$nuspec = $reader.ReadToEnd() + } finally { + $reader.Dispose() + } + $metadata = $nuspec.SelectSingleNode("/*[local-name()='package']/*[local-name()='metadata']") + if ($null -eq $metadata) { + throw "Package '$PackagePath' does not contain nuspec metadata." + } + $idNode = $metadata.SelectSingleNode("*[local-name()='id']") + $versionNode = $metadata.SelectSingleNode("*[local-name()='version']") + if ($null -eq $idNode -or $null -eq $versionNode) { + throw "Package '$PackagePath' does not declare package ID and version." + } + return [pscustomobject]@{ + Id = $idNode.InnerText.Trim() + Version = $versionNode.InnerText.Trim() + } + } finally { + $archive.Dispose() + } +} + +Export-ModuleMember -Function @( + 'Invoke-DotNet', + 'Get-RepositorySolution', + 'Get-MSBuildProperty', + 'Get-PackageMetadata' +) diff --git a/packaging/SelectReleasePackages.ps1 b/packaging/SelectReleasePackages.ps1 new file mode 100644 index 0000000..176ab9a --- /dev/null +++ b/packaging/SelectReleasePackages.ps1 @@ -0,0 +1,49 @@ +param( + [Parameter(Mandatory = $true)][string]$SourceDirectory, + [Parameter(Mandatory = $true)][string]$DestinationDirectory, + [Parameter(Mandatory = $true)][string]$ExpectedVersion +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +Import-Module (Join-Path $PSScriptRoot 'RepositoryTools.psm1') -Force + +foreach ($variableName in @('SourceDirectory', 'DestinationDirectory')) { + $value = Get-Variable -Name $variableName -ValueOnly + if (-not [System.IO.Path]::IsPathRooted($value)) { + $value = Join-Path $repositoryRoot $value + } + Set-Variable -Name $variableName -Value ([System.IO.Path]::GetFullPath($value)) +} + +if (-not (Test-Path -LiteralPath $SourceDirectory -PathType Container)) { + throw "Source package directory '$SourceDirectory' does not exist." +} +if (Test-Path -LiteralPath $DestinationDirectory) { + Remove-Item -LiteralPath $DestinationDirectory -Recurse -Force +} +New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null + +$packages = @( + Get-ChildItem -LiteralPath $SourceDirectory -Filter 'Icod.Processes.*.nupkg' -File | + Where-Object { -not $_.Name.EndsWith('.symbols.nupkg', [System.StringComparison]::OrdinalIgnoreCase) } +) +if (1 -ne $packages.Count) { + throw "Expected exactly one Icod.Processes package candidate; found $($packages.Count)." +} + +$metadata = Get-PackageMetadata -PackagePath $packages[0].FullName +if ($metadata.Version -ne $ExpectedVersion) { + throw "Package version '$($metadata.Version)' does not match release version '$ExpectedVersion'." +} + +$symbolPath = Join-Path $SourceDirectory "Icod.Processes.$ExpectedVersion.snupkg" +if (-not (Test-Path -LiteralPath $symbolPath -PathType Leaf)) { + throw "Matching symbol package '$symbolPath' does not exist." +} + +Copy-Item -LiteralPath $packages[0].FullName -Destination $DestinationDirectory +Copy-Item -LiteralPath $symbolPath -Destination $DestinationDirectory +Write-Host "Selected Icod.Processes $ExpectedVersion release package and symbols." diff --git a/packaging/VerifyDistribution.ps1 b/packaging/VerifyDistribution.ps1 new file mode 100644 index 0000000..36bbe70 --- /dev/null +++ b/packaging/VerifyDistribution.ps1 @@ -0,0 +1,53 @@ +param( + [ValidateSet('Debug', 'Staging', 'Release')] + [string]$Configuration = 'Release' +) + +$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 +$projectPath = Join-Path $repositoryRoot 'Icod.Processes.csproj' +$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' + ) + Invoke-DotNet -Arguments @( + 'pack', $projectPath, + '-c', $Configuration, + '--no-build', + '--no-restore', + '-o', $packageDirectory, + '-p:ContinuousIntegrationBuild=true' + ) + & (Join-Path $PSScriptRoot 'VerifyPackageArtifact.ps1') ` + -ArtifactDirectory $packageDirectory ` + -Configuration $Configuration + + Write-Host '' + 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..149be85 --- /dev/null +++ b/packaging/VerifyPackageArtifact.ps1 @@ -0,0 +1,118 @@ +param( + [Parameter(Mandatory = $true)][string]$ArtifactDirectory, + [ValidateSet('Debug', 'Staging', 'Release')][string]$Configuration = 'Release', + [string]$ExpectedVersion = '' +) + +$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 'Icod.Processes.*.nupkg' -File | + Where-Object { -not $_.Name.EndsWith('.symbols.nupkg', [System.StringComparison]::OrdinalIgnoreCase) } | + Sort-Object Name +) +if (1 -ne $packages.Count) { + throw "Expected exactly one Icod.Processes .nupkg; found $($packages.Count)." +} + +$package = $packages[0] +$metadata = Get-PackageMetadata -PackagePath $package.FullName +if ('Icod.Processes' -ne $metadata.Id) { + throw "Unexpected package ID '$($metadata.Id)'." +} +if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and $ExpectedVersion -ne $metadata.Version) { + throw "Package version '$($metadata.Version)' does not match expected '$ExpectedVersion'." +} + +$symbols = @( + Get-ChildItem -LiteralPath $ArtifactDirectory -Filter "Icod.Processes.$($metadata.Version).snupkg" -File +) +if (1 -ne $symbols.Count) { + throw "Expected matching Icod.Processes symbol package; found $($symbols.Count)." +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) +try { + $entries = @($archive.Entries | ForEach-Object { $_.FullName.Replace('\\', '/') }) + foreach ($required in @( + 'README.md', + 'LICENSE', + 'icon.png', + 'lib/net10.0/Icod.Processes.dll', + 'lib/net10.0/Icod.Processes.xml' + )) { + if ($required -notin $entries) { + throw "Package '$($package.Name)' is missing '$required'." + } + } + + $nuspecEntry = $archive.Entries | + Where-Object { $_.FullName.EndsWith('.nuspec', [System.StringComparison]::OrdinalIgnoreCase) } | + Select-Object -First 1 + $reader = [System.IO.StreamReader]::new($nuspecEntry.Open()) + try { + [xml]$nuspec = $reader.ReadToEnd() + } finally { + $reader.Dispose() + } + $timingDependency = $nuspec.SelectSingleNode( + "//*[local-name()='dependency' and @id='Icod.Timing']" + ) + if ($null -eq $timingDependency) { + throw "Package '$($package.Name)' does not declare its Icod.Timing dependency." + } + if ('1.0.0' -ne $timingDependency.version) { + throw "Icod.Timing dependency version '$($timingDependency.version)' does not match 1.0.0." + } +} finally { + $archive.Dispose() +} + +$symbolArchive = [System.IO.Compression.ZipFile]::OpenRead($symbols[0].FullName) +try { + $pdbEntry = $symbolArchive.Entries | + Where-Object { + $_.FullName.Replace('\\', '/') -in @( + 'lib/net10.0/Icod.Processes.pdb', + 'Icod.Processes.pdb' + ) + } | + Select-Object -First 1 + if ($null -eq $pdbEntry) { + throw "Symbol package '$($symbols[0].Name)' does not contain Icod.Processes.pdb." + } + + $stream = $pdbEntry.Open() + try { + $signature = New-Object byte[] 4 + if (4 -ne $stream.Read($signature, 0, 4)) { + throw "Symbol PDB '$($pdbEntry.FullName)' is too short." + } + if ( + 0x42 -ne $signature[0] -or + 0x53 -ne $signature[1] -or + 0x4A -ne $signature[2] -or + 0x42 -ne $signature[3] + ) { + throw "Symbol PDB '$($pdbEntry.FullName)' is not a portable PDB." + } + } finally { + $stream.Dispose() + } +} finally { + $symbolArchive.Dispose() +} + +Write-Host "Exact Icod.Processes package verification completed successfully for $($metadata.Version) ($Configuration)." diff --git a/samples/Icod.Processes.Sample/Icod.Processes.Sample.csproj b/samples/Icod.Processes.Sample/Icod.Processes.Sample.csproj index 3db8980..7722166 100644 --- a/samples/Icod.Processes.Sample/Icod.Processes.Sample.csproj +++ b/samples/Icod.Processes.Sample/Icod.Processes.Sample.csproj @@ -9,6 +9,9 @@ Icod.Processes.Sample Icod.Processes.Sample Debug;Release;Staging + portable + true + false diff --git a/tests/ProcessTestHost/Icod.Processes.ProcessTestHost.csproj b/tests/ProcessTestHost/Icod.Processes.ProcessTestHost.csproj index bd19d91..04d927c 100644 --- a/tests/ProcessTestHost/Icod.Processes.ProcessTestHost.csproj +++ b/tests/ProcessTestHost/Icod.Processes.ProcessTestHost.csproj @@ -10,26 +10,24 @@ Icod.Processes.ProcessTestHost Icod.Processes.ProcessTestHost Debug;Release;Staging + portable + true + false 2 - true - full false DEBUG;TRACE false 3 - true - full false TRACE false 4 - portable true true CS1591 diff --git a/tests/Processes.Tests/Icod.Processes.Tests.csproj b/tests/Processes.Tests/Icod.Processes.Tests.csproj index 218b90c..3c85244 100644 --- a/tests/Processes.Tests/Icod.Processes.Tests.csproj +++ b/tests/Processes.Tests/Icod.Processes.Tests.csproj @@ -11,6 +11,9 @@ Icod.Processes.Tests Icod.Processes.Tests Debug;Release;Staging + portable + true + false @@ -27,23 +30,18 @@ 2 - true - full false DEBUG;TRACE false 3 - true - full false TRACE false 4 - portable true true CS1591