From f43158245252ddd6da816825009f2cfb5a623c40 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Wed, 2 Sep 2026 00:47:01 -0400 Subject: [PATCH] Align build cycle and CI/CD with canonical protocol --- .github/scripts/verify-package-artifact.cmd | 18 ++ .github/scripts/verify-package-artifact.ps1 | 15 ++ .github/scripts/verify-package-artifact.sh | 15 ++ .../workflows/distribution-validation.yaml | 73 ++++++ .github/workflows/main.yaml | 95 +++++++ .github/workflows/pr-build-and-test.yaml | 25 -- .github/workflows/pull-request.yaml | 84 +++++++ .github/workflows/push-main.yaml | 95 ------- .github/workflows/release.yaml | 238 ++++++++++++++++++ README.md | 24 +- build.cmd | 80 +----- build.sh | 60 +---- packaging/Get-RepositoryMetadata.ps1 | 27 ++ packaging/Invoke-Build.ps1 | 82 ++++++ packaging/README.md | 65 +++++ packaging/RepositoryTools.psm1 | 112 +++++++++ packaging/SelectReleasePackages.ps1 | 55 ++++ packaging/VerifyDistribution.ps1 | 59 +++++ packaging/VerifyPackageArtifact.ps1 | 101 ++++++++ 19 files changed, 1068 insertions(+), 255 deletions(-) create mode 100644 .github/scripts/verify-package-artifact.cmd create mode 100644 .github/scripts/verify-package-artifact.ps1 create mode 100755 .github/scripts/verify-package-artifact.sh create mode 100644 .github/workflows/distribution-validation.yaml create mode 100644 .github/workflows/main.yaml delete mode 100644 .github/workflows/pr-build-and-test.yaml create mode 100644 .github/workflows/pull-request.yaml delete mode 100644 .github/workflows/push-main.yaml create mode 100644 .github/workflows/release.yaml mode change 100644 => 100755 build.sh create mode 100644 packaging/Get-RepositoryMetadata.ps1 create mode 100644 packaging/Invoke-Build.ps1 create mode 100644 packaging/README.md create mode 100644 packaging/RepositoryTools.psm1 create mode 100644 packaging/SelectReleasePackages.ps1 create mode 100644 packaging/VerifyDistribution.ps1 create mode 100644 packaging/VerifyPackageArtifact.ps1 diff --git a/.github/scripts/verify-package-artifact.cmd b/.github/scripts/verify-package-artifact.cmd new file mode 100644 index 0000000..8063de3 --- /dev/null +++ b/.github/scripts/verify-package-artifact.cmd @@ -0,0 +1,18 @@ +@echo off +setlocal EnableExtensions + +if "%~1"=="" goto usage +if "%~2"=="" goto usage + +set "ARTIFACT_DIR=%~1" +set "CONFIGURATION=%~2" + +pushd "%~dp0\..\.." >nul || exit /b 1 +powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File packaging\VerifyPackageArtifact.ps1 -ArtifactDirectory "%ARTIFACT_DIR%" -Configuration "%CONFIGURATION%" +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..1386ac5 --- /dev/null +++ b/.github/scripts/verify-package-artifact.ps1 @@ -0,0 +1,15 @@ +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..5f9c12c --- /dev/null +++ b/.github/workflows/distribution-validation.yaml @@ -0,0 +1,73 @@ +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_VERSIONS: | + 7.0.x + 8.0.x + 9.0.x + 10.0.x + +jobs: + validate: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows x64 + verify_packages: false + - os: windows-11-arm + name: Windows ARM64 + verify_packages: false + - os: ubuntu-24.04 + name: Linux x64 + verify_packages: true + - os: ubuntu-24.04-arm + name: Linux ARM64 + verify_packages: false + - os: macos-15-intel + name: macOS x64 + verify_packages: false + - os: macos-15 + name: macOS ARM64 + 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_VERSIONS }} + - name: Verify distribution with package validation + if: matrix.verify_packages + shell: pwsh + run: >- + ./packaging/VerifyDistribution.ps1 + -Configuration '${{ inputs.configuration }}' + - name: Verify build and tests + if: ${{ !matrix.verify_packages }} + shell: pwsh + run: >- + ./packaging/VerifyDistribution.ps1 + -Configuration '${{ inputs.configuration }}' + -SkipPackageValidation diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..3e1beff --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,95 @@ +name: main + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_VERSIONS: | + 7.0.x + 8.0.x + 9.0.x + 10.0.x + CONFIGURATION: Release + SOLUTION_PATH: Icod.Path.sln + +jobs: + validate: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows x64 + verify_packages: false + - os: windows-11-arm + name: Windows ARM64 + verify_packages: false + - os: ubuntu-24.04 + name: Linux x64 + verify_packages: true + - os: ubuntu-24.04-arm + name: Linux ARM64 + verify_packages: false + - os: macos-15-intel + name: macOS x64 + verify_packages: false + - os: macos-15 + name: macOS ARM64 + 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_VERSIONS }} + - name: Restore + run: dotnet restore '${{ env.SOLUTION_PATH }}' + - name: Build + run: >- + dotnet build '${{ env.SOLUTION_PATH }}' + -c ${{ env.CONFIGURATION }} + --no-restore + -p:ContinuousIntegrationBuild=true + - name: Test + run: >- + dotnet test '${{ env.SOLUTION_PATH }}' + -c ${{ env.CONFIGURATION }} + --no-build + --no-restore + --logger trx + - name: Pack Release package + if: matrix.verify_packages + run: >- + dotnet pack '${{ env.SOLUTION_PATH }}' + -c ${{ env.CONFIGURATION }} + --no-build + --no-restore + -o artifacts + -p:ContinuousIntegrationBuild=true + - name: Verify exact Release package artifacts + if: matrix.verify_packages + shell: pwsh + run: >- + ./packaging/VerifyPackageArtifact.ps1 + -ArtifactDirectory artifacts + -Configuration '${{ env.CONFIGURATION }}' + - name: Upload validated Release package artifacts + if: matrix.verify_packages + uses: actions/upload-artifact@v4 + with: + name: icod-path-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 793f06b..0000000 --- a/.github/workflows/pr-build-and-test.yaml +++ /dev/null @@ -1,25 +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: | - 7.0.x - 8.0.x - 9.0.x - 10.0.x - - - run: dotnet clean Icod.Path.sln -c Staging - - run: dotnet restore Icod.Path.sln - - run: dotnet build Icod.Path.sln -c Staging --no-restore - - run: dotnet test Icod.Path.sln -c Staging --no-build --logger trx \ No newline at end of file diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml new file mode 100644 index 0000000..75537c0 --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,84 @@ +name: pull-request + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_VERSIONS: | + 7.0.x + 8.0.x + 9.0.x + 10.0.x + CONFIGURATION: Staging + SOLUTION_PATH: Icod.Path.sln + +jobs: + validate: + 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_VERSIONS }} + - name: Restore + run: dotnet restore '${{ env.SOLUTION_PATH }}' + - name: Build + run: >- + dotnet build '${{ env.SOLUTION_PATH }}' + -c ${{ env.CONFIGURATION }} + --no-restore + -p:ContinuousIntegrationBuild=true + - name: Test + run: >- + dotnet test '${{ env.SOLUTION_PATH }}' + -c ${{ env.CONFIGURATION }} + --no-build + --no-restore + --logger trx + - name: Pack Staging package + if: matrix.verify_packages + run: >- + dotnet pack '${{ env.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 }}' + - name: Upload validated Staging package artifacts + if: matrix.verify_packages + uses: actions/upload-artifact@v4 + with: + name: icod-path-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 5234d83..0000000 --- a/.github/workflows/push-main.yaml +++ /dev/null @@ -1,95 +0,0 @@ -name: build and publish - -on: - push: - branches: - - main - -permissions: - id-token: write - contents: read - packages: write - -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: | - 7.0.x - 8.0.x - 9.0.x - 10.0.x - - - run: dotnet clean Icod.Path.sln -c Release - - run: dotnet restore Icod.Path.sln - - run: dotnet build Icod.Path.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true - - run: dotnet test Icod.Path.sln -c Release --no-build --logger trx - - # Pack the specific project only on one OS to create the artifact - - name: Pack NuGet Package - if: matrix.os == 'windows-latest' - run: dotnet pack Icod.Path.csproj -c Release --no-build -o ./artifacts - - # Save the package so the deploy job can access it - - name: Upload Artifact - if: matrix.os == 'windows-latest' - uses: actions/upload-artifact@v4 - with: - name: nuget-package - path: ./artifacts/*nupkg - - # ---------------------------------------------------- - # STAGE 2: Secure & Single NuGet Deployment - # ---------------------------------------------------- - deploy: - needs: build-and-test - runs-on: windows-latest - environment: Release - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 7.0.x - 8.0.x - 9.0.x - 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 }} - - # PowerShell friendly execution to push all target packages sequentially - - name: NuGet push - shell: pwsh - run: | - Get-ChildItem "./artifacts/Icod.Path.*.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 - } - - # PowerShell friendly execution to push all target packages sequentially - - name: Push to GitHub Packages - shell: pwsh - run: | - Get-ChildItem "./artifacts/Icod.Path.*.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 - } \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8e0b93c --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,238 @@ +name: release + +on: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + DOTNET_VERSIONS: | + 7.0.x + 8.0.x + 9.0.x + 10.0.x + CONFIGURATION: Release + SOLUTION_PATH: Icod.Path.sln + PROJECT_PATH: Icod.Path.csproj + RELEASE_DIRECTORY: artifacts/release + SELECTED_PACKAGE_DIRECTORY: artifacts/release-packages + PACKAGE_ARTIFACT: icod-path-release-packages + +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_VERSIONS }} + - 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 release 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 + $packageVersion = (dotnet msbuild '${{ env.PROJECT_PATH }}' -nologo -getProperty:PackageVersion).Trim() + if (0 -ne $LASTEXITCODE) { + throw 'Unable to read PackageVersion from Icod.Path.csproj.' + } + if ($packageVersion -ne $version) { + throw "Tag version '$version' does not match PackageVersion '$packageVersion'." + } + + "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_VERSIONS }} + - name: Restore + run: dotnet restore '${{ env.SOLUTION_PATH }}' + - name: Build Release package inputs + run: >- + dotnet build '${{ env.SOLUTION_PATH }}' + -c ${{ env.CONFIGURATION }} + --no-restore + -p:ContinuousIntegrationBuild=true + - name: Pack release candidate + run: >- + dotnet pack '${{ env.SOLUTION_PATH }}' + -c ${{ env.CONFIGURATION }} + --no-build + --no-restore + -o ${{ env.RELEASE_DIRECTORY }} + -p:ContinuousIntegrationBuild=true + - name: Select exact tagged package artifacts + shell: pwsh + run: >- + ./packaging/SelectReleasePackages.ps1 + -SourceDirectory '${{ env.RELEASE_DIRECTORY }}' + -DestinationDirectory '${{ env.SELECTED_PACKAGE_DIRECTORY }}' + -ExpectedVersion '${{ needs.metadata.outputs.version }}' + - name: Verify exact release package artifacts + shell: pwsh + run: >- + ./packaging/VerifyPackageArtifact.ps1 + -ArtifactDirectory '${{ env.SELECTED_PACKAGE_DIRECTORY }}' + -Configuration '${{ env.CONFIGURATION }}' + -ExpectedVersion '${{ needs.metadata.outputs.version }}' + - name: Upload exact release package artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ env.PACKAGE_ARTIFACT }} + path: | + ${{ env.SELECTED_PACKAGE_DIRECTORY }}/*.nupkg + ${{ env.SELECTED_PACKAGE_DIRECTORY }}/*.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: 10.0.x + - uses: actions/download-artifact@v4 + with: + name: ${{ env.PACKAGE_ARTIFACT }} + 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: | + $package = Get-Item -LiteralPath "artifacts/package/Icod.Path.${{ needs.metadata.outputs.version }}.nupkg" + 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 with status $LASTEXITCODE." + } + + publish-github-packages: + needs: [metadata, 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: ${{ env.PACKAGE_ARTIFACT }} + 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" + $package = Get-Item -LiteralPath "artifacts/package/Icod.Path.${{ needs.metadata.outputs.version }}.nupkg" + dotnet nuget push $package.FullName ` + --source $source ` + --api-key $env:GITHUB_TOKEN ` + --skip-duplicate + if (0 -ne $LASTEXITCODE) { + throw "GitHub Packages publication failed with status $LASTEXITCODE." + } + + 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: ${{ env.PACKAGE_ARTIFACT }} + path: artifacts/release-assets + - name: Create checksums + shell: pwsh + run: | + $assetDirectory = 'artifacts/release-assets' + $files = @(Get-ChildItem -LiteralPath $assetDirectory -File | Sort-Object Name) + if (2 -ne $files.Count) { + throw "Expected exactly the Icod.Path nupkg and snupkg before checksumming; 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( + (Join-Path $assetDirectory '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: | + $tag = $env:GITHUB_REF_NAME + $version = '${{ needs.metadata.outputs.version }}' + $assets = @( + Get-ChildItem -LiteralPath 'artifacts/release-assets' -File | + Sort-Object Name | + ForEach-Object { $_.FullName } + ) + + $arguments = @( + 'release', 'create', $tag, + '--verify-tag', + '--title', "Icod.Path $version", + '--generate-notes' + ) + if ('true' -eq '${{ needs.metadata.outputs.prerelease }}') { + $arguments += '--prerelease' + $arguments += '--latest=false' + } + $arguments += $assets + + & gh @arguments + if (0 -ne $LASTEXITCODE) { + throw "GitHub Release creation failed with status $LASTEXITCODE." + } diff --git a/README.md b/README.md index a27e4db..a441068 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Icod.Path +[![PR Staging build](https://github.com/uniblab/Icod.Path/actions/workflows/pull-request.yaml/badge.svg)](https://github.com/uniblab/Icod.Path/actions/workflows/pull-request.yaml) +[![Main Release validation](https://github.com/uniblab/Icod.Path/actions/workflows/main.yaml/badge.svg?branch=main)](https://github.com/uniblab/Icod.Path/actions/workflows/main.yaml) + `Icod.Path` is a standalone .NET library for deterministic pathname decomposition, normalization, physical canonicalization, and no-follow pathname-indirection inspection across POSIX and Windows path models. The library is command-neutral. It can be consumed by utility suites, applications, services, build tools, or other libraries that need canonical-path behavior without depending on a command-line implementation. @@ -77,11 +80,28 @@ System-backed physical observation uses the current host filesystem. On Windows, `Icod.Path` targets .NET 7.0, 8.0, 9.0, and 10.0 and uses C# 13. +Local development uses the repository `Debug` build cycle: + +```text +build.cmd +``` + +or on Unix-like hosts: + ```text -dotnet build Icod.Path.sln -dotnet test Icod.Path.sln +./build.sh ``` +The default local sequence is: + +```text +clean -> restore -> build -> test -> pack -> validate +``` + +Pull requests elevate to `Staging`. Pushes to `main` run the authoritative six-runner `Release` validation gate. Package publication occurs only from an immutable `v` tag whose commit is contained in `main` and whose version matches `Icod.Path.csproj:PackageVersion`. + +See [`packaging/README.md`](packaging/README.md) for the build, package-verification, and publication contract. + The repository contains the library project at the root and its test project under `tests/Path.Tests`. ## Detailed contract diff --git a/build.cmd b/build.cmd index e42bc54..5cd5b0b 100644 --- a/build.cmd +++ b/build.cmd @@ -1,82 +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.Path.sln -c Debug -exit /b %errorlevel% - - -:restore -echo. -echo === Restore === -dotnet restore Icod.Path.sln -exit /b %errorlevel% - - -:build -echo. -echo === Build === -dotnet build Icod.Path.sln -c Debug --no-restore -exit /b %errorlevel% - - -:test -echo. -echo === Test === -dotnet test Icod.Path.sln -c Debug --no-build -exit /b %errorlevel% - -:pack -echo. -echo === Pack === -dotnet pack Icod.Path.sln -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 cda304d..5bf3912 --- a/build.sh +++ b/build.sh @@ -1,59 +1,7 @@ #!/usr/bin/env sh set -eu -clean() -{ - printf '\n=== Clean ===\n' - dotnet clean Icod.Path.sln -c Debug -} - -restore() -{ - printf '\n=== Restore ===\n' - dotnet restore Icod.Path.sln -} - -build() -{ - printf '\n=== Build ===\n' - dotnet build Icod.Path.sln -c Debug --no-restore -} - -test() -{ - printf '\n=== Test ===\n' - dotnet test Icod.Path.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/packaging/Get-RepositoryMetadata.ps1 b/packaging/Get-RepositoryMetadata.ps1 new file mode 100644 index 0000000..004ed12 --- /dev/null +++ b/packaging/Get-RepositoryMetadata.ps1 @@ -0,0 +1,27 @@ +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..5a1ada7 --- /dev/null +++ b/packaging/Invoke-Build.ps1 @@ -0,0 +1,82 @@ +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', $solutionPath, + '-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..3e0acfd --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,65 @@ +# Icod.Path build and distribution tooling + +This directory adapts the canonical Icod C#/.NET build-cycle contract to the `Icod.Path` library package. + +## 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` | + +`Icod.Path` is a library-only repository. It does not produce executable release archives. + +## Target frameworks + +The library and tests target: + +```text +net7.0 +net8.0 +net9.0 +net10.0 +``` + +CI therefore installs all four SDK lines rather than assuming the template's .NET-10-only default. + +## Local Debug cycle + +The root build scripts run: + +```text +clean -> restore -> build -> test -> pack -> validate +``` + +and always use `Debug` unless `packaging/Invoke-Build.ps1` is invoked directly for a diagnostic configuration. + +## Package contract + +`VerifyPackageArtifact.ps1` validates the exact generated package rather than rebuilding a logically equivalent package. It requires: + +- exactly one `Icod.Path` `.nupkg`; +- a matching `.snupkg`; +- the expected package version when supplied; +- `README.md`, `LICENSE`, and `icon.png` in the package; +- `Icod.Path.dll` and XML documentation for net7.0, net8.0, net9.0, and net10.0; and +- portable PDB payloads for all four TFMs in the symbol package. + +## Pull requests + +Pull requests build and test `Staging` on Windows, Linux, and macOS. Linux produces and exact-verifies the canonical Staging package artifacts once. + +## Main + +Pushes to `main` are validation-only. The six-runner Release matrix covers Windows x64/ARM64, Linux x64/ARM64, and macOS x64/ARM64. Linux x64 additionally packs and exact-verifies the platform-neutral NuGet artifacts rather than making every architecture repeat identical packaging work. + +## Tagged publication + +A pushed `v` tag must point to a commit contained in `main`, and the tag version must exactly match `Icod.Path.csproj:PackageVersion`. + +The tag workflow builds and packs the exact Release package once, validates that package and its symbol package, then publishes the same `.nupkg` to NuGet.org and GitHub Packages in parallel. GitHub Release creation waits for both registry jobs and contains the `.nupkg`, `.snupkg`, and `SHA256SUMS.txt`. + +NuGet.org publication uses the `Release` environment and Trusted Publishing through `NuGet/login@v1`. GitHub Packages uses the repository `GITHUB_TOKEN` with `packages: write`. diff --git a/packaging/RepositoryTools.psm1 b/packaging/RepositoryTools.psm1 new file mode 100644 index 0000000..f0cce35 --- /dev/null +++ b/packaging/RepositoryTools.psm1 @@ -0,0 +1,112 @@ +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']") + if ($null -eq $idNode -or $null -eq $versionNode) { + throw "Package '$PackagePath' does not declare package ID and 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..c5dddf0 --- /dev/null +++ b/packaging/SelectReleasePackages.ps1 @@ -0,0 +1,55 @@ +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 ($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 + +$packagePath = Join-Path $SourceDirectory "Icod.Path.$ExpectedVersion.nupkg" +$symbolPath = Join-Path $SourceDirectory "Icod.Path.$ExpectedVersion.snupkg" +foreach ($path in @($packagePath, $symbolPath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Expected release artifact '$path' was not produced." + } + Copy-Item -LiteralPath $path -Destination (Join-Path $DestinationDirectory ([System.IO.Path]::GetFileName($path))) +} + +$metadata = Get-PackageMetadata -PackagePath $packagePath +if ('Icod.Path' -ne $metadata.Id -or $ExpectedVersion -ne $metadata.Version) { + throw "Release package metadata '$($metadata.Id) $($metadata.Version)' does not match Icod.Path $ExpectedVersion." +} + +if (-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)) { + 'has_packages=true' >> $GitHubOutputPath + 'package_count=1' >> $GitHubOutputPath +} + +Write-Host "Selected Icod.Path $ExpectedVersion package and symbol package for release." diff --git a/packaging/VerifyDistribution.ps1 b/packaging/VerifyDistribution.ps1 new file mode 100644 index 0000000..80b730e --- /dev/null +++ b/packaging/VerifyDistribution.ps1 @@ -0,0 +1,59 @@ +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 + } + + Write-Host '' + Write-Host "Distribution verification completed successfully ($Configuration)." + Write-Host " Solution: $solutionPath" +} finally { + Pop-Location +} diff --git a/packaging/VerifyPackageArtifact.ps1 b/packaging/VerifyPackageArtifact.ps1 new file mode 100644 index 0000000..cf638e3 --- /dev/null +++ b/packaging/VerifyPackageArtifact.ps1 @@ -0,0 +1,101 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDirectory, + + [ValidateSet('Debug', 'Staging', 'Release')] + [string]$Configuration = 'Release', + + [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 + +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 (1 -ne $packages.Count) { + throw "Expected exactly one Icod.Path .nupkg in '$ArtifactDirectory'; found $($packages.Count)." +} + +$package = $packages[0] +$metadata = Get-PackageMetadata -PackagePath $package.FullName +if ('Icod.Path' -ne $metadata.Id) { + throw "Expected PackageId 'Icod.Path'; found '$($metadata.Id)'." +} +if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and $ExpectedVersion -ne $metadata.Version) { + throw "Expected package version '$ExpectedVersion'; found '$($metadata.Version)'." +} +if ('README.md' -ne $metadata.Readme) { + throw "Expected package readme 'README.md'; found '$($metadata.Readme)'." +} + +$symbolPackagePath = Join-Path $ArtifactDirectory "Icod.Path.$($metadata.Version).snupkg" +if (-not (Test-Path -LiteralPath $symbolPackagePath -PathType Leaf)) { + throw "Expected symbol package '$symbolPackagePath' was not produced." +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) +try { + $entryNames = @($archive.Entries | ForEach-Object { $_.FullName }) + foreach ($requiredEntry in @( + 'README.md', + 'LICENSE', + 'icon.png', + 'lib/net7.0/Icod.Path.dll', + 'lib/net7.0/Icod.Path.xml', + 'lib/net8.0/Icod.Path.dll', + 'lib/net8.0/Icod.Path.xml', + 'lib/net9.0/Icod.Path.dll', + 'lib/net9.0/Icod.Path.xml', + 'lib/net10.0/Icod.Path.dll', + 'lib/net10.0/Icod.Path.xml' + )) { + if ($requiredEntry -notin $entryNames) { + throw "Package '$($package.Name)' is missing required entry '$requiredEntry'." + } + } +} finally { + $archive.Dispose() +} + +$symbols = [System.IO.Compression.ZipFile]::OpenRead($symbolPackagePath) +try { + $symbolEntries = @($symbols.Entries | ForEach-Object { $_.FullName }) + foreach ($requiredEntry in @( + 'lib/net7.0/Icod.Path.pdb', + 'lib/net8.0/Icod.Path.pdb', + 'lib/net9.0/Icod.Path.pdb', + 'lib/net10.0/Icod.Path.pdb' + )) { + if ($requiredEntry -notin $symbolEntries) { + throw "Symbol package is missing required entry '$requiredEntry'." + } + } +} finally { + $symbols.Dispose() +} + +if (-not [string]::IsNullOrWhiteSpace($GitHubOutputPath)) { + 'has_packages=true' >> $GitHubOutputPath + 'package_count=1' >> $GitHubOutputPath + "package_version=$($metadata.Version)" >> $GitHubOutputPath +} + +Write-Host "Exact package verification completed successfully for Icod.Path $($metadata.Version) ($Configuration)."