From 8143e239bcea87cb33a8733554a04b5e1facd408 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 14:10:02 -0700 Subject: [PATCH 01/15] Add prerelease infrastructure (workflows + docs) - release.yml triggers on push to both main and release/** - ci-build.yml triggers on PRs to both main and release/** - GitHub Releases conditionally marked as prerelease via NBGV.PrereleaseVersion - Added RELEASING.md documenting the branching model and cutover sequence This is part 1 of 2. version.json is unchanged; main continues to publish 9.4.N stable until the follow-up PR bumps it to 9.5-preview.{height}. Order of operations: 1. Merge this PR. 2. Cut release/9.x from main HEAD (it now has correct workflows). 3. Merge the follow-up version.json bump PR to start main pre-releases. --- .github/workflows/ci-build.yml | 2 +- .github/workflows/release.yml | 3 +- RELEASING.md | 68 ++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 RELEASING.md diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index d9cc41a40..b2876430e 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -2,7 +2,7 @@ name: Build on: pull_request: - branches: [ main ] + branches: [ main, release/** ] env: configuration: Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f24c80fd..2b9a923b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Build and Release on: push: - branches: [ main ] + branches: [ main, release/** ] env: configuration: Release @@ -64,6 +64,7 @@ jobs: with: tag_name: ${{ steps.nbgv.outputs.SemVer2 }} release_name: ${{ steps.nbgv.outputs.SemVer2 }} + prerelease: ${{ steps.nbgv.outputs.PrereleaseVersion != '' }} body: | ${{ steps.changelog.outputs.commitLog }} diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..a7d6a3506 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,68 @@ +# 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. +- `fetch-depth: 0` in CI is required for height to be correct. + +## Day-to-day flows + +### Shipping a patch on the current stable line (e.g. `9.4.36`) +1. Open a PR targeting `release/9.x`. +2. Merge. `release.yml` publishes `9.4.N` to NuGet automatically. + +### Shipping a pre-release of the next minor (e.g. `9.5.0-preview.42`) +1. Open a PR targeting `main`. +2. Merge. `release.yml` publishes `9.5.0-preview.N` to NuGet automatically. The GitHub Release is marked as a pre-release. + +### Promoting `main` → stable on the existing release branch (e.g. 9.5) +1. Open a PR from `main` into `release/9.x`. +2. In the same PR, edit `version.json` on `release/9.x` to bump the version field from `"9.4"` → `"9.5"` (no `-preview` suffix). +3. Merge. `release.yml` publishes `9.5.0` stable. +4. On `main`, open a follow-up PR bumping `version.json` from `"9.5-preview.{height}"` to `"9.6-preview.{height}"` (or `"10.0-preview.{height}"` if breaking changes are planned next). + +### Bumping the major version (breaking changes) +When the first breaking change PR for the next major is merging to `main`, include a `version.json` bump from `"9.5-preview.{height}"` to `"10.0-preview.{height}"`. From that commit forward, `main` publishes `10.0.0-preview.N`. + +### Cutting a new release branch for a new major (e.g. `release/10.x`) +1. Create `release/10.x` from `main` at the commit you want to ship as 10.0.0. +2. Edit `version.json` on `release/10.x`: change `"version"` from `"10.0-preview.{height}"` to `"10.0"`. +3. Push. `release.yml` publishes `10.0.0` stable. +4. On `main`, open a PR bumping to the next dev version (`"10.1-preview.{height}"` or `"11.0-preview.{height}"`). + +## Cutting `release/9.x` for the first time + +This needs to happen **once**, alongside introducing this prerelease infrastructure. The recommended sequence is **two PRs** so the cutover requires no cherry-picks or version height offsets: + +### Recommended cutover sequence + +1. **PR1 — workflow + docs only** (this commit): updates `.github/workflows/*.yml` to trigger on `main` and `release/**`, adds `RELEASING.md`. Leaves `version.json` at `"9.4"`. +2. **Merge PR1** to main. Main is now publishing `9.4.N` stable (height continues from before). +3. **Cut `release/9.x`** from the post-PR1 main HEAD: `git checkout -b release/9.x main && git push -u origin release/9.x`. `release/9.x` now has the new workflows and `version.json = "9.4"` — it will publish `9.4.N` stable on every merge. +4. **PR2 — version bump only**: changes `version.json` from `"9.4"` to `"9.5-preview.{height}"`. Target: `main`. +5. **Merge PR2** to main. Main starts publishing `9.5.0-preview.N` pre-releases. + +### Alternative: single PR + versionHeightOffset + +If you prefer one PR for everything (workflow + version bump), the cutover requires a manual `versionHeightOffset` on `release/9.x` to avoid colliding with existing tags: + +1. Merge the combined PR to main. Main starts publishing `9.5.0-preview.N`. +2. Cut `release/9.x` from post-merge main HEAD. +3. On `release/9.x`, edit `version.json`: + - Set `"version"` back to `"9.4"`. + - Add `"versionHeightOffset": ` where `` is the height of the last published `9.4.N` tag (currently `9.4.35` → offset `35`). +4. Commit and push `release/9.x`. Next build produces `9.4.{1 + N + further_height}`. From d70043856641f0e6979a71b4f832b02d8793a1af Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 14:10:10 -0700 Subject: [PATCH 02/15] Bump main to 9.5-preview.{height} for pre-release publishing This is part 2 of 2. After this commit, main publishes 9.5.0-preview.N on every PR merge. The release/.x branches continue to publish stable packages. Do NOT merge this until release/9.x has been cut from main (with the workflows from part 1 in place). --- version.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/version.json b/version.json index 7e7de878b..bbfdc8b1c 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/.+" // release/.x branches publish stable packages ], "nugetPackageVersion": { "semVer": 2 From 9748a589b6ffe49f9e5b9d88b19948afeb0afc9e Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 14:55:47 -0700 Subject: [PATCH 03/15] Add release automation workflows Adds three workflow_dispatch actions that handle all version.json edits and release-branch creation, so maintainers never have to edit version.json by hand: - promote-minor.yml: promotes main to a new minor on release/.x. Opens two PRs (promotion + main bump to next preview). - cut-major.yml: creates a new release/.x branch at .0 stable and opens a PR advancing main to the next preview. - bump-major-preview.yml: bumps main to next major preview ahead of breaking changes. Adds two passive guards: - pr-version-check.yml: blocks breaking-change PRs when main hasn't been bumped past the latest stable major. - release.yml: refuses to publish a prerelease for major.minor when a stable tag for that major.minor already exists (catches the 'main forgot to bump after promotion' regression). RELEASING.md is updated to document the automated flows and notes the GITHUB_TOKEN limitation (bot PRs don't trigger downstream CI by default). --- .github/workflows/bump-major-preview.yml | 97 +++++++++++++ .github/workflows/cut-major.yml | 134 ++++++++++++++++++ .github/workflows/pr-version-check.yml | 71 ++++++++++ .github/workflows/promote-minor.yml | 171 +++++++++++++++++++++++ .github/workflows/release.yml | 27 +++- RELEASING.md | 87 +++++++----- 6 files changed, 553 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/bump-major-preview.yml create mode 100644 .github/workflows/cut-major.yml create mode 100644 .github/workflows/pr-version-check.yml create mode 100644 .github/workflows/promote-minor.yml diff --git a/.github/workflows/bump-major-preview.yml b/.github/workflows/bump-major-preview.yml new file mode 100644 index 000000000..c724b8807 --- /dev/null +++ b/.github/workflows/bump-major-preview.yml @@ -0,0 +1,97 @@ +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 + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + 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: | + $next = '${{ inputs.next_major }}' + if ($next -notmatch '^\d+$') { + throw "next_major must be a single integer (got: '$next')" + } + $nextInt = [int]$next + + $versionJson = Get-Content version.json -Raw | ConvertFrom-Json + $ver = $versionJson.version + if ($ver -notmatch '^(\d+)\.\d+-preview') { + throw "main's version is '$ver' but should be '.-preview.{height}'" + } + $currentMajor = [int]$matches[1] + if ($nextInt -le $currentMajor) { + throw "next_major ($nextInt) must be greater than current major ($currentMajor)" + } + + $newVersion = "$next.0-preview.{height}" + 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" + + - name: Create bump branch + run: git checkout -b "$BUMP_BRANCH" + + - name: Bump version.json + shell: pwsh + run: | + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEW_VERSION`"") + [System.IO.File]::WriteAllText($path, $new) + + - name: Commit and push + run: | + git add version.json + git commit -m "Bump main to $NEW_VERSION (next major preview line)" + git push -u origin "$BUMP_BRANCH" + + - name: Open PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $body = @" + Bumps ``main`` from its current preview to ``$env:NEW_VERSION`` ahead of upcoming breaking changes for ``$env:NEXT_MAJOR.0``. + + Merge this **before** any PR labeled ``breaking-change`` so the prereleases are correctly versioned as ``$env:NEXT_MAJOR.0.0-preview.N``. + "@ + $url = gh pr create ` + --base main ` + --head "$env:BUMP_BRANCH" ` + --title "Bump main to $env:NEW_VERSION (next major preview)" ` + --body $body + 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/cut-major.yml b/.github/workflows/cut-major.yml new file mode 100644 index 000000000..2d827599a --- /dev/null +++ b/.github/workflows/cut-major.yml @@ -0,0 +1,134 @@ +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 + +jobs: + cut: + runs-on: ubuntu-latest + 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 + shell: pwsh + run: | + $major = '${{ inputs.major_version }}' + if ($major -notmatch '^\d+$') { + throw "major_version must be a single integer (got: '$major')" + } + + $releaseBranch = "release/$major.x" + $stableVersion = "$major.0" + + $mainVersionJson = git show "origin/main:version.json" | ConvertFrom-Json + $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." + } + + $existing = git ls-remote --heads origin "refs/heads/$releaseBranch" + if ($existing) { + throw "Branch '$releaseBranch' already exists. Cannot cut a new major release branch that exists." + } + + $nextMain = '${{ inputs.next_main_version }}' + if (-not $nextMain) { $nextMain = "$major.1" } + if ($nextMain -notmatch '^(\d+)\.(\d+)$') { + throw "next_main_version must be 'major.minor' (got: '$nextMain')" + } + $nextPreview = "$nextMain-preview.{height}" + + 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" + + - name: Create release branch from main + run: | + git checkout main + git checkout -b "$RELEASE_BRANCH" + + - name: Set stable version on release branch + shell: pwsh + run: | + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") + [System.IO.File]::WriteAllText($path, $new) + + - name: Commit and push release branch + run: | + git add version.json + git commit -m "Cut $RELEASE_BRANCH at $STABLE_VERSION stable" + git push -u origin "$RELEASE_BRANCH" + + - name: Create main bump branch + run: | + git checkout main + git checkout -b "$BUMP_BRANCH" + + - name: Bump main to next preview + shell: pwsh + run: | + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEXT_PREVIEW`"") + [System.IO.File]::WriteAllText($path, $new) + + - name: Commit and push main bump branch + run: | + git add version.json + git commit -m "Bump main to $NEXT_PREVIEW after cutting $RELEASE_BRANCH" + git push -u origin "$BUMP_BRANCH" + + - name: Open main bump PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $body = @" + Companion PR to cutting ``$env:RELEASE_BRANCH``. + + The release branch ``$env:RELEASE_BRANCH`` has been pushed and will publish ``$env:STABLE_VERSION.0`` stable. + + This PR advances ``main`` to ``$env:NEXT_PREVIEW`` so subsequent PRs publish that preview line. + "@ + $url = gh pr create ` + --base main ` + --head "$env:BUMP_BRANCH" ` + --title "Bump main to $env:NEXT_PREVIEW after $env:RELEASE_BRANCH cut" ` + --body $body + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_PR_URL=$url" + + - name: Summary + shell: pwsh + run: | + $summary = @" + ## Major release cut + + - **New release branch**: ``$env:RELEASE_BRANCH`` (will publish ``$env:STABLE_VERSION.0`` stable on push). + - **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..5d1ef1e1f --- /dev/null +++ b/.github/workflows/pr-version-check.yml @@ -0,0 +1,71 @@ +name: PR version check + +on: + pull_request: + branches: [main, release/**] + 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) }} + run: | + $labels = $env:LABELS_JSON | ConvertFrom-Json + $isBreaking = ($labels -contains 'breaking-change') -or ($labels -contains 'semver:major') + + if (-not $isBreaking) { + Write-Host "PR is not labeled 'breaking-change' or 'semver:major'; no major-bump check needed." + exit 0 + } + + Write-Host "PR is labeled as breaking. Verifying main's version.json reflects a newer major than the latest stable release." + + git fetch origin $env:BASE_REF --depth=1 2>$null + $baseVersionJson = git show "origin/${env:BASE_REF}:version.json" | ConvertFrom-Json + $baseVer = $baseVersionJson.version + if ($baseVer -notmatch '^(\d+)\.') { + Write-Error "::error file=version.json::Could not parse major version from base version.json: '$baseVer'" + exit 1 + } + $baseMajor = [int]$matches[1] + + git fetch --tags --force 2>$null + $latestStable = git tag --list --sort=-v:refname | + Where-Object { $_ -match '^(\d+)\.\d+\.\d+$' } | + Select-Object -First 1 + + if (-not $latestStable) { + Write-Host "No stable tags found; cannot determine latest released major. Skipping check." + exit 0 + } + + if ($latestStable -notmatch '^(\d+)\.') { + Write-Error "Could not parse latest stable tag: '$latestStable'" + exit 1 + } + $latestMajor = [int]$matches[1] + + Write-Host "Latest stable major: $latestMajor; current main major: $baseMajor" + + if ($baseMajor -le $latestMajor) { + $msg = "PR is labeled breaking-change but main's major ($baseMajor) is not greater than the latest stable release major ($latestMajor; tag '$latestStable'). Run the 'Bump main to next major preview' workflow first, then re-target this PR." + Write-Error "::error file=version.json::$msg" + exit 1 + } + + Write-Host "OK: main major ($baseMajor) > latest stable major ($latestMajor)." diff --git a/.github/workflows/promote-minor.yml b/.github/workflows/promote-minor.yml new file mode 100644 index 000000000..6414920cf --- /dev/null +++ b/.github/workflows/promote-minor.yml @@ -0,0 +1,171 @@ +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 + +jobs: + promote: + runs-on: ubuntu-latest + 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: | + $branch = '${{ inputs.target_release_branch }}' + $version = '${{ inputs.stable_version }}' + + if ($branch -notmatch '^release/(\d+)\.x$') { + throw "target_release_branch must match 'release/.x' (got: '$branch')" + } + $branchMajor = [int]$matches[1] + + if ($version -notmatch '^(\d+)\.(\d+)$') { + throw "stable_version must be 'major.minor' (got: '$version')" + } + $versionMajor = [int]$matches[1] + $versionMinor = [int]$matches[2] + + if ($branchMajor -ne $versionMajor) { + throw "stable_version major ($versionMajor) must match target_release_branch major ($branchMajor)" + } + + git fetch origin $branch 2>$null + if ($LASTEXITCODE -ne 0) { throw "Branch '$branch' does not exist on origin" } + + $branchVersionJson = git show "origin/${branch}:version.json" | ConvertFrom-Json + $branchVer = $branchVersionJson.version + if ($branchVer -notmatch '^(\d+)\.(\d+)') { + throw "Could not parse current version on '$branch': '$branchVer'" + } + $branchCurrentMinor = [int]$matches[2] + if ($versionMinor -le $branchCurrentMinor) { + throw "stable_version ($version) must be greater than current version on '$branch' ($branchVer)" + } + + $mainVersionJson = git show "origin/main:version.json" | ConvertFrom-Json + $mainVer = $mainVersionJson.version + if ($mainVer -notmatch '^(\d+)\.(\d+)-preview') { + throw "main's version.json must be in '.-preview.{height}' form (got: '$mainVer')" + } + $mainMajor = [int]$matches[1] + $mainMinor = [int]$matches[2] + + $nextMain = "$mainMajor.$($mainMinor + 1)-preview.{height}" + + 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 "PROMOTE_BRANCH=bot/promote-$version" + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-main-after-$version" + + - name: Create promotion branch (merges main into release branch) + run: | + git checkout "$TARGET_RELEASE_BRANCH" + git checkout -b "$PROMOTE_BRANCH" + git merge origin/main --no-edit + + - name: Set stable version on promotion branch + shell: pwsh + run: | + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") + [System.IO.File]::WriteAllText($path, $new) + + - name: Commit and push promotion branch + run: | + git add version.json + git commit -m "Set version to $STABLE_VERSION (stable)" + git push -u origin "$PROMOTE_BRANCH" + + - name: Open promotion PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $body = @" + Promotes ``main`` into ``$env:TARGET_RELEASE_BRANCH`` and sets ``version.json`` to ``$env:STABLE_VERSION`` stable. + + Merge this PR **first**, then merge the companion PR that bumps ``main`` to ``$env:NEXT_PREVIEW``. + "@ + $url = gh pr create ` + --base "$env:TARGET_RELEASE_BRANCH" ` + --head "$env:PROMOTE_BRANCH" ` + --title "Promote main to $env:STABLE_VERSION.0 stable" ` + --body $body + Add-Content -Path $env:GITHUB_ENV -Value "PROMOTE_PR_URL=$url" + + - name: Create main bump branch + run: | + git fetch origin main + git checkout main + git pull --ff-only origin main + git checkout -b "$BUMP_BRANCH" + + - name: Bump main to next preview + shell: pwsh + run: | + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEXT_PREVIEW`"") + [System.IO.File]::WriteAllText($path, $new) + + - name: Commit and push main bump branch + run: | + git add version.json + git commit -m "Bump main to $NEXT_PREVIEW after $STABLE_VERSION promotion" + git push -u origin "$BUMP_BRANCH" + + - name: Open main bump PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $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 + "@ + $url = gh pr create ` + --base main ` + --head "$env:BUMP_BRANCH" ` + --title "Bump main to $env:NEXT_PREVIEW after $env:STABLE_VERSION promotion" ` + --body $body + 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``.0 stable on ``$env:TARGET_RELEASE_BRANCH``. | + | 2 | $env:BUMP_PR_URL | Merge second. Bumps main to ``$env:NEXT_PREVIEW``. | + "@ + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b9a923b3..b720c2860 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,32 @@ jobs: uses: dotnet/nbgv@master with: setAllVars: true - + + - name: Verify prerelease does not regress past stable + shell: pwsh + env: + SEMVER2: ${{ steps.nbgv.outputs.SemVer2 }} + PRERELEASE: ${{ steps.nbgv.outputs.PrereleaseVersion }} + run: | + if (-not $env:PRERELEASE) { + Write-Host "Stable release ($env:SEMVER2); skipping prerelease regression check." + exit 0 + } + if ($env:SEMVER2 -notmatch '^(\d+)\.(\d+)\.\d+-') { + throw "Could not parse SemVer2 '$env:SEMVER2'" + } + $major = $matches[1] + $minor = $matches[2] + git fetch --tags --force 2>$null + $stableTags = git tag --list "$major.$minor.*" | Where-Object { $_ -match "^$major\.$minor\.\d+$" } + if ($stableTags) { + $list = $stableTags -join ', ' + $msg = "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-Error "::error::$msg" + exit 1 + } + 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 diff --git a/RELEASING.md b/RELEASING.md index a7d6a3506..e0588b907 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -19,50 +19,71 @@ NBGV walks the first-parent history from `HEAD` back to the commit where `versio - A `version.json` change resets height to 1. - `fetch-depth: 0` in CI is required for height to be correct. -## Day-to-day flows +## 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. + +| Workflow | When to run | What it does | +|---|---|---| +| **Promote main to stable minor** (`promote-minor.yml`) | Ready to ship the next minor (e.g. `9.5.0`) from `main` to an existing release branch. | Opens two PRs: (1) merges `main` into `release/.x` and sets stable version; (2) bumps `main` to the next preview minor. | +| **Cut major release** (`cut-major.yml`) | Ready to ship a new major as stable from `main` (e.g. `10.0.0`). | Creates a new `release/.x` branch with stable version, then opens a PR to advance `main` to the next preview. | +| **Bump main to next major preview** (`bump-major-preview.yml`) | First breaking change is about to land on `main`. | Opens a PR bumping `main` from `.Y-preview.{height}` to `.0-preview.{height}`. | + +Two passive guards run on every PR / release: -### Shipping a patch on the current stable line (e.g. `9.4.36`) -1. Open a PR targeting `release/9.x`. -2. Merge. `release.yml` publishes `9.4.N` to NuGet automatically. +| Workflow | Trigger | What it does | +|---|---|---| +| **PR version check** (`pr-version-check.yml`) | Pull request to `main` / `release/**`. | If the PR is labeled `breaking-change` or `semver:major`, fails the check unless `main`'s major is already greater than the latest stable tag. | +| **Prerelease regression guard** (in `release.yml`) | Every push to `main`. | Fails the publish step if a stable `X.Y.*` tag already exists for the major.minor being prereleased. | -### Shipping a pre-release of the next minor (e.g. `9.5.0-preview.42`) -1. Open a PR targeting `main`. -2. Merge. `release.yml` publishes `9.5.0-preview.N` to NuGet automatically. The GitHub Release is marked as a pre-release. +## Day-to-day flows -### Promoting `main` → stable on the existing release branch (e.g. 9.5) -1. Open a PR from `main` into `release/9.x`. -2. In the same PR, edit `version.json` on `release/9.x` to bump the version field from `"9.4"` → `"9.5"` (no `-preview` suffix). -3. Merge. `release.yml` publishes `9.5.0` stable. -4. On `main`, open a follow-up PR bumping `version.json` from `"9.5-preview.{height}"` to `"9.6-preview.{height}"` (or `"10.0-preview.{height}"` if breaking changes are planned next). +### Patch on the current stable line (e.g. `9.4.36`) +Open a PR targeting `release/9.x`. Merge. `release.yml` publishes `9.4.N` to NuGet automatically. **No manual version edits.** -### Bumping the major version (breaking changes) -When the first breaking change PR for the next major is merging to `main`, include a `version.json` bump from `"9.5-preview.{height}"` to `"10.0-preview.{height}"`. From that commit forward, `main` publishes `10.0.0-preview.N`. +### 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.** -### Cutting a new release branch for a new major (e.g. `release/10.x`) -1. Create `release/10.x` from `main` at the commit you want to ship as 10.0.0. -2. Edit `version.json` on `release/10.x`: change `"version"` from `"10.0-preview.{height}"` to `"10.0"`. -3. Push. `release.yml` publishes `10.0.0` stable. -4. On `main`, open a PR bumping to the next dev version (`"10.1-preview.{height}"` or `"11.0-preview.{height}"`). +### Promoting `main` → next stable minor (e.g. shipping `9.5.0`) +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 (promotion PR first, main-bump PR second). +3. `release.yml` ships `9.5.0` stable on the release branch; `main` continues at `9.6-preview.{height}`. -## Cutting `release/9.x` for the first time +### 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`. +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. -This needs to happen **once**, alongside introducing this prerelease infrastructure. The recommended sequence is **two PRs** so the cutover requires no cherry-picks or version height offsets: +### Cutting a new major release (e.g. shipping `10.0.0`) +1. Run the **Cut major release** workflow. Inputs: `major_version=10`. Optional: `next_main_version=11.0` if more breaking changes are queued. +2. The workflow creates `release/10.x` directly (publishes `10.0.0` stable) and opens a PR to advance `main`. +3. Merge the main-bump PR. -### Recommended cutover sequence +### 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. -1. **PR1 — workflow + docs only** (this commit): updates `.github/workflows/*.yml` to trigger on `main` and `release/**`, adds `RELEASING.md`. Leaves `version.json` at `"9.4"`. -2. **Merge PR1** to main. Main is now publishing `9.4.N` stable (height continues from before). -3. **Cut `release/9.x`** from the post-PR1 main HEAD: `git checkout -b release/9.x main && git push -u origin release/9.x`. `release/9.x` now has the new workflows and `version.json = "9.4"` — it will publish `9.4.N` stable on every merge. -4. **PR2 — version bump only**: changes `version.json` from `"9.4"` to `"9.5-preview.{height}"`. Target: `main`. -5. **Merge PR2** to main. Main starts publishing `9.5.0-preview.N` pre-releases. +## Initial cutover (one-time, when introducing this infrastructure) -### Alternative: single PR + versionHeightOffset +This is **the only time manual steps are required**, because release/9.x does not yet exist when this PR merges. -If you prefer one PR for everything (workflow + version bump), the cutover requires a manual `versionHeightOffset` on `release/9.x` to avoid colliding with existing tags: +After this PR merges to `main`: -1. Merge the combined PR to main. Main starts publishing `9.5.0-preview.N`. -2. Cut `release/9.x` from post-merge main HEAD. +1. `main` will start publishing `9.5.0-preview.N` (the version bump in this PR took effect). +2. Cut `release/9.x` from main HEAD: + ```sh + git fetch origin + git checkout -b release/9.x origin/main + ``` 3. On `release/9.x`, edit `version.json`: - Set `"version"` back to `"9.4"`. - - Add `"versionHeightOffset": ` where `` is the height of the last published `9.4.N` tag (currently `9.4.35` → offset `35`). -4. Commit and push `release/9.x`. Next build produces `9.4.{1 + N + further_height}`. + - Add `"versionHeightOffset": 36` (the height main was at just before this PR merged — needed so the next build doesn't collide with the existing `9.4.35` tag). +4. Commit and push `release/9.x`. The next build produces `9.4.37` stable. + +From this point on, all subsequent releases use the automation workflows above. No more manual `version.json` edits. + +## 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 version-bump PRs are mechanical (single-line `version.json` edits with no code impact), so this is usually fine. If you want CI checks to run on them, close and reopen the PR once, or push an empty commit, or wire the workflows to use a personal access token (PAT) stored as a repository secret. + From 0e5da59ca5a13081c1a8fd9b92903a06b349a7de Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 14:58:26 -0700 Subject: [PATCH 04/15] Enforce exactly-one-greater rule for major bumps Prevents accidentally skipping a major version (e.g., bumping from 9.x stable to 11.0-preview, leaving 10 unshipped). - pr-version-check.yml: a breaking-change PR now fails unless main's major is exactly latest_stable_major + 1. Previously allowed any major > latest_stable_major. - bump-major-preview.yml: rejects next_major inputs that aren't exactly latest_stable_major + 1. Workflow input validation makes this fail-fast at dispatch time. - RELEASING.md: notes the strict sequential-major rule. Skipping a major is still possible by editing version.json by hand (both checks reference workflow names, not version.json content). --- .github/workflows/bump-major-preview.yml | 18 ++++++++++++++++++ .github/workflows/pr-version-check.yml | 11 ++++++++--- RELEASING.md | 4 ++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bump-major-preview.yml b/.github/workflows/bump-major-preview.yml index c724b8807..31a137944 100644 --- a/.github/workflows/bump-major-preview.yml +++ b/.github/workflows/bump-major-preview.yml @@ -46,6 +46,24 @@ jobs: throw "next_major ($nextInt) must be greater than current major ($currentMajor)" } + git fetch --tags --force 2>$null + $latestStable = git tag --list --sort=-v:refname | + Where-Object { $_ -match '^(\d+)\.\d+\.\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 to avoid accidentally skipping a major." + } + Write-Host "OK: next_major ($nextInt) is exactly one greater than latest stable major ($latestStableMajor)." + } else { + Write-Host "No stable tags found; skipping sequential-major check." + } + $newVersion = "$next.0-preview.{height}" Add-Content -Path $env:GITHUB_ENV -Value "NEW_VERSION=$newVersion" Add-Content -Path $env:GITHUB_ENV -Value "NEXT_MAJOR=$next" diff --git a/.github/workflows/pr-version-check.yml b/.github/workflows/pr-version-check.yml index 5d1ef1e1f..080e4e18c 100644 --- a/.github/workflows/pr-version-check.yml +++ b/.github/workflows/pr-version-check.yml @@ -62,10 +62,15 @@ jobs: Write-Host "Latest stable major: $latestMajor; current main major: $baseMajor" - if ($baseMajor -le $latestMajor) { - $msg = "PR is labeled breaking-change but main's major ($baseMajor) is not greater than the latest stable release major ($latestMajor; tag '$latestStable'). Run the 'Bump main to next major preview' workflow first, then re-target this PR." + $expectedMajor = $latestMajor + 1 + if ($baseMajor -ne $expectedMajor) { + if ($baseMajor -le $latestMajor) { + $msg = "PR is labeled breaking-change but main's major ($baseMajor) 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 re-target this PR." + } else { + $msg = "PR is labeled breaking-change but main's major ($baseMajor) skips past major $expectedMajor. Main must be at exactly one greater than the latest stable major ($latestMajor + 1 = $expectedMajor). Major versions must be incremented sequentially. Reset main to $expectedMajor.0-preview.{height} before merging this PR." + } Write-Error "::error file=version.json::$msg" exit 1 } - Write-Host "OK: main major ($baseMajor) > latest stable major ($latestMajor)." + Write-Host "OK: main major ($baseMajor) is exactly one greater than latest stable major ($latestMajor)." diff --git a/RELEASING.md b/RELEASING.md index e0588b907..687510011 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -33,7 +33,7 @@ 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/**`. | If the PR is labeled `breaking-change` or `semver:major`, fails the check unless `main`'s major is already greater than the latest stable tag. | +| **PR version check** (`pr-version-check.yml`) | Pull request to `main` / `release/**`. | If the PR is labeled `breaking-change` or `semver:major`, fails the check unless `main`'s major is **exactly one greater** than the latest stable tag's major (no skipping). | | **Prerelease regression guard** (in `release.yml`) | Every push to `main`. | Fails the publish step if a stable `X.Y.*` tag already exists for the major.minor being prereleased. | ## Day-to-day flows @@ -50,7 +50,7 @@ Open a PR targeting `main`. Merge. `release.yml` publishes `9.5.0-preview.N`. Th 3. `release.yml` ships `9.5.0` stable on the release branch; `main` continues 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`. +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. From d35f0bccf7907d162b1668ab73e03c988dff9dea Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 15:04:50 -0700 Subject: [PATCH 05/15] Update versionHeightOffset for cutover after 9.4.37 ship main has shipped 9.4.37 since this branch was opened. The previously documented versionHeightOffset of 36 would produce 9.4.37 again (collision). Updated to 37 so the first build on release/9.x produces 9.4.38, continuing sequentially. --- RELEASING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 687510011..78b6d0b3b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -38,7 +38,7 @@ Two passive guards run on every PR / release: ## Day-to-day flows -### Patch on the current stable line (e.g. `9.4.36`) +### 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`) @@ -76,8 +76,8 @@ After this PR merges to `main`: ``` 3. On `release/9.x`, edit `version.json`: - Set `"version"` back to `"9.4"`. - - Add `"versionHeightOffset": 36` (the height main was at just before this PR merged — needed so the next build doesn't collide with the existing `9.4.35` tag). -4. Commit and push `release/9.x`. The next build produces `9.4.37` stable. + - Add `"versionHeightOffset": 37` (the height of the latest published stable tag `9.4.37`, so the next build doesn't collide with it). +4. Commit and push `release/9.x`. The next build produces `9.4.38` stable. From this point on, all subsequent releases use the automation workflows above. No more manual `version.json` edits. From e8465e187fce31eb9c933b14150202e5f54ff5c7 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 15:07:24 -0700 Subject: [PATCH 06/15] Modernize release workflow - Replace archived actions/create-release@v1.1.4 with softprops/action-gh-release@v2. The release_name input was renamed to name; GITHUB_TOKEN is now implicit via the contents:write permission on the job. - Add --skip-duplicate to dotnet nuget push so workflow re-runs are idempotent when a package version was already uploaded. - Standardize productNamespacePrefix to DynamicData (was inconsistently 'ReactiveMarbles' in release.yml vs 'DynamicData' in ci-build.yml). - Add explicit permissions: contents: write at the job level, required by softprops/action-gh-release to create the GitHub release and tag. --- .github/workflows/release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b720c2860..40f4941d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,13 +6,15 @@ on: env: configuration: Release - productNamespacePrefix: "ReactiveMarbles" + productNamespacePrefix: "DynamicData" jobs: release: runs-on: windows-latest environment: name: release + permissions: + contents: write outputs: nbgv: ${{ steps.nbgv.outputs.SemVer2 }} steps: @@ -83,12 +85,10 @@ jobs: 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 + uses: softprops/action-gh-release@v2 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 }} @@ -98,4 +98,4 @@ jobs: 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 + dotnet nuget push -s ${{ env.SOURCE_URL }} -k ${{ env.NUGET_AUTH_TOKEN }} --skip-duplicate **/*.nupkg From 124e51d2621bb951d7b7e5dae77688090105fa22 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 16:42:35 -0700 Subject: [PATCH 07/15] Address Copilot review feedback - version.json publicReleaseRefSpec: tighten 'release/.+' to 'release/\\d+\\.x' so accidental branches like 'release/test' don't get treated as public releases. - release.yml branch trigger: narrow from 'release/**' to 'release/*.x' and add an explicit branch-name validation step that refuses to publish from anything other than main or 'release/.x'. - ci-build.yml and pr-version-check.yml: same narrower trigger for consistency (no behavioural impact, just stops them firing on malformed release branch names). - promote-minor.yml: validate that stable_version matches main's current preview major.minor, so 'stable_version=9.6' while main is '9.5-preview' is rejected instead of silently mislabeling code. - promote-minor.yml: when setting the new stable version, also remove any existing versionHeightOffset so the new minor starts fresh at X.Y.1 instead of inheriting the previous minor's offset (which would otherwise produce 9.5.38 on the first 9.5 build given the cutover offset of 37). - cut-major.yml: tighten next_main_version validation. After cutting major X, the next main version must be either X.=1+ (same major continuation) or (X+1).0 (next sequential major). Skips like cutting 10.0 with next_main_version=12.0 are rejected. - cut-major.yml: also strip versionHeightOffset on the new release branch, defensively (main shouldn't have it, but in case it does). Verified the versionHeightOffset removal regex produces valid JSON in all three cases: offset in middle, offset at end, offset absent. --- .github/workflows/ci-build.yml | 2 +- .github/workflows/cut-major.yml | 22 ++++++++++++++++++++-- .github/workflows/pr-version-check.yml | 2 +- .github/workflows/promote-minor.yml | 10 ++++++++-- .github/workflows/release.yml | 12 +++++++++++- version.json | 2 +- 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index b2876430e..fb440f6e5 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -2,7 +2,7 @@ name: Build on: pull_request: - branches: [ main, release/** ] + branches: [ main, 'release/*.x' ] env: configuration: Release diff --git a/.github/workflows/cut-major.yml b/.github/workflows/cut-major.yml index 2d827599a..e9e3058ad 100644 --- a/.github/workflows/cut-major.yml +++ b/.github/workflows/cut-major.yml @@ -57,6 +57,22 @@ jobs: if ($nextMain -notmatch '^(\d+)\.(\d+)$') { throw "next_main_version must be 'major.minor' (got: '$nextMain')" } + $nextMainMajor = [int]$matches[1] + $nextMainMinor = [int]$matches[2] + $cutMajor = [int]$major + + 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)." + } + } 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}" Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_BRANCH=$releaseBranch" @@ -75,8 +91,10 @@ jobs: run: | $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) - $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") - [System.IO.File]::WriteAllText($path, $new) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*\r?\n', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) - name: Commit and push release branch run: | diff --git a/.github/workflows/pr-version-check.yml b/.github/workflows/pr-version-check.yml index 080e4e18c..8c6234e22 100644 --- a/.github/workflows/pr-version-check.yml +++ b/.github/workflows/pr-version-check.yml @@ -2,7 +2,7 @@ name: PR version check on: pull_request: - branches: [main, release/**] + branches: [main, 'release/*.x'] types: [opened, edited, labeled, unlabeled, synchronize, reopened] permissions: diff --git a/.github/workflows/promote-minor.yml b/.github/workflows/promote-minor.yml index 6414920cf..b2760c716 100644 --- a/.github/workflows/promote-minor.yml +++ b/.github/workflows/promote-minor.yml @@ -72,6 +72,10 @@ jobs: $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. To ship a different version, run 'Bump main to next major preview' first or wait for main to be at the version you want to promote." + } + $nextMain = "$mainMajor.$($mainMinor + 1)-preview.{height}" Add-Content -Path $env:GITHUB_ENV -Value "TARGET_RELEASE_BRANCH=$branch" @@ -91,8 +95,10 @@ jobs: run: | $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) - $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") - [System.IO.File]::WriteAllText($path, $new) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*\r?\n', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) - name: Commit and push promotion branch run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40f4941d3..74894292f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Build and Release on: push: - branches: [ main, release/** ] + branches: [ main, 'release/*.x' ] env: configuration: Release @@ -24,6 +24,16 @@ jobs: with: fetch-depth: 0 + - name: Validate publish branch + shell: pwsh + env: + REF_NAME: ${{ github.ref_name }} + run: | + if ($env:REF_NAME -ne 'main' -and $env:REF_NAME -notmatch '^release/\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: diff --git a/version.json b/version.json index bbfdc8b1c..6551819ae 100644 --- a/version.json +++ b/version.json @@ -2,7 +2,7 @@ "version": "9.5-preview.{height}", "publicReleaseRefSpec": [ "^refs/heads/main$", // main publishes pre-release packages - "^refs/heads/release/.+" // release/.x branches publish stable packages + "^refs/heads/release/\\d+\\.x$" // release/.x branches publish stable packages ], "nugetPackageVersion": { "semVer": 2 From 6bc57b205f2de7b0fd922802c8b381e7ec1db5c1 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 17:19:25 -0700 Subject: [PATCH 08/15] Address multi-agent adversarial review findings Reviewers: 6 (Claude Opus xhigh x2, Claude Sonnet x2, GPT-5.5 x2). ~99 raw findings consolidated; filtered to those with consensus or high-impact failure scenarios. Security and correctness: - All workflow_dispatch inputs now route through env: instead of being interpolated as inputs expressions into PowerShell single-quoted strings. Prevents shell injection via a quote-in-input. - Every pwsh block begins with $ErrorActionPreference='Stop' and $PSNativeCommandUseErrorActionPreference=$true so native command failures (gh, git) actually propagate. - 'git fetch ... 2>$null' replaced with explicit $LASTEXITCODE checks so a transient fetch failure no longer silently bypasses tag-based security guards (sequential-major check, prerelease regression guard). - 'gh pr create' output captured via Select-Object -Last 1 and trimmed; failure on empty or non-URL output. Previously a partial gh failure could leave PR_URL='' and the workflow would report success. - Input regex tightened from ^\d+$ to ^(0|[1-9]\d*)$ so leading zeros (e.g. '010') are rejected before being written to version.json. - Integer round-trip on parsed inputs to normalize string form. Workflow logic: - promote-minor uses 'git merge -X theirs origin/main', so the unavoidable conflict on the SECOND promotion (release branch's prior stable vs main's preview) resolves automatically. Verified empirically against the conflict case. - cut-major reordered: bump-main PR is opened FIRST, then the release branch is created and pushed. Previously the release branch was pushed before the bump PR, so any failure between them shipped stable but never advanced main. - cut-major now dispatches release.yml via 'gh workflow run' after pushing the new release branch. GITHUB_TOKEN-driven pushes do not trigger workflow_run events, so a bare push would never publish. - promote-minor and cut-major capture main's HEAD SHA at validation time and re-verify it before destructive ops, closing the TOCTOU window where main moves between validate and bump. - bot/* branch names include github.run_id so retries after a partial failure don't collide on the previous run's pushed branch. - All branch pushes use --force-with-lease for safer idempotency. - versionHeightOffset stripper regex now allows end-of-string in addition to \r?\n, so a missing trailing newline doesn't leave the offset in place after promotion. - bump-major-preview now also strips versionHeightOffset (consistency with promote-minor/cut-major). release.yml hardening: - Concurrency group per ref so two pushes to main don't race on tag creation. - NuGet push moved BEFORE GitHub Release creation. Previously a GitHub release/tag could exist for a version that never reached nuget.org. - New regression guard: release/*.x branches refuse to publish any prerelease (those branches must only ship stable). - workflow_dispatch trigger added so cut-major can manually invoke it. - Validate-publish-branch regex tightened to ^release/(0|[1-9]\d*)\.x$ - dotnet/nbgv pinned to v0.5.1 (was master, supply chain risk). - softprops/action-gh-release pinned to v2.6.2. - 'Build' step renamed to 'Pack' (it runs dotnet pack). - Dead 'productNamespacePrefix' env var removed. - Stable-tag regex escapes dots properly. - NuGet push glob narrowed from **/*.nupkg to src/**/bin/Release/*.nupkg. ci-build.yml: actions/upload-artifact pinned to v7 (was master). pr-version-check.yml: - Early exit when base ref is not 'main' (the check is main-only; PRs to release/*.x previously failed with a confusing error). - Validates the PR HEAD's version.json instead of the base branch, so a PR that reverts main's version is also caught. - Fail-closed when no stable tags exist for a breaking-change PR (previously silently passed). - 'semver:major' label support dropped; standardized on 'breaking-change'. - Error message now tells the maintainer to push an empty commit to re-trigger after the bump PR merges. RELEASING.md: - Corrected '9.5.0 stable' to 'first 9.5.x stable (9.5.1)' to match actual NBGV behavior (the version-bump commit is at height 1). - Documents that promotion PRs are NOT mechanical and need review. - Documents recommended branch protection for the version check. - Documents manual re-trigger requirement for breaking-change PRs. - Documents recovery paths for partial cut-major / promote-minor runs. - Shows the exact version.json shape after the initial cutover edit. Findings deliberately not addressed (false positives, low ROI, or out of scope): - ConvertFrom-Json on JSONC: verified working on PowerShell 7.5 (matches GH runners), no issue. - actions/checkout@v6 existence: verified, latest is v6.0.2. - NBGV height=0 vs 1 for cutover offset: verified, height=1 at the version-change commit, so offset of 37 produces 9.4.38 correctly. - Unicode digit injection in inputs: requires malicious maintainer with workflow_dispatch access; extremely improbable. - v-prefixed tag style: not used in this repo. - Fork PR label visibility: edge case, repo doesn't actively manage fork PRs through this label flow. - Per-step permissions scoping: requires major restructure for marginal benefit. --- .github/workflows/bump-major-preview.yml | 64 +++++++--- .github/workflows/ci-build.yml | 7 +- .github/workflows/cut-major.yml | 147 +++++++++++++++++------ .github/workflows/pr-version-check.yml | 51 +++++--- .github/workflows/promote-minor.yml | 137 +++++++++++++++------ .github/workflows/release.yml | 47 +++++--- RELEASING.md | 57 ++++++--- 7 files changed, 360 insertions(+), 150 deletions(-) diff --git a/.github/workflows/bump-major-preview.yml b/.github/workflows/bump-major-preview.yml index 31a137944..1e72d5194 100644 --- a/.github/workflows/bump-major-preview.yml +++ b/.github/workflows/bump-major-preview.yml @@ -12,9 +12,15 @@ permissions: contents: write pull-requests: write +concurrency: + group: bump-major-preview-${{ inputs.next_major }} + cancel-in-progress: false + jobs: bump: runs-on: ubuntu-latest + env: + NEXT_MAJOR_INPUT: ${{ inputs.next_major }} steps: - name: Checkout uses: actions/checkout@v6 @@ -30,15 +36,19 @@ jobs: - name: Validate inputs shell: pwsh run: | - $next = '${{ inputs.next_major }}' - if ($next -notmatch '^\d+$') { - throw "next_major must be a single integer (got: '$next')" + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + $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 + $next = [string]$nextInt $versionJson = Get-Content version.json -Raw | ConvertFrom-Json $ver = $versionJson.version - if ($ver -notmatch '^(\d+)\.\d+-preview') { + 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] @@ -46,10 +56,13 @@ jobs: throw "next_major ($nextInt) must be greater than current major ($currentMajor)" } - git fetch --tags --force 2>$null + git fetch --tags --force origin + if ($LASTEXITCODE -ne 0) { throw "git fetch --tags failed (exit $LASTEXITCODE); cannot validate sequential-major rule." } + $latestStable = git tag --list --sort=-v:refname | - Where-Object { $_ -match '^(\d+)\.\d+\.\d+$' } | + 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'" @@ -61,13 +74,20 @@ jobs: } Write-Host "OK: next_major ($nextInt) is exactly one greater than latest stable major ($latestStableMajor)." } else { - Write-Host "No stable tags found; skipping sequential-major check." + # Fail closed: if no stable tags exist, require next_major == currentMajor + 1 against version.json, + # so we still preserve the no-skipping invariant for fresh repos. + $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 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" + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-major-$next-$runId" - name: Create bump branch run: git checkout -b "$BUMP_BRANCH" @@ -75,32 +95,44 @@ jobs: - name: Bump version.json shell: pwsh run: | + $ErrorActionPreference = 'Stop' $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) - $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEW_VERSION`"") - [System.IO.File]::WriteAllText($path, $new) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEW_VERSION`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(\r?\n|$)', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) - name: Commit and push + shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true git add version.json - git commit -m "Bump main to $NEW_VERSION (next major preview line)" - git push -u origin "$BUMP_BRANCH" + git commit -m "Bump main to $env:NEW_VERSION (next major preview line)" + git push --force-with-lease -u origin "$env:BUMP_BRANCH" - name: Open PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + $nextMajor = $env:NEXT_MAJOR $body = @" - Bumps ``main`` from its current preview to ``$env:NEW_VERSION`` ahead of upcoming breaking changes for ``$env:NEXT_MAJOR.0``. + 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 ``$env:NEXT_MAJOR.0.0-preview.N``. + Merge this **before** any PR labeled ``breaking-change`` so the prereleases are correctly versioned as ``${nextMajor}.0.0-preview.N``. "@ - $url = gh pr create ` + $url = (gh pr create ` --base main ` --head "$env:BUMP_BRANCH" ` --title "Bump main to $env:NEW_VERSION (next major preview)" ` - --body $body + --body $body | Select-Object -Last 1).Trim() + 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 diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index fb440f6e5..8fd85e18d 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -6,7 +6,6 @@ on: 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@v7 with: name: nuget - path: '**/*.nupkg' + path: 'src/**/bin/Release/*.nupkg' diff --git a/.github/workflows/cut-major.yml b/.github/workflows/cut-major.yml index e9e3058ad..05ee5ff43 100644 --- a/.github/workflows/cut-major.yml +++ b/.github/workflows/cut-major.yml @@ -15,14 +15,23 @@ on: permissions: contents: write pull-requests: write + actions: write + +concurrency: + group: cut-major-${{ inputs.major_version }} + 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 @@ -33,33 +42,43 @@ jobs: - name: Validate inputs shell: pwsh run: | - $major = '${{ inputs.major_version }}' - if ($major -notmatch '^\d+$') { - throw "major_version must be a single integer (got: '$major')" + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + $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 + $major = [string]$cutMajor $releaseBranch = "release/$major.x" $stableVersion = "$major.0" - $mainVersionJson = git show "origin/main:version.json" | ConvertFrom-Json + git fetch --tags --force origin + if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE); cannot proceed." } + + $mainHeadSha = (git rev-parse origin/main).Trim() + $mainVersionJson = git show "${mainHeadSha}:version.json" | ConvertFrom-Json $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." } $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 = '${{ inputs.next_main_version }}' + $nextMain = $env:NEXT_MAIN_VERSION_INPUT if (-not $nextMain) { $nextMain = "$major.1" } - if ($nextMain -notmatch '^(\d+)\.(\d+)$') { - throw "next_main_version must be 'major.minor' (got: '$nextMain')" + 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] - $cutMajor = [int]$major + $nextMain = "$nextMainMajor.$nextMainMinor" if ($nextMainMajor -eq $cutMajor) { if ($nextMainMinor -lt 1) { @@ -74,79 +93,127 @@ jobs: } $nextPreview = "$nextMain-preview.{height}" + $runId = $env:GITHUB_RUN_ID 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" - - - name: Create release branch from main - run: | - git checkout main - git checkout -b "$RELEASE_BRANCH" + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-main-after-$stableVersion-$runId" + Add-Content -Path $env:GITHUB_ENV -Value "MAIN_HEAD_SHA=$mainHeadSha" - - name: Set stable version on release branch + - name: Create main bump branch (from validated main SHA) shell: pwsh run: | - $path = 'version.json' - $content = [System.IO.File]::ReadAllText($path) - $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") - $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*\r?\n', '') - $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') - [System.IO.File]::WriteAllText($path, $content) - - - name: Commit and push release branch - run: | - git add version.json - git commit -m "Cut $RELEASE_BRANCH at $STABLE_VERSION stable" - git push -u origin "$RELEASE_BRANCH" - - - name: Create main bump branch - run: | - git checkout main - git checkout -b "$BUMP_BRANCH" + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git checkout "$env:MAIN_HEAD_SHA" + git checkout -b "$env:BUMP_BRANCH" - name: Bump main to next preview shell: pwsh run: | + $ErrorActionPreference = 'Stop' $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) - $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEXT_PREVIEW`"") - [System.IO.File]::WriteAllText($path, $new) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEXT_PREVIEW`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(\r?\n|$)', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) - name: Commit and push main bump branch + shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true git add version.json - git commit -m "Bump main to $NEXT_PREVIEW after cutting $RELEASE_BRANCH" - git push -u origin "$BUMP_BRANCH" + git commit -m "Bump main to $env:NEXT_PREVIEW after cutting $env:RELEASE_BRANCH" + git push --force-with-lease -u origin "$env:BUMP_BRANCH" - name: Open main bump PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true $body = @" Companion PR to cutting ``$env:RELEASE_BRANCH``. - The release branch ``$env:RELEASE_BRANCH`` has been pushed and will publish ``$env:STABLE_VERSION.0`` stable. + 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. "@ - $url = gh pr create ` + $url = (gh pr create ` --base main ` --head "$env:BUMP_BRANCH" ` --title "Bump main to $env:NEXT_PREVIEW after $env:RELEASE_BRANCH cut" ` - --body $body + --body $body | Select-Object -Last 1).Trim() + 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: Verify main hasn't moved since validation + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git fetch origin main --quiet + $currentMainSha = (git rev-parse origin/main).Trim() + if ($currentMainSha -ne $env:MAIN_HEAD_SHA) { + throw "main moved during cut-major run (was $env:MAIN_HEAD_SHA, now $currentMainSha). The bump PR has been opened; the release branch was NOT pushed. Re-run the workflow after closing the bump PR." + } + Write-Host "OK: main is still at $env:MAIN_HEAD_SHA." + + - name: Create release branch from validated main SHA + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git checkout "$env:MAIN_HEAD_SHA" + git checkout -b "$env:RELEASE_BRANCH" + + - name: Set stable version on release branch + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $path = 'version.json' + $content = [System.IO.File]::ReadAllText($path) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(\r?\n|$)', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) + + - name: Commit and push release branch + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git add version.json + git commit -m "Cut $env:RELEASE_BRANCH at $env:STABLE_VERSION stable" + git push -u origin "$env:RELEASE_BRANCH" + + - name: Trigger release workflow on new release branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + gh workflow run release.yml --ref "$env:RELEASE_BRANCH" + if ($LASTEXITCODE -ne 0) { + throw "Failed to trigger release.yml on $env:RELEASE_BRANCH (exit $LASTEXITCODE). The release branch has been pushed but won't publish automatically (GITHUB_TOKEN pushes don't fire push triggers). Manually run release.yml against $env:RELEASE_BRANCH from the Actions tab." + } + Write-Host "OK: release.yml dispatched for $env:RELEASE_BRANCH." + - name: Summary shell: pwsh run: | $summary = @" ## Major release cut - - **New release branch**: ``$env:RELEASE_BRANCH`` (will publish ``$env:STABLE_VERSION.0`` stable on push). + - **New release branch**: ``$env:RELEASE_BRANCH`` (publishes the first ``$env:STABLE_VERSION.x`` stable build via release.yml, dispatched automatically). - **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 index 8c6234e22..81a8f568a 100644 --- a/.github/workflows/pr-version-check.yml +++ b/.github/workflows/pr-version-check.yml @@ -25,33 +25,46 @@ jobs: BASE_REF: ${{ github.base_ref }} LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + 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 + } + $labels = $env:LABELS_JSON | ConvertFrom-Json - $isBreaking = ($labels -contains 'breaking-change') -or ($labels -contains 'semver:major') + $isBreaking = $labels -contains 'breaking-change' if (-not $isBreaking) { - Write-Host "PR is not labeled 'breaking-change' or 'semver:major'; no major-bump check needed." + Write-Host "PR is not labeled 'breaking-change'; no major-bump check needed." exit 0 } - Write-Host "PR is labeled as breaking. Verifying main's version.json reflects a newer major than the latest stable release." + Write-Host "PR is labeled as breaking. Verifying the PR's version.json reflects a newer major than the latest stable release." - git fetch origin $env:BASE_REF --depth=1 2>$null - $baseVersionJson = git show "origin/${env:BASE_REF}:version.json" | ConvertFrom-Json - $baseVer = $baseVersionJson.version - if ($baseVer -notmatch '^(\d+)\.') { - Write-Error "::error file=version.json::Could not parse major version from base version.json: '$baseVer'" + # Validate against the PR HEAD's version.json (which is the checked-out tree), + # not the base branch, so a PR that reverts main's version is also caught. + $headVersionJson = Get-Content version.json -Raw | ConvertFrom-Json + $headVer = $headVersionJson.version + if ($headVer -notmatch '^(0|[1-9]\d*)\.') { + Write-Error "::error file=version.json::Could not parse major version from PR head version.json: '$headVer'" exit 1 } - $baseMajor = [int]$matches[1] + $headMajor = [int]$matches[1] + + git fetch --tags --force origin + if ($LASTEXITCODE -ne 0) { throw "git fetch --tags failed (exit $LASTEXITCODE); cannot safely validate breaking-change label." } - git fetch --tags --force 2>$null $latestStable = git tag --list --sort=-v:refname | - Where-Object { $_ -match '^(\d+)\.\d+\.\d+$' } | + Where-Object { $_ -match '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } | Select-Object -First 1 if (-not $latestStable) { - Write-Host "No stable tags found; cannot determine latest released major. Skipping check." - exit 0 + # Fail closed: a breaking-change PR with no known stable history is suspicious. + # Don't silently allow it; require the label to be removed for genuinely new repos. + Write-Error "::error::No stable tags found; cannot validate sequential-major rule for a breaking-change PR. Either remove the 'breaking-change' label or verify git tag history is intact." + exit 1 } if ($latestStable -notmatch '^(\d+)\.') { @@ -60,17 +73,17 @@ jobs: } $latestMajor = [int]$matches[1] - Write-Host "Latest stable major: $latestMajor; current main major: $baseMajor" + Write-Host "Latest stable major: $latestMajor; PR head major: $headMajor" $expectedMajor = $latestMajor + 1 - if ($baseMajor -ne $expectedMajor) { - if ($baseMajor -le $latestMajor) { - $msg = "PR is labeled breaking-change but main's major ($baseMajor) 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 re-target this PR." + 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 re-target this PR. After the bump PR merges, push a new commit (or close+reopen this PR) to re-run this check." } else { - $msg = "PR is labeled breaking-change but main's major ($baseMajor) skips past major $expectedMajor. Main must be at exactly one greater than the latest stable major ($latestMajor + 1 = $expectedMajor). Major versions must be incremented sequentially. Reset main to $expectedMajor.0-preview.{height} before merging this PR." + $msg = "PR is labeled breaking-change but its version.json major ($headMajor) skips past major $expectedMajor. Main must be at exactly one greater than the latest stable major ($latestMajor + 1 = $expectedMajor). Major versions must be incremented sequentially. Reset main to $expectedMajor.0-preview.{height} before merging this PR." } Write-Error "::error file=version.json::$msg" exit 1 } - Write-Host "OK: main major ($baseMajor) is exactly one greater than latest stable major ($latestMajor)." + 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 index b2760c716..3a09f8281 100644 --- a/.github/workflows/promote-minor.yml +++ b/.github/workflows/promote-minor.yml @@ -16,9 +16,16 @@ permissions: contents: write pull-requests: write +concurrency: + group: promote-minor-${{ inputs.target_release_branch }}-${{ inputs.stable_version }} + 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 @@ -33,122 +40,177 @@ jobs: - name: Validate inputs and compute next preview shell: pwsh run: | - $branch = '${{ inputs.target_release_branch }}' - $version = '${{ inputs.stable_version }}' + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + $branch = $env:TARGET_RELEASE_BRANCH_INPUT + $version = $env:STABLE_VERSION_INPUT - if ($branch -notmatch '^release/(\d+)\.x$') { - throw "target_release_branch must match 'release/.x' (got: '$branch')" + 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 '^(\d+)\.(\d+)$') { - throw "stable_version must be 'major.minor' (got: '$version')" + 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)" } - git fetch origin $branch 2>$null - if ($LASTEXITCODE -ne 0) { throw "Branch '$branch' does not exist on origin" } + $existing = git ls-remote --heads --exit-code origin "refs/heads/$branch" + $lsExit = $LASTEXITCODE + if ($lsExit -eq 2) { + throw "Branch '$branch' does not exist on origin" + } + if ($lsExit -ne 0) { + throw "git ls-remote failed (exit $lsExit); cannot verify branch state." + } $branchVersionJson = git show "origin/${branch}:version.json" | ConvertFrom-Json $branchVer = $branchVersionJson.version - if ($branchVer -notmatch '^(\d+)\.(\d+)') { - throw "Could not parse current version on '$branch': '$branchVer'" + 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)" } - $mainVersionJson = git show "origin/main:version.json" | ConvertFrom-Json + $mainHeadSha = (git rev-parse origin/main).Trim() + $mainVersionJson = git show "${mainHeadSha}:version.json" | ConvertFrom-Json $mainVer = $mainVersionJson.version - if ($mainVer -notmatch '^(\d+)\.(\d+)-preview') { + 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. To ship a different version, run 'Bump main to next major preview' first or wait for main to be at the version you want to promote." + 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 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 "PROMOTE_BRANCH=bot/promote-$version" - Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-main-after-$version" + Add-Content -Path $env:GITHUB_ENV -Value "MAIN_HEAD_SHA=$mainHeadSha" + Add-Content -Path $env:GITHUB_ENV -Value "PROMOTE_BRANCH=bot/promote-$version-$runId" + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-main-after-$version-$runId" - name: Create promotion branch (merges main into release branch) + shell: pwsh run: | - git checkout "$TARGET_RELEASE_BRANCH" - git checkout -b "$PROMOTE_BRANCH" - git merge origin/main --no-edit + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git checkout "$env:TARGET_RELEASE_BRANCH" + git checkout -b "$env:PROMOTE_BRANCH" + # -X theirs: prefer main's content on conflict. The next step overwrites version.json + # to the stable value regardless, so version.json conflicts are mooted either way. + # We invoke through cmd to avoid PowerShell halting on git's non-zero exit when + # the merge succeeds with the chosen strategy. + git merge -X theirs origin/main --no-edit + if ($LASTEXITCODE -ne 0) { + throw "git merge of origin/main into $env:PROMOTE_BRANCH failed (exit $LASTEXITCODE). Resolve manually and re-run." + } - name: Set stable version on promotion branch shell: pwsh run: | + $ErrorActionPreference = 'Stop' $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") - $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*\r?\n', '') + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(\r?\n|$)', '') $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') [System.IO.File]::WriteAllText($path, $content) - name: Commit and push promotion branch + shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true git add version.json - git commit -m "Set version to $STABLE_VERSION (stable)" - git push -u origin "$PROMOTE_BRANCH" + git commit -m "Set version to $env:STABLE_VERSION (stable)" + git push --force-with-lease -u origin "$env:PROMOTE_BRANCH" - name: Open promotion PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true $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``. "@ - $url = gh pr create ` + $url = (gh pr create ` --base "$env:TARGET_RELEASE_BRANCH" ` --head "$env:PROMOTE_BRANCH" ` - --title "Promote main to $env:STABLE_VERSION.0 stable" ` - --body $body + --title "Promote main to $env:STABLE_VERSION stable" ` + --body $body | Select-Object -Last 1).Trim() + 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 + - name: Verify main hasn't moved since validation + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git fetch origin main --quiet + $currentMainSha = (git rev-parse origin/main).Trim() + if ($currentMainSha -ne $env:MAIN_HEAD_SHA) { + throw "main moved during promotion run (was $env:MAIN_HEAD_SHA, now $currentMainSha). The promotion PR has been opened against the validated SHA, but the bump-main PR was NOT created because the computed NEXT_PREVIEW may now be stale. Re-run the workflow after merging the promotion PR." + } + Write-Host "OK: main is still at $env:MAIN_HEAD_SHA." + + - name: Create main bump branch (from validated main SHA) + shell: pwsh run: | - git fetch origin main - git checkout main - git pull --ff-only origin main - git checkout -b "$BUMP_BRANCH" + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git checkout "$env:MAIN_HEAD_SHA" + git checkout -b "$env:BUMP_BRANCH" - name: Bump main to next preview shell: pwsh run: | + $ErrorActionPreference = 'Stop' $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) - $new = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEXT_PREVIEW`"") - [System.IO.File]::WriteAllText($path, $new) + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEXT_PREVIEW`"") + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(\r?\n|$)', '') + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') + [System.IO.File]::WriteAllText($path, $content) - name: Commit and push main bump branch + shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true git add version.json - git commit -m "Bump main to $NEXT_PREVIEW after $STABLE_VERSION promotion" - git push -u origin "$BUMP_BRANCH" + git commit -m "Bump main to $env:NEXT_PREVIEW after $env:STABLE_VERSION promotion" + git push --force-with-lease -u origin "$env:BUMP_BRANCH" - name: Open main bump PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true $body = @" Companion PR to the ``$env:STABLE_VERSION`` promotion. @@ -156,11 +218,14 @@ jobs: Merge this PR **after** the promotion PR: $env:PROMOTE_PR_URL "@ - $url = gh pr create ` + $url = (gh pr create ` --base main ` --head "$env:BUMP_BRANCH" ` --title "Bump main to $env:NEXT_PREVIEW after $env:STABLE_VERSION promotion" ` - --body $body + --body $body | Select-Object -Last 1).Trim() + 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 @@ -171,7 +236,7 @@ jobs: | Order | PR | Purpose | | :-: | --- | --- | - | 1 | $env:PROMOTE_PR_URL | Merge first. Promotes main to ``$env:STABLE_VERSION``.0 stable on ``$env:TARGET_RELEASE_BRANCH``. | + | 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``. | "@ Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74894292f..0d466b7ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,14 @@ name: Build and Release on: push: branches: [ main, 'release/*.x' ] + workflow_dispatch: env: configuration: Release - productNamespacePrefix: "DynamicData" + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: release: @@ -29,7 +33,8 @@ jobs: env: REF_NAME: ${{ github.ref_name }} run: | - if ($env:REF_NAME -ne 'main' -and $env:REF_NAME -notmatch '^release/\d+\.x$') { + $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'." @@ -53,7 +58,7 @@ jobs: - name: NBGV id: nbgv - uses: dotnet/nbgv@master + uses: dotnet/nbgv@v0.5.1 with: setAllVars: true @@ -62,7 +67,15 @@ jobs: env: SEMVER2: ${{ steps.nbgv.outputs.SemVer2 }} PRERELEASE: ${{ steps.nbgv.outputs.PrereleaseVersion }} + REF_NAME: ${{ github.ref_name }} run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + 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 @@ -72,8 +85,12 @@ jobs: } $major = $matches[1] $minor = $matches[2] - git fetch --tags --force 2>$null - $stableTags = git tag --list "$major.$minor.*" | Where-Object { $_ -match "^$major\.$minor\.\d+$" } + 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 ', ' $msg = "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." @@ -86,26 +103,26 @@ jobs: run: dotnet restore DynamicData.sln working-directory: src - - name: Build + - name: Pack run: dotnet pack --no-restore --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 + run: | + dotnet nuget push -s ${{ env.SOURCE_URL }} -k ${{ env.NUGET_AUTH_TOKEN }} --skip-duplicate src/**/bin/Release/*.nupkg + - name: Changelog uses: glennawatson/ChangeLog@v1 id: changelog - - name: Create Release - uses: softprops/action-gh-release@v2 + - name: Create GitHub Release + uses: softprops/action-gh-release@v2.6.2 with: tag_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 }} --skip-duplicate **/*.nupkg diff --git a/RELEASING.md b/RELEASING.md index 78b6d0b3b..959b39170 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -15,8 +15,8 @@ Every PR merged to either branch publishes a NuGet package automatically via `.g 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. +- Two PRs merging the same day get distinct heights, no collisions. +- A `version.json` change resets height to 1 (so the commit that sets the version is published as `X.Y.1`, not `X.Y.0`). - `fetch-depth: 0` in CI is required for height to be correct. ## Automation workflows @@ -25,16 +25,20 @@ All `version.json` edits and release-branch creation are scripted via `workflow_ | Workflow | When to run | What it does | |---|---|---| -| **Promote main to stable minor** (`promote-minor.yml`) | Ready to ship the next minor (e.g. `9.5.0`) from `main` to an existing release branch. | Opens two PRs: (1) merges `main` into `release/.x` and sets stable version; (2) bumps `main` to the next preview minor. | -| **Cut major release** (`cut-major.yml`) | Ready to ship a new major as stable from `main` (e.g. `10.0.0`). | Creates a new `release/.x` branch with stable version, then opens a PR to advance `main` to the next preview. | +| **Promote main to stable minor** (`promote-minor.yml`) | Ready to ship the next minor (first published patch is `X.Y.1`) from `main` to an existing release branch. | Opens two PRs: (1) merges `main` into `release/.x` and sets stable version (contains the full diff, NOT mechanical); (2) bumps `main` to the next preview minor (mechanical). | +| **Cut major release** (`cut-major.yml`) | Ready to ship a new major as stable from `main` (first published patch is `X.0.1`). | Opens a main-bump PR first, then creates `release/.x` with the stable version, then dispatches `release.yml` to publish the first patch. | | **Bump main to next major preview** (`bump-major-preview.yml`) | First breaking change is about to land on `main`. | Opens a PR bumping `main` from `.Y-preview.{height}` to `.0-preview.{height}`. | 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/**`. | If the PR is labeled `breaking-change` or `semver:major`, fails the check unless `main`'s major is **exactly one greater** than the latest stable tag's major (no skipping). | -| **Prerelease regression guard** (in `release.yml`) | Every push to `main`. | Fails the publish step if a stable `X.Y.*` tag already exists for the major.minor being prereleased. | +| **PR version check** (`pr-version-check.yml`) | Pull request to `main` / `release/*.x`. | For PRs to `main` labeled `breaking-change`, fails the check unless the PR's `version.json` major is **exactly one greater** than the latest stable tag's major (no skipping). For PRs to `release/*.x`, the check is skipped (breaking changes are a main-only concern). | +| **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. | + +### Recommended branch protection + +To make the **PR version check** binding (not advisory), add `PR version check / check` as a required status check on `main` in branch protection settings. ## Day-to-day flows @@ -44,40 +48,54 @@ Open a PR targeting `release/9.x`. Merge. `release.yml` publishes `9.4.N` to NuG ### 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.** -### Promoting `main` → next stable minor (e.g. shipping `9.5.0`) +### 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 (promotion PR first, main-bump PR second). -3. `release.yml` ships `9.5.0` stable on the release branch; `main` continues at `9.6-preview.{height}`. +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 the main-bump PR. +4. `release.yml` ships the first `9.5.x` patch (i.e. `9.5.1`) on the release branch; `main` continues 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. +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, push an empty commit to the breaking-change PR (or close+reopen it) to re-trigger the check. -### Cutting a new major release (e.g. shipping `10.0.0`) +### 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. -2. The workflow creates `release/10.x` directly (publishes `10.0.0` stable) and opens a PR to advance `main`. +2. The workflow opens a main-bump PR, creates `release/10.x` directly, and dispatches `release.yml` against `release/10.x` to publish the first `10.0.x` patch. 3. Merge the main-bump PR. ### 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. +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. ## Initial cutover (one-time, when introducing this infrastructure) -This is **the only time manual steps are required**, because release/9.x does not yet exist when this PR merges. +This is **the only time manual steps are required**, because `release/9.x` does not yet exist when this PR merges. After this PR merges to `main`: 1. `main` will start publishing `9.5.0-preview.N` (the version bump in this PR took effect). -2. Cut `release/9.x` from main HEAD: +2. Cut `release/9.x` from `main` HEAD: ```sh git fetch origin git checkout -b release/9.x origin/main ``` 3. On `release/9.x`, edit `version.json`: - - Set `"version"` back to `"9.4"`. - - Add `"versionHeightOffset": 37` (the height of the latest published stable tag `9.4.37`, so the next build doesn't collide with it). -4. Commit and push `release/9.x`. The next build produces `9.4.38` stable. + - Change `"version"` from `"9.5-preview.{height}"` back to `"9.4"`. + - Add `"versionHeightOffset": ` where `` is the patch number of the latest stable `9.4.x` tag at cutover time. Verify with `git tag --list '9.4.*' | sort -V | tail -1`. The next stable build will be `9.4.`. (At PR author time this was `37`; verify before pushing.) + - The full file should look like: + ```jsonc + { + "version": "9.4", + "versionHeightOffset": 37, + "publicReleaseRefSpec": [ /* unchanged */ ], + "nugetPackageVersion": { /* unchanged */ }, + "cloudBuild": { /* unchanged */ } + } + ``` +4. Commit and push `release/9.x`. The next build produces `9.4.` stable. From this point on, all subsequent releases use the automation workflows above. No more manual `version.json` edits. @@ -85,5 +103,4 @@ From this point on, all subsequent releases use the automation workflows above. 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 version-bump PRs are mechanical (single-line `version.json` edits with no code impact), so this is usually fine. If you want CI checks to run on them, close and reopen the PR once, or push an empty commit, or wire the workflows to use a personal access token (PAT) stored as a repository secret. - +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. From 1bc035f63c749510315fe7bdb9e8b98194a65b63 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 17:56:23 -0700 Subject: [PATCH 09/15] Address round-2 adversarial review findings Round 2 had 4 reviewers (Opus codereview + general, Sonnet codereview, GPT-5 general). They found that round 1's fixes introduced new bugs and that round 1 missed several issues. Pattern fix: PowerShell error handling The round-1 pattern of $PSNativeCommandUseErrorActionPreference=$true made every subsequent explicit $LASTEXITCODE check unreachable: a non-zero native exit threw before the check could run. Reverted to just $ErrorActionPreference='Stop' and explicit $LASTEXITCODE checks after each native command. Curated error messages now actually fire. For 'gh pr create' specifically, the call is wrapped in try/catch so the curated message survives the strict-error throw path. Promote-minor merge strategy Replaced 'git merge -X theirs' with a plain merge that aborts on any conflict outside version.json. The -X theirs strategy would silently overwrite a release-branch-only hotfix that was never back-merged to main, dropping the fix without any conflict marker. The new strategy fails loudly on any non-version.json conflict and only auto-resolves the version.json overwrite (which the next step would overwrite anyway). cut-major step reordering Moved 'Verify main hasn't moved' BEFORE 'Open main bump PR'. Round 1 had the verify after the PR was already opened, leaving an orphan PR with stale content if main moved during the run. Cross-workflow concurrency All three main-mutating workflows (bump-major-preview, cut-major, promote-minor) now share a 'main-version-mutator' concurrency group. Two of these workflows can no longer race; the queue serializes them. Round 1 had per-workflow groups which only protected against self-races. TOCTOU coverage bump-major-preview now captures and re-verifies the main SHA the same way cut-major and promote-minor do. promote-minor now also captures and re-verifies the release-branch SHA (previously only main was checked). versionHeightOffset stripper Regex extended to tolerate trailing JSONC '//' comments on the offset line. Added a post-strip ConvertFrom-Json validation that throws if the field is somehow still present after the regex pass. Belt and suspenders: regex catches common cases, parse validation catches anything unusual. cut-major release.yml dispatch reliability Added a retry loop around 'gh workflow run release.yml' (up to 6 attempts, 5s apart) because GitHub's API can briefly fail to find a newly pushed branch. After dispatch, polls 'gh run list' to confirm a run actually queued and surfaces the run URL in the workflow summary so the maintainer can monitor. release.yml asymmetric guard fixed Added a symmetric check: stable versions can't be published from main. Round 1 only blocked prereleases from release branches. A manual edit that accidentally set main's version to a non-prerelease form would have published stable from main, bypassing the entire branching model. release.yml runs tests before pack Pack and push now follow 'dotnet test'. Previously the release pipeline ran restore -> pack -> push with no test coverage on the merge commit. Pack uses --no-build to avoid double-compilation. release.yml NuGet glob Replaced shell-expanded '**/*.nupkg' with explicit pwsh Get-ChildItem under src/**/bin/Release. Eliminates the risk of pushing 3rd-party packages restored into the workspace. Each package is pushed via an explicit $env: read of the API key (no expression interpolation inside the command string). Action pinning glennawatson/ChangeLog now pinned to commit SHA (was @v1 floating). Round 1 missed this third-party action while pinning the others. pr-version-check additions - Unconditional check: if a PR changes version.json's major, it MUST be labeled 'breaking-change'. Catches unlabeled major edits that would otherwise slip through. - Fail-closed when no stable tags exist now allows the legitimate first-breaking-change case (head major == base major + 1) on a brand-new repo without forcing the user to remove the label. - Error message tells the user to rebase onto main (not push an empty commit). The check reads version.json from the PR head; an empty commit on a stale fork point doesn't change version.json. Idempotency Bot branch names now include GITHUB_RUN_ATTEMPT in addition to GITHUB_RUN_ID, so a workflow rerun produces a fresh branch instead of failing to force-push over the prior attempt's branch. cut-major next_main_version safety When advancing main to a NEW major (next major from cut), now also verifies no stable tags already exist for that major. Prevents advancing main to a preview line that has already shipped stable. Documentation - 'push an empty commit' replaced with 'rebase onto main' (the check reads PR head; an empty commit doesn't help). - promote and cut docs emphasize merging the main-bump PR IMMEDIATELY after the stable promotion ships, because the prerelease regression guard fails between those two events. - cut-major step summary now links to the dispatched release run when available. Findings deliberately not addressed - actions/checkout@v6 and actions/setup-dotnet@v5.0.1 SHA pinning: pre-existing, scope creep, GitHub-owned (lower risk). - gh-release run for tag-already-exists recovery: rare; manual recovery is acceptable. - 'cut-major opens PR before TOCTOU': partially addressed by moving verify to between push and PR-create; the bump branch itself is still pushed before the verify, but the PR is not. Recovery is documented (delete the branch and re-run). --- .github/workflows/bump-major-preview.yml | 70 ++++++++--- .github/workflows/cut-major.yml | 117 +++++++++++++----- .github/workflows/pr-version-check.yml | 58 +++++---- .github/workflows/promote-minor.yml | 151 +++++++++++++++-------- .github/workflows/release.yml | 32 +++-- RELEASING.md | 12 +- 6 files changed, 298 insertions(+), 142 deletions(-) diff --git a/.github/workflows/bump-major-preview.yml b/.github/workflows/bump-major-preview.yml index 1e72d5194..11af8910a 100644 --- a/.github/workflows/bump-major-preview.yml +++ b/.github/workflows/bump-major-preview.yml @@ -13,7 +13,7 @@ permissions: pull-requests: write concurrency: - group: bump-major-preview-${{ inputs.next_major }} + group: main-version-mutator cancel-in-progress: false jobs: @@ -37,7 +37,6 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true $next = $env:NEXT_MAJOR_INPUT if ($next -notmatch '^(0|[1-9]\d*)$') { @@ -46,7 +45,14 @@ jobs: $nextInt = [int]$next $next = [string]$nextInt - $versionJson = Get-Content version.json -Raw | ConvertFrom-Json + 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}'" @@ -56,9 +62,6 @@ jobs: throw "next_major ($nextInt) must be greater than current major ($currentMajor)" } - git fetch --tags --force origin - if ($LASTEXITCODE -ne 0) { throw "git fetch --tags failed (exit $LASTEXITCODE); cannot validate sequential-major rule." } - $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 @@ -70,12 +73,10 @@ jobs: $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 to avoid accidentally skipping a major." + 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 { - # Fail closed: if no stable tags exist, require next_major == currentMajor + 1 against version.json, - # so we still preserve the no-skipping invariant for fresh repos. $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." @@ -85,12 +86,20 @@ jobs: $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" + 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 - run: git checkout -b "$BUMP_BRANCH" + - 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 @@ -99,18 +108,36 @@ jobs: $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|$)', '') + $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' - $PSNativeCommandUseErrorActionPreference = $true 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: @@ -118,18 +145,21 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true $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``. "@ - $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() + 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')" } diff --git a/.github/workflows/cut-major.yml b/.github/workflows/cut-major.yml index 05ee5ff43..b0a00d927 100644 --- a/.github/workflows/cut-major.yml +++ b/.github/workflows/cut-major.yml @@ -18,7 +18,7 @@ permissions: actions: write concurrency: - group: cut-major-${{ inputs.major_version }} + group: main-version-mutator cancel-in-progress: false jobs: @@ -43,7 +43,6 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true $major = $env:MAJOR_VERSION_INPUT if ($major -notmatch '^(0|[1-9]\d*)$') { @@ -59,12 +58,16 @@ jobs: 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) { @@ -88,27 +91,35 @@ jobs: 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" + 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' - $PSNativeCommandUseErrorActionPreference = $true 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 @@ -117,18 +128,38 @@ jobs: $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|$)', '') + $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' - $PSNativeCommandUseErrorActionPreference = $true 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: @@ -136,7 +167,6 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true $body = @" Companion PR to cutting ``$env:RELEASE_BRANCH``. @@ -144,35 +174,28 @@ jobs: This PR advances ``main`` to ``$env:NEXT_PREVIEW`` so subsequent PRs publish that preview line. "@ - $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() + 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: Verify main hasn't moved since validation - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true - git fetch origin main --quiet - $currentMainSha = (git rev-parse origin/main).Trim() - if ($currentMainSha -ne $env:MAIN_HEAD_SHA) { - throw "main moved during cut-major run (was $env:MAIN_HEAD_SHA, now $currentMainSha). The bump PR has been opened; the release branch was NOT pushed. Re-run the workflow after closing the bump PR." - } - Write-Host "OK: main is still at $env:MAIN_HEAD_SHA." - - name: Create release branch from validated main SHA shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true 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 @@ -181,18 +204,24 @@ jobs: $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") - $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(\r?\n|$)', '') + $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 release branch shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true 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: @@ -200,20 +229,42 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true - gh workflow run release.yml --ref "$env:RELEASE_BRANCH" - if ($LASTEXITCODE -ne 0) { - throw "Failed to trigger release.yml on $env:RELEASE_BRANCH (exit $LASTEXITCODE). The release branch has been pushed but won't publish automatically (GITHUB_TOKEN pushes don't fire push triggers). Manually run release.yml against $env:RELEASE_BRANCH from the Actions tab." + # 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" } - Write-Host "OK: release.yml dispatched for $env:RELEASE_BRANCH." - 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`` (publishes the first ``$env:STABLE_VERSION.x`` stable build via release.yml, dispatched automatically). + - **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 index 81a8f568a..4b0b9f231 100644 --- a/.github/workflows/pr-version-check.yml +++ b/.github/workflows/pr-version-check.yml @@ -26,7 +26,6 @@ jobs: LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true 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." @@ -36,23 +35,37 @@ jobs: $labels = $env:LABELS_JSON | ConvertFrom-Json $isBreaking = $labels -contains 'breaking-change' - if (-not $isBreaking) { - Write-Host "PR is not labeled 'breaking-change'; no major-bump check needed." - exit 0 - } - - Write-Host "PR is labeled as breaking. Verifying the PR's version.json reflects a newer major than the latest stable release." - - # Validate against the PR HEAD's version.json (which is the checked-out tree), - # not the base branch, so a PR that reverts main's version is also caught. $headVersionJson = Get-Content version.json -Raw | ConvertFrom-Json $headVer = $headVersionJson.version if ($headVer -notmatch '^(0|[1-9]\d*)\.') { - Write-Error "::error file=version.json::Could not parse major version from PR head version.json: '$headVer'" - exit 1 + 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." } @@ -61,15 +74,17 @@ jobs: Select-Object -First 1 if (-not $latestStable) { - # Fail closed: a breaking-change PR with no known stable history is suspicious. - # Don't silently allow it; require the label to be removed for genuinely new repos. - Write-Error "::error::No stable tags found; cannot validate sequential-major rule for a breaking-change PR. Either remove the 'breaking-change' label or verify git tag history is intact." - exit 1 + # No prior stable releases. Use main's current major as the floor, so a brand-new + # project's first breaking PR is allowed when it advances main by exactly one. + if ($headMajor -eq $baseMajor + 1) { + Write-Host "No stable tags found; PR head major ($headMajor) is exactly one greater than main ($baseMajor). OK." + exit 0 + } + throw "No stable tags found; PR head major ($headMajor) must be exactly one greater than main's current major ($baseMajor) for a breaking-change PR with no prior stable history." } if ($latestStable -notmatch '^(\d+)\.') { - Write-Error "Could not parse latest stable tag: '$latestStable'" - exit 1 + throw "Could not parse latest stable tag: '$latestStable'" } $latestMajor = [int]$matches[1] @@ -78,12 +93,11 @@ jobs: $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 re-target this PR. After the bump PR merges, push a new commit (or close+reopen this PR) to re-run this check." + $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 one greater than the latest stable major ($latestMajor + 1 = $expectedMajor). Major versions must be incremented sequentially. Reset main to $expectedMajor.0-preview.{height} before merging this PR." + $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." } - Write-Error "::error file=version.json::$msg" - exit 1 + 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 index 3a09f8281..11cf18728 100644 --- a/.github/workflows/promote-minor.yml +++ b/.github/workflows/promote-minor.yml @@ -17,7 +17,7 @@ permissions: pull-requests: write concurrency: - group: promote-minor-${{ inputs.target_release_branch }}-${{ inputs.stable_version }} + group: main-version-mutator cancel-in-progress: false jobs: @@ -41,7 +41,6 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true $branch = $env:TARGET_RELEASE_BRANCH_INPUT $version = $env:STABLE_VERSION_INPUT @@ -62,16 +61,19 @@ jobs: throw "stable_version major ($versionMajor) must match target_release_branch major ($branchMajor)" } - $existing = git ls-remote --heads --exit-code origin "refs/heads/$branch" - $lsExit = $LASTEXITCODE - if ($lsExit -eq 2) { - throw "Branch '$branch' does not exist on origin" - } - if ($lsExit -ne 0) { - throw "git ls-remote failed (exit $lsExit); cannot verify branch state." - } + # 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 "origin/${branch}:version.json" | ConvertFrom-Json + $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." @@ -82,7 +84,10 @@ jobs: } $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')" @@ -96,29 +101,48 @@ jobs: $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 "PROMOTE_BRANCH=bot/promote-$version-$runId" - Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-main-after-$version-$runId" + 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' - $PSNativeCommandUseErrorActionPreference = $true - git checkout "$env:TARGET_RELEASE_BRANCH" + 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" - # -X theirs: prefer main's content on conflict. The next step overwrites version.json - # to the stable value regardless, so version.json conflicts are mooted either way. - # We invoke through cmd to avoid PowerShell halting on git's non-zero exit when - # the merge succeeds with the chosen strategy. - git merge -X theirs origin/main --no-edit - if ($LASTEXITCODE -ne 0) { - throw "git merge of origin/main into $env:PROMOTE_BRANCH failed (exit $LASTEXITCODE). Resolve manually and re-run." + 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 { $_ } + $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 @@ -127,18 +151,40 @@ jobs: $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") - $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(\r?\n|$)', '') + $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 promotion branch shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true 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() + $currentBranchSha = (git rev-parse "origin/$env:TARGET_RELEASE_BRANCH").Trim() + 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: @@ -146,7 +192,6 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true $body = @" Promotes ``main`` into ``$env:TARGET_RELEASE_BRANCH`` and sets ``version.json`` to ``$env:STABLE_VERSION`` stable. @@ -154,35 +199,28 @@ jobs: Merge this PR **first**, then merge the companion PR that bumps ``main`` to ``$env:NEXT_PREVIEW``. "@ - $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() + 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: Verify main hasn't moved since validation - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true - git fetch origin main --quiet - $currentMainSha = (git rev-parse origin/main).Trim() - if ($currentMainSha -ne $env:MAIN_HEAD_SHA) { - throw "main moved during promotion run (was $env:MAIN_HEAD_SHA, now $currentMainSha). The promotion PR has been opened against the validated SHA, but the bump-main PR was NOT created because the computed NEXT_PREVIEW may now be stale. Re-run the workflow after merging the promotion PR." - } - Write-Host "OK: main is still at $env:MAIN_HEAD_SHA." - - name: Create main bump branch (from validated main SHA) shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true 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 @@ -191,18 +229,24 @@ jobs: $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|$)', '') + $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' - $PSNativeCommandUseErrorActionPreference = $true 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: @@ -210,7 +254,6 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true $body = @" Companion PR to the ``$env:STABLE_VERSION`` promotion. @@ -218,11 +261,15 @@ jobs: Merge this PR **after** the promotion PR: $env:PROMOTE_PR_URL "@ - $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() + 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')" } @@ -238,5 +285,7 @@ jobs: | :-: | --- | --- | | 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 0d466b7ee..6ed8323c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: with: setAllVars: true - - name: Verify prerelease does not regress past stable + - name: Verify version matches branch policy shell: pwsh env: SEMVER2: ${{ steps.nbgv.outputs.SemVer2 }} @@ -70,7 +70,10 @@ jobs: REF_NAME: ${{ github.ref_name }} run: | $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true + + 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." @@ -80,7 +83,7 @@ jobs: Write-Host "Stable release ($env:SEMVER2); skipping prerelease regression check." exit 0 } - if ($env:SEMVER2 -notmatch '^(\d+)\.(\d+)\.\d+-') { + 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] @@ -93,29 +96,38 @@ jobs: $stableTags = git tag --list "$major.$minor.*" | Where-Object { $_ -match "^${majorEsc}\.${minorEsc}\.\d+$" } if ($stableTags) { $list = $stableTags -join ', ' - $msg = "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-Error "::error::$msg" - exit 1 + 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: Run Tests + run: dotnet test --no-restore --configuration Release DynamicData.sln + working-directory: src + - name: Pack - run: dotnet pack --no-restore --configuration Release DynamicData.sln + 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: | - dotnet nuget push -s ${{ env.SOURCE_URL }} -k ${{ env.NUGET_AUTH_TOKEN }} --skip-duplicate src/**/bin/Release/*.nupkg + $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 GitHub Release diff --git a/RELEASING.md b/RELEASING.md index 959b39170..da00e739f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -51,18 +51,18 @@ Open a PR targeting `main`. Merge. `release.yml` publishes `9.5.0-preview.N`. Th ### 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 the main-bump PR. -4. `release.yml` ships the first `9.5.x` patch (i.e. `9.5.1`) on the release branch; `main` continues at `9.6-preview.{height}`. +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, push an empty commit to the breaking-change PR (or close+reopen it) to re-trigger the check. +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. -2. The workflow opens a main-bump PR, creates `release/10.x` directly, and dispatches `release.yml` against `release/10.x` to publish the first `10.0.x` patch. -3. Merge the main-bump PR. +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: From 66bf93ce5e375e169ce25677c90ad78a0a9d9465 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 17 May 2026 18:09:17 -0700 Subject: [PATCH 10/15] Address round-3 adversarial review findings Round 3 (2 reviewers) found 4 real bugs introduced by round-2 fixes: pr-version-check: bot bump PRs trigger the unconditional check The new unconditional major-change check would block exactly the PRs that bump-major-preview and cut-major open: they legitimately change the major but aren't labeled 'breaking-change' (they're preparation for breaking, not breaking themselves). Added a bypass for PRs authored by github-actions[bot] with a head branch matching bot/bump-*. The bypass is narrow: it doesn't open a hole for arbitrary bot-authored PRs, just the specific automation pattern. pr-version-check: no-stable-tags branch off-by-one The fail-closed path required head_major == base_major + 1 even in the post-bump steady state where both are already advanced. This would block every breaking-change PR after bump-major-preview merged on any project with no prior stable tags. Now allows head == base (post-bump steady state) OR head == base + 1 (PR does its own bump). promote-minor merge: empty-conflicts case mishandled If 'git merge --no-commit' failed for a reason other than file conflicts (transport error, corrupted object), the code fell through to 'git checkout --theirs version.json' with nothing to resolve, producing a misleading error. Now explicitly detects the empty- conflicts case, aborts the merge cleanly, and rethrows with a diagnostic that points at the real cause. promote-minor verify step: missing $LASTEXITCODE checks The 'Verify branch and main still at validated SHAs' step had two 'git rev-parse' calls without exit-code checks. If either failed transiently, .Trim() returned '' and the comparison would fire the 'moved' error with empty SHAs, misdiagnosing the actual problem. Added exit-code checks consistent with the sibling workflows. --- .github/workflows/pr-version-check.yml | 23 ++++++++++++++++++----- .github/workflows/promote-minor.yml | 6 ++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-version-check.yml b/.github/workflows/pr-version-check.yml index 4b0b9f231..c8e45af60 100644 --- a/.github/workflows/pr-version-check.yml +++ b/.github/workflows/pr-version-check.yml @@ -24,6 +24,8 @@ jobs: 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' @@ -32,6 +34,16 @@ jobs: exit 0 } + # The version-bump bot PRs (opened by GITHUB_TOKEN from the automation workflows) + # legitimately change the major in version.json. Don't trip the unconditional + # major-change check 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-*') + if ($isBotBumpPr) { + Write-Host "PR is an automation-authored version-bump branch ('$env:HEAD_REF'); skipping check." + exit 0 + } + $labels = $env:LABELS_JSON | ConvertFrom-Json $isBreaking = $labels -contains 'breaking-change' @@ -74,13 +86,14 @@ jobs: Select-Object -First 1 if (-not $latestStable) { - # No prior stable releases. Use main's current major as the floor, so a brand-new - # project's first breaking PR is allowed when it advances main by exactly one. - if ($headMajor -eq $baseMajor + 1) { - Write-Host "No stable tags found; PR head major ($headMajor) is exactly one greater than main ($baseMajor). OK." + # 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 be exactly one greater than main's current major ($baseMajor) for a breaking-change PR with no prior stable history." + 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+)\.') { diff --git a/.github/workflows/promote-minor.yml b/.github/workflows/promote-minor.yml index 11cf18728..a43eb670f 100644 --- a/.github/workflows/promote-minor.yml +++ b/.github/workflows/promote-minor.yml @@ -128,6 +128,10 @@ jobs: $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 ', ' @@ -177,7 +181,9 @@ jobs: 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." } From 375cca25badd5118408bd67c888a11a772f14269 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 25 May 2026 10:35:43 -0700 Subject: [PATCH 11/15] Address PR review feedback - Stable releases now publish X.Y.0 (semver convention) instead of X.Y.1 via versionHeightOffset: -1 written by cut-major/promote-minor on release branches. Previews unchanged (preview.1 first, per .NET ecosystem convention). - pr-version-check rejects any human-authored PR that modifies version.json unless labeled manual-version-edit. Bot PRs (bot/bump-*, bot/promote-*) are exempted. - Add 'Cherry-picking a fix from main to a release branch' to Day-to-day flows. - Remove branch-protection recommendation from RELEASING.md (one-time admin task; belongs elsewhere). - Remove initial cutover section from RELEASING.md (one-time task; PR description has it). - Drop redundant int-then-string cycling after regex validation in cut-major and bump-major-preview (caught by review). --- .github/workflows/bump-major-preview.yml | 1 - .github/workflows/cut-major.yml | 11 +++--- .github/workflows/pr-version-check.yml | 34 +++++++++++++----- .github/workflows/promote-minor.yml | 10 ++++-- RELEASING.md | 46 ++++++------------------ 5 files changed, 49 insertions(+), 53 deletions(-) diff --git a/.github/workflows/bump-major-preview.yml b/.github/workflows/bump-major-preview.yml index 11af8910a..78ecad33c 100644 --- a/.github/workflows/bump-major-preview.yml +++ b/.github/workflows/bump-major-preview.yml @@ -43,7 +43,6 @@ jobs: throw "next_major must be a positive integer with no leading zeros (got: '$next')" } $nextInt = [int]$next - $next = [string]$nextInt git fetch --tags --force origin if ($LASTEXITCODE -ne 0) { throw "git fetch --tags failed (exit $LASTEXITCODE); cannot validate sequential-major rule." } diff --git a/.github/workflows/cut-major.yml b/.github/workflows/cut-major.yml index b0a00d927..afa31b40e 100644 --- a/.github/workflows/cut-major.yml +++ b/.github/workflows/cut-major.yml @@ -49,7 +49,6 @@ jobs: throw "major_version must be a positive integer with no leading zeros (got: '$major')" } $cutMajor = [int]$major - $major = [string]$cutMajor $releaseBranch = "release/$major.x" $stableVersion = "$major.0" @@ -204,12 +203,16 @@ jobs: $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") - $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(//[^\r\n]*)?\s*(\r?\n|$)', '') + if ($content -match '"versionHeightOffset"') { + $content = [regex]::Replace($content, '"versionHeightOffset"\s*:\s*-?\d+', '"versionHeightOffset": -1') + } else { + $content = [regex]::Replace($content, '(?m)^(\s*)"version":\s*"[^"]+"\s*,?', "`$0`r`n`$1`"versionHeightOffset`": -1,", 1) + } $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." + if ($obj.versionHeightOffset -ne -1) { + throw "Failed to set versionHeightOffset to -1 (got: $($obj.versionHeightOffset)). Manual edit required." } - name: Commit and push release branch diff --git a/.github/workflows/pr-version-check.yml b/.github/workflows/pr-version-check.yml index c8e45af60..0384f50d0 100644 --- a/.github/workflows/pr-version-check.yml +++ b/.github/workflows/pr-version-check.yml @@ -29,24 +29,40 @@ jobs: 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 } - # The version-bump bot PRs (opened by GITHUB_TOKEN from the automation workflows) - # legitimately change the major in version.json. Don't trip the unconditional - # major-change check 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-*') if ($isBotBumpPr) { - Write-Host "PR is an automation-authored version-bump branch ('$env:HEAD_REF'); skipping check." + Write-Host "PR is an automation-authored version-bump branch ('$env:HEAD_REF'); skipping breaking-change check." exit 0 } - $labels = $env:LABELS_JSON | ConvertFrom-Json - $isBreaking = $labels -contains 'breaking-change' - $headVersionJson = Get-Content version.json -Raw | ConvertFrom-Json $headVer = $headVersionJson.version if ($headVer -notmatch '^(0|[1-9]\d*)\.') { diff --git a/.github/workflows/promote-minor.yml b/.github/workflows/promote-minor.yml index a43eb670f..9018993f1 100644 --- a/.github/workflows/promote-minor.yml +++ b/.github/workflows/promote-minor.yml @@ -155,12 +155,16 @@ jobs: $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") - $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(//[^\r\n]*)?\s*(\r?\n|$)', '') + if ($content -match '"versionHeightOffset"') { + $content = [regex]::Replace($content, '"versionHeightOffset"\s*:\s*-?\d+', '"versionHeightOffset": -1') + } else { + $content = [regex]::Replace($content, '(?m)^(\s*)"version":\s*"[^"]+"\s*,?', "`$0`r`n`$1`"versionHeightOffset`": -1,", 1) + } $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." + if ($obj.versionHeightOffset -ne -1) { + throw "Failed to set versionHeightOffset to -1 (got: $($obj.versionHeightOffset)). Manual edit required." } - name: Commit and push promotion branch diff --git a/RELEASING.md b/RELEASING.md index da00e739f..9db51c8e6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -16,7 +16,7 @@ Every PR merged to either branch publishes a NuGet package automatically via `.g 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 (so the commit that sets the version is published as `X.Y.1`, not `X.Y.0`). +- 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 @@ -25,8 +25,8 @@ All `version.json` edits and release-branch creation are scripted via `workflow_ | Workflow | When to run | What it does | |---|---|---| -| **Promote main to stable minor** (`promote-minor.yml`) | Ready to ship the next minor (first published patch is `X.Y.1`) from `main` to an existing release branch. | Opens two PRs: (1) merges `main` into `release/.x` and sets stable version (contains the full diff, NOT mechanical); (2) bumps `main` to the next preview minor (mechanical). | -| **Cut major release** (`cut-major.yml`) | Ready to ship a new major as stable from `main` (first published patch is `X.0.1`). | Opens a main-bump PR first, then creates `release/.x` with the stable version, then dispatches `release.yml` to publish the first patch. | +| **Promote main to stable minor** (`promote-minor.yml`) | Ready to ship the next minor (first published patch is `X.Y.0`) from `main` to an existing release branch. | Opens two PRs: (1) merges `main` into `release/.x` and sets stable version (contains the full diff, NOT mechanical); (2) bumps `main` to the next preview minor (mechanical). | +| **Cut major release** (`cut-major.yml`) | Ready to ship a new major as stable from `main` (first published patch is `X.0.0`). | Opens a main-bump PR first, then creates `release/.x` with the stable version, then dispatches `release.yml` to publish the first patch. | | **Bump main to next major preview** (`bump-major-preview.yml`) | First breaking change is about to land on `main`. | Opens a PR bumping `main` from `.Y-preview.{height}` to `.0-preview.{height}`. | Two passive guards run on every PR / release: @@ -36,10 +36,6 @@ Two passive guards run on every PR / release: | **PR version check** (`pr-version-check.yml`) | Pull request to `main` / `release/*.x`. | For PRs to `main` labeled `breaking-change`, fails the check unless the PR's `version.json` major is **exactly one greater** than the latest stable tag's major (no skipping). For PRs to `release/*.x`, the check is skipped (breaking changes are a main-only concern). | | **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. | -### Recommended branch protection - -To make the **PR version check** binding (not advisory), add `PR version check / check` as a required status check on `main` in branch protection settings. - ## Day-to-day flows ### Patch on the current stable line (e.g. `9.4.38`) @@ -48,6 +44,13 @@ Open a PR targeting `release/9.x`. Merge. `release.yml` publishes `9.4.N` to NuG ### 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. @@ -70,35 +73,6 @@ The automation workflows are thin wrappers around `version.json` edits. If somet - **`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. -## Initial cutover (one-time, when introducing this infrastructure) - -This is **the only time manual steps are required**, because `release/9.x` does not yet exist when this PR merges. - -After this PR merges to `main`: - -1. `main` will start publishing `9.5.0-preview.N` (the version bump in this PR took effect). -2. Cut `release/9.x` from `main` HEAD: - ```sh - git fetch origin - git checkout -b release/9.x origin/main - ``` -3. On `release/9.x`, edit `version.json`: - - Change `"version"` from `"9.5-preview.{height}"` back to `"9.4"`. - - Add `"versionHeightOffset": ` where `` is the patch number of the latest stable `9.4.x` tag at cutover time. Verify with `git tag --list '9.4.*' | sort -V | tail -1`. The next stable build will be `9.4.`. (At PR author time this was `37`; verify before pushing.) - - The full file should look like: - ```jsonc - { - "version": "9.4", - "versionHeightOffset": 37, - "publicReleaseRefSpec": [ /* unchanged */ ], - "nugetPackageVersion": { /* unchanged */ }, - "cloudBuild": { /* unchanged */ } - } - ``` -4. Commit and push `release/9.x`. The next build produces `9.4.` stable. - -From this point on, all subsequent releases use the automation workflows above. No more manual `version.json` edits. - ## 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. From 939124ce8b84509228a21c3183c71da83db5ac31 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 25 May 2026 11:07:33 -0700 Subject: [PATCH 12/15] Harden SemVer regex against version-last-property edge case The previous insertion logic relied on a trailing comma being present on the version line. If a future reorganization placed 'version' as the LAST property in version.json (no trailing comma), the trailing-comma cleanup would strip the comma between version and the inserted versionHeightOffset, producing invalid JSON. New logic absorbs any existing comma in the version-line match and emits both properties with their own commas; the cleanup then drops a trailing comma only if it lands before a closing brace. Verified against 7 edge cases including last-property, idempotency, CRLF, tabs, and existing-offset replacement. --- .github/workflows/cut-major.yml | 4 ++-- .github/workflows/promote-minor.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cut-major.yml b/.github/workflows/cut-major.yml index afa31b40e..3f7d6a82a 100644 --- a/.github/workflows/cut-major.yml +++ b/.github/workflows/cut-major.yml @@ -202,11 +202,11 @@ jobs: $ErrorActionPreference = 'Stop' $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) - $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") 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*,?', "`$0`r`n`$1`"versionHeightOffset`": -1,", 1) + $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) diff --git a/.github/workflows/promote-minor.yml b/.github/workflows/promote-minor.yml index 9018993f1..e4ef65ba0 100644 --- a/.github/workflows/promote-minor.yml +++ b/.github/workflows/promote-minor.yml @@ -154,11 +154,11 @@ jobs: $ErrorActionPreference = 'Stop' $path = 'version.json' $content = [System.IO.File]::ReadAllText($path) - $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:STABLE_VERSION`"") 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*,?', "`$0`r`n`$1`"versionHeightOffset`": -1,", 1) + $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) From ee6502bf5fcbdabe7b9dac7d5ce088df6f33155c Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 25 May 2026 11:36:11 -0700 Subject: [PATCH 13/15] Address PR review: orphan guard, drop PR-build pack/upload ci-build.yml: drop the Pack and Create NuGet Artifacts steps. PR builds gate on compile and test; producing and uploading nupkgs on every PR adds runtime, costs storage, and exposes a known intermittent silent-failure in upload-artifact@v7 (actions/upload-artifact#806). release.yml still packs and pushes on merge to main/release/*.x, which is when artifacts actually matter. bump-major-preview.yml: add pre-flight orphan check. If main is on X.Y-preview and release/X.x does not exist (or is on an older minor), the workflow now refuses to bump main to the next major unless the operator passes discard_current_preview=true. Prevents silently abandoning an in-progress preview line. RELEASING.md: expand workflow reference with a quick decision table and a full inputs/prereqs/output table. --- .github/workflows/bump-major-preview.yml | 38 ++++++++++++++++++++++++ .github/workflows/ci-build.yml | 10 ------- RELEASING.md | 26 ++++++++++++---- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/.github/workflows/bump-major-preview.yml b/.github/workflows/bump-major-preview.yml index 78ecad33c..b86282df3 100644 --- a/.github/workflows/bump-major-preview.yml +++ b/.github/workflows/bump-major-preview.yml @@ -7,6 +7,11 @@ on: 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 @@ -21,6 +26,7 @@ jobs: 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 @@ -57,10 +63,42 @@ jobs: 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 diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 8fd85e18d..798fa39c4 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -54,13 +54,3 @@ jobs: - name: Run Tests run: dotnet test --no-restore --configuration Release DynamicData.sln working-directory: src - - - name: Pack - run: dotnet pack --no-restore --configuration Release DynamicData.sln - working-directory: src - - - name: Create NuGet Artifacts - uses: actions/upload-artifact@v7 - with: - name: nuget - path: 'src/**/bin/Release/*.nupkg' diff --git a/RELEASING.md b/RELEASING.md index 9db51c8e6..df505c751 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -23,17 +23,31 @@ NBGV walks the first-parent history from `HEAD` back to the commit where `versio 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. -| Workflow | When to run | What it does | -|---|---|---| -| **Promote main to stable minor** (`promote-minor.yml`) | Ready to ship the next minor (first published patch is `X.Y.0`) from `main` to an existing release branch. | Opens two PRs: (1) merges `main` into `release/.x` and sets stable version (contains the full diff, NOT mechanical); (2) bumps `main` to the next preview minor (mechanical). | -| **Cut major release** (`cut-major.yml`) | Ready to ship a new major as stable from `main` (first published patch is `X.0.0`). | Opens a main-bump PR first, then creates `release/.x` with the stable version, then dispatches `release.yml` to publish the first patch. | -| **Bump main to next major preview** (`bump-major-preview.yml`) | First breaking change is about to land on `main`. | Opens a PR bumping `main` from `.Y-preview.{height}` to `.0-preview.{height}`. | +### 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`. | For PRs to `main` labeled `breaking-change`, fails the check unless the PR's `version.json` major is **exactly one greater** than the latest stable tag's major (no skipping). For PRs to `release/*.x`, the check is skipped (breaking changes are a main-only concern). | +| **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 From 05004acb0af8df418b199d8008a3e9c9b074f39e Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 25 May 2026 11:56:55 -0700 Subject: [PATCH 14/15] Restore Pack + upload-artifact on PR build, pin to v6.0.0 Reverting the previous drop. Pack-on-PR is a real correctness check (catches packaging-only failures: invalid PackageLicenseExpression, missing nuspec metadata, broken Pack='true' inclusions) that would otherwise blow up only at release time, after merge. Artifact upload kept for 'try this PR' downstream testing workflows and SLSA-style build trail transparency. Pinned to actions/upload-artifact@v6.0.0 (not v7) to avoid the ESM-rewrite silent-failure regression seen on the previous PR build (actions/upload-artifact#806). v6 runs on Node 24 so it doesn't sunset with Node 20 in September. Matches the repo convention of tag-pinning trusted publishers (compare actions/setup-dotnet@v5.0.1). --- .github/workflows/ci-build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 798fa39c4..28f9bbd8e 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -54,3 +54,13 @@ jobs: - name: Run Tests run: dotnet test --no-restore --configuration Release DynamicData.sln working-directory: src + + - name: Pack + run: dotnet pack --no-restore --configuration Release DynamicData.sln + working-directory: src + + - name: Create NuGet Artifacts + uses: actions/upload-artifact@v6.0.0 + with: + name: nuget + path: 'src/**/bin/Release/*.nupkg' From acde646b8832eea95c879917477a3ffae3468e6b Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 25 May 2026 11:59:15 -0700 Subject: [PATCH 15/15] Pin actions/upload-artifact to v4.6.2 Avoids the v7 ESM silent-failure regression seen on the previous PR build (actions/upload-artifact#806). v4 runs on Node 20, which is being removed from the Actions runner on Sept 16, 2026. Tracked by #1089. --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 28f9bbd8e..ebafe1cf8 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -60,7 +60,7 @@ jobs: working-directory: src - name: Create NuGet Artifacts - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v4.6.2 with: name: nuget path: 'src/**/bin/Release/*.nupkg'