|
| 1 | +name: Bump main to next major preview |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + next_major: |
| 7 | + description: 'Next major version (e.g., 10) for the new preview line on main' |
| 8 | + required: true |
| 9 | + type: string |
| 10 | + discard_current_preview: |
| 11 | + 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.' |
| 12 | + required: false |
| 13 | + type: boolean |
| 14 | + default: false |
| 15 | + |
| 16 | +permissions: |
| 17 | + contents: write |
| 18 | + pull-requests: write |
| 19 | + |
| 20 | +concurrency: |
| 21 | + group: main-version-mutator |
| 22 | + cancel-in-progress: false |
| 23 | + |
| 24 | +jobs: |
| 25 | + bump: |
| 26 | + runs-on: ubuntu-latest |
| 27 | + env: |
| 28 | + NEXT_MAJOR_INPUT: ${{ inputs.next_major }} |
| 29 | + DISCARD_PREVIEW_INPUT: ${{ inputs.discard_current_preview }} |
| 30 | + steps: |
| 31 | + - name: Checkout |
| 32 | + uses: actions/checkout@v6 |
| 33 | + with: |
| 34 | + ref: main |
| 35 | + fetch-depth: 0 |
| 36 | + |
| 37 | + - name: Configure git |
| 38 | + run: | |
| 39 | + git config user.email "github-actions[bot]@users.noreply.github.com" |
| 40 | + git config user.name "github-actions[bot]" |
| 41 | +
|
| 42 | + - name: Validate inputs |
| 43 | + shell: pwsh |
| 44 | + run: | |
| 45 | + $ErrorActionPreference = 'Stop' |
| 46 | +
|
| 47 | + $next = $env:NEXT_MAJOR_INPUT |
| 48 | + if ($next -notmatch '^(0|[1-9]\d*)$') { |
| 49 | + throw "next_major must be a positive integer with no leading zeros (got: '$next')" |
| 50 | + } |
| 51 | + $nextInt = [int]$next |
| 52 | +
|
| 53 | + git fetch --tags --force origin |
| 54 | + if ($LASTEXITCODE -ne 0) { throw "git fetch --tags failed (exit $LASTEXITCODE); cannot validate sequential-major rule." } |
| 55 | +
|
| 56 | + $mainHeadSha = (git rev-parse origin/main).Trim() |
| 57 | + if ($LASTEXITCODE -ne 0 -or -not $mainHeadSha) { throw "git rev-parse origin/main failed (exit $LASTEXITCODE)." } |
| 58 | +
|
| 59 | + $versionJson = git show "${mainHeadSha}:version.json" | ConvertFrom-Json |
| 60 | + if ($LASTEXITCODE -ne 0) { throw "Failed to read version.json from origin/main." } |
| 61 | + $ver = $versionJson.version |
| 62 | + if ($ver -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)-preview') { |
| 63 | + throw "main's version is '$ver' but should be '<major>.<minor>-preview.{height}'" |
| 64 | + } |
| 65 | + $currentMajor = [int]$matches[1] |
| 66 | + $currentMinor = [int]$matches[2] |
| 67 | + if ($nextInt -le $currentMajor) { |
| 68 | + throw "next_major ($nextInt) must be greater than current major ($currentMajor)" |
| 69 | + } |
| 70 | +
|
| 71 | + # Pre-flight: detect whether the current major's preview work would be orphaned. |
| 72 | + # The only workflow that creates release branches is cut-major.yml, and it requires |
| 73 | + # main to be on <major>.0-preview. If main has moved past .0-preview without the |
| 74 | + # major ever being cut (or has accumulated minor work past the existing release |
| 75 | + # branch's stable minor), bumping to the next major silently loses that work. |
| 76 | + $discardOverride = $env:DISCARD_PREVIEW_INPUT -eq 'true' |
| 77 | + $releaseBranch = "release/$currentMajor.x" |
| 78 | + $releaseExists = git ls-remote --heads origin "refs/heads/$releaseBranch" |
| 79 | + if ($LASTEXITCODE -ne 0) { throw "git ls-remote for $releaseBranch failed (exit $LASTEXITCODE); cannot verify orphan state." } |
| 80 | +
|
| 81 | + if (-not $releaseExists) { |
| 82 | + $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." |
| 83 | + if (-not $discardOverride) { throw $msg } |
| 84 | + Write-Host "WARNING (discard_current_preview=true): release/$currentMajor.x does not exist. All $currentMajor.x work is being orphaned." |
| 85 | + } else { |
| 86 | + $releaseVerJson = git show "origin/${releaseBranch}:version.json" | ConvertFrom-Json |
| 87 | + if ($LASTEXITCODE -ne 0) { throw "Failed to read version.json from origin/$releaseBranch." } |
| 88 | + $releaseVer = $releaseVerJson.version |
| 89 | + if ($releaseVer -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)$') { |
| 90 | + throw "'$releaseBranch' version is '$releaseVer'; expected stable '<major>.<minor>' form. Cannot verify orphan state." |
| 91 | + } |
| 92 | + $releaseMinor = [int]$matches[2] |
| 93 | + if ($currentMinor -gt $releaseMinor) { |
| 94 | + $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." |
| 95 | + if (-not $discardOverride) { throw $msg } |
| 96 | + Write-Host "WARNING (discard_current_preview=true): orphaning $currentMajor.$currentMinor preview work (release/$currentMajor.x is at $currentMajor.$releaseMinor)." |
| 97 | + } else { |
| 98 | + Write-Host "OK: $releaseBranch is at $currentMajor.$releaseMinor stable and main is on $currentMajor.$currentMinor-preview ($currentMinor <= $releaseMinor); no orphaning." |
| 99 | + } |
| 100 | + } |
| 101 | +
|
| 102 | + $latestStable = git tag --list --sort=-v:refname | |
| 103 | + Where-Object { $_ -match '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } | |
| 104 | + Select-Object -First 1 |
| 105 | +
|
| 106 | + if ($latestStable) { |
| 107 | + if ($latestStable -notmatch '^(\d+)\.') { |
| 108 | + throw "Could not parse latest stable tag: '$latestStable'" |
| 109 | + } |
| 110 | + $latestStableMajor = [int]$matches[1] |
| 111 | + $expectedMajor = $latestStableMajor + 1 |
| 112 | + if ($nextInt -ne $expectedMajor) { |
| 113 | + 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." |
| 114 | + } |
| 115 | + Write-Host "OK: next_major ($nextInt) is exactly one greater than latest stable major ($latestStableMajor)." |
| 116 | + } else { |
| 117 | + $expectedMajor = $currentMajor + 1 |
| 118 | + if ($nextInt -ne $expectedMajor) { |
| 119 | + throw "No stable tags found; falling back to comparing against main's current major ($currentMajor). next_major ($nextInt) must be exactly $expectedMajor." |
| 120 | + } |
| 121 | + Write-Host "No stable tags found; next_major ($nextInt) matches currentMajor+1 ($expectedMajor)." |
| 122 | + } |
| 123 | +
|
| 124 | + $newVersion = "$next.0-preview.{height}" |
| 125 | + $runId = $env:GITHUB_RUN_ID |
| 126 | + $runAttempt = $env:GITHUB_RUN_ATTEMPT |
| 127 | + Add-Content -Path $env:GITHUB_ENV -Value "NEW_VERSION=$newVersion" |
| 128 | + Add-Content -Path $env:GITHUB_ENV -Value "NEXT_MAJOR=$next" |
| 129 | + Add-Content -Path $env:GITHUB_ENV -Value "BUMP_BRANCH=bot/bump-major-$next-$runId-$runAttempt" |
| 130 | + Add-Content -Path $env:GITHUB_ENV -Value "MAIN_HEAD_SHA=$mainHeadSha" |
| 131 | +
|
| 132 | + - name: Create bump branch (from validated main SHA) |
| 133 | + shell: pwsh |
| 134 | + run: | |
| 135 | + $ErrorActionPreference = 'Stop' |
| 136 | + git checkout "$env:MAIN_HEAD_SHA" |
| 137 | + if ($LASTEXITCODE -ne 0) { throw "git checkout failed (exit $LASTEXITCODE)." } |
| 138 | + git checkout -b "$env:BUMP_BRANCH" |
| 139 | + if ($LASTEXITCODE -ne 0) { throw "git checkout -b failed (exit $LASTEXITCODE)." } |
| 140 | +
|
| 141 | + - name: Bump version.json |
| 142 | + shell: pwsh |
| 143 | + run: | |
| 144 | + $ErrorActionPreference = 'Stop' |
| 145 | + $path = 'version.json' |
| 146 | + $content = [System.IO.File]::ReadAllText($path) |
| 147 | + $content = [regex]::Replace($content, '"version":\s*"[^"]+"', "`"version`": `"$env:NEW_VERSION`"") |
| 148 | + $content = [regex]::Replace($content, '(?m)^\s*"versionHeightOffset"\s*:\s*-?\d+\s*,?\s*(//[^\r\n]*)?\s*(\r?\n|$)', '') |
| 149 | + $content = [regex]::Replace($content, ',(\s*[}\]])', '$1') |
| 150 | + [System.IO.File]::WriteAllText($path, $content) |
| 151 | + $obj = $content | ConvertFrom-Json |
| 152 | + if ($obj.PSObject.Properties.Name -contains 'versionHeightOffset') { |
| 153 | + throw "Failed to strip versionHeightOffset from version.json. Manual edit required." |
| 154 | + } |
| 155 | +
|
| 156 | + - name: Commit and push |
| 157 | + shell: pwsh |
| 158 | + run: | |
| 159 | + $ErrorActionPreference = 'Stop' |
| 160 | + git add version.json |
| 161 | + if ($LASTEXITCODE -ne 0) { throw "git add failed (exit $LASTEXITCODE)." } |
| 162 | + git commit -m "Bump main to $env:NEW_VERSION (next major preview line)" |
| 163 | + if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)." } |
| 164 | + git push --force-with-lease -u origin "$env:BUMP_BRANCH" |
| 165 | + if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)." } |
| 166 | +
|
| 167 | + - name: Verify main hasn't moved since validation |
| 168 | + shell: pwsh |
| 169 | + run: | |
| 170 | + $ErrorActionPreference = 'Stop' |
| 171 | + git fetch origin main --quiet |
| 172 | + if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)." } |
| 173 | + $currentMainSha = (git rev-parse origin/main).Trim() |
| 174 | + if ($currentMainSha -ne $env:MAIN_HEAD_SHA) { |
| 175 | + 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." |
| 176 | + } |
| 177 | + Write-Host "OK: main is still at $env:MAIN_HEAD_SHA." |
| 178 | +
|
| 179 | + - name: Open PR |
| 180 | + env: |
| 181 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 182 | + shell: pwsh |
| 183 | + run: | |
| 184 | + $ErrorActionPreference = 'Stop' |
| 185 | + $nextMajor = $env:NEXT_MAJOR |
| 186 | + $body = @" |
| 187 | + Bumps ``main`` from its current preview to ``$env:NEW_VERSION`` ahead of upcoming breaking changes for ``${nextMajor}.0``. |
| 188 | +
|
| 189 | + Merge this **before** any PR labeled ``breaking-change`` so the prereleases are correctly versioned as ``${nextMajor}.0.0-preview.N``. |
| 190 | + "@ |
| 191 | + try { |
| 192 | + $url = (gh pr create ` |
| 193 | + --base main ` |
| 194 | + --head "$env:BUMP_BRANCH" ` |
| 195 | + --title "Bump main to $env:NEW_VERSION (next major preview)" ` |
| 196 | + --body $body | Select-Object -Last 1).Trim() |
| 197 | + } catch { |
| 198 | + throw "gh pr create failed: $($_.Exception.Message)" |
| 199 | + } |
| 200 | + if ($LASTEXITCODE -ne 0 -or -not $url -or $url -notmatch '^https?://') { |
| 201 | + throw "gh pr create did not return a valid URL (exit $LASTEXITCODE; got: '$url')" |
| 202 | + } |
| 203 | + Add-Content -Path $env:GITHUB_ENV -Value "PR_URL=$url" |
| 204 | +
|
| 205 | + - name: Summary |
| 206 | + shell: pwsh |
| 207 | + run: | |
| 208 | + $summary = @" |
| 209 | + ## Major preview bump |
| 210 | +
|
| 211 | + - **PR**: $env:PR_URL |
| 212 | + - Merge this before landing the first breaking-change PR. |
| 213 | + "@ |
| 214 | + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary |
0 commit comments