diff --git a/.github/workflows/bump-major-preview.yml b/.github/workflows/bump-major-preview.yml new file mode 100644 index 000000000..b86282df3 --- /dev/null +++ b/.github/workflows/bump-major-preview.yml @@ -0,0 +1,214 @@ +name: Bump main to next major preview + +on: + workflow_dispatch: + inputs: + next_major: + description: 'Next major version (e.g., 10) for the new preview line on main' + required: true + type: string + discard_current_preview: + description: 'Allow orphaning the current major''s preview work (no release branch will ever ship for it). Check this only when intentionally abandoning the current X.Y-preview line.' + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + +concurrency: + group: main-version-mutator + cancel-in-progress: false + +jobs: + bump: + runs-on: ubuntu-latest + env: + NEXT_MAJOR_INPUT: ${{ inputs.next_major }} + DISCARD_PREVIEW_INPUT: ${{ inputs.discard_current_preview }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + + - name: Configure git + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + - name: Validate inputs + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $next = $env:NEXT_MAJOR_INPUT + if ($next -notmatch '^(0|[1-9]\d*)$') { + throw "next_major must be a positive integer with no leading zeros (got: '$next')" + } + $nextInt = [int]$next + + git fetch --tags --force origin + if ($LASTEXITCODE -ne 0) { throw "git fetch --tags failed (exit $LASTEXITCODE); cannot validate sequential-major rule." } + + $mainHeadSha = (git rev-parse origin/main).Trim() + if ($LASTEXITCODE -ne 0 -or -not $mainHeadSha) { throw "git rev-parse origin/main failed (exit $LASTEXITCODE)." } + + $versionJson = git show "${mainHeadSha}:version.json" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "Failed to read version.json from origin/main." } + $ver = $versionJson.version + if ($ver -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)-preview') { + throw "main's version is '$ver' but should be '.-preview.{height}'" + } + $currentMajor = [int]$matches[1] + $currentMinor = [int]$matches[2] + if ($nextInt -le $currentMajor) { + throw "next_major ($nextInt) must be greater than current major ($currentMajor)" + } + + # Pre-flight: detect whether the current major's preview work would be orphaned. + # The only workflow that creates release branches is cut-major.yml, and it requires + # main to be on .0-preview. If main has moved past .0-preview without the + # major ever being cut (or has accumulated minor work past the existing release + # branch's stable minor), bumping to the next major silently loses that work. + $discardOverride = $env:DISCARD_PREVIEW_INPUT -eq 'true' + $releaseBranch = "release/$currentMajor.x" + $releaseExists = git ls-remote --heads origin "refs/heads/$releaseBranch" + if ($LASTEXITCODE -ne 0) { throw "git ls-remote for $releaseBranch failed (exit $LASTEXITCODE); cannot verify orphan state." } + + if (-not $releaseExists) { + $msg = "main is on $currentMajor.$currentMinor-preview but '$releaseBranch' does not exist. Bumping main to $nextInt would orphan ALL '$currentMajor.x' work (no stable '$currentMajor.*' would ever ship). To ship $currentMajor.0 stable first, run 'Cut major release' with major_version=$currentMajor and next_main_version=$nextInt.0. To intentionally discard the $currentMajor preview line, re-run this workflow with 'discard_current_preview' checked." + if (-not $discardOverride) { throw $msg } + Write-Host "WARNING (discard_current_preview=true): release/$currentMajor.x does not exist. All $currentMajor.x work is being orphaned." + } else { + $releaseVerJson = git show "origin/${releaseBranch}:version.json" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "Failed to read version.json from origin/$releaseBranch." } + $releaseVer = $releaseVerJson.version + if ($releaseVer -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)$') { + throw "'$releaseBranch' version is '$releaseVer'; expected stable '.' form. Cannot verify orphan state." + } + $releaseMinor = [int]$matches[2] + if ($currentMinor -gt $releaseMinor) { + $msg = "main is on $currentMajor.$currentMinor-preview but '$releaseBranch' is at $currentMajor.$releaseMinor stable. Bumping main to $nextInt would orphan the $currentMajor.$currentMinor preview work (never shipped as stable). To ship $currentMajor.$currentMinor stable first, run 'Promote main to stable minor' with target_release_branch=$releaseBranch and stable_version=$currentMajor.$currentMinor. To intentionally discard $currentMajor.$currentMinor preview, re-run this workflow with 'discard_current_preview' checked." + if (-not $discardOverride) { throw $msg } + Write-Host "WARNING (discard_current_preview=true): orphaning $currentMajor.$currentMinor preview work (release/$currentMajor.x is at $currentMajor.$releaseMinor)." + } else { + Write-Host "OK: $releaseBranch is at $currentMajor.$releaseMinor stable and main is on $currentMajor.$currentMinor-preview ($currentMinor <= $releaseMinor); no orphaning." + } + } + + $latestStable = git tag --list --sort=-v:refname | + Where-Object { $_ -match '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } | + Select-Object -First 1 + + if ($latestStable) { + if ($latestStable -notmatch '^(\d+)\.') { + throw "Could not parse latest stable tag: '$latestStable'" + } + $latestStableMajor = [int]$matches[1] + $expectedMajor = $latestStableMajor + 1 + if ($nextInt -ne $expectedMajor) { + throw "next_major ($nextInt) must be exactly one greater than the latest stable major ($latestStableMajor; tag '$latestStable'). Expected: $expectedMajor. Major versions must be incremented sequentially." + } + Write-Host "OK: next_major ($nextInt) is exactly one greater than latest stable major ($latestStableMajor)." + } else { + $expectedMajor = $currentMajor + 1 + if ($nextInt -ne $expectedMajor) { + throw "No stable tags found; falling back to comparing against main's current major ($currentMajor). next_major ($nextInt) must be exactly $expectedMajor." + } + Write-Host "No stable tags found; next_major ($nextInt) matches currentMajor+1 ($expectedMajor)." + } + + $newVersion = "$next.0-preview.{height}" + $runId = $env:GITHUB_RUN_ID + $runAttempt = $env:GITHUB_RUN_ATTEMPT + Add-Content -Path $env:GITHUB_ENV -Value "NEW_VERSION=$newVersion" + Add-Content -Path $env:GITHUB_ENV -Value "NEXT_MAJOR=$next" + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-major-$next-$runId-$runAttempt" + Add-Content -Path $env:GITHUB_ENV -Value "MAIN_HEAD_SHA=$mainHeadSha" + + - name: Create bump branch (from validated main SHA) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git checkout "$env:MAIN_HEAD_SHA" + if ($LASTEXITCODE -ne 0) { throw "git checkout failed (exit $LASTEXITCODE)." } + git checkout -b "$env:BUMP_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git checkout -b failed (exit $LASTEXITCODE)." } + + - name: Bump version.json + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEW_VERSION`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(//[^\r\n]*)?\s*(\r?\n|$)', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) + $obj = $content | ConvertFrom-Json + if ($obj.PSObject.Properties.Name -contains 'versionHeightOffset') { + throw "Failed to strip versionHeightOffset from version.json. Manual edit required." + } + + - name: Commit and push + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git add version.json + if ($LASTEXITCODE -ne 0) { throw "git add failed (exit $LASTEXITCODE)." } + git commit -m "Bump main to $env:NEW_VERSION (next major preview line)" + if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)." } + git push --force-with-lease -u origin "$env:BUMP_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)." } + + - name: Verify main hasn't moved since validation + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git fetch origin main --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)." } + $currentMainSha = (git rev-parse origin/main).Trim() + if ($currentMainSha -ne $env:MAIN_HEAD_SHA) { + throw "main moved during bump-major-preview (was $env:MAIN_HEAD_SHA, now $currentMainSha). The bump branch was pushed but no PR was created. Delete the bump branch and re-run." + } + Write-Host "OK: main is still at $env:MAIN_HEAD_SHA." + + - name: Open PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $nextMajor = $env:NEXT_MAJOR + $body = @" + Bumps ``main`` from its current preview to ``$env:NEW_VERSION`` ahead of upcoming breaking changes for ``${nextMajor}.0``. + + Merge this **before** any PR labeled ``breaking-change`` so the prereleases are correctly versioned as ``${nextMajor}.0.0-preview.N``. + "@ + try { + $url = (gh pr create ` + --base main ` + --head "$env:BUMP_BRANCH" ` + --title "Bump main to $env:NEW_VERSION (next major preview)" ` + --body $body | Select-Object -Last 1).Trim() + } catch { + throw "gh pr create failed: $($_.Exception.Message)" + } + if ($LASTEXITCODE -ne 0 -or -not $url -or $url -notmatch '^https?://') { + throw "gh pr create did not return a valid URL (exit $LASTEXITCODE; got: '$url')" + } + Add-Content -Path $env:GITHUB_ENV -Value "PR_URL=$url" + + - name: Summary + shell: pwsh + run: | + $summary = @" + ## Major preview bump + + - **PR**: $env:PR_URL + - Merge this before landing the first breaking-change PR. + "@ + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index d9cc41a40..ebafe1cf8 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -2,11 +2,10 @@ name: Build on: pull_request: - branches: [ main ] + branches: [ main, 'release/*.x' ] env: configuration: Release - productNamespacePrefix: "DynamicData" jobs: build: @@ -40,7 +39,7 @@ jobs: - name: NBGV id: nbgv - uses: dotnet/nbgv@master + uses: dotnet/nbgv@v0.5.1 with: setAllVars: true @@ -61,7 +60,7 @@ jobs: working-directory: src - name: Create NuGet Artifacts - uses: actions/upload-artifact@master + uses: actions/upload-artifact@v4.6.2 with: name: nuget - path: '**/*.nupkg' + path: 'src/**/bin/Release/*.nupkg' diff --git a/.github/workflows/cut-major.yml b/.github/workflows/cut-major.yml new file mode 100644 index 000000000..3f7d6a82a --- /dev/null +++ b/.github/workflows/cut-major.yml @@ -0,0 +1,273 @@ +name: Cut major release + +on: + workflow_dispatch: + inputs: + major_version: + description: 'Major version to ship as stable (e.g., 10). Main must currently be on this major''s preview line.' + required: true + type: string + next_main_version: + description: 'Next preview version for main as major.minor (default: .1). Use the next major if more breaking changes are queued.' + required: false + type: string + +permissions: + contents: write + pull-requests: write + actions: write + +concurrency: + group: main-version-mutator + cancel-in-progress: false + +jobs: + cut: + runs-on: ubuntu-latest + env: + MAJOR_VERSION_INPUT: ${{ inputs.major_version }} + NEXT_MAIN_VERSION_INPUT: ${{ inputs.next_main_version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + + - name: Configure git + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + - name: Validate inputs + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $major = $env:MAJOR_VERSION_INPUT + if ($major -notmatch '^(0|[1-9]\d*)$') { + throw "major_version must be a positive integer with no leading zeros (got: '$major')" + } + $cutMajor = [int]$major + + $releaseBranch = "release/$major.x" + $stableVersion = "$major.0" + + git fetch --tags --force origin + if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE); cannot proceed." } + + $mainHeadSha = (git rev-parse origin/main).Trim() + if ($LASTEXITCODE -ne 0 -or -not $mainHeadSha) { throw "git rev-parse origin/main failed (exit $LASTEXITCODE)." } + + $mainVersionJson = git show "${mainHeadSha}:version.json" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "Failed to read version.json from origin/main." } + $mainVer = $mainVersionJson.version + if ($mainVer -notmatch "^$major\.0-preview") { + throw "main's version is '$mainVer' but should be '$major.0-preview.{height}' to cut $major.0 stable. Run 'Bump main to next major preview' first if needed." + } + + # Test branch existence without --exit-code (which throws on no-match under strict mode). + $existing = git ls-remote --heads origin "refs/heads/$releaseBranch" + if ($LASTEXITCODE -ne 0) { throw "git ls-remote failed (exit $LASTEXITCODE); cannot verify branch state." } + if ($existing) { + throw "Branch '$releaseBranch' already exists. Cannot cut a new major release branch that exists." + } + + $nextMain = $env:NEXT_MAIN_VERSION_INPUT + if (-not $nextMain) { $nextMain = "$major.1" } + if ($nextMain -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)$') { + throw "next_main_version must be 'major.minor' with no leading zeros (got: '$nextMain')" + } + $nextMainMajor = [int]$matches[1] + $nextMainMinor = [int]$matches[2] + $nextMain = "$nextMainMajor.$nextMainMinor" + + if ($nextMainMajor -eq $cutMajor) { + if ($nextMainMinor -lt 1) { + throw "next_main_version ($nextMain) for the same major as the cut release must have minor >= 1. After cutting $major.0, the next main version should be $major.1 or later." + } + } elseif ($nextMainMajor -eq $cutMajor + 1) { + if ($nextMainMinor -ne 0) { + throw "next_main_version ($nextMain) for the next major must be $($cutMajor + 1).0. Use sequential major bumps (no skipping)." + } + # When advancing to the next major, verify no stable tags already exist for it. + $existingNextStable = git tag --list "$nextMainMajor.*" | Where-Object { $_ -match "^$nextMainMajor\.\d+\.\d+$" } + if ($existingNextStable) { + $list = $existingNextStable -join ', ' + throw "next_main_version ($nextMain) targets major $nextMainMajor, but stable tags already exist for it: $list. Advancing main to ${nextMain}-preview would conflict with already-shipped stables. Pick a higher version or investigate." + } + } else { + throw "next_main_version ($nextMain) must be either '$major.' (continued minors of the cut major) or '$($cutMajor + 1).0' (next sequential major). To skip a major, edit version.json by hand." + } + + $nextPreview = "$nextMain-preview.{height}" + $runId = $env:GITHUB_RUN_ID + $runAttempt = $env:GITHUB_RUN_ATTEMPT + + Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_BRANCH=$releaseBranch" + Add-Content -Path $env:GITHUB_ENV -Value "STABLE_VERSION=$stableVersion" + Add-Content -Path $env:GITHUB_ENV -Value "NEXT_PREVIEW=$nextPreview" + Add-Content -Path $env:GITHUB_ENV -Value "NEXT_MAIN_VERSION=$nextMain" + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-main-after-$stableVersion-$runId-$runAttempt" + Add-Content -Path $env:GITHUB_ENV -Value "MAIN_HEAD_SHA=$mainHeadSha" + + - name: Create main bump branch (from validated main SHA) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git checkout "$env:MAIN_HEAD_SHA" + if ($LASTEXITCODE -ne 0) { throw "git checkout failed (exit $LASTEXITCODE)." } + git checkout -b "$env:BUMP_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git checkout -b failed (exit $LASTEXITCODE)." } + + - name: Bump main to next preview + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEXT_PREVIEW`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(//[^\r\n]*)?\s*(\r?\n|$)', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) + # Verify removal worked (defends against regex misses on unusual formatting). + $obj = $content | ConvertFrom-Json + if ($obj.PSObject.Properties.Name -contains 'versionHeightOffset') { + throw "Failed to strip versionHeightOffset from version.json. Manual edit required." + } + + - name: Commit and push main bump branch + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git add version.json + if ($LASTEXITCODE -ne 0) { throw "git add failed (exit $LASTEXITCODE)." } + git commit -m "Bump main to $env:NEXT_PREVIEW after cutting $env:RELEASE_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)." } + git push --force-with-lease -u origin "$env:BUMP_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)." } + + - name: Verify main hasn't moved since validation + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git fetch origin main --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch origin main failed (exit $LASTEXITCODE)." } + $currentMainSha = (git rev-parse origin/main).Trim() + if ($LASTEXITCODE -ne 0) { throw "git rev-parse failed (exit $LASTEXITCODE)." } + if ($currentMainSha -ne $env:MAIN_HEAD_SHA) { + throw "main moved during cut-major run (was $env:MAIN_HEAD_SHA, now $currentMainSha). The bump branch was pushed but no PR was created and the release branch was NOT pushed. Delete the bump branch and re-run the workflow." + } + Write-Host "OK: main is still at $env:MAIN_HEAD_SHA." + + - name: Open main bump PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $body = @" + Companion PR to cutting ``$env:RELEASE_BRANCH``. + + Once ``$env:RELEASE_BRANCH`` is pushed it will publish the first ``$env:STABLE_VERSION.x`` stable build (first patch is ``$env:STABLE_VERSION.1``). + + This PR advances ``main`` to ``$env:NEXT_PREVIEW`` so subsequent PRs publish that preview line. + "@ + try { + $url = (gh pr create ` + --base main ` + --head "$env:BUMP_BRANCH" ` + --title "Bump main to $env:NEXT_PREVIEW after $env:RELEASE_BRANCH cut" ` + --body $body | Select-Object -Last 1).Trim() + } catch { + throw "gh pr create failed: $($_.Exception.Message)" + } + if ($LASTEXITCODE -ne 0 -or -not $url -or $url -notmatch '^https?://') { + throw "gh pr create did not return a valid URL (exit $LASTEXITCODE; got: '$url')" + } + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_PR_URL=$url" + + - name: Create release branch from validated main SHA + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git checkout "$env:MAIN_HEAD_SHA" + if ($LASTEXITCODE -ne 0) { throw "git checkout failed (exit $LASTEXITCODE)." } + git checkout -b "$env:RELEASE_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git checkout -b failed (exit $LASTEXITCODE)." } + + - name: Set stable version on release branch + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + if ($content -match '"versionHeightOffset"') { + $content = [regex]::Replace($content, '"versionHeightOffset"\s*:\s*-?\d+', '"versionHeightOffset": -1') + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") + } else { + $content = [regex]::Replace($content, '(?m)^(\s*)"version":\s*"[^"]+"\s*,?', "`$1`"version`": `"$env:STABLE_VERSION`",`r`n`$1`"versionHeightOffset`": -1,", 1) + } + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) + $obj = $content | ConvertFrom-Json + if ($obj.versionHeightOffset -ne -1) { + throw "Failed to set versionHeightOffset to -1 (got: $($obj.versionHeightOffset)). Manual edit required." + } + + - name: Commit and push release branch + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git add version.json + if ($LASTEXITCODE -ne 0) { throw "git add failed (exit $LASTEXITCODE)." } + git commit -m "Cut $env:RELEASE_BRANCH at $env:STABLE_VERSION stable" + if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)." } + git push -u origin "$env:RELEASE_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)." } + + - name: Trigger release workflow on new release branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # Wait briefly for GitHub to index the newly pushed branch so workflow_dispatch can find it. + $maxAttempts = 6 + for ($i = 1; $i -le $maxAttempts; $i++) { + try { + gh workflow run release.yml --ref "$env:RELEASE_BRANCH" 2>$null + } catch { + # swallow; we check LASTEXITCODE + } + if ($LASTEXITCODE -eq 0) { + Write-Host "release.yml dispatched for $env:RELEASE_BRANCH on attempt $i." + break + } + if ($i -eq $maxAttempts) { + throw "Failed to trigger release.yml on $env:RELEASE_BRANCH after $maxAttempts attempts. The release branch is pushed but won't publish automatically. Manually run release.yml against $env:RELEASE_BRANCH from the Actions tab." + } + Start-Sleep -Seconds 5 + } + # Verify a run actually queued. + Start-Sleep -Seconds 5 + $runs = gh run list --workflow=release.yml --branch="$env:RELEASE_BRANCH" --limit 1 --json databaseId,status,url 2>$null | ConvertFrom-Json + if (-not $runs -or $runs.Count -eq 0) { + Write-Warning "Dispatched release.yml but no run is visible yet. Check the Actions tab and verify a run starts; if not, dispatch manually." + } else { + $runUrl = $runs[0].url + Write-Host "Triggered run: $runUrl" + Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_RUN_URL=$runUrl" + } + + - name: Summary + shell: pwsh + run: | + $runLink = if ($env:RELEASE_RUN_URL) { "[release run]($env:RELEASE_RUN_URL)" } else { "the Actions tab (run not yet visible; check manually)" } + $summary = @" + ## Major release cut + + - **New release branch**: ``$env:RELEASE_BRANCH`` publishing the first ``$env:STABLE_VERSION.x`` stable build (first patch is ``$env:STABLE_VERSION.1``). See $runLink. + - **Main bump PR** (merge to advance main to next preview): $env:BUMP_PR_URL + "@ + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary diff --git a/.github/workflows/pr-version-check.yml b/.github/workflows/pr-version-check.yml new file mode 100644 index 000000000..0384f50d0 --- /dev/null +++ b/.github/workflows/pr-version-check.yml @@ -0,0 +1,132 @@ +name: PR version check + +on: + pull_request: + branches: [main, 'release/*.x'] + types: [opened, edited, labeled, unlabeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Validate version.json against PR labels and history + shell: pwsh + env: + BASE_REF: ${{ github.base_ref }} + LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + $ErrorActionPreference = 'Stop' + + # The version-bump bot PRs (opened by GITHUB_TOKEN from the automation workflows) + # legitimately change version.json. Don't trip the manual-edit guard on them; + # their content is reviewed in the workflow that opened them. + $isBotBumpPr = ($env:PR_AUTHOR -eq 'github-actions[bot]') -and ($env:HEAD_REF -like 'bot/bump-*' -or $env:HEAD_REF -like 'bot/promote-*') + + $labels = $env:LABELS_JSON | ConvertFrom-Json + $isBreaking = $labels -contains 'breaking-change' + $isManualEdit = $labels -contains 'manual-version-edit' + + # Reject any human-authored PR that touches version.json unless explicitly + # labeled 'manual-version-edit'. version.json is owned by the automation + # workflows (cut-major, promote-minor, bump-major-preview); manual edits + # bypass the regression guards and should be a deliberate, labeled exception. + if (-not $isBotBumpPr) { + $base = "origin/$env:BASE_REF" + git fetch origin $env:BASE_REF --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch origin $env:BASE_REF failed (exit $LASTEXITCODE)." } + $diff = git diff --name-only "$base...HEAD" + if ($LASTEXITCODE -ne 0) { throw "git diff against $base failed (exit $LASTEXITCODE)." } + if (($diff -split "`r?`n") -contains 'version.json' -and -not $isManualEdit) { + throw "This PR modifies version.json but is not authored by the automation bot and is not labeled 'manual-version-edit'. version.json is managed by the workflows in .github/workflows/ (cut-major, promote-minor, bump-major-preview). To override (e.g. for the one-time cutover or a recovery operation), add the 'manual-version-edit' label." + } + } + + if ($env:BASE_REF -ne 'main') { + Write-Host "PR targets '$env:BASE_REF' (not main); breaking-change check only applies to PRs targeting main. Skipping." + exit 0 + } + + if ($isBotBumpPr) { + Write-Host "PR is an automation-authored version-bump branch ('$env:HEAD_REF'); skipping breaking-change check." + exit 0 + } + + $headVersionJson = Get-Content version.json -Raw | ConvertFrom-Json + $headVer = $headVersionJson.version + if ($headVer -notmatch '^(0|[1-9]\d*)\.') { + throw "Could not parse major version from PR head version.json: '$headVer'" + } + $headMajor = [int]$matches[1] + + git fetch origin main --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch origin main failed (exit $LASTEXITCODE)." } + + $baseVersionJson = git show "origin/main:version.json" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "Failed to read version.json from origin/main." } + $baseVer = $baseVersionJson.version + if ($baseVer -notmatch '^(0|[1-9]\d*)\.') { + throw "Could not parse major version from main's version.json: '$baseVer'" + } + $baseMajor = [int]$matches[1] + + # Unconditional check: if this PR changes the major in version.json, it MUST be + # labeled breaking-change. Catches unlabeled major-version edits. + if ($headMajor -ne $baseMajor -and -not $isBreaking) { + throw "This PR changes version.json's major from $baseMajor to $headMajor but is not labeled 'breaking-change'. Major-version changes must be tagged." + } + + if (-not $isBreaking) { + Write-Host "PR is not labeled 'breaking-change' and does not change the major; no further checks." + exit 0 + } + + Write-Host "PR is labeled as breaking. Verifying the PR's version.json reflects a newer major than the latest stable release." + + git fetch --tags --force origin + if ($LASTEXITCODE -ne 0) { throw "git fetch --tags failed (exit $LASTEXITCODE); cannot safely validate breaking-change label." } + + $latestStable = git tag --list --sort=-v:refname | + Where-Object { $_ -match '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } | + Select-Object -First 1 + + if (-not $latestStable) { + # No prior stable releases. Accept either the post-bump steady state + # (head == base, both already at the new major) or the rare case where + # the breaking PR itself does the bump (head == base + 1). + if ($headMajor -eq $baseMajor -or $headMajor -eq $baseMajor + 1) { + Write-Host "No stable tags found; PR head major ($headMajor) is consistent with main ($baseMajor). OK." + exit 0 + } + throw "No stable tags found; PR head major ($headMajor) must equal or be exactly one greater than main's current major ($baseMajor) for a breaking-change PR with no prior stable history." + } + + if ($latestStable -notmatch '^(\d+)\.') { + throw "Could not parse latest stable tag: '$latestStable'" + } + $latestMajor = [int]$matches[1] + + Write-Host "Latest stable major: $latestMajor; PR head major: $headMajor" + + $expectedMajor = $latestMajor + 1 + if ($headMajor -ne $expectedMajor) { + if ($headMajor -le $latestMajor) { + $msg = "PR is labeled breaking-change but its version.json major ($headMajor) is not greater than the latest stable release major ($latestMajor; tag '$latestStable'). Run the 'Bump main to next major preview' workflow with next_major=$expectedMajor first, then rebase this PR onto the updated main." + } else { + $msg = "PR is labeled breaking-change but its version.json major ($headMajor) skips past major $expectedMajor. Main must be at exactly latest_stable_major + 1 ($latestMajor + 1 = $expectedMajor). Reset main to $expectedMajor.0-preview.{height} before merging this PR." + } + throw $msg + } + + Write-Host "OK: PR head major ($headMajor) is exactly one greater than latest stable major ($latestMajor)." diff --git a/.github/workflows/promote-minor.yml b/.github/workflows/promote-minor.yml new file mode 100644 index 000000000..e4ef65ba0 --- /dev/null +++ b/.github/workflows/promote-minor.yml @@ -0,0 +1,301 @@ +name: Promote main to stable minor + +on: + workflow_dispatch: + inputs: + target_release_branch: + description: 'Existing release branch to promote into (e.g., release/9.x)' + required: true + type: string + stable_version: + description: 'Stable version to ship as major.minor (e.g., 9.5)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: main-version-mutator + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-latest + env: + TARGET_RELEASE_BRANCH_INPUT: ${{ inputs.target_release_branch }} + STABLE_VERSION_INPUT: ${{ inputs.stable_version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + - name: Validate inputs and compute next preview + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $branch = $env:TARGET_RELEASE_BRANCH_INPUT + $version = $env:STABLE_VERSION_INPUT + + if ($branch -notmatch '^release/(0|[1-9]\d*)\.x$') { + throw "target_release_branch must match 'release/.x' with no leading zeros (got: '$branch')" + } + $branchMajor = [int]$matches[1] + + if ($version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)$') { + throw "stable_version must be 'major.minor' with no leading zeros (got: '$version')" + } + $versionMajor = [int]$matches[1] + $versionMinor = [int]$matches[2] + $version = "$versionMajor.$versionMinor" + + if ($branchMajor -ne $versionMajor) { + throw "stable_version major ($versionMajor) must match target_release_branch major ($branchMajor)" + } + + # Fetch latest refs explicitly so we don't rely on checkout-time state. + git fetch origin --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch origin failed (exit $LASTEXITCODE); cannot proceed." } + + $existing = git ls-remote --heads origin "refs/heads/$branch" + if ($LASTEXITCODE -ne 0) { throw "git ls-remote failed (exit $LASTEXITCODE); cannot verify branch state." } + if (-not $existing) { throw "Branch '$branch' does not exist on origin." } + + $branchHeadSha = (git rev-parse "origin/$branch").Trim() + if ($LASTEXITCODE -ne 0 -or -not $branchHeadSha) { throw "git rev-parse origin/$branch failed (exit $LASTEXITCODE)." } + + $branchVersionJson = git show "${branchHeadSha}:version.json" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "Failed to read version.json from origin/$branch." } + $branchVer = $branchVersionJson.version + if ($branchVer -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)$') { + throw "version.json on '$branch' is '$branchVer', which is not a clean stable '.' value. Refusing to promote on top of a non-stable release branch." + } + $branchCurrentMinor = [int]$matches[2] + if ($versionMinor -le $branchCurrentMinor) { + throw "stable_version ($version) must be greater than current version on '$branch' ($branchVer)" + } + + $mainHeadSha = (git rev-parse origin/main).Trim() + if ($LASTEXITCODE -ne 0 -or -not $mainHeadSha) { throw "git rev-parse origin/main failed (exit $LASTEXITCODE)." } + + $mainVersionJson = git show "${mainHeadSha}:version.json" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "Failed to read version.json from origin/main." } + $mainVer = $mainVersionJson.version + if ($mainVer -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)-preview') { + throw "main's version.json must be in '.-preview.{height}' form (got: '$mainVer')" + } + $mainMajor = [int]$matches[1] + $mainMinor = [int]$matches[2] + + if ($versionMajor -ne $mainMajor -or $versionMinor -ne $mainMinor) { + throw "stable_version ($version) must match main's current preview ($mainMajor.$mainMinor-preview.{height}). Promoting any other version would mislabel main's code as the wrong stable release." + } + + $nextMain = "$mainMajor.$($mainMinor + 1)-preview.{height}" + $runId = $env:GITHUB_RUN_ID + $runAttempt = $env:GITHUB_RUN_ATTEMPT + + Add-Content -Path $env:GITHUB_ENV -Value "TARGET_RELEASE_BRANCH=$branch" + Add-Content -Path $env:GITHUB_ENV -Value "STABLE_VERSION=$version" + Add-Content -Path $env:GITHUB_ENV -Value "NEXT_PREVIEW=$nextMain" + Add-Content -Path $env:GITHUB_ENV -Value "MAIN_HEAD_SHA=$mainHeadSha" + Add-Content -Path $env:GITHUB_ENV -Value "BRANCH_HEAD_SHA=$branchHeadSha" + Add-Content -Path $env:GITHUB_ENV -Value "PROMOTE_BRANCH=bot/promote-$version-$runId-$runAttempt" + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-main-after-$version-$runId-$runAttempt" + + - name: Create promotion branch (merges main into release branch) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git checkout "$env:BRANCH_HEAD_SHA" + if ($LASTEXITCODE -ne 0) { throw "git checkout of release branch SHA failed (exit $LASTEXITCODE)." } + git checkout -b "$env:PROMOTE_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git checkout -b failed (exit $LASTEXITCODE)." } + + # Plain merge first, so any unexpected conflict is surfaced loudly. + # The known-conflict case is version.json (both sides edit it). Resolve only that + # one path automatically; abort on anything else so a forgotten hotfix on the + # release branch is never silently overwritten by main. + git merge --no-commit --no-ff "$env:MAIN_HEAD_SHA" + $mergeExit = $LASTEXITCODE + if ($mergeExit -ne 0) { + $conflicts = (git diff --name-only --diff-filter=U) -split "`r?`n" | Where-Object { $_ } + if (-not $conflicts) { + git merge --abort 2>$null + throw "git merge failed (exit $mergeExit) with no conflicted files. Likely a transport or object error; check the runner log above." + } + $unexpected = $conflicts | Where-Object { $_ -ne 'version.json' } + if ($unexpected) { + $list = $unexpected -join ', ' + git merge --abort 2>$null + throw "Merge of main into $env:TARGET_RELEASE_BRANCH produced conflicts in non-version.json files: $list. This usually means a release-branch-only change conflicts with main; resolve manually." + } + # Only version.json conflicted. The next step overwrites it anyway, so take main's + # side here to keep the merge moving (the stable version is written immediately after). + git checkout --theirs version.json + if ($LASTEXITCODE -ne 0) { throw "git checkout --theirs version.json failed (exit $LASTEXITCODE)." } + git add version.json + if ($LASTEXITCODE -ne 0) { throw "git add version.json failed (exit $LASTEXITCODE)." } + } + git commit --no-edit -m "Merge main into $env:TARGET_RELEASE_BRANCH for $env:STABLE_VERSION promotion" + if ($LASTEXITCODE -ne 0) { throw "git commit failed after merge (exit $LASTEXITCODE)." } + + - name: Set stable version on promotion branch + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + if ($content -match '"versionHeightOffset"') { + $content = [regex]::Replace($content, '"versionHeightOffset"\s*:\s*-?\d+', '"versionHeightOffset": -1') + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") + } else { + $content = [regex]::Replace($content, '(?m)^(\s*)"version":\s*"[^"]+"\s*,?', "`$1`"version`": `"$env:STABLE_VERSION`",`r`n`$1`"versionHeightOffset`": -1,", 1) + } + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) + $obj = $content | ConvertFrom-Json + if ($obj.versionHeightOffset -ne -1) { + throw "Failed to set versionHeightOffset to -1 (got: $($obj.versionHeightOffset)). Manual edit required." + } + + - name: Commit and push promotion branch + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git add version.json + if ($LASTEXITCODE -ne 0) { throw "git add failed (exit $LASTEXITCODE)." } + git commit -m "Set version to $env:STABLE_VERSION (stable)" + if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)." } + git push --force-with-lease -u origin "$env:PROMOTE_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)." } + + - name: Verify branch and main still at validated SHAs + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git fetch origin --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)." } + $currentMainSha = (git rev-parse origin/main).Trim() + if ($LASTEXITCODE -ne 0 -or -not $currentMainSha) { throw "git rev-parse origin/main failed (exit $LASTEXITCODE)." } + $currentBranchSha = (git rev-parse "origin/$env:TARGET_RELEASE_BRANCH").Trim() + if ($LASTEXITCODE -ne 0 -or -not $currentBranchSha) { throw "git rev-parse origin/$env:TARGET_RELEASE_BRANCH failed (exit $LASTEXITCODE)." } + if ($currentMainSha -ne $env:MAIN_HEAD_SHA) { + throw "main moved during promotion (was $env:MAIN_HEAD_SHA, now $currentMainSha). The promotion branch was pushed but no PRs were created. Delete the promotion branch and re-run." + } + if ($currentBranchSha -ne $env:BRANCH_HEAD_SHA) { + throw "$env:TARGET_RELEASE_BRANCH moved during promotion (was $env:BRANCH_HEAD_SHA, now $currentBranchSha). The promotion branch was pushed but no PRs were created. Delete the promotion branch and re-run." + } + Write-Host "OK: refs are stable." + + - name: Open promotion PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $body = @" + Promotes ``main`` into ``$env:TARGET_RELEASE_BRANCH`` and sets ``version.json`` to ``$env:STABLE_VERSION`` stable. + + > This PR is NOT mechanical: it contains the full diff of ``main`` since the last promotion. Review accordingly. Because it is bot-authored, GitHub does not run CI on it by default; close and reopen the PR to trigger CI. + + Merge this PR **first**, then merge the companion PR that bumps ``main`` to ``$env:NEXT_PREVIEW``. + "@ + try { + $url = (gh pr create ` + --base "$env:TARGET_RELEASE_BRANCH" ` + --head "$env:PROMOTE_BRANCH" ` + --title "Promote main to $env:STABLE_VERSION stable" ` + --body $body | Select-Object -Last 1).Trim() + } catch { + throw "gh pr create failed: $($_.Exception.Message)" + } + if ($LASTEXITCODE -ne 0 -or -not $url -or $url -notmatch '^https?://') { + throw "gh pr create did not return a valid URL (exit $LASTEXITCODE; got: '$url')" + } + Add-Content -Path $env:GITHUB_ENV -Value "PROMOTE_PR_URL=$url" + + - name: Create main bump branch (from validated main SHA) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git checkout "$env:MAIN_HEAD_SHA" + if ($LASTEXITCODE -ne 0) { throw "git checkout failed (exit $LASTEXITCODE)." } + git checkout -b "$env:BUMP_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git checkout -b failed (exit $LASTEXITCODE)." } + + - name: Bump main to next preview + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEXT_PREVIEW`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(//[^\r\n]*)?\s*(\r?\n|$)', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) + $obj = $content | ConvertFrom-Json + if ($obj.PSObject.Properties.Name -contains 'versionHeightOffset') { + throw "Failed to strip versionHeightOffset from version.json. Manual edit required." + } + + - name: Commit and push main bump branch + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + git add version.json + if ($LASTEXITCODE -ne 0) { throw "git add failed (exit $LASTEXITCODE)." } + git commit -m "Bump main to $env:NEXT_PREVIEW after $env:STABLE_VERSION promotion" + if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)." } + git push --force-with-lease -u origin "$env:BUMP_BRANCH" + if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)." } + + - name: Open main bump PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $body = @" + Companion PR to the ``$env:STABLE_VERSION`` promotion. + + Bumps ``main`` to ``$env:NEXT_PREVIEW`` so subsequent PRs publish the next preview line. + + Merge this PR **after** the promotion PR: $env:PROMOTE_PR_URL + "@ + try { + $url = (gh pr create ` + --base main ` + --head "$env:BUMP_BRANCH" ` + --title "Bump main to $env:NEXT_PREVIEW after $env:STABLE_VERSION promotion" ` + --body $body | Select-Object -Last 1).Trim() + } catch { + throw "gh pr create failed: $($_.Exception.Message)" + } + if ($LASTEXITCODE -ne 0 -or -not $url -or $url -notmatch '^https?://') { + throw "gh pr create did not return a valid URL (exit $LASTEXITCODE; got: '$url')" + } + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_PR_URL=$url" + + - name: Summary + shell: pwsh + run: | + $summary = @" + ## Promotion PRs created + + | Order | PR | Purpose | + | :-: | --- | --- | + | 1 | $env:PROMOTE_PR_URL | Merge first. Promotes main to ``$env:STABLE_VERSION`` stable on ``$env:TARGET_RELEASE_BRANCH``. First published patch is ``$env:STABLE_VERSION.1``. | + | 2 | $env:BUMP_PR_URL | Merge second. Bumps main to ``$env:NEXT_PREVIEW``. | + + > Merge the bump PR (row 2) AS SOON AS the promotion PR (row 1) merges. Otherwise, the next push to main will fail the prerelease-regression guard because a stable ``$env:STABLE_VERSION.*`` tag now exists. + "@ + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f24c80fd..6ed8323c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,17 +2,23 @@ name: Build and Release on: push: - branches: [ main ] + branches: [ main, 'release/*.x' ] + workflow_dispatch: env: configuration: Release - productNamespacePrefix: "ReactiveMarbles" + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: release: runs-on: windows-latest environment: name: release + permissions: + contents: write outputs: nbgv: ${{ steps.nbgv.outputs.SemVer2 }} steps: @@ -22,6 +28,17 @@ jobs: with: fetch-depth: 0 + - name: Validate publish branch + shell: pwsh + env: + REF_NAME: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:REF_NAME -ne 'main' -and $env:REF_NAME -notmatch '^release/(0|[1-9]\d*)\.x$') { + throw "release.yml triggered on '$env:REF_NAME' which is neither 'main' nor 'release/.x'. Refusing to publish." + } + Write-Host "OK: publishing from '$env:REF_NAME'." + - name: Setup .NET (With cache) uses: actions/setup-dotnet@v5.0.1 with: @@ -41,35 +58,83 @@ jobs: - name: NBGV id: nbgv - uses: dotnet/nbgv@master + uses: dotnet/nbgv@v0.5.1 with: setAllVars: true - + + - name: Verify version matches branch policy + shell: pwsh + env: + SEMVER2: ${{ steps.nbgv.outputs.SemVer2 }} + PRERELEASE: ${{ steps.nbgv.outputs.PrereleaseVersion }} + REF_NAME: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + + if ($env:REF_NAME -eq 'main' -and -not $env:PRERELEASE) { + throw "Refusing to publish stable '$env:SEMVER2' from main. main must always publish prereleases. Check version.json for an accidental switch to a non-prerelease form." + } + + if ($env:PRERELEASE -and $env:REF_NAME -match '^release/(0|[1-9]\d*)\.x$') { + throw "Refusing to publish prerelease '$env:SEMVER2' from a release branch ('$env:REF_NAME'). Release branches must only publish stable versions." + } + + if (-not $env:PRERELEASE) { + Write-Host "Stable release ($env:SEMVER2); skipping prerelease regression check." + exit 0 + } + if ($env:SEMVER2 -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-') { + throw "Could not parse SemVer2 '$env:SEMVER2'" + } + $major = $matches[1] + $minor = $matches[2] + git fetch --tags --force origin + if ($LASTEXITCODE -ne 0) { throw "git fetch --tags failed (exit $LASTEXITCODE); cannot validate prerelease regression." } + + $majorEsc = [regex]::Escape($major) + $minorEsc = [regex]::Escape($minor) + $stableTags = git tag --list "$major.$minor.*" | Where-Object { $_ -match "^${majorEsc}\.${minorEsc}\.\d+$" } + if ($stableTags) { + $list = $stableTags -join ', ' + throw "Stable for $major.$minor has already shipped (tags: $list); cannot publish prerelease '$env:SEMVER2'. Bump main's version.json to the next minor or major preview line." + } + Write-Host "OK: no stable $major.$minor.* tag exists; '$env:SEMVER2' is safe to publish." + - name: NuGet Restore run: dotnet restore DynamicData.sln working-directory: src - - - name: Build - run: dotnet pack --no-restore --configuration Release DynamicData.sln + + - name: Run Tests + run: dotnet test --no-restore --configuration Release DynamicData.sln working-directory: src + - name: Pack + run: dotnet pack --no-restore --no-build --configuration Release DynamicData.sln + working-directory: src + + - name: NuGet Push + env: + NUGET_AUTH_TOKEN: ${{ secrets.NUGET_API_KEY }} + SOURCE_URL: https://api.nuget.org/v3/index.json + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packages = Get-ChildItem -Path src -Recurse -Filter '*.nupkg' | Where-Object { $_.FullName -like '*\bin\Release\*' } + if (-not $packages) { throw "No .nupkg files found under src/**/bin/Release/." } + foreach ($pkg in $packages) { + dotnet nuget push -s $env:SOURCE_URL -k $env:NUGET_AUTH_TOKEN --skip-duplicate $pkg.FullName + if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($pkg.Name) (exit $LASTEXITCODE)." } + } + - name: Changelog - uses: glennawatson/ChangeLog@v1 + uses: glennawatson/ChangeLog@0464dd89b26f61fecf24b41d675f8ffdb11c4c3f # v1 id: changelog - - name: Create Release - uses: actions/create-release@v1.1.4 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token + - name: Create GitHub Release + uses: softprops/action-gh-release@v2.6.2 with: tag_name: ${{ steps.nbgv.outputs.SemVer2 }} - release_name: ${{ steps.nbgv.outputs.SemVer2 }} + name: ${{ steps.nbgv.outputs.SemVer2 }} + prerelease: ${{ steps.nbgv.outputs.PrereleaseVersion != '' }} body: | ${{ steps.changelog.outputs.commitLog }} - - - name: NuGet Push - env: - NUGET_AUTH_TOKEN: ${{ secrets.NUGET_API_KEY }} - SOURCE_URL: https://api.nuget.org/v3/index.json - run: | - dotnet nuget push -s ${{ env.SOURCE_URL }} -k ${{ env.NUGET_AUTH_TOKEN }} **/*.nupkg diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..df505c751 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,94 @@ +# Releasing DynamicData + +DynamicData uses [Nerdbank.GitVersioning (NBGV)](https://github.com/dotnet/Nerdbank.GitVersioning) to compute package versions from git history. Versions are deterministic: the patch number is git height (commit count since `version.json`'s `version` field last changed). + +## Branching model + +| Branch | Purpose | Package version | +|---|---|---| +| `main` | Active development for the next minor (or major). | Pre-release: `X.Y.0-preview.N` | +| `release/.x` | Lifetime branch for an entire major version (e.g. `release/9.x` hosts all 9.x minors). | Stable: `X.Y.N` | + +Every PR merged to either branch publishes a NuGet package automatically via `.github/workflows/release.yml`. + +## How patch numbers are computed + +NBGV walks the first-parent history from `HEAD` back to the commit where `version.json`'s `version` field last changed. The count of commits is the "height". `{height}` in `version.json` is replaced with that count. + +- Two PRs merging the same day get distinct heights, no collisions. +- A `version.json` change resets height to 1. Combined with `"versionHeightOffset": -1` (written by the automation workflows on stable branches), the commit that sets a new stable version publishes as `X.Y.0` (per semver convention; previews stay at `X.Y.0-preview.1` because main does not use the offset). +- `fetch-depth: 0` in CI is required for height to be correct. + +## Automation workflows + +All `version.json` edits and release-branch creation are scripted via `workflow_dispatch` actions in the GitHub Actions UI. Maintainers should not edit `version.json` by hand. + +### Which workflow do I run? + +| Situation | Run | +|---|---| +| Ship a stable patch (e.g. `9.4.42`) | No workflow. Just PR to `release/.x` and merge. | +| Cherry-pick a fix from main to a release branch | No workflow. Cherry-pick locally and PR to `release/.x`. | +| Ship a preview | No workflow. Just PR to `main` and merge. | +| Next minor stable on an existing release branch (e.g. `9.5` after `9.4` line) | **Promote main to stable minor** | +| First stable of a new major (main has been parked on `X.0-preview`) | **Cut major release** | +| First breaking change for the next major is about to land | **Bump main to next major preview** | +| Hand-edit `version.json` (rare; recovery, manual cutover) | No workflow. Add the `manual-version-edit` label to your PR. | + +### Workflow reference + +| Workflow | Inputs | Prereq on main | Prereq on release branch | What it produces | +|---|---|---|---|---| +| **Promote main to stable minor** (`promote-minor.yml`) | `target_release_branch` (e.g. `release/9.x`); `stable_version` (e.g. `9.5`) | Main is on `.-preview` where X.Y matches `stable_version` | Branch must EXIST; current `version` must be stable `.` form; `stable_version` major must match; minor must be greater | Two PRs: (1) promotion PR with the full diff of main, sets version to `stable_version` + `versionHeightOffset: -1`; (2) companion main-bump PR moving main to `.-preview`. **Merge promotion first, then bump immediately.** | +| **Cut major release** (`cut-major.yml`) | `major_version` (required); `next_main_version` (optional; default `.1`, may also be `.0`) | Main is on `.0-preview` **strictly** (the `.0`, not `.5`) | `release/.x` must NOT exist | Two artifacts: (1) main-bump PR to `-preview`; (2) new `release/.x` at `.0` + `versionHeightOffset: -1`, with `release.yml` dispatched to publish `.0.0`. | +| **Bump main to next major preview** (`bump-major-preview.yml`) | `next_major` (required); `discard_current_preview` (optional, default false) | Main is on `.-preview`. `next_major` must equal `latest_stable_major + 1` (no skipping). Pre-flight orphan check: refuses to run if any current `` preview work hasn't been cut/promoted to stable, unless `discard_current_preview` is checked | n/a | One PR: bumps main to `.0-preview.{height}`. **Merge before landing the first breaking-change PR.** | + +Two passive guards run on every PR / release: + +| Workflow | Trigger | What it does | +|---|---|---| +| **PR version check** (`pr-version-check.yml`) | Pull request to `main` / `release/*.x`. | Rejects any human-authored PR that modifies `version.json` unless labeled `manual-version-edit` (bot PRs from `bot/bump-*` and `bot/promote-*` are exempt). For PRs to `main` labeled `breaking-change`, also enforces that main's major is exactly one greater than the latest stable tag's major (no skipping). Any PR (labeled or not) that changes the major must carry the `breaking-change` label. | +| **Prerelease regression guard** (in `release.yml`) | Every push to `main` and dispatched run on `release/*.x`. | Refuses to publish a prerelease from a `release/*.x` branch (those must be stable only). Refuses to publish a prerelease for `X.Y` if a stable `X.Y.*` tag already exists. | + +## Day-to-day flows + +### Patch on the current stable line (e.g. `9.4.38`) +Open a PR targeting `release/9.x`. Merge. `release.yml` publishes `9.4.N` to NuGet automatically. **No manual version edits.** + +### Preview of the next minor (e.g. `9.5.0-preview.42`) +Open a PR targeting `main`. Merge. `release.yml` publishes `9.5.0-preview.N`. The GitHub Release is automatically marked as a pre-release. **No manual version edits.** + +### Cherry-picking a fix from `main` to a release branch +The promote workflow merges all of `main` into the release branch. For backporting just one (or a few) commits without promoting everything: +1. `git checkout release/9.x && git pull` +2. `git checkout -b fix/backport-XYZ release/9.x` +3. `git cherry-pick ` (repeat for each commit) +4. Push and open a PR targeting `release/9.x`. Merging publishes the next patch via `release.yml`. **No manual version edits.** + +### Promoting `main` → next stable minor (e.g. shipping the first `9.5.x` stable) +1. Run the **Promote main to stable minor** workflow from the GitHub Actions tab. Inputs: `target_release_branch=release/9.x`, `stable_version=9.5`. +2. Review and merge the two PRs it creates. **The promotion PR is NOT mechanical**: it contains the full diff of `main` since the last promotion. Review it carefully. +3. Merge the promotion PR first, then **immediately** merge the main-bump PR. Don't leave the bump PR sitting: every push to `main` between the stable ship and the bump merge fails the prerelease-regression guard. +4. `release.yml` ships the first `9.5.x` patch (i.e. `9.5.1`) on the release branch; `main` resumes at `9.6-preview.{height}`. + +### Breaking change landing on `main` +1. Run the **Bump main to next major preview** workflow before merging the first breaking change. Inputs: `next_major=10` (must be exactly one greater than the latest stable major; the workflow refuses skips like `next_major=11` when stable is `9.x`). +2. Merge the PR it creates. `main` now publishes `10.0.0-preview.N`. +3. Label the breaking-change PR with `breaking-change`. The **PR version check** workflow will block it until step 2 has merged. After step 2 merges, rebase the breaking-change PR onto the updated `main` (or merge `main` into it) so the PR head includes the bumped `version.json`. Pushing an empty commit alone is not enough: the check reads `version.json` from the PR head, which is unchanged until the bump lands in the PR's branch. + +### Cutting a new major release (e.g. shipping the first `10.x` stable) +1. Run the **Cut major release** workflow. Inputs: `major_version=10`. Optional: `next_main_version=11.0` if more breaking changes are queued (the workflow refuses values that aren't `10.` or exactly `11.0`). +2. The workflow opens a main-bump PR, creates `release/10.x`, and dispatches `release.yml` against the new branch to publish the first `10.0.x` patch. The dispatched run URL is in the workflow summary. +3. Merge the main-bump PR. Don't wait: every push to `main` after the new stable ships will fail the prerelease-regression guard until this PR merges. + +### Manual escape hatch +The automation workflows are thin wrappers around `version.json` edits. If something goes wrong, you can always perform the equivalent edits by hand. See the workflow YAML files for the exact operations. Recovery scenarios: + +- **`cut-major` failed after pushing the release branch but before dispatching `release.yml`**: manually run `release.yml` against the new `release/.x` branch from the Actions tab. +- **`promote-minor` failed mid-flight**: the bot branches `bot/promote--` and `bot/bump-main-after--` carry the run ID, so a retry produces fresh branches. Delete any stale bot branches or PRs from the failed run before re-running. + +## Known limitation: bot PRs and downstream CI + +PRs opened by the automation workflows are authored by `GITHUB_TOKEN`. GitHub deliberately suppresses workflow runs triggered by this token to prevent recursive workflows, so `ci-build.yml` and `pr-version-check.yml` will **not** run on these bot-authored PRs by default. + +The main-bump PRs are mechanical (single-line `version.json` edits with no code impact). The promotion PRs from `promote-minor` carry the full diff of `main` and SHOULD be reviewed carefully, and CI should be run on them. To trigger CI on a bot PR, close and reopen it, push an empty commit, or wire the workflows to use a personal access token (PAT) stored as a repository secret. diff --git a/version.json b/version.json index 7e7de878b..6551819ae 100644 --- a/version.json +++ b/version.json @@ -1,9 +1,8 @@ { - "version": "9.4", + "version": "9.5-preview.{height}", "publicReleaseRefSpec": [ - "^refs/heads/main$", // we release out of master - "^refs/heads/preview/.*", // we release previews - "^refs/heads/rel/\\d+\\.\\d+\\.\\d+" // we also release branches starting with rel/N.N.N + "^refs/heads/main$", // main publishes pre-release packages + "^refs/heads/release/\\d+\\.x$" // release/.x branches publish stable packages ], "nugetPackageVersion": { "semVer": 2