diff --git a/.github/scripts/verify-package-artifact.cmd b/.github/scripts/verify-package-artifact.cmd new file mode 100644 index 0000000..2d87ac7 --- /dev/null +++ b/.github/scripts/verify-package-artifact.cmd @@ -0,0 +1,15 @@ +@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..9919172 --- /dev/null +++ b/.github/scripts/verify-package-artifact.sh @@ -0,0 +1,15 @@ +#!/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..2eb3002 --- /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: + 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: ${{ 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..abda559 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,72 @@ +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: + validate: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows x64 + verify_package: false + - os: windows-11-arm + name: Windows ARM64 + verify_package: false + - os: ubuntu-24.04 + name: Linux x64 + verify_package: true + - os: ubuntu-24.04-arm + name: Linux ARM64 + verify_package: false + - os: macos-15-intel + name: macOS x64 + verify_package: false + - os: macos-15 + name: macOS ARM64 + verify_package: 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 Icod.Host.sln + - name: Build + run: dotnet build Icod.Host.sln -c ${{ env.CONFIGURATION }} --no-restore -p:ContinuousIntegrationBuild=true + - name: Test + run: dotnet test Icod.Host.sln -c ${{ env.CONFIGURATION }} --no-build --no-restore --logger trx + - name: Pack Release package + if: matrix.verify_package + run: dotnet pack Icod.Host.csproj -c ${{ env.CONFIGURATION }} --no-build --no-restore -o artifacts -p:ContinuousIntegrationBuild=true + - name: Verify exact Release package artifacts + if: matrix.verify_package + shell: pwsh + run: ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory artifacts -Configuration '${{ env.CONFIGURATION }}' + - name: Upload validated Release package artifacts + if: matrix.verify_package + uses: actions/upload-artifact@v4 + with: + name: icod-host-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 b85e792..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.Host.sln -c Staging - - run: dotnet restore Icod.Host.sln - - run: dotnet build Icod.Host.sln -c Staging --no-restore - - run: dotnet test Icod.Host.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..d282946 --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,61 @@ +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: + validate: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows + verify_package: false + - os: ubuntu-latest + name: Linux + verify_package: true + - os: macos-latest + name: macOS + verify_package: 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 Icod.Host.sln + - name: Build + run: dotnet build Icod.Host.sln -c ${{ env.CONFIGURATION }} --no-restore -p:ContinuousIntegrationBuild=true + - name: Test + run: dotnet test Icod.Host.sln -c ${{ env.CONFIGURATION }} --no-build --no-restore --logger trx + - name: Pack Staging package + if: matrix.verify_package + run: dotnet pack Icod.Host.csproj -c ${{ env.CONFIGURATION }} --no-build --no-restore -o artifacts -p:ContinuousIntegrationBuild=true + - name: Verify exact Staging package artifacts + if: matrix.verify_package + shell: pwsh + run: ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory artifacts -Configuration '${{ env.CONFIGURATION }}' + - name: Upload validated Staging package artifacts + if: matrix.verify_package + uses: actions/upload-artifact@v4 + with: + name: icod-host-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 746b8db..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.Host.sln -c Release - - run: dotnet restore Icod.Host.sln - - run: dotnet build Icod.Host.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true - - run: dotnet test Icod.Host.sln -c Release --no-build --logger trx - - - name: Pack NuGet Package - if: matrix.os == 'windows-latest' - run: dotnet pack Icod.Host.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.Host.*.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.Host.*.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..a08b031 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,177 @@ +name: release + +on: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +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 }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Require tagged commit on 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' is not contained in main." + } + - id: version + name: Validate tag and package 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 "Release tag '$env:GITHUB_REF_NAME' is not a supported v tag." + } + $version = $Matches.version + $projectVersion = (dotnet msbuild Icod.Host.csproj -nologo -getProperty:PackageVersion).Trim() + if ($version -ne $projectVersion) { + throw "Tag version '$version' does not match PackageVersion '$projectVersion'." + } + "version=$version" >> $env:GITHUB_OUTPUT + "prerelease=$($version.Contains('-').ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + + package: + needs: metadata + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore + run: dotnet restore Icod.Host.sln + - name: Build + run: dotnet build Icod.Host.sln -c ${{ env.CONFIGURATION }} --no-restore -p:ContinuousIntegrationBuild=true + - name: Test + run: dotnet test Icod.Host.sln -c ${{ env.CONFIGURATION }} --no-build --no-restore --logger trx + - name: Pack + run: dotnet pack Icod.Host.csproj -c ${{ env.CONFIGURATION }} --no-build --no-restore -o artifacts/release -p:ContinuousIntegrationBuild=true + - name: Select tag-matching package + shell: pwsh + run: ./packaging/SelectReleasePackages.ps1 -SourceDirectory artifacts/release -DestinationDirectory artifacts/selected -ExpectedVersion '${{ needs.metadata.outputs.version }}' + - name: Verify exact Release package artifacts + shell: pwsh + run: ./packaging/VerifyPackageArtifact.ps1 -ArtifactDirectory artifacts/selected -Configuration '${{ env.CONFIGURATION }}' -ExpectedVersion '${{ needs.metadata.outputs.version }}' + - name: Upload exact Release package artifacts + uses: actions/upload-artifact@v4 + with: + name: icod-host-release-packages + path: | + artifacts/selected/*.nupkg + artifacts/selected/*.snupkg + if-no-files-found: error + retention-days: 7 + + publish-nuget: + needs: [metadata, package] + 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: icod-host-release-packages + path: artifacts/package + - name: Exchange GitHub OIDC token for NuGet credential + id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + - name: Publish to NuGet.org + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: | + Get-ChildItem artifacts/package -Filter '*.nupkg' -File | + Where-Object { -not $_.Name.EndsWith('.symbols.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.org publication failed for '$($_.Name)'." } + } + + publish-github-packages: + needs: [metadata, package] + 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: icod-host-release-packages + path: artifacts/package + - name: Publish to GitHub Packages + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_OWNER: ${{ github.repository_owner }} + run: | + $source = "https://nuget.pkg.github.com/$env:GITHUB_OWNER/index.json" + Get-ChildItem artifacts/package -Filter '*.nupkg' -File | + Where-Object { -not $_.Name.EndsWith('.symbols.nupkg') } | + ForEach-Object { + dotnet nuget push $_.FullName --api-key $env:GITHUB_TOKEN --source $source --skip-duplicate + if (0 -ne $LASTEXITCODE) { throw "GitHub Packages publication failed for '$($_.Name)'." } + } + + github-release: + needs: [metadata, package, publish-nuget, publish-github-packages] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: icod-host-release-packages + path: artifacts/release-assets + - name: Create checksums + shell: pwsh + run: | + $files = @(Get-ChildItem artifacts/release-assets -File | Sort-Object Name) + if (2 -ne $files.Count) { throw "Expected exactly two package assets before checksums; 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.Host ${{ needs.metadata.outputs.version }}",'--generate-notes') + if ('true' -eq '${{ needs.metadata.outputs.prerelease }}') { + $arguments += '--prerelease' + $arguments += '--latest=false' + } + $arguments += @(Get-ChildItem artifacts/release-assets -File | Sort-Object Name | ForEach-Object { $_.FullName }) + & gh @arguments + if (0 -ne $LASTEXITCODE) { throw 'GitHub Release creation failed.' } diff --git a/README.md b/README.md index 30edf8e..6a015cf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Icod.Host +[![PR Staging build](https://github.com/uniblab/Icod.Host/actions/workflows/pull-request.yaml/badge.svg)](https://github.com/uniblab/Icod.Host/actions/workflows/pull-request.yaml) +[![Main Release validation](https://github.com/uniblab/Icod.Host/actions/workflows/main.yaml/badge.svg?branch=main)](https://github.com/uniblab/Icod.Host/actions/workflows/main.yaml) + `Icod.Host` is a cross-platform .NET library for factual host identity and processor-resource observation. It provides neutral system facts without tying callers to a command framework or to a command suite such as CoreUtils or @@ -148,12 +151,32 @@ On Unix-like hosts: ./build.sh ``` -Both scripts support `clean`, `restore`, `build`, `test`, and `pack`. With no -argument they run the complete local sequence. +Both scripts support `clean`, `restore`, `build`, `test`, `pack`, and `validate`. +With no argument they run: + +```text +clean -> restore -> build -> test -> pack -> validate +``` + +Local builds always use `Debug`. + +The repository lifecycle is: + +```text +local build.* -> Debug +pull request -> Staging +push to main -> Release validation +v tag -> Release publication +``` + +Pull requests build/test on Windows, Linux, and macOS; Linux additionally packs +and verifies the exact Staging `.nupkg` / `.snupkg` artifacts. Pushes to `main` +run validation-only Release builds on Windows x64/ARM64, Linux x64/ARM64, and +macOS x64/ARM64. Only a `v*` tag whose commit is contained in `main` and whose +version matches `PackageVersion` may publish to NuGet.org and GitHub Packages. -Pull requests build and test the Staging configuration on Windows, Ubuntu, and -macOS. Pushes to `main` build and test Release on all three platforms and -publish only after the Release matrix succeeds. +See [`packaging/README.md`](packaging/README.md) for the current build and +distribution contract. ## Author diff --git a/build.cmd b/build.cmd index dcf72ad..5cd5b0b 100644 --- a/build.cmd +++ b/build.cmd @@ -1,71 +1,8 @@ @echo off setlocal -if "%~1"=="" goto all -if /I "%~1"=="clean" goto clean -if /I "%~1"=="restore" goto restore -if /I "%~1"=="build" goto build -if /I "%~1"=="test" goto test -if /I "%~1"=="pack" goto pack +set "SECTION=%~1" +if "%SECTION%"=="" set "SECTION=all" -echo Invalid section: %~1 -echo Usage: %~nx0 [clean^|restore^|build^|test^|pack] -exit /b 1 - -:all -call :do_clean || exit /b 1 -call :do_restore || exit /b 1 -call :do_build || exit /b 1 -call :do_test || exit /b 1 -call :do_pack || exit /b 1 -exit /b 0 - -:clean -call :do_clean -exit /b %errorlevel% - -:restore -call :do_restore -exit /b %errorlevel% - -:build -call :do_build -exit /b %errorlevel% - -:test -call :do_test -exit /b %errorlevel% - -:pack -call :do_pack -exit /b %errorlevel% - -:do_clean -echo. -echo === Clean === -dotnet clean Icod.Host.sln -c Debug -exit /b %errorlevel% - -:do_restore -echo. -echo === Restore === -dotnet restore Icod.Host.sln -exit /b %errorlevel% - -:do_build -echo. -echo === Build === -dotnet build Icod.Host.sln -c Debug --no-restore -exit /b %errorlevel% - -:do_test -echo. -echo === Test === -dotnet test Icod.Host.sln -c Debug --no-build -exit /b %errorlevel% - -:do_pack -echo. -echo === Pack === -dotnet pack Icod.Host.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 index 380bafb..5bf3912 100755 --- a/build.sh +++ b/build.sh @@ -1,62 +1,7 @@ #!/usr/bin/env sh set -eu -clean() -{ - printf '\n=== Clean ===\n' - dotnet clean Icod.Host.sln -c Debug -} - -restore() -{ - printf '\n=== Restore ===\n' - dotnet restore Icod.Host.sln -} - -build() -{ - printf '\n=== Build ===\n' - dotnet build Icod.Host.sln -c Debug --no-restore -} - -test() -{ - printf '\n=== Test ===\n' - dotnet test Icod.Host.sln -c Debug --no-build -} - -pack() -{ - printf '\n=== Pack ===\n' - dotnet pack Icod.Host.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/Get-RepositoryMetadata.ps1 b/packaging/Get-RepositoryMetadata.ps1 new file mode 100644 index 0000000..2c7f66a --- /dev/null +++ b/packaging/Get-RepositoryMetadata.ps1 @@ -0,0 +1,21 @@ +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 +$result = [ordered]@{ + RepositoryRoot = $repositoryRoot + HasSolution = $hasSolution + SolutionPath = if ($hasSolution) { $solutionPath } else { '' } +} +if (-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)) { + "has_solution=$($hasSolution.ToString().ToLowerInvariant())" >> $GitHubOutputPath + "solution_path=$($result.SolutionPath)" >> $GitHubOutputPath +} +[pscustomobject]$result diff --git a/packaging/Invoke-Build.ps1 b/packaging/Invoke-Build.ps1 new file mode 100644 index 0000000..6fb4cae --- /dev/null +++ b/packaging/Invoke-Build.ps1 @@ -0,0 +1,60 @@ +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 { + 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', (Join-Path $repositoryRoot 'Icod.Host.csproj'), '-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..2f05a6b --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,39 @@ +# Icod.Host build and distribution tooling + +This directory adapts the canonical Icod C#/.NET build-cycle contract to `Icod.Host`. + +## Lifecycle + +| 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` | + +`main` is validation-only. Package publication occurs only from a valid release tag. + +## Package contract + +`Icod.Host` is one `net10.0` library package plus its matching symbol package. + +`VerifyPackageArtifact.ps1` checks the exact generated package set: + +- exactly one `Icod.Host` `.nupkg`; +- one matching `.snupkg`; +- package ID/version metadata; +- `README.md`, `LICENSE`, and `icon.png`; +- `lib/net10.0/Icod.Host.dll`; +- `lib/net10.0/Icod.Host.xml`; and +- `Icod.Host.pdb` in the symbol package. + +The package that passes this verification is the package later published. + +## CI/CD dependency model + +Pull requests build and test Staging on Windows, Linux, and macOS. Linux produces and verifies the canonical Staging package artifact. + +`main` builds and tests Release on six OS/architecture runners. Linux x64 reuses its validated build to pack and verify the platform-neutral package; the other runners do not rebuild the package. + +Tagged releases rebuild/test the exact tagged commit, pack and verify the exact Release package, then publish NuGet.org and GitHub Packages in parallel. GitHub Release creation remains the final rendezvous and contains the `.nupkg`, `.snupkg`, and checksum manifest. diff --git a/packaging/RepositoryTools.psm1 b/packaging/RepositoryTools.psm1 new file mode 100644 index 0000000..9ebce6c --- /dev/null +++ b/packaging/RepositoryTools.psm1 @@ -0,0 +1,48 @@ +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-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']") + $readmeNode = $metadata.SelectSingleNode("*[local-name()='readme']") + return [pscustomobject]@{ + Id = $idNode.InnerText.Trim() + Version = $versionNode.InnerText.Trim() + Readme = if ($null -eq $readmeNode) { '' } else { $readmeNode.InnerText.Trim().Replace('\\', '/') } + } + } 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..b917bc3 --- /dev/null +++ b/packaging/SelectReleasePackages.ps1 @@ -0,0 +1,32 @@ +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 ($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 (-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.Host.*.nupkg' -File | Where-Object { -not $_.Name.EndsWith('.symbols.nupkg', [System.StringComparison]::OrdinalIgnoreCase) }) +$selected = @() +foreach ($package in $packages) { + $metadata = Get-PackageMetadata -PackagePath $package.FullName + if ($metadata.Version -eq $ExpectedVersion) { + Copy-Item -LiteralPath $package.FullName -Destination (Join-Path $DestinationDirectory $package.Name) + $symbolPath = Join-Path $SourceDirectory "Icod.Host.$ExpectedVersion.snupkg" + if (-not (Test-Path -LiteralPath $symbolPath -PathType Leaf)) { throw "Expected symbol package '$symbolPath'." } + Copy-Item -LiteralPath $symbolPath -Destination (Join-Path $DestinationDirectory (Split-Path $symbolPath -Leaf)) + $selected += $package.Name + } +} +if (1 -ne $selected.Count) { throw "Expected exactly one Icod.Host package matching '$ExpectedVersion'; found $($selected.Count)." } +Write-Host "Selected Icod.Host $ExpectedVersion for release." diff --git a/packaging/VerifyDistribution.ps1 b/packaging/VerifyDistribution.ps1 new file mode 100644 index 0000000..3518674 --- /dev/null +++ b/packaging/VerifyDistribution.ps1 @@ -0,0 +1,23 @@ +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 +$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', (Join-Path $repositoryRoot 'Icod.Host.csproj'), '-c', $Configuration, '--no-build', '--no-restore', '-o', $packageDirectory, '-p:ContinuousIntegrationBuild=true') + & (Join-Path $PSScriptRoot 'VerifyPackageArtifact.ps1') -ArtifactDirectory $packageDirectory -Configuration $Configuration + 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..ac44d9a --- /dev/null +++ b/packaging/VerifyPackageArtifact.ps1 @@ -0,0 +1,43 @@ +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.Host.*.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.Host .nupkg; found $($packages.Count)." } +$package = $packages[0] +$metadata = Get-PackageMetadata -PackagePath $package.FullName +if ('Icod.Host' -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.Host.$($metadata.Version).snupkg" -File) +if (1 -ne $symbols.Count) { throw "Expected matching Icod.Host 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.Host.dll','lib/net10.0/Icod.Host.xml')) { + if ($required -notin $entries) { throw "Package '$($package.Name)' is missing '$required'." } + } +} finally { $archive.Dispose() } + +$symbolArchive = [System.IO.Compression.ZipFile]::OpenRead($symbols[0].FullName) +try { + $symbolEntries = @($symbolArchive.Entries | ForEach-Object { $_.FullName.Replace('\\','/') }) + if ('lib/net10.0/Icod.Host.pdb' -notin $symbolEntries -and 'Icod.Host.pdb' -notin $symbolEntries) { + throw "Symbol package '$($symbols[0].Name)' does not contain Icod.Host.pdb." + } +} finally { $symbolArchive.Dispose() } + +Write-Host "Exact Icod.Host package verification completed successfully for $($metadata.Version) ($Configuration)."