diff --git a/.github/DEVOPS.md b/.github/DEVOPS.md index c2de66e5..defb5ed5 100644 --- a/.github/DEVOPS.md +++ b/.github/DEVOPS.md @@ -17,7 +17,8 @@ run at least once on the default branch**. Easiest order: the job names). 4. Configure branch protection (section 1a) in one pass, including required checks. -5. Tag `v0.1.0-preview.1` to dry-run `release.yml` (section 5). +5. Open **Actions → Release → Run workflow** and make the first coordinated + preview release (section 5). ## 1a. Branch protection on `main` @@ -62,7 +63,7 @@ the repo, nothing to rotate. | Repository | `ColorPicker.Maui` | | Workflow File | `release.yml` | | Environment | `nuget-prod` | - | Package Glob / IDs | `ColorPicker.Maui` | + | Package Glob / IDs | `ColorPicker.Maui*` | 3. Save. First push from `release.yml` (after the package exists) will bind the policy to the package owner — that's normal. @@ -70,7 +71,7 @@ the repo, nothing to rotate. If the package doesn't exist on nuget.org yet, the **very first** publish still needs a one-shot API key: - Generate at https://www.nuget.org/account/apikeys - scope **Push new packages and package versions**, Glob `ColorPicker.Maui`, + scope **Push new packages and package versions**, Glob `ColorPicker.Maui*`, expires in **1 day**. - Add as repo secret `NUGET_API_KEY` temporarily. - Temporarily change `release.yml` to use `${{ secrets.NUGET_API_KEY }}` @@ -87,7 +88,7 @@ still needs a one-shot API key: - ✅ Required reviewers: **vpapenko** → Manual approval gate before any push to nuget.org. -- ✅ Deployment branches and tags: only allow tags matching `v*`. +- ✅ Deployment branches: only allow `main`. The environment name **must** match the `Environment` field in the Trusted Publishing policy above, otherwise OIDC exchange will be rejected. @@ -96,15 +97,22 @@ Trusted Publishing policy above, otherwise OIDC exchange will be rejected. ## 4. GitHub Packages (preview feed) -No setup needed — `ci.yml` pushes to `https://nuget.pkg.github.com/vpapenko/index.json` -using the built-in `GITHUB_TOKEN`. To consume previews locally: +No setup needed — `ci.yml` pushes a coherent Picker/Core preview pair to +`https://nuget.pkg.github.com/vpapenko/index.json` using the built-in +`GITHUB_TOKEN`. Both packages receive the same +`0.0.0-preview.` version, and Picker depends on that exact Core preview. +Core is uploaded and restored from the feed before Picker is uploaded; rerunning +the workflow keeps the same package version. + +The exact version is shown in the workflow summary. To consume it locally: ```sh dotnet nuget add source https://nuget.pkg.github.com/vpapenko/index.json \ --name github-vpapenko \ --username \ --password -dotnet add package ColorPicker.Maui --prerelease +dotnet add package ColorPicker.Maui --version +dotnet add package ColorPicker.Maui.Core --version ``` ### 4a. Consumer-smoke validation of the packed nupkg @@ -119,59 +127,52 @@ jobs close that gap: | `build-and-test.yml → consumer-smoke` | every PR (when `pack: true`) | local feed = the just-packed `nupkgs/` artifact | catch packaging bugs **before merge** | | `ci.yml → consumer-e2e-github-packages` | every push to `main` | GitHub Packages (just-published preview) | catch upload/index/auth issues that only show up via the real feed | -Both build the [`samples/ConsumerSmoke/`](../samples/ConsumerSmoke/README.md) -class library against the version under test, on Android **and** Windows -TFMs. If either job fails on a PR, the underlying package is broken and -must not be promoted to a release tag. +Both jobs build [`samples/ConsumerSmoke/`](../samples/ConsumerSmoke/README.md) +against `ColorPicker.Maui` on Android and Windows, +[`samples/CoreConsumerSmoke/`](../samples/CoreConsumerSmoke/README.md) against +`ColorPicker.Maui.Core` on `netstandard2.0` and `net8.0`, and run +`samples/PackageCompatibilitySmoke/` to verify the Core assembly identity and +every type forwarder in `ColorPicker.dll`. The MAUI package also consumes Core +transitively, so the smoke pass verifies the dependency between the two +packages. If either job fails on a PR, the packages must not be +promoted to a stable release. --- -## 5. First release dry-run - -After this PR merges: - -```sh -git checkout main && git pull -git tag v0.1.0-preview.1 -git push origin v0.1.0-preview.1 -``` +## 5. Stable releases from the GitHub UI -Watch `release.yml` run; approve the `nuget-prod` deployment when prompted. -After it succeeds you should see `0.1.0-preview.1` on -https://www.nuget.org/packages/ColorPicker.Maui. +Open **Actions → Release → Run workflow**, keep the branch set to `main`, and +choose a target: -When you're confident in the API, cut a stable release: +| Target | Required fields | Result | +|---|---|---| +| `picker` | Picker version; Core version may be blank | Publishes only `ColorPicker.Maui`. Blank Core selects the latest `core-v*` release. | +| `core` | Core version | Publishes only `ColorPicker.Maui.Core`. | +| `both` | Picker version and Core version | Packs and tests both, publishes Core first, waits for NuGet indexing, then publishes Picker. | -```sh -git tag v0.1.0 -git push origin v0.1.0 -``` +The `nuget-prod` environment asks for approval immediately before publication. +The workflow validates semantic versions, package contents, consumers, and safe +retries. It creates package-specific tags and GitHub releases automatically: ---- +- `picker-v2.0.0` +- `core-v1.0.0` -## 6. Versioning rules (enforced by MinVer) +No local tag or `dotnet nuget push` command is required. -- No tag yet → `0.0.0-preview.0.` on every push to main. -- Tag `v1.0.0-preview.5` → that commit packs as `1.0.0-preview.5`. - Subsequent commits pack as `1.0.0-preview.5.`. -- Tag `v1.0.0` → packs as exactly `1.0.0`. Subsequent commits pack as - `1.0.1-preview.0.` (next-patch preview). -- Bumping major/minor: just push a tag. No file edits, no PRs. +For the first coordinated release, choose `both`, Picker `2.0.0-preview.1`, and +Core `1.0.0-preview.1`. When ready for stable packages, run `both` again with +Picker `2.0.0` and Core `1.0.0`. --- -## 7. Useful labels (apply to PRs for release-drafter categorization) - -Create these labels in **Issues → Labels**: - -| Label | Color | Effect on changelog | Effect on next version | -|---|---|---|---| -| `feature` / `enhancement` | `#84b6eb` | "🚀 Features" | minor bump | -| `fix` / `bug` | `#d73a4a` | "🐛 Bug fixes" | patch bump | -| `breaking` / `major` | `#b60205` | implicit | **major bump** | -| `test` | `#fbca04` | "🧪 Tests" | patch | -| `docs` | `#0075ca` | "📚 Documentation" | patch | -| `ci` / `devops` | `#5319e7` | "🏗️ CI / DevOps" | patch | -| `dependencies` | `#0366d6` | "⬆️ Dependencies" | patch | -| `refactor` / `chore` | `#cfd3d7` | "♻️ Refactor / chores" | patch | -| `skip-changelog` | `#cccccc` | excluded | n/a | +## 6. Independent stable versioning + +- Picker-only UI, renderer, or MAUI changes bump only `ColorPicker.Maui`. +- Core-only compatible changes bump only `ColorPicker.Maui.Core`. +- When Picker needs a new Core API, release `both`; Picker is packaged against + the new Core nupkg before either package is published. +- Picker declares a compatible Core major range. For example, a minimum Core + version of `1.1.0` produces `[1.1.0,2.0.0)`. +- A breaking Core major normally requires a Picker major because Picker exposes + Core types and maintains type forwarders. +- Package-specific tags record exactly which commit produced each stable package. diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index 69f132ee..00000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,38 +0,0 @@ -name-template: 'v$RESOLVED_VERSION' -tag-template: 'v$RESOLVED_VERSION' -template: | - ## What's changed - $CHANGES - - **Full changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION - -categories: - - title: '🚀 Features' - labels: [feature, enhancement] - - title: '🐛 Bug fixes' - labels: [fix, bug] - - title: '🧪 Tests' - labels: [test] - - title: '📚 Documentation' - labels: [docs, documentation] - - title: '🏗️ CI / DevOps' - labels: [ci, devops] - - title: '⬆️ Dependencies' - labels: [dependencies] - - title: '♻️ Refactor / chores' - labels: [refactor, chore] - -change-template: '- $TITLE (#$NUMBER) @$AUTHOR' -no-changes-template: '- No user-facing changes' - -version-resolver: - major: - labels: [major, breaking] - minor: - labels: [feature, enhancement, minor] - patch: - labels: [fix, bug, patch, chore, docs, ci, dependencies, refactor] - default: patch - -exclude-labels: - - skip-changelog diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 6315c576..520b60f9 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,9 +1,9 @@ name: Build and test (reusable) # Reusable workflow: builds the library + sample app for Android/Windows and -# runs the Appium UI tests on Windows. Optionally produces NuGet packages. -# Called by pr.yml (gate), ci.yml (gate + preview publish), release.yml (gate -# + stable publish). +# runs the Appium UI tests on Windows. Optionally produces either package or +# a coherent pair. Normal builds use ProjectReference; package builds consume +# Core through the generated nupkg to validate the real distribution boundary. on: workflow_call: @@ -13,10 +13,33 @@ on: required: false default: false type: boolean + package_selection: + description: "Packages to create: picker, core, or both." + required: false + default: both + type: string + picker_version: + description: "ColorPicker.Maui package version. Blank creates a CI preview version." + required: false + default: "" + type: string + core_version: + description: "ColorPicker.Maui.Core package version. Blank creates a CI preview version." + required: false + default: "" + type: string + core_dependency_version: + description: "NuGet range used by ColorPicker.Maui. Blank pins the generated Core preview exactly." + required: false + default: "" + type: string outputs: - package-version: - description: "Version assigned by MinVer to the packed nupkg." - value: ${{ jobs.pack.outputs.version }} + picker-version: + description: "Version assigned to ColorPicker.Maui, if packed." + value: ${{ jobs.pack.outputs.picker-version }} + core-version: + description: "Version assigned to ColorPicker.Maui.Core, if packed or consumed by Picker." + value: ${{ jobs.pack.outputs.core-version }} permissions: contents: read @@ -45,7 +68,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - fetch-depth: 0 # MinVer needs full history + tags + fetch-depth: 0 - uses: actions/setup-dotnet@v6 with: dotnet-version: | @@ -80,6 +103,7 @@ jobs: run: | dotnet restore ColorPicker.Maui.sln dotnet restore ColorPickerTestApp/ColorPickerTestApp.csproj -r win-x64 -p:TargetFramework=net10.0-windows10.0.19041.0 + dotnet restore ColorPicker.Core/ColorPicker.Core.csproj - name: Build library (Windows) run: dotnet build ColorPicker/ColorPicker.csproj --configuration Release --no-restore -f net10.0-windows10.0.19041.0 - name: Build sample (Windows) @@ -107,6 +131,7 @@ jobs: run: | dotnet restore ColorPicker.Maui.sln dotnet restore ColorPickerTestApp/ColorPickerTestApp.csproj -r win-x64 -p:TargetFramework=net10.0-windows10.0.19041.0 + dotnet restore ColorPicker.Core/ColorPicker.Core.csproj - name: Build sample (Windows) run: dotnet build ColorPickerTestApp/ColorPickerTestApp.csproj --configuration Release --no-restore -f net10.0-windows10.0.19041.0 -r win-x64 - name: Enable Windows Developer Mode @@ -188,37 +213,118 @@ jobs: runs-on: windows-latest needs: [ build-android, build-windows, ui-tests-windows ] outputs: - version: ${{ steps.minver.outputs.version }} + picker-version: ${{ steps.versions.outputs.picker-version }} + core-version: ${{ steps.versions.outputs.core-version }} steps: - uses: actions/checkout@v7 with: - fetch-depth: 0 # MinVer needs full history + tags + fetch-depth: 0 - uses: actions/setup-dotnet@v6 with: dotnet-version: | 8.0.x 10.0.302 - name: Install MAUI workloads + if: ${{ inputs.package_selection != 'core' }} run: dotnet workload install maui-android maui-windows - - name: Restore - run: dotnet restore ColorPicker/ColorPicker.csproj - - name: Install Android SDK platform (API 36) - run: dotnet build ColorPicker/ColorPicker.csproj -t:InstallAndroidDependencies -f net10.0-android -p:AcceptAndroidSDKLicenses=True + - name: Resolve package versions + id: versions + shell: pwsh + env: + PACKAGE_SELECTION: ${{ inputs.package_selection }} + REQUESTED_PICKER_VERSION: ${{ inputs.picker_version }} + REQUESTED_CORE_VERSION: ${{ inputs.core_version }} + REQUESTED_CORE_DEPENDENCY: ${{ inputs.core_dependency_version }} + run: | + if ($env:PACKAGE_SELECTION -notin @("picker", "core", "both")) { + throw "Unknown package selection '$env:PACKAGE_SELECTION'." + } + + $previewVersion = "0.0.0-preview.$env:GITHUB_RUN_NUMBER" + $pickerVersion = if ($env:PACKAGE_SELECTION -eq "core") { + "" + } elseif ($env:REQUESTED_PICKER_VERSION) { + $env:REQUESTED_PICKER_VERSION + } else { + $previewVersion + } + $coreVersion = if ($env:REQUESTED_CORE_VERSION) { $env:REQUESTED_CORE_VERSION } else { $previewVersion } + $coreDependency = if ($env:REQUESTED_CORE_DEPENDENCY) { + $env:REQUESTED_CORE_DEPENDENCY + } else { + "[$coreVersion]" + } + + if ($env:PACKAGE_SELECTION -eq "picker" -and -not $env:REQUESTED_CORE_VERSION) { + throw "core_version is required when packing Picker without Core." + } + + "picker-version=$pickerVersion" >> $env:GITHUB_OUTPUT + "core-version=$coreVersion" >> $env:GITHUB_OUTPUT + "core-dependency=$coreDependency" >> $env:GITHUB_OUTPUT + Write-Host "Selection: $env:PACKAGE_SELECTION" + Write-Host "Picker version: $pickerVersion" + Write-Host "Core version: $coreVersion" + Write-Host "Picker Core dependency: $coreDependency" - name: Pack - id: minver shell: pwsh + env: + PACKAGE_SELECTION: ${{ inputs.package_selection }} + PICKER_VERSION: ${{ steps.versions.outputs.picker-version }} + CORE_VERSION: ${{ steps.versions.outputs.core-version }} + CORE_DEPENDENCY: ${{ steps.versions.outputs.core-dependency }} run: | - dotnet pack ColorPicker/ColorPicker.csproj ` - --configuration Release ` - --no-restore ` - --output ${{ github.workspace }}/nupkgs ` - -p:ContinuousIntegrationBuild=true - $pkg = Get-ChildItem ${{ github.workspace }}/nupkgs -Filter "*.nupkg" | Select-Object -First 1 - if (-not $pkg) { throw "No .nupkg produced." } - # Filename: ColorPicker.Maui..nupkg - $version = $pkg.BaseName -replace '^ColorPicker\.Maui\.', '' - echo "version=$version" >> $env:GITHUB_OUTPUT - Write-Host "Packed version: $version" + $output = Join-Path $env:GITHUB_WORKSPACE "nupkgs" + New-Item -ItemType Directory -Path $output -Force | Out-Null + + if ($env:PACKAGE_SELECTION -in @("core", "both")) { + $env:ColorPickerCoreVersion = $env:CORE_VERSION + dotnet restore ColorPicker.Core/ColorPicker.Core.csproj + if ($LASTEXITCODE -ne 0) { throw "Core restore failed." } + dotnet pack ColorPicker.Core/ColorPicker.Core.csproj ` + --configuration Release ` + --no-restore ` + --output $output ` + -p:ContinuousIntegrationBuild=true + if ($LASTEXITCODE -ne 0) { throw "Core pack failed." } + } + + if ($env:PACKAGE_SELECTION -in @("picker", "both")) { + $env:UseCorePackageReference = "true" + $env:ColorPickerCoreDependencyVersion = $env:CORE_DEPENDENCY + $env:ColorPickerMauiVersion = $env:PICKER_VERSION + $config = Join-Path $env:RUNNER_TEMP "ColorPicker-pack.NuGet.config" + Copy-Item samples/ConsumerSmoke/NuGet.config $config + dotnet nuget add source $output ` + --name local-colorpicker ` + --configfile $config + if ($LASTEXITCODE -ne 0) { throw "Local package source registration failed." } + + dotnet restore ColorPicker/ColorPicker.csproj ` + --configfile $config + if ($LASTEXITCODE -ne 0) { throw "Picker package-mode restore failed." } + dotnet build ColorPicker/ColorPicker.csproj ` + -t:InstallAndroidDependencies ` + --no-restore ` + -f net10.0-android ` + -p:AcceptAndroidSDKLicenses=True + if ($LASTEXITCODE -ne 0) { throw "Android SDK installation failed." } + dotnet pack ColorPicker/ColorPicker.csproj ` + --configuration Release ` + --no-restore ` + --output $output ` + -p:ContinuousIntegrationBuild=true + if ($LASTEXITCODE -ne 0) { throw "Picker pack failed." } + } + + $packages = @(Get-ChildItem $output -Filter "*.nupkg") + $mauiPackages = @($packages | Where-Object Name -NotLike "ColorPicker.Maui.Core.*") + $corePackages = @($packages | Where-Object Name -Like "ColorPicker.Maui.Core.*") + $expectedMaui = if ($env:PACKAGE_SELECTION -in @("picker", "both")) { 1 } else { 0 } + $expectedCore = if ($env:PACKAGE_SELECTION -in @("core", "both")) { 1 } else { 0 } + if ($mauiPackages.Count -ne $expectedMaui -or $corePackages.Count -ne $expectedCore) { + throw "Unexpected package count: Maui=$($mauiPackages.Count)/$expectedMaui Core=$($corePackages.Count)/$expectedCore." + } - name: Upload nupkgs uses: actions/upload-artifact@v7 with: @@ -240,6 +346,7 @@ jobs: 8.0.x 10.0.302 - name: Install MAUI workloads + if: ${{ inputs.package_selection != 'core' }} run: dotnet workload install maui-android maui-windows - uses: actions/download-artifact@v8 with: @@ -251,22 +358,49 @@ jobs: dotnet nuget add source "${{ github.workspace }}\nupkgs" ` --name local-colorpicker ` --configfile samples/ConsumerSmoke/NuGet.config - - name: Restore consumer against packed version + - name: Restore Picker consumers + if: ${{ inputs.package_selection != 'core' }} shell: pwsh env: - VERSION: ${{ needs.pack.outputs.version }} + PICKER_VERSION: ${{ needs.pack.outputs.picker-version }} run: | - Write-Host "Restoring ConsumerSmoke against ColorPicker.Maui = $env:VERSION" + Write-Host "Restoring ConsumerSmoke against ColorPicker.Maui = $env:PICKER_VERSION" dotnet restore samples/ConsumerSmoke/ConsumerSmoke.csproj ` - -p:ColorPickerVersion=$env:VERSION + --configfile samples/ConsumerSmoke/NuGet.config ` + -p:ColorPickerVersion=$env:PICKER_VERSION + Write-Host "Restoring PackageCompatibilitySmoke against ColorPicker.Maui = $env:PICKER_VERSION" + dotnet restore samples/PackageCompatibilitySmoke/PackageCompatibilitySmoke.csproj ` + --configfile samples/ConsumerSmoke/NuGet.config ` + -p:ColorPickerVersion=$env:PICKER_VERSION + - name: Restore Core consumer + if: ${{ inputs.package_selection != 'picker' }} + shell: pwsh + env: + CORE_VERSION: ${{ needs.pack.outputs.core-version }} + run: | + Write-Host "Restoring CoreConsumerSmoke against ColorPicker.Maui.Core = $env:CORE_VERSION" + dotnet restore samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj ` + --configfile samples/ConsumerSmoke/NuGet.config ` + -p:ColorPickerVersion=$env:CORE_VERSION - name: Install Android SDK platform (API 36) + if: ${{ inputs.package_selection != 'core' }} run: dotnet build ColorPicker/ColorPicker.csproj -t:InstallAndroidDependencies -f net10.0-android -p:AcceptAndroidSDKLicenses=True - name: Build consumer (Android) - run: dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj --configuration Release --no-restore -f net10.0-android -p:ColorPickerVersion=${{ needs.pack.outputs.version }} + if: ${{ inputs.package_selection != 'core' }} + run: dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj --configuration Release --no-restore -f net10.0-android -p:ColorPickerVersion=${{ needs.pack.outputs.picker-version }} - name: Build consumer (Windows) - run: dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj --configuration Release --no-restore -f net10.0-windows10.0.19041.0 -p:ColorPickerVersion=${{ needs.pack.outputs.version }} + if: ${{ inputs.package_selection != 'core' }} + run: dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj --configuration Release --no-restore -f net10.0-windows10.0.19041.0 -p:ColorPickerVersion=${{ needs.pack.outputs.picker-version }} + - name: Build Core consumer + if: ${{ inputs.package_selection != 'picker' }} + run: dotnet build samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj --configuration Release --no-restore -p:ColorPickerVersion=${{ needs.pack.outputs.core-version }} + - name: Verify package assembly compatibility + if: ${{ inputs.package_selection != 'core' }} + run: dotnet run --project samples/PackageCompatibilitySmoke/PackageCompatibilitySmoke.csproj --configuration Release --no-restore -p:ColorPickerVersion=${{ needs.pack.outputs.picker-version }} - name: Job summary shell: pwsh run: | "## Consumer smoke passed" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append - "Consumed `ColorPicker.Maui` **${{ needs.pack.outputs.version }}** from local feed." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + "Selection: **${{ inputs.package_selection }}**" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + "Picker version: **${{ needs.pack.outputs.picker-version }}**" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + "Core version: **${{ needs.pack.outputs.core-version }}**" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf56a296..d15e3336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,9 @@ name: CI -# Runs on every push to main: full build + UI tests + pack, then publishes -# the resulting nupkg as a PREVIEW package to GitHub Packages. Stable -# releases are produced by release.yml (tag-driven). MinVer assigns -# preview versions like "1.1.0-preview.0.42" between tags. +# Runs on every push to main: full build + UI tests, packs a coherent preview +# pair, publishes both packages to GitHub Packages, then consumes that exact +# pair through the real feed. Stable package versions remain independent and +# are published through the manual Release workflow. on: push: @@ -30,27 +30,76 @@ jobs: if: github.repository == 'vpapenko/ColorPicker.Maui' runs-on: ubuntu-latest steps: + - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.302 - uses: actions/download-artifact@v8 with: name: nupkgs path: nupkgs - name: Push to GitHub Packages + env: + CORE_VERSION: ${{ needs.build-and-test.outputs.core-version }} + PICKER_VERSION: ${{ needs.build-and-test.outputs.picker-version }} + GH_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - dotnet nuget push "nupkgs/*.nupkg" \ + set -euo pipefail + + dotnet nuget push "nupkgs/ColorPicker.Maui.Core.${CORE_VERSION}.nupkg" \ + --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ + --api-key "$GH_PACKAGES_TOKEN" \ + --no-symbols \ + --skip-duplicate + dotnet nuget push "nupkgs/ColorPicker.Maui.Core.${CORE_VERSION}.snupkg" \ + --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ + --api-key "$GH_PACKAGES_TOKEN" \ + --skip-duplicate || true + + config="$RUNNER_TEMP/NuGet.config" + cp samples/ConsumerSmoke/NuGet.config "$config" + dotnet nuget add source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ + --name github-packages \ + --username "${{ github.repository_owner }}" \ + --password "$GH_PACKAGES_TOKEN" \ + --store-password-in-clear-text \ + --configfile "$config" + + ready=false + for attempt in $(seq 1 60); do + if dotnet restore samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj \ + --configfile "$config" \ + -p:ColorPickerVersion="$CORE_VERSION" \ + --force \ + --no-cache \ + --verbosity quiet > "$RUNNER_TEMP/core-restore.log" 2>&1; then + ready=true + break + fi + sleep 10 + done + if [ "$ready" != "true" ]; then + cat "$RUNNER_TEMP/core-restore.log" + echo "::error::Timed out waiting for ColorPicker.Maui.Core ${CORE_VERSION} in GitHub Packages." + exit 1 + fi + + dotnet nuget push "nupkgs/ColorPicker.Maui.${PICKER_VERSION}.nupkg" \ --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ - --api-key ${{ secrets.GITHUB_TOKEN }} \ + --api-key "$GH_PACKAGES_TOKEN" \ + --no-symbols \ --skip-duplicate - dotnet nuget push "nupkgs/*.snupkg" \ + dotnet nuget push "nupkgs/ColorPicker.Maui.${PICKER_VERSION}.snupkg" \ --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ - --api-key ${{ secrets.GITHUB_TOKEN }} \ + --api-key "$GH_PACKAGES_TOKEN" \ --skip-duplicate || true - name: Job summary run: | echo "## Preview published" >> $GITHUB_STEP_SUMMARY - echo "Version: \`${{ needs.build-and-test.outputs.package-version }}\`" >> $GITHUB_STEP_SUMMARY + echo "Picker: \`${{ needs.build-and-test.outputs.picker-version }}\`" >> $GITHUB_STEP_SUMMARY + echo "Core: \`${{ needs.build-and-test.outputs.core-version }}\`" >> $GITHUB_STEP_SUMMARY echo "Source: \`https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json\`" >> $GITHUB_STEP_SUMMARY consumer-e2e-github-packages: @@ -89,33 +138,33 @@ jobs: - name: Restore consumer against just-published version shell: pwsh env: - VERSION: ${{ needs.build-and-test.outputs.package-version }} + PICKER_VERSION: ${{ needs.build-and-test.outputs.picker-version }} + CORE_VERSION: ${{ needs.build-and-test.outputs.core-version }} run: | - Write-Host "Restoring ConsumerSmoke against ColorPicker.Maui = $env:VERSION from GitHub Packages" + Write-Host "Restoring ConsumerSmoke against ColorPicker.Maui = $env:PICKER_VERSION from GitHub Packages" dotnet restore samples/ConsumerSmoke/ConsumerSmoke.csproj ` - -p:ColorPickerVersion=$env:VERSION + --configfile samples/ConsumerSmoke/NuGet.config ` + -p:ColorPickerVersion=$env:PICKER_VERSION + Write-Host "Restoring CoreConsumerSmoke against ColorPicker.Maui.Core = $env:CORE_VERSION from GitHub Packages" + dotnet restore samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj ` + --configfile samples/ConsumerSmoke/NuGet.config ` + -p:ColorPickerVersion=$env:CORE_VERSION + Write-Host "Restoring PackageCompatibilitySmoke against ColorPicker.Maui = $env:PICKER_VERSION from GitHub Packages" + dotnet restore samples/PackageCompatibilitySmoke/PackageCompatibilitySmoke.csproj ` + --configfile samples/ConsumerSmoke/NuGet.config ` + -p:ColorPickerVersion=$env:PICKER_VERSION - name: Install Android SDK platform (API 36) run: dotnet build ColorPicker/ColorPicker.csproj -t:InstallAndroidDependencies -f net10.0-android -p:AcceptAndroidSDKLicenses=True - name: Build consumer (Android) - run: dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj --configuration Release --no-restore -f net10.0-android -p:ColorPickerVersion=${{ needs.build-and-test.outputs.package-version }} + run: dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj --configuration Release --no-restore -f net10.0-android -p:ColorPickerVersion=${{ needs.build-and-test.outputs.picker-version }} - name: Build consumer (Windows) - run: dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj --configuration Release --no-restore -f net10.0-windows10.0.19041.0 -p:ColorPickerVersion=${{ needs.build-and-test.outputs.package-version }} + run: dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj --configuration Release --no-restore -f net10.0-windows10.0.19041.0 -p:ColorPickerVersion=${{ needs.build-and-test.outputs.picker-version }} + - name: Build Core consumer + run: dotnet build samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj --configuration Release --no-restore -p:ColorPickerVersion=${{ needs.build-and-test.outputs.core-version }} + - name: Verify package assembly compatibility + run: dotnet run --project samples/PackageCompatibilitySmoke/PackageCompatibilitySmoke.csproj --configuration Release --no-restore -p:ColorPickerVersion=${{ needs.build-and-test.outputs.picker-version }} - name: Job summary shell: pwsh run: | "## Consumer E2E passed" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append - "Consumed `ColorPicker.Maui` **${{ needs.build-and-test.outputs.package-version }}** from GitHub Packages." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append - - draft-release-notes: - name: Update release draft - needs: [ build-and-test ] - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: read - steps: - - uses: release-drafter/release-drafter@v7 - with: - config-name: release-drafter.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + "Consumed Picker **${{ needs.build-and-test.outputs.picker-version }}** and Core **${{ needs.build-and-test.outputs.core-version }}** from GitHub Packages and verified the Core type-forwarding contract." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a4acae9..6b649e81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,97 +1,436 @@ name: Release -# Triggered by a `vX.Y.Z` (or `vX.Y.Z-preview.N`) tag pushed to the -# repository. Re-runs the full gate (build + UI tests) against the tagged -# commit, then publishes the nupkg + snupkg to nuget.org and creates a -# GitHub Release with auto-drafted notes. -# -# Cutting a release: -# git tag v1.0.0 && git push origin v1.0.0 -# git tag v1.1.0-preview.1 && git push origin v1.1.0-preview.1 +run-name: Release ${{ inputs.target }} (Picker ${{ inputs.picker_version || 'unchanged' }}, Core ${{ inputs.core_version || 'auto' }}) on: - push: - tags: [ 'v*' ] + workflow_dispatch: + inputs: + target: + description: Package release target + required: true + default: both + type: choice + options: + - picker + - core + - both + picker_version: + description: ColorPicker.Maui version, without a leading v (required for Picker or Both) + required: false + type: string + core_version: + description: ColorPicker.Maui.Core version (required for Core or Both; blank uses latest Core for Picker-only) + required: false + type: string permissions: contents: read +concurrency: + group: stable-nuget-release + cancel-in-progress: false + jobs: + prepare: + name: Prepare release + runs-on: ubuntu-latest + outputs: + target: ${{ steps.release.outputs.target }} + picker-version: ${{ steps.release.outputs.picker-version }} + core-version: ${{ steps.release.outputs.core-version }} + core-dependency-version: ${{ steps.release.outputs.core-dependency-version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Validate release request + id: release + shell: bash + env: + RELEASE_TARGET: ${{ inputs.target }} + REQUESTED_PICKER_VERSION: ${{ inputs.picker_version }} + REQUESTED_CORE_VERSION: ${{ inputs.core_version }} + run: | + set -euo pipefail + + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "::error::Stable releases must be run from the main branch." + exit 1 + fi + + validate_version() { + local name="$1" + local version="$2" + local identifier='(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' + local pattern="^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-${identifier}(\\.${identifier})*)?$" + if [[ ! "$version" =~ $pattern ]]; then + echo "::error::${name} '${version}' is not a canonical NuGet semantic version." + exit 1 + fi + } + + picker_version="$REQUESTED_PICKER_VERSION" + core_version="$REQUESTED_CORE_VERSION" + + case "$RELEASE_TARGET" in + picker) + if [ -z "$picker_version" ]; then + echo "::error::picker_version is required for a Picker release." + exit 1 + fi + validate_version "Picker version" "$picker_version" + + if [ -z "$core_version" ]; then + core_tag="$(git tag --list 'core-v*' | + grep -E '^core-v[0-9]+\.[0-9]+\.[0-9]+$' | + sort -V | + tail -n 1 || true)" + if [ -z "$core_tag" ]; then + core_tag="$(git tag --list 'core-v*' --sort=-version:refname | head -n 1)" + fi + if [ -z "$core_tag" ]; then + echo "::error::No Core release tag exists. Use target 'both' for the first release." + exit 1 + fi + core_version="${core_tag#core-v}" + fi + validate_version "Core version" "$core_version" + + if ! git rev-parse --verify "refs/tags/core-v${core_version}" >/dev/null 2>&1; then + echo "::error::Core tag 'core-v${core_version}' does not exist. Release Core first or select target 'both'." + exit 1 + fi + + core_status="$(curl --silent --show-error --location \ + --output /dev/null \ + --write-out "%{http_code}" \ + "https://api.nuget.org/v3-flatcontainer/colorpicker.maui.core/${core_version,,}/colorpicker.maui.core.${core_version,,}.nupkg")" + if [ "$core_status" != "200" ]; then + echo "::error::ColorPicker.Maui.Core ${core_version} is not available on nuget.org (HTTP ${core_status})." + exit 1 + fi + ;; + core) + if [ -z "$core_version" ]; then + echo "::error::core_version is required for a Core release." + exit 1 + fi + validate_version "Core version" "$core_version" + ;; + both) + if [ -z "$picker_version" ] || [ -z "$core_version" ]; then + echo "::error::picker_version and core_version are both required when target is 'both'." + exit 1 + fi + validate_version "Picker version" "$picker_version" + validate_version "Core version" "$core_version" + ;; + *) + echo "::error::Unknown release target '$RELEASE_TARGET'." + exit 1 + ;; + esac + + core_major="${core_version%%.*}" + next_core_major="$((core_major + 1))" + core_dependency="[${core_version},${next_core_major}.0.0)" + + echo "target=$RELEASE_TARGET" >> "$GITHUB_OUTPUT" + echo "picker-version=$picker_version" >> "$GITHUB_OUTPUT" + echo "core-version=$core_version" >> "$GITHUB_OUTPUT" + echo "core-dependency-version=$core_dependency" >> "$GITHUB_OUTPUT" + + { + echo "## Release request" + echo "- Target: \`$RELEASE_TARGET\`" + echo "- Picker: \`${picker_version:-not published}\`" + echo "- Core: \`$core_version\`" + if [ "$RELEASE_TARGET" != "core" ]; then + echo "- Picker dependency: \`ColorPicker.Maui.Core $core_dependency\`" + fi + } >> "$GITHUB_STEP_SUMMARY" + build-and-test: + name: Build, test, and pack + needs: [ prepare ] uses: ./.github/workflows/build-and-test.yml with: pack: true + package_selection: ${{ needs.prepare.outputs.target }} + picker_version: ${{ needs.prepare.outputs.picker-version }} + core_version: ${{ needs.prepare.outputs.core-version }} + core_dependency_version: ${{ needs.prepare.outputs.core-dependency-version }} publish-nuget: name: Publish to nuget.org - needs: [ build-and-test ] + needs: [ prepare, build-and-test ] runs-on: ubuntu-latest permissions: - id-token: write # required for NuGet Trusted Publishing (OIDC) + id-token: write contents: read environment: name: nuget-prod url: https://www.nuget.org/packages/ColorPicker.Maui steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 - uses: actions/setup-dotnet@v6 with: - dotnet-version: '8.0.x' + dotnet-version: | + 8.0.x + 10.0.302 - uses: actions/download-artifact@v8 with: name: nupkgs path: nupkgs - - name: Verify version matches tag - shell: bash - run: | - tag="${GITHUB_REF#refs/tags/v}" - ver="${{ needs.build-and-test.outputs.package-version }}" - ver="${ver%.nupkg}" - if [ "$tag" != "$ver" ]; then - echo "::error::Tag '$tag' does not match packed version '$ver'." - exit 1 - fi - echo "Tag and package version match: $ver" - name: NuGet login (OIDC -> short-lived API key) id: nuget-login uses: NuGet/login@v1 with: user: VictorPapenko - - name: Push to nuget.org + - name: Publish selected packages + shell: bash + env: + RELEASE_TARGET: ${{ needs.prepare.outputs.target }} + PICKER_VERSION: ${{ needs.prepare.outputs.picker-version }} + CORE_VERSION: ${{ needs.prepare.outputs.core-version }} + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} run: | - dotnet nuget push "nupkgs/*.nupkg" \ - --source https://api.nuget.org/v3/index.json \ - --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ - --skip-duplicate - dotnet nuget push "nupkgs/*.snupkg" \ - --source https://api.nuget.org/v3/index.json \ - --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ - --skip-duplicate - - github-release: - name: Create GitHub Release - needs: [ publish-nuget ] + set -euo pipefail + + package_status() { + local package_id="$1" + local version="$2" + local lowercase_id="${package_id,,}" + local lowercase_version="${version,,}" + curl --silent --show-error --location \ + --output "$3" \ + --write-out "%{http_code}" \ + "https://api.nuget.org/v3-flatcontainer/${lowercase_id}/${lowercase_version}/${lowercase_id}.${lowercase_version}.nupkg" + } + + package_content_hash() { + local package="$1" + local unpacked + unpacked="$(mktemp -d)" + unzip -qq "$package" -d "$unpacked" + find "$unpacked" -name '.signature.p7s' -delete + + local content_hash + content_hash="$( + cd "$unpacked" + while IFS= read -r -d '' file; do + case "$file" in + './.signature.p7s'|'./[Content_Types].xml'|'./_rels/'*|'./package/services/metadata/'*) + continue + ;; + esac + printf '%s\0' "$file" + sha256sum "$file" | cut -d ' ' -f 1 + done < <(find . -type f -print0 | sort -z) | + sha256sum | + cut -d ' ' -f 1 + )" + rm -rf "$unpacked" + printf '%s' "$content_hash" + } + + preflight_package() { + local package_id="$1" + local version="$2" + local package="$3" + local existing_package + existing_package="$(mktemp)" + local status + status="$(package_status "$package_id" "$version" "$existing_package")" + + if [ "$status" = "200" ]; then + local local_hash + local existing_hash + local_hash="$(package_content_hash "$package")" + existing_hash="$(package_content_hash "$existing_package")" + if [ "$local_hash" != "$existing_hash" ]; then + echo "::error::${package_id} ${version} already exists with different package contents." + rm -f "$existing_package" + exit 1 + fi + echo "${package_id} ${version} already exists with identical contents; continuing a safe retry." + elif [ "$status" != "404" ]; then + echo "::error::NuGet lookup for ${package_id} ${version} returned HTTP ${status}." + rm -f "$existing_package" + exit 1 + fi + rm -f "$existing_package" + } + + preflight_tag() { + local tag="$1" + if git rev-parse --verify "refs/tags/${tag}" >/dev/null 2>&1; then + local existing_commit + existing_commit="$(git rev-list -n 1 "$tag")" + if [ "$existing_commit" != "$GITHUB_SHA" ]; then + echo "::error::Tag ${tag} already points to ${existing_commit}, not ${GITHUB_SHA}." + exit 1 + fi + fi + } + + publish_package() { + local package_id="$1" + local version="$2" + local package="nupkgs/${package_id}.${version}.nupkg" + local symbols="nupkgs/${package_id}.${version}.snupkg" + + if [ ! -f "$package" ] || [ ! -f "$symbols" ]; then + echo "::error::Expected package pair not found for ${package_id} ${version}." + exit 1 + fi + + dotnet nuget push "$package" \ + --source https://api.nuget.org/v3/index.json \ + --api-key "$NUGET_API_KEY" \ + --no-symbols \ + --skip-duplicate + dotnet nuget push "$symbols" \ + --source https://api.nuget.org/v3/index.json \ + --api-key "$NUGET_API_KEY" \ + --skip-duplicate + } + + require_package_pair() { + local package_id="$1" + local version="$2" + if [ ! -f "nupkgs/${package_id}.${version}.nupkg" ] || + [ ! -f "nupkgs/${package_id}.${version}.snupkg" ]; then + echo "::error::Expected package pair not found for ${package_id} ${version}." + exit 1 + fi + } + + wait_for_package() { + local package_id="$1" + local version="$2" + local config="$RUNNER_TEMP/NuGet.config" + cp samples/ConsumerSmoke/NuGet.config "$config" + for attempt in $(seq 1 60); do + if dotnet restore samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj \ + --configfile "$config" \ + -p:ColorPickerVersion="$version" \ + --force \ + --no-cache \ + --verbosity quiet > "$RUNNER_TEMP/core-restore.log" 2>&1; then + echo "${package_id} ${version} is available from nuget.org." + return + fi + sleep 10 + done + cat "$RUNNER_TEMP/core-restore.log" + echo "::error::Timed out waiting for ${package_id} ${version} to become available." + exit 1 + } + + if [ "$RELEASE_TARGET" = "core" ] || [ "$RELEASE_TARGET" = "both" ]; then + require_package_pair "ColorPicker.Maui.Core" "$CORE_VERSION" + preflight_package \ + "ColorPicker.Maui.Core" \ + "$CORE_VERSION" \ + "nupkgs/ColorPicker.Maui.Core.${CORE_VERSION}.nupkg" + preflight_tag "core-v${CORE_VERSION}" + fi + if [ "$RELEASE_TARGET" = "picker" ] || [ "$RELEASE_TARGET" = "both" ]; then + require_package_pair "ColorPicker.Maui" "$PICKER_VERSION" + preflight_package \ + "ColorPicker.Maui" \ + "$PICKER_VERSION" \ + "nupkgs/ColorPicker.Maui.${PICKER_VERSION}.nupkg" + preflight_tag "picker-v${PICKER_VERSION}" + fi + + if [ "$RELEASE_TARGET" = "core" ] || [ "$RELEASE_TARGET" = "both" ]; then + publish_package "ColorPicker.Maui.Core" "$CORE_VERSION" + fi + if [ "$RELEASE_TARGET" = "both" ]; then + wait_for_package "ColorPicker.Maui.Core" "$CORE_VERSION" + fi + if [ "$RELEASE_TARGET" = "picker" ] || [ "$RELEASE_TARGET" = "both" ]; then + publish_package "ColorPicker.Maui" "$PICKER_VERSION" + fi + + github-releases: + name: Create GitHub releases + needs: [ prepare, publish-nuget ] runs-on: ubuntu-latest permissions: contents: write - pull-requests: read steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 - uses: actions/download-artifact@v8 with: name: nupkgs path: nupkgs - - name: Publish release-drafter draft for this tag - uses: release-drafter/release-drafter@v7 - with: - config-name: release-drafter.yml - publish: true - tag: ${{ github.ref_name }} - name: ${{ github.ref_name }} + - name: Create package-specific releases + shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Attach nupkgs to release - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ github.ref_name }} - files: | - nupkgs/*.nupkg - nupkgs/*.snupkg + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TARGET: ${{ needs.prepare.outputs.target }} + PICKER_VERSION: ${{ needs.prepare.outputs.picker-version }} + CORE_VERSION: ${{ needs.prepare.outputs.core-version }} + run: | + set -euo pipefail + + create_release() { + local package_id="$1" + local version="$2" + local tag_prefix="$3" + local latest="$4" + local tag="${tag_prefix}-v${version}" + local previous_tag + previous_tag="$(git tag --merged "$GITHUB_SHA" --list "${tag_prefix}-v*" --sort=-creatordate | + grep -Fvx "$tag" | + head -n 1 || true)" + local package="nupkgs/${package_id}.${version}.nupkg" + local symbols="nupkgs/${package_id}.${version}.snupkg" + + if git rev-parse --verify "refs/tags/${tag}" >/dev/null 2>&1; then + existing_commit="$(git rev-list -n 1 "$tag")" + if [ "$existing_commit" != "$GITHUB_SHA" ]; then + echo "::error::Tag ${tag} already points to ${existing_commit}, not ${GITHUB_SHA}." + exit 1 + fi + fi + + if gh release view "$tag" >/dev/null 2>&1; then + gh release upload "$tag" "$package" "$symbols" --clobber + echo "Release ${tag} already exists; refreshed its assets." + return + fi + + release_flags=( + "$tag" + "$package" + "$symbols" + --target "$GITHUB_SHA" + --title "${package_id} ${version}" + --generate-notes + ) + if [ -n "$previous_tag" ]; then + release_flags+=(--notes-start-tag "$previous_tag") + fi + if [[ "$version" == *-* ]]; then + release_flags+=(--prerelease) + fi + if [ "$latest" = "false" ]; then + release_flags+=(--latest=false) + fi + gh release create "${release_flags[@]}" + } + + if [ "$RELEASE_TARGET" = "core" ] || [ "$RELEASE_TARGET" = "both" ]; then + create_release "ColorPicker.Maui.Core" "$CORE_VERSION" "core" "false" + fi + if [ "$RELEASE_TARGET" = "picker" ] || [ "$RELEASE_TARGET" = "both" ]; then + create_release "ColorPicker.Maui" "$PICKER_VERSION" "picker" "true" + fi diff --git a/AGENTS.md b/AGENTS.md index 6e199c76..819db569 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,17 +7,24 @@ Practical notes accumulated from prior coding sessions. Read before working in t | Path | Purpose | |---|---| | `ColorPicker/` | The library — the published NuGet package (`ColorPicker.Maui`) | -| `ColorPicker.Core/` | Pure platform-agnostic math (HSL/RGB, polar, unit-square primitives). No MAUI / Skia deps. Multi-targets `netstandard2.0` + `net8.0`. | +| `ColorPicker.Core/` | Published `ColorPicker.Maui.Core` package: pure platform-agnostic math (HSL/RGB, polar, unit-square primitives). No MAUI / Skia deps. Multi-targets `netstandard2.0` + `net8.0`. | | `ColorPicker.Core.Tests/` | xUnit tests for `ColorPicker.Core` (runs on every PR, ubuntu, sub-second) | | `ColorPickerTestApp/` | MAUI app for manual visual testing | | `ColorPicker.UITests/` | Appium-driven xUnit UI test suite (~213 tests) | -| `samples/ConsumerSmoke/` | Smoke project that consumes the **packed nupkg**, *not* a ProjectReference | +| `samples/ConsumerSmoke/` | Smoke project that consumes the packed `ColorPicker.Maui` package | +| `samples/CoreConsumerSmoke/` | Smoke project that consumes the packed `ColorPicker.Maui.Core` package for both Core TFMs | +| `samples/PackageCompatibilitySmoke/` | Executable smoke that verifies the Core assembly identity and all type forwarders in the packed MAUI package | + +The Core namespaces remain `ColorPicker.Core`; the assembly and NuGet package are +`ColorPicker.Maui.Core` because `ColorPicker.Core` is already owned on nuget.org. +`ColorPicker.dll` contains type forwarders for Core types that were embedded before +the package split. ## Target frameworks - Library: `net10.0-android`, `net10.0-windows10.0.19041.0` (+ `net10.0-ios`, `net10.0-maccatalyst` on macOS) - Test app: same TFMs -- The package is **net10-only** (net8 was dropped in the .NET 10 migration; the package has no external consumers so back-compat wasn't required). +- `ColorPicker.Maui` is **net10-only**; `ColorPicker.Maui.Core` targets `netstandard2.0` and `net8.0`. - Test/tooling projects (`ColorPicker.Core`, `*.Tests`, `ColorPicker.UITests`, `tools/IconGen`) stay `net8.0`/`netstandard2.0` and run on the net8 runtime that CI also installs. ## Dependencies & prerequisites @@ -35,20 +42,28 @@ Everything needed to build/test/pack from a clean machine: | Node.js | **20+** | For Appium (UI tests only) | | Appium | **2** + `appium-windows-driver` + WinAppDriver 1.2.1 + Windows Developer Mode | UI tests — full setup in [`ColorPicker.UITests/README.md`](ColorPicker.UITests/README.md) | -Key NuGet: `SkiaSharp` 4.151.0 + `SkiaSharp.Views.Maui.Controls` 4.151.0, `Microsoft.Maui.Controls` 10.0.20, `Appium.WebDriver` (UITests), `MinVer` 5.0.0. -**MinVer needs full git history + tags** to compute the pack version — clone with full depth (`fetch-depth: 0` in CI). +Key NuGet: `SkiaSharp` 4.151.1 + `SkiaSharp.Views.Maui.Controls` 4.151.1, `Microsoft.Maui.Controls` 10.0.20, `Appium.WebDriver` (UITests). +Package versions are supplied by CI. Stable `ColorPicker.Maui` and +`ColorPicker.Maui.Core` versions are independent; main-branch previews use a +coherent same-run version pair. ### .NET 10 / MAUI 10 gotchas (hard-won — don't rediscover) - **`Microsoft.Maui.Controls` is pinned to the SDK band** (10.0.20 ↔ SDK 10.0.302). Bumping MAUI out of lockstep (even a patch, e.g. 10.0.90) makes restore pull an unpublished runtime pack → `NU1102`. Dependabot ignores `Microsoft.Maui.*` for this reason; bump MAUI **and** the SDK together. - **`Directory.Build.props` sets `UseMonoRuntime=false` for Windows.** The .NET 10 MAUI Windows head otherwise tries to restore the deprecated `Microsoft.NETCore.App.Runtime.Mono.win-x64` pack (dotnet/maui#27215) → `NU1102`. - **Restore the Windows app scoped to its TFM.** `dotnet restore ColorPickerTestApp.csproj -r win-x64` on the multi-TFM project applies win-x64 across all TFMs and re-triggers the Mono-win-x64 bug; add `-p:TargetFramework=net10.0-windows10.0.19041.0`. +- **Restore `ColorPicker.Core` again after that scoped Windows app restore.** + The scoped restore propagates through project references and replaces Core's + assets file with a single-target graph; without the final Core restore, + `--no-restore` builds fail with `NETSDK1005` for `net8.0`. - **net8 and net10 Android can't be multi-targeted in one build** — the .NET 10 Android workload only recognizes `net10.0-android`; the net8 SDK can't parse `net10.0-*`. (This is why the package went net10-only.) Environment variables: - `JAVA_HOME` — JDK 17 path (Android builds) - `UITEST_APP_PATH` — CI-only; path to the built sample `.exe` the UI tests launch -- `ColorPickerVersion` — nupkg version consumed by `samples/ConsumerSmoke` +- `ColorPickerVersion` — package version consumed by an individual smoke project +- `ColorPickerMauiVersion` / `ColorPickerCoreVersion` — package versions supplied during pack +- `ColorPickerCoreDependencyVersion` — exact version or compatible range used when packaging Picker - `PROBE_OUT` — dev-only; enables `VisualProbe` scenario dumper (off in CI) ## Build @@ -66,7 +81,12 @@ dotnet build ColorPicker\ColorPicker.csproj -c Release -f net10.0-android ### ConsumerSmoke pitfall -`samples/ConsumerSmoke/ConsumerSmoke.csproj` references the library via **PackageReference** to the locally packed nupkg, not a ProjectReference. Building it from the solution will fail locally with bogus `CS0234: 'Classes'/'Controls' does not exist in namespace 'ColorPicker'` errors — **ignore those**. Only the CI "Consumer Smoke" job builds it correctly (after Pack NuGet produces the nupkg). +All three smoke projects reference packages from the local `nupkgs/` feed, not project references, and are intentionally excluded from the solution. Build or run them only after packing both packages and registering that local feed. + +Normal development always uses the `ColorPicker` → `ColorPicker.Core` +`ProjectReference`. Package jobs set `UseCorePackageReference=true` so Picker is +compiled against the actual Core nupkg. This prevents a Picker-only release from +silently depending on unreleased Core source. ## Tests @@ -93,6 +113,13 @@ Every PR runs 6 checks (workflow `.github/workflows/build-and-test.yml`): only starts after `Pack NuGet` and re-installs the MAUI workloads, so it finishes several minutes *after* the ~12-min UI Tests. Size any CI-watch/poll loop to **~25 min**. +Every merge to `main` publishes both packages to GitHub Packages with the same +`0.0.0-preview.` version and an exact Picker → Core dependency. Workflow +retries reuse the same version. +Stable nuget.org releases are started from **Actions → Release → Run workflow**, +where the target is `picker`, `core`, or `both` and stable versions are entered +independently. + ## Branch protection / merging `main` is protected: diff --git a/ColorPicker.Core/ColorPicker.Core.csproj b/ColorPicker.Core/ColorPicker.Core.csproj index 61cc1428..036ec1f7 100644 --- a/ColorPicker.Core/ColorPicker.Core.csproj +++ b/ColorPicker.Core/ColorPicker.Core.csproj @@ -9,17 +9,47 @@ true $(NoWarn);CS1591 ColorPicker.Core - ColorPicker.Core + ColorPicker.Maui.Core + $(ColorPickerCoreVersion) + 0.0.0-local + $(Version) + + true + ColorPicker.Maui.Core + Victor Papenko + Victor Papenko + Copyright (c) $([System.DateTime]::Now.Year) Victor Papenko Platform-agnostic math for the ColorPicker.Maui library: HSL/RGB conversions, polar geometry, unit-square color-picker primitives. - colorpicker;hsl;hsv;rgb;color;math + colorpicker;color-picker;hsl;hsv;rgb;color;math;geometry + MIT + https://github.com/vpapenko/ColorPicker.Maui + https://github.com/vpapenko/ColorPicker.Maui + git + README.md + icon.png + true + snupkg + + true + true + true + true - - + + + + + + + + diff --git a/ColorPicker.Core/README.md b/ColorPicker.Core/README.md new file mode 100644 index 00000000..74ba243d --- /dev/null +++ b/ColorPicker.Core/README.md @@ -0,0 +1,50 @@ +# ColorPicker.Maui.Core + +Platform-independent color math and picker geometry used by +[ColorPicker.Maui](https://github.com/vpapenko/ColorPicker.Maui). + +The package has no MAUI or SkiaSharp dependency and targets `netstandard2.0` +and `net8.0`. + +## Install + +```bash +dotnet add package ColorPicker.Maui.Core +``` + +`ColorPicker.Maui` already references this package, so MAUI applications do not +need to install it separately. Add it directly when color conversion, normalized + +Core and the MAUI controls are developed together in the same repository through +a direct project reference. Their stable NuGet versions are independent, so a +Picker-only release does not republish unchanged Core binaries. Add it directly when color conversion, normalized +picker geometry, or interaction logic is needed in a platform-independent +project. + +## Example + +```csharp +using ColorPicker.Core; + +var hsla = new HslaColor(h: 0.58, s: 0.75, l: 0.5); +RgbaColor rgba = hsla.ToRgba(); + +var disc = new HueSaturationDisc(); +UnitPoint indicator = disc.ColorToPoint(hsla); + +var updated = disc.UpdateColor( + new UnitPoint(x: 0.8f, y: 0.25f), + hsla); +``` + +## Included APIs + +- `HslaColor`, `HsvaColor`, and `RgbaColor` value types and conversions +- normalized `UnitPoint` and `PolarPoint` geometry +- hue/saturation discs, hue and luminosity rings, and saturation/value triangles +- HSL, RGB, and alpha channel slider models +- stateful disc and triangle interaction controllers +- cycle-safe connection graphs +- DPI-aware indicator-radius calculations + +All color channels and normalized coordinates use the range `0..1`. diff --git a/ColorPicker/ColorPicker.csproj b/ColorPicker/ColorPicker.csproj index 68c2d225..0443715b 100644 --- a/ColorPicker/ColorPicker.csproj +++ b/ColorPicker/ColorPicker.csproj @@ -24,8 +24,11 @@ 10.0.17763.0 AnyCPU;x64 - + + $(ColorPickerMauiVersion) + 0.0.0-local + $(Version) true ColorPicker.Maui Victor Papenko @@ -59,8 +62,7 @@ - - + @@ -69,22 +71,18 @@ - - + + - - v - preview.0 - + + + - diff --git a/ColorPicker/CoreTypeForwarders.cs b/ColorPicker/CoreTypeForwarders.cs new file mode 100644 index 00000000..870c9b6d --- /dev/null +++ b/ColorPicker/CoreTypeForwarders.cs @@ -0,0 +1,27 @@ +using System.Runtime.CompilerServices; + +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.AlphaSlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.BlueSlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.ColorConversions))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.Connection.ConnectionGraph<>))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.GreenSlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.HslaColor))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.HslChannelSlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.HsvaColor))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.HueRing))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.HueSaturationDisc))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.HueSlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.IColorPickerArea))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.IndicatorRadius))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.Interaction.ColorDiscInteraction))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.Interaction.TriangleAreaInteraction))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.LinearTrack))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.LuminosityRing))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.LuminositySlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.PolarPoint))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.RedSlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.RgbaColor))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.RgbChannelSlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.SaturationSlider))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.SaturationValueTriangle))] +[assembly: TypeForwardedTo(typeof(global::ColorPicker.Core.UnitPoint))] diff --git a/README.md b/README.md index 667a2018..90c15fdf 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,20 @@ data-bindable — and any number of pickers can be linked so they stay in sync. dotnet add package ColorPicker.Maui ``` -> Preview builds are published to **GitHub Packages** on every push to `main`; stable -> releases go to **nuget.org**. +For platform-independent color conversion and picker geometry without MAUI or +SkiaSharp, install the companion package: + +```bash +dotnet add package ColorPicker.Maui.Core +``` + +`ColorPicker.Maui.Core` targets `netstandard2.0` and `net8.0`. It is installed +transitively by `ColorPicker.Maui`; reference it directly only when using the +core APIs from a non-MAUI project. + +> A matching preview pair is published to **GitHub Packages** on every push to +> `main`. Stable `ColorPicker.Maui` and `ColorPicker.Maui.Core` releases go to +> **nuget.org** and are versioned independently. ## Quick start diff --git a/samples/ConsumerSmoke/README.md b/samples/ConsumerSmoke/README.md index 6f82e8e1..263cf67a 100644 --- a/samples/ConsumerSmoke/README.md +++ b/samples/ConsumerSmoke/README.md @@ -4,7 +4,8 @@ Tiny MAUI class library that consumes **ColorPicker.Maui** as a `PackageReference` (not a `ProjectReference`). Its sole purpose is to be built by CI against the freshly-packed `.nupkg` so packaging regressions (missing `.targets`, broken MAUI resource glob, dropped public type, TFM -mismatch, transitive-dep hole) fail loudly before consumers ever see them. +mismatch, or a missing `ColorPicker.Maui.Core` dependency) fail loudly before +consumers ever see them. ## How it's used @@ -23,8 +24,8 @@ dotnet restore samples/ConsumerSmoke/ConsumerSmoke.csproj \ ## Run locally ```powershell -# After running `dotnet pack ColorPicker/ColorPicker.csproj -o nupkgs`: +# After packing the selected package set into nupkgs/: dotnet nuget add source (Resolve-Path ./nupkgs) -n local-colorpicker --configfile samples/ConsumerSmoke/NuGet.config -dotnet restore samples/ConsumerSmoke/ConsumerSmoke.csproj -p:ColorPickerVersion= -dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj -f net8.0-windows10.0.19041.0 +dotnet restore samples/ConsumerSmoke/ConsumerSmoke.csproj -p:ColorPickerVersion= +dotnet build samples/ConsumerSmoke/ConsumerSmoke.csproj -f net10.0-windows10.0.19041.0 ``` diff --git a/samples/ConsumerSmoke/SmokePage.xaml.cs b/samples/ConsumerSmoke/SmokePage.xaml.cs index 2404febb..7ef050ef 100644 --- a/samples/ConsumerSmoke/SmokePage.xaml.cs +++ b/samples/ConsumerSmoke/SmokePage.xaml.cs @@ -18,5 +18,9 @@ public SmokePage() _ = typeof(AlphaSlider); _ = typeof(LuminositySlider); _ = typeof(ColorDisc); + + // ColorPicker.Core is a transitive dependency of ColorPicker.Maui. + // Referencing it here catches a missing or mismatched package dependency. + _ = new ColorPicker.Core.HslaColor(0.5, 0.75, 0.5).ToRgba(); } } diff --git a/samples/CoreConsumerSmoke/CoreApiSmoke.cs b/samples/CoreConsumerSmoke/CoreApiSmoke.cs new file mode 100644 index 00000000..c2f6d96d --- /dev/null +++ b/samples/CoreConsumerSmoke/CoreApiSmoke.cs @@ -0,0 +1,30 @@ +using ColorPicker.Core; +using ColorPicker.Core.Connection; +using ColorPicker.Core.Interaction; + +namespace CoreConsumerSmoke; + +public static class CoreApiSmoke +{ + public static RgbaColor ExercisePublicApi(HslaColor color) + { + var disc = new HueSaturationDisc(); + var triangle = new SaturationValueTriangle(); + var discInteraction = new ColorDiscInteraction(); + var triangleInteraction = new TriangleAreaInteraction(); + var graph = new ConnectionGraph(); + + discInteraction.SyncFromColor(color); + triangleInteraction.SyncFromColor(color); + graph.AddEdge("disc", "triangle"); + + var discPoint = disc.ColorToPoint(color); + var trianglePoint = triangle.ColorToPoint(color); + var updated = disc.UpdateColor(discPoint, color); + updated = triangle.UpdateColor(trianglePoint, updated); + + return graph.AreConnected("disc", "triangle") + ? updated.ToRgba() + : color.ToRgba(); + } +} diff --git a/samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj b/samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj new file mode 100644 index 00000000..462a1dee --- /dev/null +++ b/samples/CoreConsumerSmoke/CoreConsumerSmoke.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0;net8.0 + latest + enable + enable + false + * + + + + + + + diff --git a/samples/CoreConsumerSmoke/README.md b/samples/CoreConsumerSmoke/README.md new file mode 100644 index 00000000..d41dcb08 --- /dev/null +++ b/samples/CoreConsumerSmoke/README.md @@ -0,0 +1,5 @@ +# CoreConsumerSmoke + +Compile-only project that consumes the packed `ColorPicker.Maui.Core` NuGet +package for both `netstandard2.0` and `net8.0`. CI uses it to catch missing +assets, dependency errors, and accidental public API removals. diff --git a/samples/PackageCompatibilitySmoke/PackageCompatibilitySmoke.csproj b/samples/PackageCompatibilitySmoke/PackageCompatibilitySmoke.csproj new file mode 100644 index 00000000..f1fdaa73 --- /dev/null +++ b/samples/PackageCompatibilitySmoke/PackageCompatibilitySmoke.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0-windows10.0.19041.0 + enable + enable + false + * + + + + + + + diff --git a/samples/PackageCompatibilitySmoke/Program.cs b/samples/PackageCompatibilitySmoke/Program.cs new file mode 100644 index 00000000..57129869 --- /dev/null +++ b/samples/PackageCompatibilitySmoke/Program.cs @@ -0,0 +1,40 @@ +using ColorPicker.Controls; +using ColorPicker.Core; + +var mauiAssembly = typeof(ColorWheel).Assembly; +var coreAssembly = typeof(HslaColor).Assembly; + +const string expectedCoreAssemblyName = "ColorPicker.Maui.Core"; +if (coreAssembly.GetName().Name != expectedCoreAssemblyName) +{ + throw new InvalidOperationException( + $"Expected Core assembly '{expectedCoreAssemblyName}', got '{coreAssembly.GetName().Name}'."); +} + +var publicCoreTypes = coreAssembly.ExportedTypes + .Select(type => type.FullName!) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); +var forwardedCoreTypes = mauiAssembly.GetForwardedTypes() + .Select(type => type.FullName!) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + +var missingForwarders = publicCoreTypes.Except(forwardedCoreTypes, StringComparer.Ordinal).ToArray(); +var unexpectedForwarders = forwardedCoreTypes.Except(publicCoreTypes, StringComparer.Ordinal).ToArray(); +if (missingForwarders.Length != 0 || unexpectedForwarders.Length != 0) +{ + throw new InvalidOperationException( + $"Core type forwarders differ. Missing: [{string.Join(", ", missingForwarders)}]. " + + $"Unexpected: [{string.Join(", ", unexpectedForwarders)}]."); +} + +var legacyType = Type.GetType($"{typeof(HslaColor).FullName}, ColorPicker", throwOnError: true)!; +if (legacyType.Assembly != coreAssembly) +{ + throw new InvalidOperationException( + $"Legacy ColorPicker type lookup resolved to '{legacyType.Assembly.GetName().Name}'."); +} + +Console.WriteLine( + $"Verified {forwardedCoreTypes.Length} Core type forwarders to {expectedCoreAssemblyName}.");