diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9d3b2f..b5dec78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,15 @@ on: branches: [ main ] jobs: + workflow-lint: + name: Lint GitHub Actions workflows + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: actionlint + uses: rhysd/actionlint@v1.7.12 + changes: name: Detect package-impacting changes runs-on: ubuntu-latest @@ -21,7 +30,7 @@ jobs: any_changed: ${{ steps.flags.outputs.any_changed }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -33,6 +42,12 @@ jobs: workflows: - '.github/workflows/ci.yml' - '.github/workflows/release.yml' + tooling: + - 'Directory.Build.props' + - 'Directory.Packages.props' + - 'src/analyticsLibrary.ReleaseTooling/**' + - 'tools/analyticsLibrary.NuGetMaintenance/**' + - 'tests/analyticsLibrary.ReleaseTooling.Tests/**' core: - 'src/analyticsLibrary.Core/**' algorithms: @@ -51,6 +66,13 @@ jobs: - name: Compute effective package flags id: flags run: | + if [ '${{ steps.filter.outputs.tooling }}' = "true" ]; then + for n in core_changed algorithms_changed statistics_changed excel_changed access_changed hadoop_changed root_changed any_changed; do + echo "${n}=true" >> "$GITHUB_OUTPUT" + done + exit 0 + fi + WORKFLOWS_CHANGED='${{ steps.filter.outputs.workflows }}' set_flag() { local name="$1" @@ -86,7 +108,9 @@ jobs: no-package-changes: name: No package-impacting changes runs-on: ubuntu-latest - needs: changes + needs: + - changes + - workflow-lint if: needs.changes.outputs.any_changed != 'true' steps: - name: Skip message @@ -95,15 +119,17 @@ jobs: build-test-pack: name: Build, Test, and Pack runs-on: ubuntu-latest - needs: changes + needs: + - changes + - workflow-lint if: needs.changes.outputs.any_changed == 'true' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '8.0.x' @@ -114,7 +140,7 @@ jobs: run: dotnet build -c Release --no-restore - name: Test - run: dotnet test -c Release --no-build --verbosity normal + run: dotnet test -c Release --no-build --verbosity normal --filter "Category!=Integration" - name: Pack run: | @@ -180,7 +206,7 @@ jobs: local package_name="$2" if [ "$should_pack" = "true" ]; then EXPECTED=$((EXPECTED+1)) - if ! ls ./artifacts/${package_name}.*.nupkg 1>/dev/null 2>&1; then + if ! ls "./artifacts/${package_name}."*.nupkg 1>/dev/null 2>&1; then echo "ERROR: Expected package not found: ${package_name}" exit 1 fi @@ -202,7 +228,7 @@ jobs: echo "All expected packages found." - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nuget-packages-${{ github.run_id }} path: ./artifacts/*.nupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d923308..6d71b00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,125 +12,17 @@ on: default: '2.0.0' jobs: - changes: - name: Detect package-impacting changes - runs-on: ubuntu-latest - outputs: - core_changed: ${{ steps.flags.outputs.core_changed }} - algorithms_changed: ${{ steps.flags.outputs.algorithms_changed }} - statistics_changed: ${{ steps.flags.outputs.statistics_changed }} - excel_changed: ${{ steps.flags.outputs.excel_changed }} - access_changed: ${{ steps.flags.outputs.access_changed }} - hadoop_changed: ${{ steps.flags.outputs.hadoop_changed }} - root_changed: ${{ steps.flags.outputs.root_changed }} - any_changed: ${{ steps.flags.outputs.any_changed }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Resolve comparison base - id: base - run: | - if [ "${{ github.event_name }}" = "push" ]; then - BASE="${{ github.event.before }}" - if [ -n "$BASE" ] && [ "$BASE" != "0000000000000000000000000000000000000000" ]; then - echo "base_ref=$BASE" >> "$GITHUB_OUTPUT" - exit 0 - fi - fi - - PREV_TAG="$(git tag --sort=-creatordate | sed -n '2p')" - if [ -z "$PREV_TAG" ]; then - BASE="$(git rev-list --max-parents=0 HEAD | sed -n '1p')" - else - BASE="$(git rev-list -n 1 "$PREV_TAG")" - fi - echo "base_ref=$BASE" >> "$GITHUB_OUTPUT" - - - name: Detect changed paths - id: filter - uses: dorny/paths-filter@v3 - with: - base: ${{ steps.base.outputs.base_ref }} - ref: ${{ github.sha }} - filters: | - workflows: - - '.github/workflows/ci.yml' - - '.github/workflows/release.yml' - core: - - 'src/analyticsLibrary.Core/**' - algorithms: - - 'src/analyticsLibrary.Algorithms/**' - statistics: - - 'src/analyticsLibrary.Statistics/**' - excel: - - 'src/analyticsLibrary.Excel/**' - access: - - 'src/analyticsLibrary.Access/**' - hadoop: - - 'src/analyticsLibrary.Hadoop/**' - root: - - 'src/analyticsLibrary/**' - - - name: Compute effective package flags - id: flags - run: | - WORKFLOWS_CHANGED='${{ steps.filter.outputs.workflows }}' - set_flag() { - local name="$1" - local raw="$2" - if [ "$raw" = "true" ] || [ "$WORKFLOWS_CHANGED" = "true" ]; then - echo "${name}=true" >> "$GITHUB_OUTPUT" - else - echo "${name}=false" >> "$GITHUB_OUTPUT" - fi - } - - set_flag core_changed '${{ steps.filter.outputs.core }}' - set_flag algorithms_changed '${{ steps.filter.outputs.algorithms }}' - set_flag statistics_changed '${{ steps.filter.outputs.statistics }}' - set_flag excel_changed '${{ steps.filter.outputs.excel }}' - set_flag access_changed '${{ steps.filter.outputs.access }}' - set_flag hadoop_changed '${{ steps.filter.outputs.hadoop }}' - set_flag root_changed '${{ steps.filter.outputs.root }}' - - if [ '${{ steps.filter.outputs.core }}' = "true" ] || \ - [ '${{ steps.filter.outputs.algorithms }}' = "true" ] || \ - [ '${{ steps.filter.outputs.statistics }}' = "true" ] || \ - [ '${{ steps.filter.outputs.excel }}' = "true" ] || \ - [ '${{ steps.filter.outputs.access }}' = "true" ] || \ - [ '${{ steps.filter.outputs.hadoop }}' = "true" ] || \ - [ '${{ steps.filter.outputs.root }}' = "true" ] || \ - [ "$WORKFLOWS_CHANGED" = "true" ]; then - echo "any_changed=true" >> "$GITHUB_OUTPUT" - else - echo "any_changed=false" >> "$GITHUB_OUTPUT" - fi - - no-package-changes: - name: No package-impacting changes - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.any_changed != 'true' - steps: - - name: Skip message - run: echo "No package-impacting changes detected since the release baseline; skipping NuGet release." - release: - name: Build, Test, Pack, and Deploy + name: Build, Test, Pack, Deploy, and Deprecate runs-on: ubuntu-latest environment: nuget-release - needs: changes - if: needs.changes.outputs.any_changed == 'true' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '8.0.x' @@ -140,10 +32,9 @@ jobs: if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ github.event.inputs.version }}" else - # Strip the leading 'v' from tag name (e.g. v2.0.0 -> 2.0.0) VERSION="${GITHUB_REF_NAME#v}" fi - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" echo "Release version: $VERSION" - name: Restore @@ -153,85 +44,70 @@ jobs: run: dotnet build -c Release --no-restore - name: Test - run: dotnet test -c Release --no-build --verbosity normal + run: dotnet test -c Release --no-build --verbosity normal --filter "Category!=Integration" - - name: Pack + - name: Validate release version against project versions run: | - mkdir -p ./artifacts - PACKED=0 - - if [ "${{ needs.changes.outputs.core_changed }}" = "true" ]; then - dotnet pack src/analyticsLibrary.Core/analyticsLibrary.Core.csproj -c Release --no-build -o ./artifacts - PACKED=$((PACKED+1)) - fi - if [ "${{ needs.changes.outputs.algorithms_changed }}" = "true" ]; then - dotnet pack src/analyticsLibrary.Algorithms/analyticsLibrary.Algorithms.csproj -c Release --no-build -o ./artifacts - PACKED=$((PACKED+1)) - fi - if [ "${{ needs.changes.outputs.statistics_changed }}" = "true" ]; then - dotnet pack src/analyticsLibrary.Statistics/analyticsLibrary.Statistics.csproj -c Release --no-build -o ./artifacts - PACKED=$((PACKED+1)) - fi - if [ "${{ needs.changes.outputs.excel_changed }}" = "true" ]; then - dotnet pack src/analyticsLibrary.Excel/analyticsLibrary.Excel.csproj -c Release --no-build -o ./artifacts - PACKED=$((PACKED+1)) - fi - if [ "${{ needs.changes.outputs.access_changed }}" = "true" ]; then - dotnet pack src/analyticsLibrary.Access/analyticsLibrary.Access.csproj -c Release --no-build -o ./artifacts - PACKED=$((PACKED+1)) - fi - if [ "${{ needs.changes.outputs.hadoop_changed }}" = "true" ]; then - dotnet pack src/analyticsLibrary.Hadoop/analyticsLibrary.Hadoop.csproj -c Release --no-build -o ./artifacts - PACKED=$((PACKED+1)) - fi - if [ "${{ needs.changes.outputs.root_changed }}" = "true" ]; then - dotnet pack src/analyticsLibrary/analyticsLibrary.csproj -c Release --no-build -o ./artifacts - PACKED=$((PACKED+1)) - fi - - if [ "$PACKED" -eq 0 ]; then - echo "No package projects selected for packing." + V="${{ steps.version.outputs.VERSION }}" + if ! [[ "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: Release version must be SemVer major.minor.patch (got '$V')." exit 1 fi + PROJECTS=( + src/analyticsLibrary.Core/analyticsLibrary.Core.csproj + src/analyticsLibrary.Algorithms/analyticsLibrary.Algorithms.csproj + src/analyticsLibrary.Statistics/analyticsLibrary.Statistics.csproj + src/analyticsLibrary.Excel/analyticsLibrary.Excel.csproj + src/analyticsLibrary.Access/analyticsLibrary.Access.csproj + src/analyticsLibrary.Hadoop/analyticsLibrary.Hadoop.csproj + src/analyticsLibrary/analyticsLibrary.csproj + ) + + for csproj in "${PROJECTS[@]}"; do + current=$(grep -oPm1 '\K[^<]+' "$csproj") + if [ -z "$current" ]; then + echo "ERROR: Could not read from $csproj" + exit 1 + fi + + max_version=$(printf '%s\n%s\n' "$V" "$current" | sort -V | tail -n1) + if [ "$max_version" != "$V" ]; then + echo "ERROR: Requested release version $V is lower than project version $current in $csproj" + exit 1 + fi + done + + - name: Pack all packages + run: | + mkdir -p ./artifacts + V="${{ steps.version.outputs.VERSION }}" + dotnet pack src/analyticsLibrary.Core/analyticsLibrary.Core.csproj -c Release --no-build "-p:Version=$V" -o ./artifacts + dotnet pack src/analyticsLibrary.Algorithms/analyticsLibrary.Algorithms.csproj -c Release --no-build "-p:Version=$V" -o ./artifacts + dotnet pack src/analyticsLibrary.Statistics/analyticsLibrary.Statistics.csproj -c Release --no-build "-p:Version=$V" -o ./artifacts + dotnet pack src/analyticsLibrary.Excel/analyticsLibrary.Excel.csproj -c Release --no-build "-p:Version=$V" -o ./artifacts + dotnet pack src/analyticsLibrary.Access/analyticsLibrary.Access.csproj -c Release --no-build "-p:Version=$V" -o ./artifacts + dotnet pack src/analyticsLibrary.Hadoop/analyticsLibrary.Hadoop.csproj -c Release --no-build "-p:Version=$V" -o ./artifacts + dotnet pack src/analyticsLibrary/analyticsLibrary.csproj -c Release --no-build "-p:Version=$V" -o ./artifacts + - name: Inspect packages run: | echo "Produced packages:" ls -la ./artifacts/*.nupkg - EXPECTED=0 - MISSING=0 - verify_pkg() { - local should_pack="$1" - local package_name="$2" - if [ "$should_pack" = "true" ]; then - EXPECTED=$((EXPECTED+1)) - if ! ls ./artifacts/${package_name}.*.nupkg 1>/dev/null 2>&1; then - echo "ERROR: Expected package not found: ${package_name}" - MISSING=$((MISSING+1)) - fi + for id in analyticsLibrary.Core analyticsLibrary.Algorithms analyticsLibrary.Statistics analyticsLibrary.Excel analyticsLibrary.Access analyticsLibrary.Hadoop; do + if ! ls "./artifacts/${id}."*.nupkg 1>/dev/null 2>&1; then + echo "ERROR: Expected package not found: ${id}" + exit 1 fi - } - - verify_pkg "${{ needs.changes.outputs.core_changed }}" "analyticsLibrary.Core" - verify_pkg "${{ needs.changes.outputs.algorithms_changed }}" "analyticsLibrary.Algorithms" - verify_pkg "${{ needs.changes.outputs.statistics_changed }}" "analyticsLibrary.Statistics" - verify_pkg "${{ needs.changes.outputs.excel_changed }}" "analyticsLibrary.Excel" - verify_pkg "${{ needs.changes.outputs.access_changed }}" "analyticsLibrary.Access" - verify_pkg "${{ needs.changes.outputs.hadoop_changed }}" "analyticsLibrary.Hadoop" - verify_pkg "${{ needs.changes.outputs.root_changed }}" "analyticsLibrary" - - if [ "$EXPECTED" -eq 0 ]; then - echo "Aborting: No packages were expected after change detection." + done + if ! ls ./artifacts/analyticsLibrary.[0-9]*.nupkg 1>/dev/null 2>&1; then + echo "ERROR: Expected root package not found: analyticsLibrary..nupkg" exit 1 fi - if [ "$MISSING" -gt 0 ]; then - echo "Aborting: $MISSING expected package(s) are missing." - exit 1 - fi - echo "All expected packages found and ready for deployment." + echo "All ${{ steps.version.outputs.VERSION }} packages found and ready for deployment." - name: Upload release artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nuget-packages-${{ steps.version.outputs.VERSION }} path: ./artifacts/*.nupkg @@ -240,6 +116,16 @@ jobs: - name: Deploy to NuGet run: | dotnet nuget push ./artifacts/*.nupkg \ - --api-key ${{ secrets.NUGET_API_KEY }} \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate + + - name: Deprecate prior versions on NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "NUGET_API_KEY is not set; skipping deprecation." + exit 0 + fi + dotnet run -c Release --no-build --project tools/analyticsLibrary.NuGetMaintenance -- apply-deprecations "${{ steps.version.outputs.VERSION }}" diff --git a/Analytics Library.sln b/Analytics Library.sln index 75a7170..0741ad0 100755 --- a/Analytics Library.sln +++ b/Analytics Library.sln @@ -29,6 +29,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "analyticsLibrary.Algorithms EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "analyticsLibrary.Excel.Tests", "tests\analyticsLibrary.Excel.Tests\analyticsLibrary.Excel.Tests.csproj", "{660F8C89-9149-452D-9E97-222E18D769D3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "analyticsLibrary.ReleaseTooling", "src\analyticsLibrary.ReleaseTooling\analyticsLibrary.ReleaseTooling.csproj", "{D5B7B9CE-2BF9-44D1-991F-8D175B183A90}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "analyticsLibrary.NuGetMaintenance", "tools\analyticsLibrary.NuGetMaintenance\analyticsLibrary.NuGetMaintenance.csproj", "{1AAC51AF-5725-4377-8B33-0C094DE34B17}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "analyticsLibrary.ReleaseTooling.Tests", "tests\analyticsLibrary.ReleaseTooling.Tests\analyticsLibrary.ReleaseTooling.Tests.csproj", "{9BB1015E-6FA9-4734-9527-23ACB23898C2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution CD_ROM|Any CPU = CD_ROM|Any CPU @@ -516,6 +524,132 @@ Global {660F8C89-9149-452D-9E97-222E18D769D3}.SingleImage|x64.Build.0 = Debug|Any CPU {660F8C89-9149-452D-9E97-222E18D769D3}.SingleImage|x86.ActiveCfg = Debug|Any CPU {660F8C89-9149-452D-9E97-222E18D769D3}.SingleImage|x86.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.CD_ROM|Any CPU.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.CD_ROM|Any CPU.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.CD_ROM|x64.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.CD_ROM|x64.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.CD_ROM|x86.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.CD_ROM|x86.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug x86|Any CPU.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug x86|Any CPU.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug x86|x64.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug x86|x64.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug x86|x86.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug x86|x86.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug|x64.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Debug|x86.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.DVD-5|Any CPU.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.DVD-5|Any CPU.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.DVD-5|x64.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.DVD-5|x64.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.DVD-5|x86.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.DVD-5|x86.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release x86|Any CPU.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release x86|Any CPU.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release x86|x64.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release x86|x64.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release x86|x86.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release x86|x86.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release|Any CPU.Build.0 = Release|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release|x64.ActiveCfg = Release|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release|x64.Build.0 = Release|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release|x86.ActiveCfg = Release|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.Release|x86.Build.0 = Release|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.SingleImage|Any CPU.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.SingleImage|Any CPU.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.SingleImage|x64.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.SingleImage|x64.Build.0 = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.SingleImage|x86.ActiveCfg = Debug|Any CPU + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90}.SingleImage|x86.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.CD_ROM|Any CPU.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.CD_ROM|Any CPU.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.CD_ROM|x64.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.CD_ROM|x64.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.CD_ROM|x86.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.CD_ROM|x86.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug x86|Any CPU.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug x86|Any CPU.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug x86|x64.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug x86|x64.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug x86|x86.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug x86|x86.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug|x64.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug|x64.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug|x86.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Debug|x86.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.DVD-5|Any CPU.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.DVD-5|Any CPU.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.DVD-5|x64.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.DVD-5|x64.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.DVD-5|x86.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.DVD-5|x86.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release x86|Any CPU.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release x86|Any CPU.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release x86|x64.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release x86|x64.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release x86|x86.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release x86|x86.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release|Any CPU.Build.0 = Release|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release|x64.ActiveCfg = Release|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release|x64.Build.0 = Release|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release|x86.ActiveCfg = Release|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.Release|x86.Build.0 = Release|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.SingleImage|Any CPU.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.SingleImage|Any CPU.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.SingleImage|x64.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.SingleImage|x64.Build.0 = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.SingleImage|x86.ActiveCfg = Debug|Any CPU + {1AAC51AF-5725-4377-8B33-0C094DE34B17}.SingleImage|x86.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.CD_ROM|Any CPU.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.CD_ROM|Any CPU.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.CD_ROM|x64.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.CD_ROM|x64.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.CD_ROM|x86.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.CD_ROM|x86.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug x86|Any CPU.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug x86|Any CPU.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug x86|x64.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug x86|x64.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug x86|x86.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug x86|x86.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug|x64.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug|x64.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug|x86.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Debug|x86.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.DVD-5|Any CPU.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.DVD-5|Any CPU.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.DVD-5|x64.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.DVD-5|x64.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.DVD-5|x86.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.DVD-5|x86.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release x86|Any CPU.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release x86|Any CPU.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release x86|x64.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release x86|x64.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release x86|x86.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release x86|x86.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release|Any CPU.Build.0 = Release|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release|x64.ActiveCfg = Release|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release|x64.Build.0 = Release|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release|x86.ActiveCfg = Release|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.Release|x86.Build.0 = Release|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.SingleImage|Any CPU.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.SingleImage|Any CPU.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.SingleImage|x64.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.SingleImage|x64.Build.0 = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.SingleImage|x86.ActiveCfg = Debug|Any CPU + {9BB1015E-6FA9-4734-9527-23ACB23898C2}.SingleImage|x86.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -532,6 +666,9 @@ Global {23A3F035-185E-485F-80D8-DCBBDBD6CFA9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {1C80F5C6-8A99-4941-8A81-97A28C60C3E8} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {660F8C89-9149-452D-9E97-222E18D769D3} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {D5B7B9CE-2BF9-44D1-991F-8D175B183A90} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {1AAC51AF-5725-4377-8B33-0C094DE34B17} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {9BB1015E-6FA9-4734-9527-23ACB23898C2} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {52B0CB57-8044-4EA5-BC6B-F17324054845} diff --git a/Directory.Packages.props b/Directory.Packages.props index 32f3799..3caa684 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,5 +27,6 @@ + \ No newline at end of file diff --git a/README.md b/README.md index f4a9e7a..1ad77c8 100644 --- a/README.md +++ b/README.md @@ -249,18 +249,24 @@ dotnet pack -c Release -o ./nupkgs **Beta packages (pull requests)** -Opening or updating a pull request against `main` triggers `ci.yml`. The workflow builds, tests, packs beta packages versioned `2.0.0-beta.pr-.`, and uploads them as workflow artifacts. Beta packages are only pushed to NuGet.org when the `NUGET_API_KEY` secret is available and the PR is not from a fork. +Opening or updating a pull request against `main` triggers `ci.yml`. The workflow builds, tests, packs only the package projects whose `src/analyticsLibrary.*` paths changed (or when central/tooling paths change—see workflow filters), using beta versions read from each `.csproj` `` plus `-beta.pr-.`. It uploads `.nupkg` files as workflow artifacts. Beta packages are pushed to NuGet.org only on the `pull_request` event when `NUGET_API_KEY` is set and the PR branch is from the same repository (not a fork). Merging the PR to `main` does **not** publish betas to NuGet.org. -**Release packages** +**Stable release packages** -Push a version tag (e.g. `v2.0.0`) or trigger `release.yml` manually via workflow dispatch. The workflow: -1. Builds and tests the solution from the tagged commit. -2. Packs all seven packages. -3. Verifies all expected `.nupkg` files are present. -4. Deploys to NuGet.org under the `nuget-release` GitHub Environment, which requires explicit approval before the deploy step runs. +Publishing a stable release to NuGet.org is **not** triggered by merging a pull request alone. You must either push a SemVer tag matching `v*.*.*` (for example `v3.0.0`) at the commit you want to ship, or run `release.yml` manually via **workflow dispatch** and enter the version (for example `3.0.0`). + +The `release.yml` workflow then: + +1. Builds and tests the full solution (including release-tooling tests under `tests/analyticsLibrary.ReleaseTooling.Tests/`). +2. Packs **all seven** packages at the same release version (no path-based skipping). +3. Verifies every expected `.nupkg` is present. +4. Pushes packages to NuGet.org. This step uses the `nuget-release` GitHub Environment so you can require manual approval before anything is published. +5. Deprecates previously published versions on NuGet.org: all CI beta builds whose versions contain `-beta.pr-` are marked with an “other” deprecation reason and a testing-only message; every older **stable** version below the version you just shipped is marked **legacy** with the message: `This package is legacy and is no longer maintained`. The deprecation client calls NuGet.org’s HTTP API (`PUT https://www.nuget.org/api/v2/package/{packageId}/deprecations`) using the same `NUGET_API_KEY`. The key must include permission to **push** and **unlist** (deprecation uses the unlist scope on nuget.org). If NuGet.org returns **403** for deprecation, the account may not yet have the manage-deprecation API enabled for API keys; in that case you can deprecate versions manually on nuget.org until the API is available. Store the NuGet API key as a repository secret named `NUGET_API_KEY`. Never commit credentials. +CI also runs [actionlint](https://github.com/rhysd/actionlint) on workflow files. + --- ## Contributing diff --git a/src/analyticsLibrary.ReleaseTooling/FlatContainerIndexParser.cs b/src/analyticsLibrary.ReleaseTooling/FlatContainerIndexParser.cs new file mode 100644 index 0000000..5efa69c --- /dev/null +++ b/src/analyticsLibrary.ReleaseTooling/FlatContainerIndexParser.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace analyticsLibrary.ReleaseTooling; + +public static class FlatContainerIndexParser +{ + public static IReadOnlyList ParseVersions(string json) + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("versions", out var versions) || versions.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + var list = new List(); + foreach (var el in versions.EnumerateArray()) + { + if (el.ValueKind == JsonValueKind.String) + { + var s = el.GetString(); + if (!string.IsNullOrEmpty(s)) + { + list.Add(s); + } + } + } + + return list; + } +} diff --git a/src/analyticsLibrary.ReleaseTooling/NuGetFlatContainerHttp.cs b/src/analyticsLibrary.ReleaseTooling/NuGetFlatContainerHttp.cs new file mode 100644 index 0000000..c9759fc --- /dev/null +++ b/src/analyticsLibrary.ReleaseTooling/NuGetFlatContainerHttp.cs @@ -0,0 +1,24 @@ +namespace analyticsLibrary.ReleaseTooling; + +public static class NuGetFlatContainerHttp +{ + public static Uri GetPackageIndexUri(string packageId) => + new($"https://api.nuget.org/v3-flatcontainer/{packageId.ToLowerInvariant()}/index.json", UriKind.Absolute); + + public static async Task> GetPublishedVersionsAsync( + HttpClient http, + string packageId, + CancellationToken cancellationToken = default) + { + var uri = GetPackageIndexUri(packageId); + using var response = await http.GetAsync(uri, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return Array.Empty(); + } + + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return FlatContainerIndexParser.ParseVersions(json); + } +} diff --git a/src/analyticsLibrary.ReleaseTooling/NuGetGalleryDeprecationApi.cs b/src/analyticsLibrary.ReleaseTooling/NuGetGalleryDeprecationApi.cs new file mode 100644 index 0000000..2d26ae0 --- /dev/null +++ b/src/analyticsLibrary.ReleaseTooling/NuGetGalleryDeprecationApi.cs @@ -0,0 +1,62 @@ +namespace analyticsLibrary.ReleaseTooling; + +public static class NuGetGalleryDeprecationApi +{ + public const string DeprecationEndpointTemplate = "https://www.nuget.org/api/v2/package/{0}/deprecations"; + + public static HttpRequestMessage CreateDeprecationRequest( + string packageId, + IReadOnlyList versions, + bool isLegacy, + bool hasCriticalBugs, + bool isOther, + string? customMessage, + string alternatePackageId, + string alternatePackageVersion, + string apiKey) + { + if (versions.Count == 0) + { + throw new ArgumentException("At least one version is required.", nameof(versions)); + } + + var pairs = new List>(); + for (var i = 0; i < versions.Count; i++) + { + pairs.Add(new KeyValuePair($"versions[{i}]", versions[i])); + } + + if (isLegacy) + { + pairs.Add(new KeyValuePair("isLegacy", "true")); + } + + if (hasCriticalBugs) + { + pairs.Add(new KeyValuePair("hasCriticalBugs", "true")); + } + + if (isOther) + { + pairs.Add(new KeyValuePair("isOther", "true")); + } + + if (!string.IsNullOrEmpty(customMessage)) + { + pairs.Add(new KeyValuePair("message", customMessage)); + } + + pairs.Add(new KeyValuePair("alternatePackageId", alternatePackageId)); + pairs.Add(new KeyValuePair("alternatePackageVersion", alternatePackageVersion)); + pairs.Add(new KeyValuePair("listedVerb", "Unchanged")); + + var url = string.Format(DeprecationEndpointTemplate, Uri.EscapeDataString(packageId)); + var request = new HttpRequestMessage(HttpMethod.Put, url); + request.Headers.TryAddWithoutValidation("X-NuGet-ApiKey", apiKey); + request.Headers.TryAddWithoutValidation( + "User-Agent", + "analyticsLibrary-release-workflow/1.0 (+https://github.com/hpractv/analyticsLibrary)"); + request.Content = new FormUrlEncodedContent(pairs); + return request; + } +} diff --git a/src/analyticsLibrary.ReleaseTooling/ReleaseConstants.cs b/src/analyticsLibrary.ReleaseTooling/ReleaseConstants.cs new file mode 100644 index 0000000..9922ef9 --- /dev/null +++ b/src/analyticsLibrary.ReleaseTooling/ReleaseConstants.cs @@ -0,0 +1,23 @@ +namespace analyticsLibrary.ReleaseTooling; + +public static class ReleaseConstants +{ + public const string LegacyDeprecationMessage = + "This package is legacy and is no longer maintained"; + + public const string PrereleaseTestingDeprecationMessage = + "This pre-release package was published for pull request validation only. Install the latest stable release instead."; + + public static readonly string[] PackageIds = + [ + "analyticsLibrary.Core", + "analyticsLibrary.Algorithms", + "analyticsLibrary.Statistics", + "analyticsLibrary.Excel", + "analyticsLibrary.Access", + "analyticsLibrary.Hadoop", + "analyticsLibrary", + ]; + + public const string PrereleaseLabelPrefix = "-beta.pr-"; +} diff --git a/src/analyticsLibrary.ReleaseTooling/VersionDeprecationPlanner.cs b/src/analyticsLibrary.ReleaseTooling/VersionDeprecationPlanner.cs new file mode 100644 index 0000000..2dd1e4e --- /dev/null +++ b/src/analyticsLibrary.ReleaseTooling/VersionDeprecationPlanner.cs @@ -0,0 +1,39 @@ +using NuGet.Versioning; + +namespace analyticsLibrary.ReleaseTooling; + +public static class VersionDeprecationPlanner +{ + public static bool IsCiBetaPrerelease(string versionString) + { + if (string.IsNullOrWhiteSpace(versionString)) + { + return false; + } + + if (!NuGetVersion.TryParse(versionString, out var v) || !v.IsPrerelease) + { + return false; + } + + return versionString.Contains(ReleaseConstants.PrereleaseLabelPrefix, StringComparison.OrdinalIgnoreCase); + } + + public static bool IsOlderStableThan(string versionString, NuGetVersion releaseVersion) + { + if (!NuGetVersion.TryParse(versionString, out var v)) + { + return false; + } + + if (v.IsPrerelease) + { + return false; + } + + return v < releaseVersion; + } + + public static NuGetVersion? TryParseReleaseVersion(string versionString) => + NuGetVersion.TryParse(versionString, out var v) ? v : null; +} diff --git a/src/analyticsLibrary.ReleaseTooling/analyticsLibrary.ReleaseTooling.csproj b/src/analyticsLibrary.ReleaseTooling/analyticsLibrary.ReleaseTooling.csproj new file mode 100644 index 0000000..251d4a1 --- /dev/null +++ b/src/analyticsLibrary.ReleaseTooling/analyticsLibrary.ReleaseTooling.csproj @@ -0,0 +1,12 @@ + + + net8.0 + enable + enable + false + analyticsLibrary.ReleaseTooling + + + + + diff --git a/tests/analyticsLibrary.ReleaseTooling.Tests/FlatContainerIndexParserTests.cs b/tests/analyticsLibrary.ReleaseTooling.Tests/FlatContainerIndexParserTests.cs new file mode 100644 index 0000000..aafac55 --- /dev/null +++ b/tests/analyticsLibrary.ReleaseTooling.Tests/FlatContainerIndexParserTests.cs @@ -0,0 +1,26 @@ +using analyticsLibrary.ReleaseTooling; + +namespace analyticsLibrary.ReleaseTooling.Tests; + +public class FlatContainerIndexParserTests +{ + [Fact] + public void ParseVersions_extracts_ordered_strings() + { + const string json = """ + {"versions":["1.0.0","2.0.0-beta.pr-1.2"]} + """; + + var v = FlatContainerIndexParser.ParseVersions(json); + + Assert.Equal(new[] { "1.0.0", "2.0.0-beta.pr-1.2" }, v); + } + + [Fact] + public void ParseVersions_missing_array_returns_empty() + { + const string json = """{"other":[]}"""; + + Assert.Empty(FlatContainerIndexParser.ParseVersions(json)); + } +} diff --git a/tests/analyticsLibrary.ReleaseTooling.Tests/GlobalUsings.cs b/tests/analyticsLibrary.ReleaseTooling.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/analyticsLibrary.ReleaseTooling.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetFlatContainerTests.cs b/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetFlatContainerTests.cs new file mode 100644 index 0000000..c59d322 --- /dev/null +++ b/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetFlatContainerTests.cs @@ -0,0 +1,27 @@ +using System.Net; +using System.Net.Http; +using analyticsLibrary.ReleaseTooling; + +namespace analyticsLibrary.ReleaseTooling.Tests; + +public class NuGetFlatContainerTests +{ + [Fact] + public async Task Flat_container_index_for_core_has_versions_array() + { + var handler = new StubHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"versions":["1.0.0","2.0.0"]}""") + }); + using var http = new HttpClient(handler); + var versions = await NuGetFlatContainerHttp.GetPublishedVersionsAsync(http, "analyticsLibrary.Core"); + Assert.NotEmpty(versions); + } + + private sealed class StubHttpMessageHandler(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(handler(request)); + } +} diff --git a/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetGalleryDeprecationApiTests.cs b/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetGalleryDeprecationApiTests.cs new file mode 100644 index 0000000..31e71e7 --- /dev/null +++ b/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetGalleryDeprecationApiTests.cs @@ -0,0 +1,28 @@ +using System.Net.Http; +using analyticsLibrary.ReleaseTooling; + +namespace analyticsLibrary.ReleaseTooling.Tests; + +public class NuGetGalleryDeprecationApiTests +{ + [Fact] + public void CreateDeprecationRequest_uses_put_form_urlencoded() + { + using var req = NuGetGalleryDeprecationApi.CreateDeprecationRequest( + "Some.Package", + new[] { "1.0.0", "1.0.1" }, + isLegacy: true, + hasCriticalBugs: false, + isOther: false, + "msg", + "Some.Package", + "2.0.0", + "fake-key"); + + Assert.Equal(HttpMethod.Put, req.Method); + Assert.Contains("Some.Package", req.RequestUri!.AbsoluteUri, StringComparison.OrdinalIgnoreCase); + Assert.True(req.Headers.Contains("X-NuGet-ApiKey")); + Assert.NotNull(req.Content); + Assert.Equal("application/x-www-form-urlencoded", req.Content.Headers.ContentType!.MediaType); + } +} diff --git a/tests/analyticsLibrary.ReleaseTooling.Tests/VersionDeprecationPlannerTests.cs b/tests/analyticsLibrary.ReleaseTooling.Tests/VersionDeprecationPlannerTests.cs new file mode 100644 index 0000000..1e9b2e5 --- /dev/null +++ b/tests/analyticsLibrary.ReleaseTooling.Tests/VersionDeprecationPlannerTests.cs @@ -0,0 +1,33 @@ +using analyticsLibrary.ReleaseTooling; +using NuGet.Versioning; + +namespace analyticsLibrary.ReleaseTooling.Tests; + +public class VersionDeprecationPlannerTests +{ + [Theory] + [InlineData("2.0.0-beta.pr-6.2", true)] + [InlineData("2.0.0-beta", false)] + [InlineData("2.0.0", false)] + [InlineData("2.0.0-rc.1", false)] + public void IsCiBetaPrerelease_matches_pr_build_suffix(string version, bool expected) => + Assert.Equal(expected, VersionDeprecationPlanner.IsCiBetaPrerelease(version)); + + [Fact] + public void IsOlderStableThan_excludes_prerelease_and_current() + { + var rel = NuGetVersion.Parse("3.0.0"); + Assert.True(VersionDeprecationPlanner.IsOlderStableThan("2.0.0", rel)); + Assert.False(VersionDeprecationPlanner.IsOlderStableThan("3.0.0", rel)); + Assert.False(VersionDeprecationPlanner.IsOlderStableThan("3.0.1", rel)); + Assert.False(VersionDeprecationPlanner.IsOlderStableThan("3.0.0-beta.pr-1.1", rel)); + } + + [Fact] + public void Legacy_message_constant_is_exact() + { + Assert.Equal( + "This package is legacy and is no longer maintained", + ReleaseConstants.LegacyDeprecationMessage); + } +} diff --git a/tests/analyticsLibrary.ReleaseTooling.Tests/analyticsLibrary.ReleaseTooling.Tests.csproj b/tests/analyticsLibrary.ReleaseTooling.Tests/analyticsLibrary.ReleaseTooling.Tests.csproj new file mode 100644 index 0000000..7a609eb --- /dev/null +++ b/tests/analyticsLibrary.ReleaseTooling.Tests/analyticsLibrary.ReleaseTooling.Tests.csproj @@ -0,0 +1,24 @@ + + + net8.0 + false + enable + enable + analyticsLibrary.ReleaseTooling.Tests + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/tools/analyticsLibrary.NuGetMaintenance/Program.cs b/tools/analyticsLibrary.NuGetMaintenance/Program.cs new file mode 100644 index 0000000..d270a29 --- /dev/null +++ b/tools/analyticsLibrary.NuGetMaintenance/Program.cs @@ -0,0 +1,167 @@ +using System.Net; +using analyticsLibrary.ReleaseTooling; +using NuGet.Versioning; + +if (args.Length < 2 || !string.Equals(args[0], "apply-deprecations", StringComparison.Ordinal)) +{ + Console.Error.WriteLine("Usage: analyticsLibrary.NuGetMaintenance apply-deprecations "); + return 1; +} + +var releaseRaw = args[1]; +var release = VersionDeprecationPlanner.TryParseReleaseVersion(releaseRaw); +if (release is null) +{ + Console.Error.WriteLine($"Invalid release version: {releaseRaw}"); + return 1; +} + +var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); +if (string.IsNullOrWhiteSpace(apiKey)) +{ + Console.Error.WriteLine("NUGET_API_KEY is not set."); + return 1; +} + +using var http = new HttpClient(); +return await RunAsync(http, apiKey, release, CancellationToken.None).ConfigureAwait(false); + +static async Task RunAsync(HttpClient http, string apiKey, NuGetVersion releaseVersion, CancellationToken cancellationToken) +{ + const int chunkSize = 40; + foreach (var packageId in ReleaseConstants.PackageIds) + { + IReadOnlyList published; + try + { + published = await NuGetFlatContainerHttp.GetPublishedVersionsAsync(http, packageId, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to list versions for {packageId}: {ex.Message}"); + return 1; + } + + if (published.Count == 0) + { + Console.WriteLine($"No published versions found for {packageId}; skipping."); + continue; + } + + var betas = published.Where(VersionDeprecationPlanner.IsCiBetaPrerelease).ToList(); + var legacyStables = published + .Where(v => VersionDeprecationPlanner.IsOlderStableThan(v, releaseVersion)) + .ToList(); + + foreach (var chunk in Chunk(betas, chunkSize)) + { + var code = await SendDeprecationAsync( + http, + apiKey, + packageId, + chunk, + isLegacy: false, + hasCriticalBugs: false, + isOther: true, + ReleaseConstants.PrereleaseTestingDeprecationMessage, + releaseVersion, + cancellationToken) + .ConfigureAwait(false); + if (code != 0) + { + return code; + } + } + + foreach (var chunk in Chunk(legacyStables, chunkSize)) + { + var code = await SendDeprecationAsync( + http, + apiKey, + packageId, + chunk, + isLegacy: true, + hasCriticalBugs: false, + isOther: false, + ReleaseConstants.LegacyDeprecationMessage, + releaseVersion, + cancellationToken) + .ConfigureAwait(false); + if (code != 0) + { + return code; + } + } + } + + return 0; +} + +static List> Chunk(IReadOnlyList items, int size) +{ + var chunks = new List>(); + for (var i = 0; i < items.Count; i += size) + { + var end = Math.Min(i + size, items.Count); + var chunk = new List(end - i); + for (var j = i; j < end; j++) + { + chunk.Add(items[j]); + } + + chunks.Add(chunk); + } + + return chunks; +} + +static async Task SendDeprecationAsync( + HttpClient http, + string apiKey, + string packageId, + IReadOnlyList versions, + bool isLegacy, + bool hasCriticalBugs, + bool isOther, + string message, + NuGetVersion releaseVersion, + CancellationToken cancellationToken) +{ + if (versions.Count == 0) + { + return 0; + } + + var normalizedRelease = releaseVersion.ToNormalizedString(); + using var request = NuGetGalleryDeprecationApi.CreateDeprecationRequest( + packageId, + versions, + isLegacy, + hasCriticalBugs, + isOther, + message, + packageId, + normalizedRelease, + apiKey); + + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + Console.WriteLine( + $"Deprecated {versions.Count} version(s) of {packageId} (legacy={isLegacy}, other={isOther})."); + return 0; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + Console.Error.WriteLine( + $"NuGet deprecation failed for {packageId}: {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}"); + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + Console.Error.WriteLine( + "Hint: ensure the API key allows package push/unlist and that the NuGet.org account has the manage-deprecation API enabled for this key."); + } + + return 1; +} diff --git a/tools/analyticsLibrary.NuGetMaintenance/analyticsLibrary.NuGetMaintenance.csproj b/tools/analyticsLibrary.NuGetMaintenance/analyticsLibrary.NuGetMaintenance.csproj new file mode 100644 index 0000000..4567cdb --- /dev/null +++ b/tools/analyticsLibrary.NuGetMaintenance/analyticsLibrary.NuGetMaintenance.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + enable + enable + false + analyticsLibrary.NuGetMaintenance + + + + +