diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml index 7996318f2..50c5de442 100644 --- a/.github/workflows/features.yml +++ b/.github/workflows/features.yml @@ -17,6 +17,6 @@ jobs: # Track the live SDK feature spec on main rather than pinning. uses: ably/features/.github/workflows/sdk-features.yml@main # zizmor: ignore[unpinned-uses] with: - repository-name: ably-dotnet + repository-name: ably-pubsub-dotnet secrets: ABLY_AWS_ACCOUNT_ID_SDK: ${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }} diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml deleted file mode 100644 index 41c3fe968..000000000 --- a/.github/workflows/package.yml +++ /dev/null @@ -1,132 +0,0 @@ -# ============================================================================ -# ACTIVE PACKAGING WORKFLOW - Uses Cake Build System -# ============================================================================ -# This is the primary workflow for creating NuGet packages and Unity packages. -# -# To create a release: -# 1. Go to Actions tab → Package ably workflow -# 2. Click "Run workflow" and enter the version number (e.g., 2.0.0) -# 3. Download artifacts: -# - ably-package: NuGet packages (.nupkg) -# - ably-unity-package: Unity package (.unitypackage) -# ============================================================================ - -name: Package ably -on: - workflow_dispatch: - inputs: - version: - description: 'Ably version' - required: true - -permissions: - contents: read - -jobs: - package-library: - runs-on: windows-2022 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - submodules: 'recursive' - persist-credentials: false - - - name: Download dotnet framework - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - with: - dotnet-version: | - 3.1.426 - 6.0.428 - 7.0.410 - - - name: Download cake tool - run: dotnet tool restore - - - name: Package - env: - VERSION: ${{ github.event.inputs.version }} - run: ./package.cmd $env:VERSION - - - name: Archive package - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: ably-package - path: | - ${{ github.workspace }}/*.nupkg - - package-unity: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - submodules: 'recursive' - persist-credentials: false - - - name: Download dotnet framework - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - with: - dotnet-version: | - 2.1.818 - 6.0.428 - - - name: Download cake tool - run: dotnet tool restore - - - name: Package Unity - env: - VERSION: ${{ github.event.inputs.version }} - run: ./package-unity.sh "$VERSION" - - - name: Archive Unity package - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: ably-unity-package - path: | - ${{ github.workspace }}/*.unitypackage - - merge-artifacts: - runs-on: ubuntu-22.04 - needs: [package-library, package-unity] - steps: - - name: Download all artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - path: artifacts - - - name: Merge artifacts into single directory - run: | - mkdir -p output-package - - # Copy all .nupkg files from ably-package - if [ -d "artifacts/ably-package" ]; then - find artifacts/ably-package -name "*.nupkg" -exec cp {} output-package/ \; - echo "Copied .nupkg files from ably-package" - else - echo "Warning: ably-package artifact not found" - fi - - # Copy .unitypackage file - if [ -d "artifacts/ably-unity-package" ]; then - find artifacts/ably-unity-package -name "*.unitypackage" -exec cp {} output-package/ \; - echo "Copied .unitypackage files from ably-unity-package" - else - echo "Warning: ably-unity-package artifact not found" - fi - - # Verify we have files and list merged contents - if [ -z "$(ls -A output-package)" ]; then - echo "Error: No files were copied to output-package" - exit 1 - fi - - echo "" - echo "Merged artifact contents:" - ls -lh output-package/ - echo "" - echo "Total files: $(ls -1 output-package | wc -l)" - - - name: Upload merged artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: output-package - path: output-package/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..e237d80be --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,314 @@ +# Publishes Ably.PubSub.Core, Ably.PubSub.Device and Ably.PubSub.Server to +# nuget.org in lockstep at one version, and creates the GitHub release with the +# .unitypackage attached. +# +# Dispatch only, and safe to sit on a branch it cannot release: the pre-flight +# refuses any checkout whose nuget/ directory is not exactly the 2.0 lockstep set, +# so dispatching this against a 1.x ref (which has nuget/io.ably.nuspec) aborts +# before anything is built. That is what lets the workflow live on the default +# branch - `gh workflow run` only reads workflow files from the default branch - +# while the 2.0 work is still on an integration branch: +# +# gh workflow run publish.yml --ref integration/v2 -f version=2.0.0 -f dry_run=true +# +# Run it with dry_run=true first. That does everything except the push, including +# the packed-package assertions, and prints exactly what would be pushed. +# +# Ordering and idempotency. The doors pin the core as [], so a door must +# never be visible on nuget.org before the exact core version it pins: the push +# order is core -> device -> server, and the doors wait until the core version is +# listed. Before each push the registry is queried and an already-published +# version is skipped, so a run that fails partway (network, validation, a revoked +# credential) is completed by re-running it with the same version rather than by +# hand. `--skip-duplicate` is the belt to that braces. +# +# Authentication is nuget.org Trusted Publishing: GitHub's OIDC token is exchanged +# by NuGet/login for an API key valid for one hour, requested immediately before +# the push. There is no long-lived key in this repository's secrets. It requires, +# on nuget.org, a Trusted Publishing policy owned by the account that owns these +# package ids, bound to this repository owner, this repository, and the workflow +# file name `publish.yml` - see CONTRIBUTING.md. `secrets.NUGET_USER` is the +# nuget.org *username* (profile name, not an email address); it is not a +# credential, it is a secret only to keep the account name out of public logs. +# +# To fall back to a long-lived API key instead (for example while a policy is +# being set up): delete the "NuGet login" step, drop `id-token: write` from this +# job's permissions, add a `NUGET_API_KEY` repository secret and replace +# `${{ steps.login.outputs.NUGET_API_KEY }}` below with +# `${{ secrets.NUGET_API_KEY }}`. Nothing else changes. + +name: Publish + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release, e.g. 2.0.0. Must equal src/CommonAssemblyInfo.cs and unity/Assets/Ably/version.txt on the dispatched ref' + required: true + type: string + dry_run: + description: 'Build, pack and assert everything, but do not push and do not create the release' + required: false + type: boolean + default: true + +permissions: {} + +# Two dispatches of the same version must not run concurrently: they would race on +# the wait-for-core-listed loop and on the `gh release ... --clobber` upload. Never +# cancel an in-progress publish. +concurrency: + group: publish-${{ github.event.inputs.version }} + cancel-in-progress: false + +jobs: + publish: + # windows-2022 because the core and server packages carry a lib/net46 asset + # built by an old-style MSBuild head. The Unity package is produced in this + # same job, from this same build, so the .unitypackage and the .nupkg files + # can never come from different builds of the same version. + runs-on: windows-2022 + # Required-reviewer gate. Trusted Publishing authorises this repository to + # publish, not any particular person, so the human approval lives here: the + # `release` environment (repo Settings -> Environments, with required + # reviewers configured) must be approved before this job starts. + environment: release + permissions: + contents: read + id-token: write # for NuGet Trusted Publishing (OIDC) + env: + DOTNET_NOLOGO: true + RELEASE_VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + submodules: 'recursive' + persist-credentials: false + + - name: Download dotnet framework + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 3.1.426 + 6.0.428 + 7.0.410 + + - name: Download cake tool + run: dotnet tool restore + + # First thing that runs, and the only step that can stop a release cheaply. + # It asserts that the dispatched version equals both committed version files, + # that nuget/ holds exactly the three lockstep packages with the expected + # ids, and that every door pins Ably.PubSub.Core as [$version$]. + - name: Release pre-flight + run: ./build.cmd -- --target=Release.Preflight --version=${{ inputs.version }} + + # Pre-flight -> build every head -> assert every nuspec files entry resolves + # -> pack -> assert the packed .nupkg files, including the packed pin. + - name: Build and pack all three packages + run: ./build.cmd -- --target=Package --version=${{ inputs.version }} --packageOutput=release-packages + + # Prove the release SHA can reproduce the committed Unity plugin: copy the + # committed DLL aside, regenerate it from this checkout, and cross-check. + # The committed DLL remains canonical (it is what the Unity EditMode/PlayMode + # pass validated); a mismatch means it is stale relative to this source. + - name: Regenerate and cross-check the Unity plugin + shell: pwsh + run: | + $plugin = 'unity/Assets/Ably/Plugins/Ably.PubSub.Device.dll' + Copy-Item $plugin "$env:RUNNER_TEMP/committed-plugin.dll" + ./build.cmd -- --target=Build.NetStandard --define=UNITY_PACKAGE + ./build.cmd -- --target=Update.AblyUnity + $fresh = [Reflection.AssemblyName]::GetAssemblyName((Resolve-Path $plugin)) + $comm = [Reflection.AssemblyName]::GetAssemblyName("$env:RUNNER_TEMP/committed-plugin.dll") + $freshInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path $plugin)).ProductVersion + $commInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo("$env:RUNNER_TEMP/committed-plugin.dll").ProductVersion + if (($fresh.Version -ne $comm.Version) -or ($freshInfo -ne $commInfo)) { + throw "Committed Unity plugin is stale: committed $($comm.Version)/$commInfo vs regenerated $($fresh.Version)/$freshInfo. Re-run ./unity-plugins-updater.sh and commit." + } + # Ship the committed (Unity-tested) copy, not the fresh one. + Copy-Item "$env:RUNNER_TEMP/committed-plugin.dll" $plugin -Force + + - name: Create the Unity package + run: ./build.cmd -- --target=UnityPackage --version=${{ inputs.version }} + + - name: Assert the packed packages + run: ./build.cmd -- --target=Release.VerifyPackages --version=${{ inputs.version }} --packageOutput=release-packages + + # Requested here, immediately before the push, because the key it returns + # lives for one hour and each OIDC token buys exactly one key. + - name: NuGet login (OIDC -> short-lived API key) + id: login + if: ${{ !inputs.dry_run }} + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Publish core, then the doors + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + $ErrorActionPreference = 'Stop' + $version = $env:RELEASE_VERSION + $dryRun = $env:DRY_RUN -eq 'true' + $source = 'https://api.nuget.org/v3/index.json' + + # nuget.org's flat container lists every published version of a package + # id, including unlisted ones, and 404s for an id that has never been + # published. It is the cheapest published-or-not question there is. + function Get-PublishedVersions([string] $id) { + $url = "https://api.nuget.org/v3-flatcontainer/$($id.ToLowerInvariant())/index.json" + try { + return (Invoke-RestMethod -Uri $url -Method Get).versions + } catch { + if ($_.Exception.Response -and $_.Exception.Response.StatusCode.value__ -eq 404) { + return @() # the package id has never been published + } + throw + } + } + + function Test-Published([string] $id, [string] $v) { + return (Get-PublishedVersions $id) -contains $v + } + + function Push-Package([string] $id) { + $package = "release-packages/$id.$version.nupkg" + if (-not (Test-Path $package)) { throw "$package was not produced" } + + if (Test-Published $id $version) { + # This is what makes a failed run completable by re-running it. + Write-Host "SKIP $id $version is already on nuget.org" + return + } + + if ($dryRun) { + Write-Host "DRY RUN would push $package to $source" + return + } + + if (-not $env:NUGET_API_KEY) { throw "No NuGet API key; the trusted-publishing login step did not run" } + + Write-Host "PUSH $package" + dotnet nuget push $package --source $source --api-key $env:NUGET_API_KEY --skip-duplicate + if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $id" } + } + + # Core first. Both doors declare a dependency on exactly this version, so + # a door reaching the registry before it would be unresolvable for as long + # as the gap lasted. + Push-Package 'Ably.PubSub.Core' + + if (-not $dryRun) { + # nuget.org validates and indexes asynchronously; a push that returned + # 202 is not yet resolvable. Wait for the core to be listed before the + # doors that pin it go out. + $listed = $false + for ($i = 1; $i -le 40; $i++) { + if (Test-Published 'Ably.PubSub.Core' $version) { $listed = $true; break } + Write-Host "Waiting for Ably.PubSub.Core $version to appear on nuget.org ($i/40)..." + Start-Sleep -Seconds 15 + } + if (-not $listed) { + throw ("Ably.PubSub.Core $version did not appear on nuget.org within 10 minutes. Nothing else " + + "has been pushed. Check the package's validation status on nuget.org, then re-run this " + + "workflow with the same version: the core push will be skipped and the doors will go out.") + } + } + + Push-Package 'Ably.PubSub.Device' + Push-Package 'Ably.PubSub.Server' + + if ($dryRun) { + Write-Host '' + Write-Host "Dry run complete. Nothing was pushed. Re-dispatch with dry_run=false to release $version." + } else { + Write-Host '' + Write-Host "Published Ably.PubSub.Core, Ably.PubSub.Device and Ably.PubSub.Server at $version." + } + + # Printed on every run, successful or not, because it is needed exactly + # when nobody wants to be reading a workflow file. + Write-Host '' + Write-Host '--- If this release has to be abandoned ---' + Write-Host 'NuGet cannot delete a published version, and the version number can never be reused.' + Write-Host 'Unlist it instead, which hides it from search and from version resolution while leaving' + Write-Host 'it restorable for anyone who already pinned it:' + Write-Host '' + Write-Host " dotnet nuget delete Ably.PubSub.Server $version --source https://api.nuget.org/v3/index.json --api-key --non-interactive" + Write-Host " dotnet nuget delete Ably.PubSub.Device $version --source https://api.nuget.org/v3/index.json --api-key --non-interactive" + Write-Host " dotnet nuget delete Ably.PubSub.Core $version --source https://api.nuget.org/v3/index.json --api-key --non-interactive" + Write-Host '' + Write-Host '(dotnet nuget delete unlists on nuget.org; it does not delete. Or use the Listing tab on' + Write-Host 'each package page.) Unlist the doors before the core, the reverse of the publish order, so' + Write-Host 'no door is ever resolvable without the core version it pins. Then release a new version.' + + - name: Archive the release artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-artifacts-${{ inputs.version }} + path: | + release-packages/*.nupkg + *.unitypackage + if-no-files-found: error + + # Separate job purely so that contents: write is scoped to the one thing that + # needs it. Skipped on a dry run, and skipped if the publish job did not finish. + github-release: + needs: publish + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-22.04 + permissions: + contents: write + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Download the release artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-artifacts-${{ inputs.version }} + path: artifacts + + - name: Create the GitHub release and attach the artifacts + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_REF: ${{ github.sha }} + run: | + set -euo pipefail + v="${RELEASE_VERSION}" + + # A SemVer2 prerelease version (contains a hyphen, e.g. 2.0.0-beta.1) + # must be flagged so a lockstep beta never shows up as the Latest + # GitHub release. Empty for a stable version. + prerelease_flag="" + case "${v}" in + *-*) prerelease_flag="--prerelease" ;; + esac + + files=$(find artifacts -type f \( -name '*.nupkg' -o -name '*.unitypackage' \) | sort) + if [ -z "${files}" ]; then + echo "No artifacts to attach" + exit 1 + fi + echo "Attaching:" + echo "${files}" + + # Idempotent in the same way the pushes are: re-running a run that failed + # after the release was created must not fail on the release existing. + if gh release view "${v}" >/dev/null 2>&1; then + echo "Release ${v} already exists; uploading assets with --clobber" + # shellcheck disable=SC2086 + gh release upload "${v}" ${files} --clobber + else + # shellcheck disable=SC2086 + gh release create "${v}" ${files} ${prerelease_flag} \ + --target "${RELEASE_REF}" \ + --title "${v}" \ + --notes "Ably.PubSub.Core, Ably.PubSub.Device and Ably.PubSub.Server ${v} are published on nuget.org. See CHANGELOG.md for what changed." + fi diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml new file mode 100644 index 000000000..9174015c4 --- /dev/null +++ b/.github/workflows/release-dry-run.yml @@ -0,0 +1,91 @@ +# Everything publish.yml does except the push. +# +# Ably.PubSub.Core, Ably.PubSub.Device and Ably.PubSub.Server release in lockstep +# at one version, and both doors pin the core with an exact version range. Those +# invariants live in the version files and the nuspecs, none of which the compiler +# reads, so a regression in them is invisible until release day - and a NuGet +# version, once published, can never be changed. This job runs the release +# pre-flight and a full pack on every pull request so that a version-file skew, a +# loosened pin, a renamed package id or a nuspec files entry that no longer +# resolves fails on the PR that introduced it. +# +# Windows, not Linux: the core and server packages carry a lib/net46 asset built +# by an old-style MSBuild head, and packing on Linux would silently produce +# packages missing that asset. (`dotnet pack -p:NuspecFile=...` does pack a +# nuspec Mono-free, so the pack step itself is not what needs Windows - the +# net462 build is.) _Release_Verify_Files fails rather than allowing that. + +name: Release dry run + +on: + pull_request: + push: + branches: + - main + - 'integration/**' + +permissions: + contents: read + +jobs: + dry-run: + runs-on: windows-2022 + env: + DOTNET_NOLOGO: true + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + submodules: 'recursive' + persist-credentials: false + + - name: Download dotnet framework + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 3.1.426 + 6.0.428 + 7.0.410 + + - name: Download cake tool + run: dotnet tool restore + + # The version is read from the committed files, never supplied by hand: the + # point of the dry run is to check what a release of this commit would do. + - name: Read the version under release + id: version + run: | + $match = Select-String -Path src/CommonAssemblyInfo.cs -Pattern 'AssemblyInformationalVersion\("([^"]+)"\)' + if (-not $match) { throw "No AssemblyInformationalVersion in src/CommonAssemblyInfo.cs" } + $version = $match.Matches[0].Groups[1].Value + Write-Host "Version under release: $version" + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + # Runs on its own first so that a pre-flight failure is unmistakable in the + # log, and so a broken release is reported in seconds rather than after a + # full packaging build. Package depends on it again anyway. + - name: Release pre-flight + run: ./build.cmd -- --target=Release.Preflight --version=${{ steps.version.outputs.version }} + + # Package = pre-flight -> build every head -> assert every nuspec files + # entry resolves -> pack all three -> assert the packed .nupkg files. + # Nothing is pushed: this target has never known how to push. + - name: Build and pack all three packages + run: ./build.cmd -- --target=Package --version=${{ steps.version.outputs.version }} --packageOutput=dry-run-packages + + # Re-run standalone so the packed door -> core pin is asserted against the + # artifacts exactly as they are about to be uploaded, in a step whose name + # says what failed if it does. + - name: Assert the packed packages + run: ./build.cmd -- --target=Release.VerifyPackages --version=${{ steps.version.outputs.version }} --packageOutput=dry-run-packages + + - name: List what a release would push + run: | + Get-ChildItem dry-run-packages/*.nupkg | ForEach-Object { "{0} {1:N0} bytes" -f $_.Name, $_.Length } + + - name: Archive the packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dry-run-packages-${{ steps.version.outputs.version }} + path: dry-run-packages/*.nupkg + if-no-files-found: error diff --git a/.github/workflows/run-tests-linux.yml b/.github/workflows/run-tests-linux.yml index e2c7f04ac..9a1d2aff5 100644 --- a/.github/workflows/run-tests-linux.yml +++ b/.github/workflows/run-tests-linux.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - 'integration/**' permissions: contents: read diff --git a/.github/workflows/run-tests-macos-mono.yml b/.github/workflows/run-tests-macos-mono.yml index aadc89e9b..b6c47e0fb 100644 --- a/.github/workflows/run-tests-macos-mono.yml +++ b/.github/workflows/run-tests-macos-mono.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - 'integration/**' permissions: contents: read diff --git a/.github/workflows/run-tests-macos.yml b/.github/workflows/run-tests-macos.yml index a081592e1..43b565724 100644 --- a/.github/workflows/run-tests-macos.yml +++ b/.github/workflows/run-tests-macos.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - 'integration/**' permissions: contents: read diff --git a/.github/workflows/run-tests-windows-netframework.yml b/.github/workflows/run-tests-windows-netframework.yml index 960f7fda7..5951b455b 100644 --- a/.github/workflows/run-tests-windows-netframework.yml +++ b/.github/workflows/run-tests-windows-netframework.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - 'integration/**' permissions: contents: read diff --git a/.github/workflows/run-tests-windows.yml b/.github/workflows/run-tests-windows.yml index 85e037faa..aba49ac8c 100644 --- a/.github/workflows/run-tests-windows.yml +++ b/.github/workflows/run-tests-windows.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - 'integration/**' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index a99a81395..bf25520ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Change Log +## [2.0.0](https://github.com/ably/ably-pubsub-dotnet/tree/2.0.0) + +Ably Pub/Sub 2.0 splits the SDK into device-side and server-side packages so that every client is classified for MAU-based billing. See [UPDATING.md](UPDATING.md) for the full migration guide. + +**Breaking changes** + +- The `ably.io` package is no longer published from this repository. It is replaced by three packages: **`Ably.PubSub.Device`** (end-user device apps — mobile, desktop, Unity, set-top boxes), **`Ably.PubSub.Server`** (backends — ASP.NET, workers, console apps) and **`Ably.PubSub.Core`** (the shared implementation, resolved transitively; do not reference it directly). +- Construct clients through the door factories, not the `AblyRealtime`/`AblyRest` constructors: `PubSubDevice.CreateClient(...)`, `PubSubServer.CreateRealtimeClient(...)`, `PubSubServer.CreateHttpClient(...)`. A client built directly from `Ably.PubSub.Core` is not classified as device- or server-side, and is rejected once MAU-based pricing is live. +- The runtime API is otherwise unchanged: the namespace stays `IO.Ably` and the factories return the ordinary `AblyRealtime`/`AblyRest`. + +**Notes** + +- Device push-receive (push activation on Android/iOS) is not part of the 2.0 packages; a `net8.0-android`/`net8.0-ios` port on `Ably.PubSub.Device` is a post-GA follow-up. Push administration (from a backend) is unaffected. See [PushNotifications.md](PushNotifications.md). +- The `ably.io` 1.x line continues to receive security and critical fixes from its maintenance branch for one year from this release, then reaches end of life. + ## [1.2.18](https://github.com/ably/ably-dotnet/tree/1.2.18) (2025-11-27) [Full Changelog](https://github.com/ably/ably-dotnet/compare/1.2.17...1.2.18) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a7d53744..c4df32b30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,23 +97,66 @@ Note: it was recently decided to remove explicit mention of Windows 7 as a suppo >- We have created `.devcontainer` folder at root specifying necessary dependencies for environment setup needed for release process. >- Visit [ably-dotnet](https://github.com/ably/ably-dotnet) repo, click on `Code` button at the right corner and create codespace for current branch from `Codespaces` tab. It will take some time to create the environment. Once codespace is created for the project, you can proceed with release steps mentioned below. -This library uses [semantic versioning](http://semver.org/). For each release, the following needs to be done: - -1. Create a branch for the release, named like release/1.2.3 (where 1.2.3 is the new version number). -2. Replace all references of the current version number with the new version number and commit the changes (In current case, files are `src/CommonAssemblyInfo.cs` and `unity/Assets/Ably/version.txt`). -3. Run `./unity-plugins-updater.sh 1.2.3` (linux/mac) / `.\unity-plugins-updater.cmd 1.2.3` (windows) at root and commit generated `.dll` and `.pdb` files. -4. Run [`github_changelog_generator`](https://github.com/github-changelog-generator/github-changelog-generator) to automate the update of the [CHANGELOG](./CHANGELOG.md). This may require some manual intervention, both in terms of how the command is run and how the change log file is modified. Your mileage may vary: - - The command you will need to run will look something like this: `github_changelog_generator -u ably -p ably-dotnet --since-tag 1.2.3 --output delta.md --token $GITHUB_TOKEN_WITH_REPO_ACCESS`. Generate token [here](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token). +This library uses [semantic versioning](http://semver.org/). + +**`Ably.PubSub.Core`, `Ably.PubSub.Device` and `Ably.PubSub.Server` are released together, always, at one +version.** Each door package declares an exact dependency on `[]` of the core, so a door +released without its core, or at a different version from it, is unresolvable for consumers. There is +therefore no such thing as releasing one of the three: one version number is bumped in one commit, and one +dispatch of `publish.yml` publishes all three in the order core → device → server. + +The guards are in the build, not in this document. `Release.Preflight` refuses to release unless the +version input equals both version files, `nuget/` holds exactly the three packages, and every door pins the +core exactly; after the pack, `Release.VerifyPackages` opens each `.nupkg` and asserts the packed pin. The +[Release dry run](https://github.com/ably/ably-dotnet/actions/workflows/release-dry-run.yml) job runs both +on every pull request, so any of this being wrong is a red PR, not a bad release. + +For each release: + +1. Create a branch for the release, named like release/2.0.1 (where 2.0.1 is the new version number). +2. Replace all references of the current version number with the new version number and commit the changes. There are exactly two files: `src/CommonAssemblyInfo.cs` (all three assembly version attributes) and `unity/Assets/Ably/version.txt`. Every nuspec takes its version, and each door its core pin, from the release input, so nothing else is edited. +3. Run `./unity-plugins-updater.sh 2.0.1` (linux/mac) / `.\unity-plugins-updater.cmd 2.0.1` (windows) at root and commit the generated `.dll` and `.pdb` files. (Needs Mono or Windows.) +4. Optionally check the release locally before pushing: `./build.sh -- --target=Release.Preflight --version=2.0.1`. Note the `--`: Cake reserves `--version` for itself, so arguments for the build script go after it. A full local pack (`./package.cmd 2.0.1`) needs Windows or Mono, because the core and server packages carry a `lib/net46` asset. +5. Run [`github_changelog_generator`](https://github.com/github-changelog-generator/github-changelog-generator) to automate the update of the [CHANGELOG](./CHANGELOG.md). This may require some manual intervention, both in terms of how the command is run and how the change log file is modified. Your mileage may vary: + - The command you will need to run will look something like this: `github_changelog_generator -u ably -p ably-dotnet --since-tag 2.0.0 --output delta.md --token $GITHUB_TOKEN_WITH_REPO_ACCESS`. Generate token [here](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token). - Using the command above, `--output delta.md` writes changes made after `--since-tag` to a new file. - The contents of that new file (`delta.md`) then need to be manually inserted at the top of the `CHANGELOG.md`, changing the "Unreleased" heading and linking with the current version numbers. - Also ensure that the "Full Changelog" link points to the new version tag instead of the `HEAD`. -5. Commit this change: `git add CHANGELOG.md && git commit -m "Update change log."`. -6. Push the branch and create a release PR (ensure you include an SDK Team Engineering Lead and the SDK Team Product Manager as reviewers) and gain approvals for it, then merge that to `main`. -7. Go to [Github Actions tab](https://github.com/ably/ably-dotnet/actions), click on [Package Ably](https://github.com/ably/ably-dotnet/actions/workflows/package.yml) workflow at the left nav-bar. On the right corner, click on `Run workflow` with the current release tag as a input to `Ably version`. - - You can check all latest workflows under [Github Actions Tab](https://github.com/ably/ably-dotnet/actions). Download the generated artifact named `output-package` at the end of the latest successful workflow run. - - `output-package` artifact is a zip containing => `Ably.PubSub.Core.2.0.0.nupkg` and `ably.pubsub.2.0.0.unitypackage`. - - If using github codespaces, you can upload downloaded `output-package` artifact by dragging into it. -8. Extract `output-package`, open bash/powershell in the same folder and run `dotnet nuget push *.nupkg --api-key GENERATED_API_KEY_FROM_NUGET_ACCOUNT --source https://api.nuget.org/v3/index.json` (More information on publishing nuget package can be found [here](https://learn.microsoft.com/en-us/nuget/quickstart/create-and-publish-a-package-using-visual-studio?tabs=netcore-cli#publish-with-the-net-cli-or-nuget-cli)) -9. Add a tag to the new `main` head commit and push to origin such as `git tag 1.2.3 && git push origin 1.2.3` -10. Visit [https://github.com/ably/ably-dotnet/tags](https://github.com/ably/ably-dotnet/tags) and `Add release notes` for the release including links to the changelog entry, upload `ably.pubsub.1.2.3.unitypackage` as a file to the latest release note. -11. Create the entry on the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)). +6. Commit this change: `git add CHANGELOG.md && git commit -m "Update change log."`. +7. Push the branch and create a release PR (ensure you include an SDK Team Engineering Lead and the SDK Team Product Manager as reviewers) and gain approvals for it, then merge it to the release branch. The `release-dry-run` check on that PR is the packaging gate: it packs all three packages from your commit and asserts them. Until the 2.0 integration branch merges to `main`, the release branch is `integration/v2`; after that it is `main`. +8. **Dry run.** Dispatch the [Publish](https://github.com/ably/ably-dotnet/actions/workflows/publish.yml) workflow with `version` = the new version, `dry_run` = **true**, against the ref you just merged to: + + ``` + gh workflow run publish.yml --ref integration/v2 -f version=2.0.1 -f dry_run=true + ``` + + (Or use `Run workflow` in the Actions tab. `workflow_dispatch` reads the workflow file from the default branch but runs it against the ref you choose.) This builds, packs, asserts and prints exactly what it would push, and pushes nothing. Read the output. +9. **Publish.** Dispatch the same workflow again with `dry_run` = **false**. It publishes core → device → server, waiting for the core version to be listed on nuget.org before the doors that pin it go out, skipping any version already published, and then creates the `2.0.1` tag and GitHub release with the three `.nupkg` files and the `.unitypackage` attached. No API key is involved: the workflow authenticates with nuget.org Trusted Publishing over GitHub OIDC. + - **If it fails partway, re-run it with the same version.** Every push is skipped if that version is already on nuget.org, and the GitHub release is created only if it does not already exist, so a re-run completes the release instead of duplicating it. Do not push anything by hand. + - **If the release has to be abandoned:** NuGet cannot delete a published version and the number can never be reused. The workflow prints the `dotnet nuget delete` commands that *unlist* each package (doors first, then the core), and then a new version has to be released. +10. Create the entry on the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)). + +### nuget.org Trusted Publishing setup + +`publish.yml` has no long-lived NuGet API key. It exchanges a GitHub OIDC token for an API key valid for one +hour, via the [`NuGet/login`](https://github.com/NuGet/login) action, immediately before pushing. This +requires a one-time setup on nuget.org, which someone who can administer the packages' owning account has to do: + +- Sign in to nuget.org → your username → **Trusted Publishing** → add a policy, owned by the account (or + organization) that owns the `Ably.PubSub.*` package ids, with **Repository Owner** `ably`, **Repository** + the name of this repository, and **Workflow File** `publish.yml` (the file name only, not the path). Leave + **Environment** empty; this workflow uses no GitHub environment. +- Set the policy's **scope** to allow publishing new packages as well as new versions, with a glob covering + `Ably.PubSub.*`. A policy does not require the package to exist already: the "new packages" scope is how + the first version of a brand-new id is published, which is what claims the name. +- Add a repository secret `NUGET_USER` holding the nuget.org **username** (the profile name, not an email + address). It is not a credential; it is a secret only to keep the account name out of public logs. +- A policy on a private repository starts out *temporarily active* for 7 days and becomes permanent on the + first successful publish, because nuget.org needs the repository and owner IDs from a real token to lock + the policy against repository-resurrection attacks. +- The policy matches the repository by **name**, so it must be created (or re-created) **after** any rename + of this repository. + +To fall back to a long-lived API key instead: delete the `NuGet login` step in `publish.yml`, drop +`id-token: write` from that job's permissions, add a `NUGET_API_KEY` repository secret, and use it in the +push step. The workflow says so at the top, in a comment next to the step. diff --git a/README.md b/README.md index 2105de7ba..88bb7a143 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Find out more: > > Each factory also takes a `ClientOptions` or an `Action`. The returned clients are the ordinary `AblyRealtime` and `AblyRest`, so the whole of the `IO.Ably` API remains available — including device-side connectionless operations such as message history, presence reads and token requests, which is why the device package has one door and no separate HTTP factory. > +> All three packages are released together, always, at one version, and each door package declares an **exact** dependency on `[]` of `Ably.PubSub.Core`. There is no supported combination of different versions of them: NuGet will refuse to resolve a `Ably.PubSub.Device` and a `Ably.PubSub.Server` that were not built from the same release, which is deliberate — it is what guarantees that a project cannot end up running two copies of the core, and that the door you installed is the door that was tested against the core it gets. When you upgrade, upgrade all the `Ably.PubSub.*` packages you reference to the same version. This applies to prereleases too: a `2.0.0-beta.1` door pins exactly `[2.0.0-beta.1]` of the core, so keep every `Ably.PubSub.*` reference on the same full prerelease version. +> > The compiled assembly is now `Ably.PubSub.Core.dll`. The code namespace is unchanged: `using IO.Ably;` and every public type name stay as they are for now. > > Today's [`ably.io`](https://www.nuget.org/packages/ably.io) 1.x package is unaffected and continues from a 1.x maintenance branch for a year after 2.0 becomes generally available; it is never published from this branch again. The same applies to `ably.io.push.android` and `ably.io.push.ios`, whose Xamarin-era projects are not part of the 2.0 set (see [PushNotifications.md](./PushNotifications.md)). diff --git a/UPDATING.md b/UPDATING.md new file mode 100644 index 000000000..648b301fb --- /dev/null +++ b/UPDATING.md @@ -0,0 +1,71 @@ +# Upgrading from ably.io 1.x to Ably Pub/Sub 2.0 + +Ably Pub/Sub 2.0 splits the single `ably.io` package into device-side and server-side packages. This lets Ably classify every client as device-side or server-side, which the platform's behaviour and monthly-active-user (MAU) billing depend on. The runtime API is unchanged — the namespace is still `IO.Ably`, and you still get an `AblyRealtime` or `AblyRest` — only the package you install and the way you construct the client change. + +## Package coordinates + +| 1.x | 2.0 | Install in | +|-----|-----|------------| +| `ably.io` | `Ably.PubSub.Device` | An end-user device app: mobile, desktop, Unity, a set-top box — any client the end user holds | +| `ably.io` | `Ably.PubSub.Server` | A backend: ASP.NET or Azure host, a worker, a console app — any server the end user does not hold | +| — | `Ably.PubSub.Core` | **Never install directly.** The shared implementation, pulled in transitively by the two packages above. | + +Choose the package by *where the code runs*, not by which Ably features you use. Both doors expose the full `IO.Ably` API. + +## Construct clients through the door factories + +The `new AblyRealtime(...)` / `new AblyRest(...)` constructors still exist on the core, but a client built that way carries no device/server classification. Use the factories instead. + +Device (was `new AblyRealtime("your-ably-api-key")`): + +```csharp +using IO.Ably; +using IO.Ably.PubSub.Device; + +AblyRealtime realtime = PubSubDevice.CreateClient("your-ably-api-key"); +// also: CreateClient(ClientOptions), CreateClient(o => o.ClientId = "...") +``` + +Server realtime (was `new AblyRealtime(options)` in a backend): + +```csharp +using IO.Ably; +using IO.Ably.PubSub.Server; + +AblyRealtime realtime = PubSubServer.CreateRealtimeClient("your-ably-api-key"); +``` + +Server REST (was `new AblyRest(options)`): + +```csharp +using IO.Ably; +using IO.Ably.PubSub.Server; + +AblyRest rest = PubSubServer.CreateHttpClient("your-ably-api-key"); +``` + +Every factory accepts an API key string, an Ably token string, a `ClientOptions`, or an `Action`. + +## Do not mix 1.x and 2.0 in one project + +`ably.io` and `Ably.PubSub.*` both define the `IO.Ably` types. They are independent packages with no type-forwarding between them, so any project that resolves **both** — even transitively, through a library that still depends on `ably.io` 1.x — has each `IO.Ably` type defined twice. That is a compile error (CS0433) where your code names the type, or a runtime type-identity failure where a library exposes an `IO.Ably` type across its API. Move the whole graph to `Ably.PubSub.*` in one step; use `dotnet nuget why ably.io` to find a stray transitive reference. + +## Pin the door and the core at the same version + +`Ably.PubSub.Device` and `Ably.PubSub.Server` depend on `Ably.PubSub.Core` with an **exact** version range (e.g. `[2.0.0]`), so restoring a door restores exactly the matching core. Do not add a separate `Ably.PubSub.Core` reference at a different version — keep the whole set on one version, prereleases included (a `2.0.0-beta.2` door pins `[2.0.0-beta.2]`). + +## The MAU forcing function + +Once MAU-based pricing is live, a client that is not classified as device- or server-side is rejected. Constructing directly from `Ably.PubSub.Core`, or reusing a `ClientOptions` you already passed to a door and then handing it to `new AblyRealtime(...)`, produces such a client. Always go through the factories, and use a fresh or door-appropriate `ClientOptions` per client. + +## Xamarin and older device apps + +Xamarin-era apps consume `Ably.PubSub.Device` through its `netstandard2.0` asset, the same way they consumed `ably.io`. **One gap:** device push-receive (push activation on Android/iOS) is not in the 2.0 packages yet — the two platform satellites were not carried over. If your app only publishes/subscribes, reads message history or presence, or requests tokens, it is unaffected. If it registers to *receive* push notifications on the device, stay on `ably.io` 1.x until the device push-receive port lands on `Ably.PubSub.Device`. Push administration (sending pushes and managing devices from a backend) is unaffected and available through `Ably.PubSub.Server`. + +## Unity + +Install the `.unitypackage` attached to the GitHub release. Its bundled plugin, `Ably.PubSub.Device.dll`, is the core merged with the device door and dependencies, so construct your client through the door: `using IO.Ably.PubSub.Device;` then `PubSubDevice.CreateClient(...)` — see the sample under `Assets/Ably/Examples`. + +## The 1.x line + +`ably.io` 1.x continues to receive security and critical fixes from its maintenance branch for one year from the 2.0 release, then reaches end of life. diff --git a/cake-build/README.md b/cake-build/README.md index 33134426f..3f8bb290d 100644 --- a/cake-build/README.md +++ b/cake-build/README.md @@ -12,7 +12,7 @@ Migrated from FAKE (F#) to Cake (C#) to: ## Prerequisites - .NET SDK 6.0+ (for building) - Cake.Tool (installed via `dotnet tool restore`) -- NuGet CLI (for package creation) +- NuGet CLI (for package creation; Windows or Mono) ## Getting Started @@ -86,22 +86,36 @@ Additional `--framework` flag can be supplied to test for target framework `net6 ## Create NuGet Packages +`Ably.PubSub.Core`, `Ably.PubSub.Device` and `Ably.PubSub.Server` are packed and +released together at one version, and each door package pins the core at exactly +that version. The `Package` target enforces that; see +`cake-build/tasks/release.cake`. + +> **Cake reserves `--version` for itself**, so release arguments have to go after +> a `--` separator: `./build.sh -- --target=Package --version=2.0.0`. `package.cmd` +> already does this for you. + ### package.cmd -- Responsible for creating the `Ably.PubSub.Core` NuGet package. -- Works only on Windows due to a dependency on the .NET Framework head. +- Responsible for creating all three NuGet packages. +- Works only on Windows (or with Mono): the core and server packages carry a + `lib/net46` asset built by an old-style MSBuild head, and `NuGetPack` needs + `nuget.exe`. ```cmd .\package.cmd 2.0.0 ``` -Above command creates `Ably.PubSub.Core.2.0.0.nupkg` at root, from -`nuget/ably.pubsub.core.nuspec`. The nuspec list lives in -`cake-build/tasks/package.cake` (`_Package_Create_NuGet`); the device and server -packages join it later in this stack. +Above command creates `Ably.PubSub.Core.2.0.0.nupkg`, +`Ably.PubSub.Device.2.0.0.nupkg` and `Ably.PubSub.Server.2.0.0.nupkg` at root, +from the nuspec list in `cake-build/tasks/package.cake` +(`_Package_Create_NuGet`), which is packed core-first to match the publish order. +Add `--packageOutput=` to pack somewhere other than the repository root. -During release process, this package is hosted on -[nuget.org/packages/Ably.PubSub.Core](https://www.nuget.org/packages/Ably.PubSub.Core). +During release process, these packages are hosted on +[nuget.org/packages/Ably.PubSub.Server](https://www.nuget.org/packages/Ably.PubSub.Server), +[.Device](https://www.nuget.org/packages/Ably.PubSub.Device) and +[.Core](https://www.nuget.org/packages/Ably.PubSub.Core). ### package-unity.sh @@ -115,6 +129,49 @@ Above command creates `ably.pubsub.2.0.0.unitypackage` at root. The merged plugin assembly it packages is produced separately by `./unity-plugins-updater.sh 2.0.0`, which needs Mono (for ILRepack). +## Release Targets + +These are the release guards. They are wired into `Package` - `_Release_Preflight` +before the build, `_Release_Verify_Files` between the build and the pack, and +`_Release_Verify_Packages` after it - so an ordinary `Package` run already +enforces all of them. They are also public targets so they can be run alone. + +### Release.Preflight + +```bash +./build.sh -- --target=Release.Preflight --version=2.0.0 +``` + +Source-only, no build, no pack, no network. Asserts that: + +- the `--version` input equals all three attributes in `src/CommonAssemblyInfo.cs` + and equals `unity/Assets/Ably/version.txt`; +- `nuget/` holds *exactly* `ably.pubsub.core.nuspec`, `ably.pubsub.device.nuspec` + and `ably.pubsub.server.nuspec`, with the matching ``s, each taking its + version from `$version$`; +- every dependency group of both door nuspecs pins `Ably.PubSub.Core` as + `[$version$]` - the exact range, not a minimum; +- nothing depends on `ably.io` or the 1.x push satellites. + +Because it demands the 2.0 nuspec set, it also refuses a 1.x checkout outright, +which is what makes `publish.yml` safe to keep on the default branch. + +### Release.VerifyPackages + +```bash +./build.sh -- --target=Release.VerifyPackages --version=2.0.0 --packageOutput= +``` + +Post-pack. Opens each produced `.nupkg` and asserts its id, its version, one +`lib/` assembly for every target the nuspec declares, and - the one that +cannot be checked from source - that the packed door dependency is literally +`[]`, proving the `$version$` token substituted inside the dependency's +version attribute. + +The `release-dry-run` GitHub workflow runs the pre-flight, a full `Package` and +this target on every pull request. `publish.yml` runs the same three and then +pushes. + ## Advanced Options ### Build with specific configuration diff --git a/cake-build/build.cake b/cake-build/build.cake index bf8a69fce..47496674f 100644 --- a/cake-build/build.cake +++ b/cake-build/build.cake @@ -8,6 +8,7 @@ #load "tasks/build.cake" #load "tasks/test.cake" #load "tasks/package.cake" +#load "tasks/release.cake" /////////////////////////////////////////////////////////////////////////////// // ARGUMENTS @@ -18,6 +19,7 @@ var configuration = Argument("configuration", "Release"); var version = Argument("version", ""); var defineConstants = Argument("define", ""); var framework = Argument("framework", ""); +var packageOutput = Argument("packageOutput", ""); /////////////////////////////////////////////////////////////////////////////// // SETUP / TEARDOWN diff --git a/cake-build/helpers/utils.cake b/cake-build/helpers/utils.cake index 5788ed546..c88e61ca5 100644 --- a/cake-build/helpers/utils.cake +++ b/cake-build/helpers/utils.cake @@ -20,49 +20,57 @@ public void RestoreSolution(FilePath solutionPath) // Needed for projects using old packages.config format for maintaining dependencies. // This will not be needed once deprecated projects are removed. Information("Running NuGet restore..."); - try + if (IsRunningOnWindows()) { - if (IsRunningOnWindows()) + Information("Windows system detected, running direct NuGetRestore command"); + // Throws on failure, which is what we want: a failed packages.config + // restore must stop the build here, not resurface later as a confusing + // EnsureNuGetPackageBuildImports error. + NuGetRestore(solutionPath.FullPath, new NuGetRestoreSettings { - Information("Windows system detected, running direct NuGetRestore command"); - NuGetRestore(solutionPath.FullPath, new NuGetRestoreSettings - { - Verbosity = NuGetVerbosity.Quiet - }); + Verbosity = NuGetVerbosity.Quiet + }); + } + else + { + // On macOS/Linux the `nuget` CLI exists only where Mono tooling was installed + // (the mono workflow runs ./tools/mono-install.sh; the plain macOS/Linux test + // legs do not, and macos-14/ubuntu-24.04 runners ship no Mono). The legacy + // packages.config heads it restores are only *built* on Windows/Mono anyway, + // so a missing tool is skippable — but a present tool that fails is a real + // error. StartProcess returns the exit code rather than throwing, so check it. + var nugetTool = Context.Tools.Resolve("nuget") ?? Context.Tools.Resolve("nuget.exe"); + if (nugetTool == null) + { + Warning("nuget CLI not found; skipping packages.config restore (only needed for the net46 heads, which build on Windows/Mono)."); } else { Information("macOS/Linux system detected, running nuget restore from CLI"); - // On macOS/Linux, use nuget command (installed via mono) - StartProcess("nuget", new ProcessSettings + var nugetExit = StartProcess(nugetTool, new ProcessSettings { Arguments = $"restore \"{solutionPath.FullPath}\" -Verbosity quiet" }); + if (nugetExit != 0) + { + throw new Exception( + $"nuget restore failed for {solutionPath.GetFilename()} with exit code {nugetExit}."); + } } } - catch (Exception ex) - { - Warning($"NuGet restore failed: {ex.Message}"); - } - // dotnet restore (all platforms, for SDK-style projects) - try - { - Information("Running dotnet restore..."); - // Suppress restore warning as errors NU1503 for xamarin/old style projects - var restoreSettings = new DotNetRestoreSettings - { - MSBuildSettings = new DotNetMSBuildSettings() - .WithProperty("WarningsNotAsErrors", "NU1503") - .WithProperty("NoWarn", "NU1503") - }; - DotNetRestore(solutionPath.FullPath, restoreSettings); - Information($"✓ dotnet restore completed"); - } - catch (Exception e) + // dotnet restore (all platforms, for SDK-style projects). No try/catch: a + // restore failure must fail the build at the point it happens. + Information("Running dotnet restore..."); + // Suppress restore warning as errors NU1503 for xamarin/old style projects + var restoreSettings = new DotNetRestoreSettings { - Warning($"dotnet restore failed: {e.Message}"); - } + MSBuildSettings = new DotNetMSBuildSettings() + .WithProperty("WarningsNotAsErrors", "NU1503") + .WithProperty("NoWarn", "NU1503") + }; + DotNetRestore(solutionPath.FullPath, restoreSettings); + Information($"✓ dotnet restore completed"); } /// diff --git a/cake-build/tasks/build.cake b/cake-build/tasks/build.cake index 435edd971..1f34a7863 100644 --- a/cake-build/tasks/build.cake +++ b/cake-build/tasks/build.cake @@ -21,20 +21,32 @@ Task("_Restore_Main") }); Task("_Version") + // The pre-flight runs first and unconditionally: it is the only chance to + // compare the --version input against the committed version files, because + // this task then overwrites CommonAssemblyInfo.cs with that same input. + .IsDependentOn("_Release_Preflight") .WithCriteria(() => !string.IsNullOrEmpty(version)) .Does(() => { Information($"Setting version to {version}"); - + + // AssemblyVersion and AssemblyFileVersion accept only a numeric + // MAJOR.MINOR.PATCH[.REVISION]; a SemVer2 prerelease label such as + // "2.0.0-beta.1" in either is compile error CS7034. Only + // AssemblyInformationalVersion may carry the full label, so a prerelease is + // stamped as numeric identity + full informational version. For a stable + // version (no '-') numericVersion == version and nothing changes. + var numericVersion = version.Split('-')[0]; + var assemblyInfoPath = paths.Src.CombineWithFilePath("CommonAssemblyInfo.cs"); - + CreateAssemblyInfo(assemblyInfoPath, new AssemblyInfoSettings { Company = "Ably", Product = "Ably .NET Library", Copyright = $"Copyright © Ably {DateTime.Now.Year}", - Version = version, - FileVersion = version, + Version = numericVersion, + FileVersion = numericVersion, InformationalVersion = version }); }); diff --git a/cake-build/tasks/package.cake b/cake-build/tasks/package.cake index 54e17ac9a..c885c533b 100644 --- a/cake-build/tasks/package.cake +++ b/cake-build/tasks/package.cake @@ -29,6 +29,9 @@ Task("_Package_Build_All") Task("_Package_Create_NuGet") .IsDependentOn("_Package_Build_All") + // Runs between the build and the pack: nuget silently omits a files entry it + // cannot resolve, so this is the last moment an empty package can be caught. + .IsDependentOn("_Release_Verify_Files") .WithCriteria(() => !string.IsNullOrEmpty(version)) .Does(() => { @@ -51,9 +54,11 @@ Task("_Package_Create_NuGet") { { "Configuration", "Release" } }, - OutputDirectory = paths.Root + OutputDirectory = ReleasePackageOutputDirectory() }; + CreateDirectory(nugetSettings.OutputDirectory); + // Use local nuget.exe if available var nugetPath = paths.Root.CombineWithFilePath("tools/nuget.exe"); if (FileExists(nugetPath)) @@ -136,7 +141,11 @@ Task("_Package_Unity") Task("Package") .Description("Create the NuGet packages (Ably.PubSub.Core, Ably.PubSub.Device, Ably.PubSub.Server)") - .IsDependentOn("_Package_Create_NuGet"); + // Dependencies run in declaration order: pack, then assert the produced + // .nupkg files. Together with _Release_Preflight (before the build) and + // _Release_Verify_Files (before the pack), the pack is bracketed by checks. + .IsDependentOn("_Package_Create_NuGet") + .IsDependentOn("_Release_Verify_Packages"); Task("UnityPackage") .Description("Create Unity package") diff --git a/cake-build/tasks/release.cake b/cake-build/tasks/release.cake new file mode 100644 index 000000000..3940e4f5f --- /dev/null +++ b/cake-build/tasks/release.cake @@ -0,0 +1,569 @@ +using System.Text.RegularExpressions; + +/////////////////////////////////////////////////////////////////////////////// +// RELEASE PRE-FLIGHT AND PACKAGE ASSERTIONS +/////////////////////////////////////////////////////////////////////////////// +// +// Ably.PubSub.Core, Ably.PubSub.Device and Ably.PubSub.Server are released in +// lockstep at one version, and both door packages pin the core with an exact +// version range. Nothing about that is enforced by the build itself, so these +// checks enforce it, and they run *before* anything is produced: +// +// _Release_Preflight source-only assertions. Runs before _Version +// regenerates CommonAssemblyInfo.cs, so "the +// --version input matches the committed version +// files" is a real assertion and not a tautology. +// _Release_Verify_Files every glob in every nuspec resolves to +// at least one real file. Runs after the packaging +// build and before the pack, because nuget silently +// omits files it cannot find: a stale or partial +// build would otherwise ship an empty package. +// _Release_Verify_Packages post-pack assertions against the produced .nupkg +// files themselves, including the packed door -> core +// pin, which is the only place the $version$ token +// substitution inside a dependency version attribute +// can actually be proven. +// +/////////////////////////////////////////////////////////////////////////////// + +// The lockstep package set: nuspec file name -> package id. This set is exact. +// A resurrected nuget/io.ably.nuspec, or a fourth package added without a +// decision, fails the pre-flight. It is also what makes publish.yml safe to +// live on the default branch while that branch is still 1.x: a 1.x checkout has +// nuget/io.ably.nuspec and none of these, so the pre-flight refuses it. +var releasePackageSet = new Dictionary(StringComparer.OrdinalIgnoreCase) +{ + { "ably.pubsub.core.nuspec", "Ably.PubSub.Core" }, + { "ably.pubsub.device.nuspec", "Ably.PubSub.Device" }, + { "ably.pubsub.server.nuspec", "Ably.PubSub.Server" } +}; + +// The door packages. Each must pin the core at the exact version being released. +var releaseDoorNuspecs = new[] { "ably.pubsub.device.nuspec", "ably.pubsub.server.nuspec" }; + +// The package id the doors depend on and pin exactly. +const string CorePackageId = "Ably.PubSub.Core"; + +// Package ids that must never appear as a dependency of anything shipped from +// this branch. ably.io 1.x is published from the maintenance branch only, and a +// door that depended on it would put two Ably cores in one project. +var releaseForbiddenDependencyIds = new[] { "ably.io", "io.ably", "ably.io.push.android", "ably.io.push.ios" }; + +public DirectoryPath ReleaseNuspecDirectory() +{ + return paths.Root.Combine("nuget"); +} + +public DirectoryPath ReleasePackageOutputDirectory() +{ + if (string.IsNullOrEmpty(packageOutput)) + { + return paths.Root; + } + + // Resolved against the repository root, not Cake's working directory (which + // is cake-build/), so --packageOutput=dry-run-packages means what it reads like. + DirectoryPath candidate = packageOutput; + return candidate.IsRelative ? paths.Root.Combine(candidate) : candidate; +} + +public string ReleaseReadRequiredVersionArgument() +{ + if (string.IsNullOrWhiteSpace(version)) + { + throw new Exception( + "No --version was supplied. The release version is an explicit input so that it can be " + + "checked against the committed version files; pass it, e.g. --version=2.0.0."); + } + + if (!Regex.IsMatch(version, @"^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$")) + { + throw new Exception( + $"--version='{version}' is not a release version. Expected MAJOR.MINOR.PATCH with an " + + "optional prerelease suffix, e.g. 2.0.0 or 2.0.0-rc.1."); + } + + return version; +} + +/////////////////////////////////////////////////////////////////////////////// +// Assertion 1: the version input and the two committed version files agree. +/////////////////////////////////////////////////////////////////////////////// + +public void ReleaseAssertVersionFilesAgree(string releaseVersion, List errors) +{ + var assemblyInfo = paths.Src.CombineWithFilePath("CommonAssemblyInfo.cs"); + if (!FileExists(assemblyInfo)) + { + errors.Add($"{assemblyInfo.FullPath} is missing. It is the single source of the runtime version."); + } + else + { + var assemblyInfoText = System.IO.File.ReadAllText(assemblyInfo.FullPath); + + // AssemblyVersion/AssemblyFileVersion carry the numeric identity only + // (a SemVer2 label in either is CS7034); AssemblyInformationalVersion + // carries the full release string, prerelease label included. This + // mirrors exactly how _Version stamps the file. + var numericVersion = releaseVersion.Split('-')[0]; + var expectedByAttribute = new Dictionary + { + { "AssemblyVersion", numericVersion }, + { "AssemblyFileVersion", numericVersion }, + { "AssemblyInformationalVersion", releaseVersion } + }; + + foreach (var pair in expectedByAttribute) + { + var attribute = pair.Key; + var expected = pair.Value; + var match = Regex.Match(assemblyInfoText, attribute + @"\(""([^""]*)""\)"); + if (!match.Success) + { + errors.Add($"src/CommonAssemblyInfo.cs has no [assembly: {attribute}(\"...\")] attribute."); + } + else if (match.Groups[1].Value != expected) + { + errors.Add( + $"src/CommonAssemblyInfo.cs {attribute} is '{match.Groups[1].Value}' but for --version " + + $"'{releaseVersion}' it must be '{expected}' (AssemblyVersion/AssemblyFileVersion carry the " + + "numeric core only; AssemblyInformationalVersion carries the full string). Bump the version " + + "files in their own commit and release the merged version; the release input never overrides " + + "what is committed."); + } + } + } + + var unityVersion = paths.Root.CombineWithFilePath("unity/Assets/Ably/version.txt"); + if (!FileExists(unityVersion)) + { + errors.Add($"{unityVersion.FullPath} is missing. The Unity package reads its version from it."); + } + else + { + var unityVersionText = System.IO.File.ReadAllText(unityVersion.FullPath).Trim(); + if (unityVersionText != releaseVersion) + { + errors.Add( + $"unity/Assets/Ably/version.txt is '{unityVersionText}' but --version is '{releaseVersion}'. " + + "Both version files must be bumped together: the .unitypackage ships from the same run as " + + "the NuGet packages and must carry the same version."); + } + } +} + +/////////////////////////////////////////////////////////////////////////////// +// Assertion 1b: the Unity plugin assembly bundled into the .unitypackage was +// built at the version being released. _Package_Unity zips the committed +// unity/Assets/Ably tree as-is; it does not rebuild the plugin, so a stale +// unity/Assets/Ably/Plugins/Ably.PubSub.Device.dll (regenerated by the separate +// ./unity-plugins-updater.sh step) would otherwise ship inside the +// .unitypackage at the release label while every .nupkg is correct. +/////////////////////////////////////////////////////////////////////////////// + +public void ReleaseAssertUnityPluginVersion(string releaseVersion, List errors) +{ + var pluginDll = paths.Root.CombineWithFilePath("unity/Assets/Ably/Plugins/Ably.PubSub.Device.dll"); + if (!FileExists(pluginDll)) + { + errors.Add( + $"{pluginDll.FullPath} is missing. The .unitypackage bundles this merged plugin assembly, and it " + + "is produced by a separate step (./unity-plugins-updater.sh ). Regenerate and commit it " + + "before releasing, or the Unity package ships without its core."); + return; + } + + var numericVersion = releaseVersion.Split('-')[0]; + + // AssemblyVersion is the numeric identity ILRepack carries over from the + // netstandard2.0 core via /attr; compare its MAJOR.MINOR.PATCH to the + // numeric core of the release (prerelease-safe: identity is never labelled). + var identity = System.Reflection.AssemblyName.GetAssemblyName(pluginDll.FullPath).Version; + var identityCore = $"{identity.Major}.{identity.Minor}.{identity.Build}"; + if (identityCore != numericVersion) + { + errors.Add( + $"unity/Assets/Ably/Plugins/Ably.PubSub.Device.dll has AssemblyVersion '{identity}' (core " + + $"'{identityCore}') but --version is '{releaseVersion}'. The bundled Unity plugin is stale; " + + $"rebuild it with ./unity-plugins-updater.sh {releaseVersion} and commit it."); + } + + // The informational version carries the full SemVer2 string, prerelease label + // included. Strip any +build metadata before comparing. + var informational = System.Diagnostics.FileVersionInfo.GetVersionInfo(pluginDll.FullPath).ProductVersion; + var informationalCore = (informational ?? string.Empty).Split('+')[0]; + if (informationalCore != releaseVersion) + { + errors.Add( + $"unity/Assets/Ably/Plugins/Ably.PubSub.Device.dll AssemblyInformationalVersion is '{informational}' " + + $"but --version is '{releaseVersion}'. Rebuild the Unity plugin with " + + $"./unity-plugins-updater.sh {releaseVersion} and commit it."); + } +} + +/////////////////////////////////////////////////////////////////////////////// +// Assertion 2: the nuspec set is exactly the lockstep set, with the expected ids. +// Assertion 3: every door pins the core as [$version$] and nothing depends on +// the 1.x package ids. +/////////////////////////////////////////////////////////////////////////////// + +public void ReleaseAssertNuspecs(List errors) +{ + var nuspecDirectory = ReleaseNuspecDirectory(); + if (!DirectoryExists(nuspecDirectory)) + { + errors.Add($"{nuspecDirectory.FullPath} does not exist, so there is nothing to release."); + return; + } + + var found = GetFiles(nuspecDirectory.FullPath + "/*.nuspec") + .Select(f => f.GetFilename().FullPath) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var expected = releasePackageSet.Keys.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList(); + + var missing = expected.Where(e => !found.Contains(e, StringComparer.OrdinalIgnoreCase)).ToList(); + var unexpected = found.Where(f => !releasePackageSet.ContainsKey(f)).ToList(); + + if (missing.Count > 0 || unexpected.Count > 0) + { + errors.Add( + "nuget/ does not hold exactly the lockstep package set.\n" + + $" expected: {string.Join(", ", expected)}\n" + + $" found: {(found.Count == 0 ? "(none)" : string.Join(", ", found))}\n" + + (missing.Count > 0 ? $" missing: {string.Join(", ", missing)}\n" : string.Empty) + + (unexpected.Count > 0 ? $" unexpected: {string.Join(", ", unexpected)}\n" : string.Empty) + + " Every package in this set is released together at one version. If this is a 1.x checkout\n" + + " (nuget/io.ably.nuspec), it cannot be released by this workflow: ably.io ships from the 1.x\n" + + " maintenance branch with its own tooling."); + return; + } + + foreach (var entry in releasePackageSet) + { + var nuspec = nuspecDirectory.CombineWithFilePath(entry.Key); + var text = System.IO.File.ReadAllText(nuspec.FullPath); + + var idMatch = Regex.Match(text, @"\s*([^<\s]+)\s*"); + if (!idMatch.Success) + { + errors.Add($"nuget/{entry.Key} has no ."); + } + else if (idMatch.Groups[1].Value != entry.Value) + { + errors.Add( + $"nuget/{entry.Key} declares {idMatch.Groups[1].Value} but the lockstep set expects " + + $"'{entry.Value}'. Renaming a published package id is not a rename, it is a new package."); + } + + var versionMatch = Regex.Match(text, @"\s*([^<\s]+)\s*"); + if (!versionMatch.Success || versionMatch.Groups[1].Value != "$version$") + { + errors.Add( + $"nuget/{entry.Key} must declare $version$ so that the pack version comes " + + "from the release input alone. A hard-coded version here would let a package ship at a " + + "different version from its siblings."); + } + + // Every dependency in this nuspec, with its declared version range. + var dependencies = Regex.Matches(text, @"[^""]+)""\s+version=""(?[^""]*)""") + .Cast() + .Select(m => new { Id = m.Groups["id"].Value, Version = m.Groups["version"].Value }) + .ToList(); + + foreach (var forbidden in releaseForbiddenDependencyIds) + { + if (dependencies.Any(d => string.Equals(d.Id, forbidden, StringComparison.OrdinalIgnoreCase))) + { + errors.Add( + $"nuget/{entry.Key} depends on '{forbidden}'. The 1.x packages are never a dependency of " + + "the 2.0 set: a project that resolved both would have two Ably cores in it."); + } + } + + var isDoor = releaseDoorNuspecs.Contains(entry.Key, StringComparer.OrdinalIgnoreCase); + var corePins = dependencies + .Where(d => string.Equals(d.Id, CorePackageId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (isDoor) + { + var groupCount = Regex.Matches(text, @" p.Version != "[$version$]")) + { + errors.Add( + $"nuget/{entry.Key} pins {CorePackageId} as version=\"{pin.Version}\", expected the exact " + + "range token \"[$version$]\". Square brackets are what make it exact: '2.0.0' without " + + "them is a minimum, so a consumer could silently resolve a newer core than the door was " + + "built and tested against, and two doors could pull two different cores into one project."); + } + } + else if (corePins.Count > 0) + { + errors.Add($"nuget/{entry.Key} is the core package but depends on {CorePackageId}."); + } + } +} + +/////////////////////////////////////////////////////////////////////////////// +// Assertion 5: every glob resolves to at least one real file. +/////////////////////////////////////////////////////////////////////////////// + +public void ReleaseAssertNuspecFilesResolve(List errors) +{ + var nuspecDirectory = ReleaseNuspecDirectory(); + + foreach (var nuspecName in releasePackageSet.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) + { + var nuspec = nuspecDirectory.CombineWithFilePath(nuspecName); + if (!FileExists(nuspec)) + { + continue; // already reported by the nuspec set assertion + } + + var text = System.IO.File.ReadAllText(nuspec.FullPath); + + foreach (Match file in Regex.Matches(text, @"[^""]+)""\s+target=""(?[^""]*)""\s*/>")) + { + var src = file.Groups["src"].Value + .Replace("$configuration$", configuration) + .Replace('\\', '/'); + + var pattern = nuspecDirectory.Combine(System.IO.Path.GetDirectoryName(src) ?? string.Empty) + .CombineWithFilePath(System.IO.Path.GetFileName(src)); + var absolute = MakeAbsolute(pattern).FullPath; + + var resolved = absolute.Contains("*") + ? GetFiles(absolute).Count + : (System.IO.File.Exists(absolute) ? 1 : 0); + + if (resolved == 0) + { + errors.Add( + $"nuget/{nuspecName}: matches nothing " + + $"({absolute}). nuget does not fail on a files entry that matches nothing, it silently " + + "omits it, so a stale or partial build would ship a package missing that asset. Build " + + $"the {configuration} configuration of every head first."); + } + } + } +} + +/////////////////////////////////////////////////////////////////////////////// +// Assertion 4: post-pack assertions against the produced .nupkg files. +/////////////////////////////////////////////////////////////////////////////// + +public void ReleaseAssertPackedPackages(string releaseVersion, List errors) +{ + var outputDirectory = ReleasePackageOutputDirectory(); + var extractRoot = paths.Root.Combine("test-results/release-verify"); + + if (DirectoryExists(extractRoot)) + { + DeleteDirectory(extractRoot, new DeleteDirectorySettings { Recursive = true, Force = true }); + } + CreateDirectory(extractRoot); + + foreach (var entry in releasePackageSet.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)) + { + var packageId = entry.Value; + var nupkg = outputDirectory.CombineWithFilePath($"{packageId}.{releaseVersion}.nupkg"); + + if (!FileExists(nupkg)) + { + errors.Add( + $"{nupkg.FullPath} was not produced. All {releasePackageSet.Count} packages of the lockstep " + + "set are packed from one run; a missing one means the release is partial before it even " + + "reaches the registry."); + continue; + } + + var extracted = extractRoot.Combine(packageId); + Unzip(nupkg, extracted); + + var packedNuspec = GetFiles(extracted.FullPath + "/*.nuspec").FirstOrDefault(); + if (packedNuspec == null) + { + errors.Add($"{nupkg.GetFilename()} contains no .nuspec."); + continue; + } + + var packedText = System.IO.File.ReadAllText(packedNuspec.FullPath); + + var packedId = Regex.Match(packedText, @"\s*([^<\s]+)\s*").Groups[1].Value; + if (packedId != packageId) + { + errors.Add($"{nupkg.GetFilename()} declares id '{packedId}', expected '{packageId}'."); + } + + var packedVersion = Regex.Match(packedText, @"\s*([^<\s]+)\s*").Groups[1].Value; + if (packedVersion != releaseVersion) + { + errors.Add( + $"{nupkg.GetFilename()} declares version '{packedVersion}', expected '{releaseVersion}'. " + + "The $version$ token did not substitute."); + } + + var isDoor = releaseDoorNuspecs.Contains(entry.Key, StringComparer.OrdinalIgnoreCase); + if (isDoor) + { + var packedPins = Regex.Matches(packedText, @"[^""]+)""\s+version=""(?[^""]*)""") + .Cast() + .Where(m => string.Equals(m.Groups["id"].Value, CorePackageId, StringComparison.OrdinalIgnoreCase)) + .Select(m => m.Groups["version"].Value) + .ToList(); + + if (packedPins.Count == 0) + { + errors.Add($"{nupkg.GetFilename()} has no packed {CorePackageId} dependency."); + } + + foreach (var pin in packedPins) + { + // This is the assertion the whole exact-pin design rests on: the + // $version$ token has to substitute inside a dependency's version + // attribute, not just in . If it did not, the pin would + // ship as the literal "[$version$]" or as an empty range. + if (pin != $"[{releaseVersion}]") + { + errors.Add( + $"{nupkg.GetFilename()} pins {CorePackageId} as '{pin}', expected " + + $"'[{releaseVersion}]'. The exact pin is what stops a consumer resolving a core " + + "version this door was never built against."); + } + else + { + Information($" {packageId}: packed pin "); + } + } + } + + // Every lib/ the source nuspec targets must actually contain the + // package's own assembly in the produced .nupkg. + var sourceText = System.IO.File.ReadAllText(ReleaseNuspecDirectory().CombineWithFilePath(entry.Key).FullPath); + var libTargets = Regex.Matches(sourceText, @"target=""lib[\\/](?[^""\\/]+)""") + .Cast() + .Select(m => m.Groups["tfm"].Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (libTargets.Count == 0) + { + errors.Add($"nuget/{entry.Key} declares no lib/ targets."); + } + + var packedFiles = GetFiles(extracted.FullPath + "/**/*") + .Select(f => MakeAbsolute(f).FullPath.Substring(MakeAbsolute(extracted).FullPath.Length + 1).Replace('\\', '/')) + .ToList(); + + foreach (var tfm in libTargets) + { + var expectedAssembly = $"lib/{tfm}/{packageId}.dll"; + if (!packedFiles.Any(f => string.Equals(f, expectedAssembly, StringComparison.OrdinalIgnoreCase))) + { + errors.Add( + $"{nupkg.GetFilename()} has no {expectedAssembly}. The nuspec targets lib/{tfm}, so the " + + $"{tfm} head either did not build or its files entry matched nothing."); + } + } + + Information($" {packageId} {packedVersion}: {packedFiles.Count} files, lib/{string.Join(", lib/", libTargets)}"); + } +} + +/////////////////////////////////////////////////////////////////////////////// +// TASKS (Internal) +/////////////////////////////////////////////////////////////////////////////// + +Task("_Release_Preflight") + .Description("Assert the release is coherent before anything is built, packed or pushed") + .Does(() => +{ + var releaseVersion = ReleaseReadRequiredVersionArgument(); + + Information($"Release pre-flight for {releaseVersion}"); + Information($" packages: {string.Join(", ", releasePackageSet.Values.OrderBy(v => v))}"); + + var errors = new List(); + + ReleaseAssertVersionFilesAgree(releaseVersion, errors); + ReleaseAssertUnityPluginVersion(releaseVersion, errors); + ReleaseAssertNuspecs(errors); + + if (errors.Count > 0) + { + throw new Exception( + $"Release pre-flight failed with {errors.Count} problem(s); nothing has been built, packed or " + + "pushed:\n\n - " + string.Join("\n\n - ", errors) + "\n"); + } + + Information("Release pre-flight passed:"); + Information($" --version, src/CommonAssemblyInfo.cs and unity/Assets/Ably/version.txt agree on {releaseVersion} (assembly identity attributes carry its numeric core)"); + Information($" nuget/ holds exactly the lockstep set and each door pins {CorePackageId} as [$version$]"); +}); + +Task("_Release_Verify_Files") + .Description("Assert every nuspec files entry resolves, so no package can ship empty") + .Does(() => +{ + var errors = new List(); + ReleaseAssertNuspecFilesResolve(errors); + + if (errors.Count > 0) + { + throw new Exception( + $"Nuspec files verification failed with {errors.Count} problem(s); nothing has been packed:\n\n - " + + string.Join("\n\n - ", errors) + "\n"); + } + + Information($"Every nuspec glob resolves against the {configuration} build output."); +}); + +Task("_Release_Verify_Packages") + .Description("Assert the produced .nupkg files carry the right version and the exact core pin") + .Does(() => +{ + var releaseVersion = ReleaseReadRequiredVersionArgument(); + + Information($"Verifying packed packages in {ReleasePackageOutputDirectory().FullPath}"); + + var errors = new List(); + ReleaseAssertPackedPackages(releaseVersion, errors); + + if (errors.Count > 0) + { + throw new Exception( + $"Packed package verification failed with {errors.Count} problem(s); do not publish these " + + "artifacts:\n\n - " + string.Join("\n\n - ", errors) + "\n"); + } + + Information($"All {releasePackageSet.Count} packages are at {releaseVersion} and both doors pin " + + $"{CorePackageId} as [{releaseVersion}]."); +}); + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC TARGETS +/////////////////////////////////////////////////////////////////////////////// + +Task("Release.Preflight") + .Description("Run the release pre-flight assertions on the working tree (no build, no pack, no push)") + .IsDependentOn("_Release_Preflight"); + +Task("Release.VerifyPackages") + .Description("Run the post-pack assertions against .nupkg files already produced") + .IsDependentOn("_Release_Verify_Packages"); diff --git a/nuget/ably.pubsub.core.nuspec b/nuget/ably.pubsub.core.nuspec index bec111641..013286650 100644 --- a/nuget/ably.pubsub.core.nuspec +++ b/nuget/ably.pubsub.core.nuspec @@ -7,15 +7,15 @@ Martin Georgiev, Sachin Shinde, Yavor Ivanov, Jack Rutherford, Tom Kirby-Green Ably Real-time Ltd Apache-2.0 - https://github.com/ably/ably-dotnet + https://github.com/ably/ably-pubsub-dotnet icon.png README.md false Internal implementation package for Ably's own Ably.PubSub.Device and Ably.PubSub.Server packages. It is not intended for direct use. Install Ably.PubSub.Server in a server-side application, or Ably.PubSub.Device in an end-user device application, and create clients through those packages' factory methods. This package carries the shared Ably Pub/Sub implementation (pub-sub messaging, presence, message history, push administration) and is resolved transitively; referencing it directly means the clients you create are not classified as device-side or server-side, which Ably's platform behaviour and billing depend on. See https://www.ably.com for more info. - https://github.com/ably/ably-dotnet/releases + https://github.com/ably/ably-pubsub-dotnet/releases ©2026 Ably Real-time Ltd ably realtime messaging websocket pubsub presence dotnet csharp maui netstandard unity - + diff --git a/nuget/ably.pubsub.device.nuspec b/nuget/ably.pubsub.device.nuspec index d6a8e2e18..365a50ca6 100644 --- a/nuget/ably.pubsub.device.nuspec +++ b/nuget/ably.pubsub.device.nuspec @@ -7,21 +7,21 @@ Martin Georgiev, Sachin Shinde, Yavor Ivanov, Jack Rutherford, Tom Kirby-Green Ably Real-time Ltd Apache-2.0 - https://github.com/ably/ably-dotnet + https://github.com/ably/ably-pubsub-dotnet icon.png README.md false The supported entry point for Ably Pub/Sub in an end-user device application - a mobile or desktop app, a Unity game, a set-top box or any other client the end user holds. Create clients through PubSubDevice.CreateClient(...) in the IO.Ably.PubSub.Device namespace; it accepts an API key, an Ably token, a ClientOptions or an Action<ClientOptions>, and returns the ordinary AblyRealtime so the whole of the IO.Ably API remains available, including device-side connectionless operations such as message history, presence reads and token requests. Creating a client any other way - including directly from the Ably.PubSub.Core implementation package this depends on - does not classify it as device-side, which Ably's platform behaviour and billing depend on. Install Ably.PubSub.Server instead in a server-side application. See https://www.ably.com for more info. - https://github.com/ably/ably-dotnet/releases + https://github.com/ably/ably-pubsub-dotnet/releases ©2026 Ably Real-time Ltd ably realtime messaging websocket pubsub presence device mobile dotnet csharp maui unity netstandard - + + versions. NuGet does substitute $version$ inside a dependency version attribute + (verified: the packed pin comes out as [2.0.0]), and _Release_Verify_Packages + asserts it against the produced .nupkg on every release and every dry run. --> diff --git a/nuget/ably.pubsub.server.nuspec b/nuget/ably.pubsub.server.nuspec index 34417e59a..f11ea93e1 100644 --- a/nuget/ably.pubsub.server.nuspec +++ b/nuget/ably.pubsub.server.nuspec @@ -7,21 +7,21 @@ Martin Georgiev, Sachin Shinde, Yavor Ivanov, Jack Rutherford, Tom Kirby-Green Ably Real-time Ltd Apache-2.0 - https://github.com/ably/ably-dotnet + https://github.com/ably/ably-pubsub-dotnet icon.png README.md false The supported entry point for Ably Pub/Sub in a server-side application - an ASP.NET or Azure host, a worker, a console app or any other backend the end user does not hold. Create clients through PubSubServer.CreateRealtimeClient(...) and PubSubServer.CreateHttpClient(...) in the IO.Ably.PubSub.Server namespace; each accepts an API key, an Ably token, a ClientOptions or an Action<ClientOptions>, and returns the ordinary AblyRealtime or AblyRest so the whole of the IO.Ably API remains available. Creating a client any other way - including directly from the Ably.PubSub.Core implementation package this depends on - does not classify it as server-side, which Ably's platform behaviour and billing depend on. Install Ably.PubSub.Device instead in an end-user device application. See https://www.ably.com for more info. - https://github.com/ably/ably-dotnet/releases + https://github.com/ably/ably-pubsub-dotnet/releases ©2026 Ably Real-time Ltd ably realtime messaging websocket pubsub presence server backend dotnet csharp aspnet netstandard - + + versions. NuGet does substitute $version$ inside a dependency version attribute + (verified: the packed pin comes out as [2.0.0]), and _Release_Verify_Packages + asserts it against the produced .nupkg on every release and every dry run. --> diff --git a/src/Ably.PubSub.Core/Ably.PubSub.Core.csproj b/src/Ably.PubSub.Core/Ably.PubSub.Core.csproj index 643839c27..fa3e0100f 100644 --- a/src/Ably.PubSub.Core/Ably.PubSub.Core.csproj +++ b/src/Ably.PubSub.Core/Ably.PubSub.Core.csproj @@ -16,6 +16,10 @@ true ..\..\IO.Ably.snk + + $(NoWarn);CS8002 @@ -65,4 +69,13 @@ true + + true + true + + + + + + diff --git a/src/Ably.PubSub.Device/Ably.PubSub.Device.csproj b/src/Ably.PubSub.Device/Ably.PubSub.Device.csproj index 3bdcfeff3..d105ace16 100644 --- a/src/Ably.PubSub.Device/Ably.PubSub.Device.csproj +++ b/src/Ably.PubSub.Device/Ably.PubSub.Device.csproj @@ -58,4 +58,13 @@ + + true + true + + + + + + diff --git a/src/Ably.PubSub.Server/Ably.PubSub.Server.csproj b/src/Ably.PubSub.Server/Ably.PubSub.Server.csproj index f1669d01a..7bc093b7c 100644 --- a/src/Ably.PubSub.Server/Ably.PubSub.Server.csproj +++ b/src/Ably.PubSub.Server/Ably.PubSub.Server.csproj @@ -57,4 +57,13 @@ + + true + true + + + + + + diff --git a/src/Ably.PubSub.Tests.DotNET/PubSub/PackagingSpecs.cs b/src/Ably.PubSub.Tests.DotNET/PubSub/PackagingSpecs.cs new file mode 100644 index 000000000..4806c995a --- /dev/null +++ b/src/Ably.PubSub.Tests.DotNET/PubSub/PackagingSpecs.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using FluentAssertions; +using Xunit; + +namespace IO.Ably.Tests.PubSub +{ + /// + /// The lockstep packaging contract, asserted from the nuspec files themselves. + /// + /// The same assertions live in the Cake release pre-flight (`cake-build/tasks/release.cake`), + /// which is what guards the actual release. They are duplicated here so that a PR which edits + /// a nuspec fails the ordinary unit test run rather than waiting for a packaging job: the + /// mistakes being guarded against - a door pinned loosely, a resurrected `ably.io` package, a + /// hard-coded version - are all silent at build time and only visible to a consumer after the + /// version has been published and can never be changed. + /// + /// What cannot be asserted here is the packed output: whether nuget substitutes `$version$` + /// inside a dependency's version attribute is a property of the packer, not of the source, so + /// it is asserted post-pack by `_Release_Verify_Packages` against the produced .nupkg files. + /// + public class PackagingSpecs + { + private const string CorePackageId = "Ably.PubSub.Core"; + + /// + /// The exact set of packages released from this repository, nuspec file name to package id. + /// Every one of them ships at the same version from the same run. + /// + private static readonly IReadOnlyDictionary PackageSet = new Dictionary + { + { "ably.pubsub.core.nuspec", CorePackageId }, + { "ably.pubsub.device.nuspec", "Ably.PubSub.Device" }, + { "ably.pubsub.server.nuspec", "Ably.PubSub.Server" }, + }; + + /// + /// The door packages: the ones that must pin the core exactly. + /// + private static readonly string[] DoorNuspecs = { "ably.pubsub.device.nuspec", "ably.pubsub.server.nuspec" }; + + /// + /// Package ids that must never be a dependency of anything released from this branch. + /// `ably.io` and the old push satellites are published from the 1.x maintenance branch; + /// a 2.0 package that depended on one of them would put two Ably cores in one project. + /// + private static readonly string[] ForbiddenDependencyIds = + { + "ably.io", "io.ably", "ably.io.push.android", "ably.io.push.ios", + }; + + /// + /// Gets the nuspec file names, as xunit theory data. + /// + public static IEnumerable Nuspecs => PackageSet.Keys.Select(k => new object[] { k }); + + [Fact] + [Trait("spec", "packaging")] + public void NugetDirectory_ContainsExactlyTheLockstepPackageSet() + { + var found = Directory.GetFiles(NuspecDirectory(), "*.nuspec") + .Select(Path.GetFileName) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // Exact, not "contains": a fourth package, or a resurrected nuget/io.ably.nuspec, + // means something is being published that this lockstep release was not designed for. + found.Should().BeEquivalentTo( + PackageSet.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase), + "the release publishes exactly this set of packages at one version"); + } + + [Theory] + [MemberData(nameof(Nuspecs))] + [Trait("spec", "packaging")] + public void Nuspec_DeclaresTheExpectedPackageId(string nuspecName) + { + Child(Metadata(nuspecName), "id").Should().NotBeNull(); + Child(Metadata(nuspecName), "id").Value.Trim() + .Should().Be(PackageSet[nuspecName], "a published package id cannot be renamed, only replaced"); + } + + [Theory] + [MemberData(nameof(Nuspecs))] + [Trait("spec", "packaging")] + public void Nuspec_TakesItsVersionFromTheReleaseInput(string nuspecName) + { + // A hard-coded version here would let one package of the set ship at a different + // version from its siblings, which is exactly what lockstep exists to prevent. + Child(Metadata(nuspecName), "version").Value.Trim() + .Should().Be("$version$", "the pack version comes from the release input alone"); + } + + [Theory] + [MemberData(nameof(Nuspecs))] + [Trait("spec", "packaging")] + public void Nuspec_DoesNotDependOnThe1xPackages(string nuspecName) + { + var ids = Dependencies(nuspecName).Select(d => d.Id).ToArray(); + + foreach (var forbidden in ForbiddenDependencyIds) + { + ids.Should().NotContain( + id => string.Equals(id, forbidden, StringComparison.OrdinalIgnoreCase), + $"{forbidden} is a 1.x package published from the maintenance branch"); + } + } + + [Theory] + [MemberData(nameof(Nuspecs))] + [Trait("spec", "packaging")] + public void DoorNuspec_PinsTheCoreExactly_InEveryTargetFrameworkGroup(string nuspecName) + { + var corePins = Dependencies(nuspecName) + .Where(d => string.Equals(d.Id, CorePackageId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (!DoorNuspecs.Contains(nuspecName, StringComparer.OrdinalIgnoreCase)) + { + corePins.Should().BeEmpty("the core package cannot depend on itself"); + return; + } + + var groupCount = Metadata(nuspecName) + .Descendants() + .Count(e => e.Name.LocalName == "group"); + + groupCount.Should().BeGreaterThan(0); + + // One pin per group. A group without one leaves that target framework free to + // resolve whatever core version it likes. + corePins.Should().HaveCount( + groupCount, + $"every one of the {groupCount} dependency groups must pin {CorePackageId}"); + + foreach (var pin in corePins) + { + // The square brackets are the whole point: "2.0.0" is a minimum version, so a + // consumer could silently resolve a newer core than this door was built and + // tested against, and two doors could pull two different cores into one project. + pin.Version.Should().Be( + "[$version$]", + $"{CorePackageId} must be pinned to the exact version being released"); + } + } + + [Fact] + [Trait("spec", "packaging")] + public void TheTwoVersionFiles_Agree() + { + var assemblyInfo = File.ReadAllText(Path.Combine(RepositoryRoot(), "src", "CommonAssemblyInfo.cs")); + var unityVersion = File.ReadAllText( + Path.Combine(RepositoryRoot(), "unity", "Assets", "Ably", "version.txt")).Trim(); + + // Mirrors _Version and ReleaseAssertVersionFilesAgree: AssemblyVersion and + // AssemblyFileVersion carry the numeric identity only (a SemVer2 label in + // either is CS7034); AssemblyInformationalVersion carries the full string, + // prerelease label included. + var numericVersion = unityVersion.Split('-')[0]; + + foreach (var (attribute, expected) in new[] + { + ("AssemblyVersion", numericVersion), + ("AssemblyFileVersion", numericVersion), + ("AssemblyInformationalVersion", unityVersion), + }) + { + var match = Regex.Match(assemblyInfo, attribute + @"\(""([^""]*)""\)"); + match.Success.Should().BeTrue($"src/CommonAssemblyInfo.cs declares [assembly: {attribute}]"); + match.Groups[1].Value.Should().Be( + expected, + "src/CommonAssemblyInfo.cs and unity/Assets/Ably/version.txt are bumped together; the " + + ".unitypackage ships from the same run as the NuGet packages"); + } + } + + private static XElement Child(XElement parent, string localName) + { + return parent.Elements().SingleOrDefault(e => e.Name.LocalName == localName); + } + + private static XElement Metadata(string nuspecName) + { + var document = XDocument.Load(Path.Combine(NuspecDirectory(), nuspecName)); + + // Matched by local name so the assertions do not depend on the nuspec schema + // namespace, which nuget has changed before. + return document.Root.Elements().Single(e => e.Name.LocalName == "metadata"); + } + + private static IEnumerable<(string Id, string Version)> Dependencies(string nuspecName) + { + return Metadata(nuspecName) + .Descendants() + .Where(e => e.Name.LocalName == "dependency") + .Select(e => ( + Id: (string)e.Attribute("id"), + Version: (string)e.Attribute("version"))) + .ToArray(); + } + + private static string NuspecDirectory() => Path.Combine(RepositoryRoot(), "nuget"); + + /// + /// Walks up from the test assembly to the repository root. The test assembly lives under + /// src/Ably.PubSub.Tests.DotNET/bin/<configuration>/<tfm>/, but the depth differs + /// between a local run and CI, so the root is found by looking for what identifies it + /// rather than by counting directories. + /// + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "nuget", "ably.pubsub.core.nuspec"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not find the repository root (a directory containing nuget/ably.pubsub.core.nuspec) " + + $"above {AppContext.BaseDirectory}."); + } + } +}