Commit 335ecbc
authored
Add Preview workflows and enforce versioning rules (#1088)
* 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.
* 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/<major>.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).
* 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/<major>.x.
Opens two PRs (promotion + main bump to next preview).
- cut-major.yml: creates a new release/<major>.x branch at <major>.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).
* 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).
* 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.
* 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.
* 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/<digits>.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.<minor>=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.
* 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.
* 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).
* 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.
* 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).
* 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.
* 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.
* 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).
* 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.1 parent 28adb4f commit 335ecbc
8 files changed
Lines changed: 1106 additions & 29 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
10 | 9 | | |
11 | 10 | | |
12 | 11 | | |
| |||
40 | 39 | | |
41 | 40 | | |
42 | 41 | | |
43 | | - | |
| 42 | + | |
44 | 43 | | |
45 | 44 | | |
46 | 45 | | |
| |||
61 | 60 | | |
62 | 61 | | |
63 | 62 | | |
64 | | - | |
| 63 | + | |
65 | 64 | | |
66 | 65 | | |
67 | | - | |
| 66 | + | |
0 commit comments