feat(release): commit-driven auto-generated changesets - #1753
Conversation
Adopt stock Changesets for versioning/changelog/publishing, with the only bespoke surface being a script that creates changesets automatically from Conventional Commits. Auto-generated changeset files never live on `main` — they are written to the CI working tree, consumed by changesets/action into a rolling Version PR, and discarded. Manually authored changesets still work. - scripts/create-changeset.mjs: derive per-package bumps from Conventional Commits (paths -> package, type -> bump). Includes the version-vs-tag guard so a merged Version PR publishes instead of re-opening a PR. - scripts/version.mjs: `changeset version` + append a `## Contributors` list to each bumped package's CHANGELOG.md (idempotent, pure rewrite unit-tested). - .github/workflows/release-pr.yml: version-only changesets/action (no publish). - .github/workflows/publish.yml: guarded OIDC publish (preserves `vp pm publish --provenance`); version now comes from `changeset version`. - Reconcile packages/vinext version (0.0.5 -> 0.0.55) so the guard is coherent. Unit tests for both scripts pass (35). The release orchestration (Version PR creation, OIDC publish, gh contributor resolution) can only be validated in a live CI run.
commit: |
|
Let changesets/action own as much as possible. Delete the separate publish.yml (guard job, manual bump, manual OIDC/tag/release/notify steps) and the release-pr.yml split. One workflow now: - create-changeset.mjs writes auto changesets to the working tree (its version-vs-tag guard yields nothing right after a Version PR merges). - changesets/action maintains the Version PR and, when no changesets remain, publishes via `changeset publish` with OIDC trusted publishing + provenance, and creates the git tag + GitHub Release. Removes the unused release:version script. Contributors list still handled by scripts/version.mjs as the action's version command.
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| fetch-depth: 0 # full history + tags for the guard and contributor list |
There was a problem hiding this comment.
changesets/action pushes the Version PR branch, tags, and GitHub Releases using credentials persisted by actions/checkout, so persist-credentials: false would break it. The # zizmor: ignore[artipacked] above the step suppresses this; the job uploads no artifacts, so the credential-in-artifact leak vector does not apply.
Replace the .mjs + hand-written .d.mts declaration pairs with real TypeScript
(.mts) source. Node >=24 (the setup default) runs .mts directly via native type
stripping; .mts is unambiguously ESM so it needs no "type": "module" and emits
no MODULE_TYPELESS warning. The cross-import uses an explicit .mts specifier
(Node requires it), permitted in tsc via allowImportingTsExtensions (safe: the
project is noEmit).
- scripts/create-changeset.{mjs,d.mts} -> scripts/create-changeset.mts
- scripts/version.{mjs,d.mts} -> scripts/version.mts
- tsconfig: allowImportingTsExtensions
- release.yml: run node scripts/*.mts
vp check (format + lint + types) clean; 35/35 unit tests pass.
Cut comment bloat, remove dead code, tighten without dropping behavior: - Condense verbose JSDoc/@PARAM blocks to one-line purpose comments; keep the load-bearing "why" (correctness-rule header, insertContributors idempotency). - Delete unused `newestChangelogVersion` (+ its tests) — exported/tested but never called. - Tighten run()/insertContributors/latestTagVersion without behavior change. Multi-package machinery and the bottom `## Contributors` list are retained per requirements. vp check clean; 33 unit tests pass.
The default changesets changelog groups by bump level (### Minor Changes) and renders our changeset summary as a nested bullet dump. Replace it with a real conventional-commits changelog: version.mts now regroups each release's commits into ### Features / ### Bug Fixes / ### Performance sections (scope bolded, type prefix dropped) and rewrites the newest CHANGELOG section, then appends the ## Contributors list with [bot] accounts filtered out. - create-changeset.mts: extract conventionalParts + collectReleaseCommits + releaseRangeStart and export the commit-walk so version.mts reuses it (no duplicate git logic). - version.mts: groupedChangelogBody + rewriteReleaseSection (idempotent: only `## <digit>` is a section boundary); dedupeSortLogins drops `[bot]` logins. - Tests updated for the new pure builders. vp check clean; 33 unit tests pass. Verified end-to-end with a local dry run.
- discoverPublishablePackages: scan only packages/* (publishable packages don't live elsewhere), drop the apps/examples/benchmarks scan. - Delete isReleaseCommit: the "chore: version packages" release commit is a `chore`, already excluded by parseBumpFromSubject returning null. No need to hardcode release-message formats. vp check clean; 31 unit tests pass.
Per PR review: the guard was confusing. Document inline that it only suppresses the post-merge/pre-publish window (version bumped, tag not yet created), which would otherwise re-open a Version PR instead of publishing. Keeping the never-on-main design per review decision.
Within each type section (### Features / ### Bug Fixes / …), areas (commit scopes) with more than 3 items now get their own `#### <Area>` sub-group with the scope prefix dropped; smaller areas and scopeless commits fall under `#### Other`. When no area qualifies the list stays flat. Area names are humanized (app-router → "App Router", css → "CSS", ppr → "PPR", i18n → "i18n") in headings and in the bold prefixes. Also: groupedChangelogBody now renders only known release types (GROUPS) — it no longer leaks non-release types into an "Other Changes" bucket. vp check clean; 32 unit tests pass. Verified with a local dry run.
|
/bigbonk review |
There was a problem hiding this comment.
/bigbonk review — PR #1753
Solid, well-tested refactor. The two scripts are clean, the pure logic is properly unit-tested, and the single-workflow design is a nice simplification over the old publish.yml. vp check is clean and the script unit tests pass locally.
A few things worth addressing before merge — one of them (the no-tag bootstrap window) is a real correctness risk for the first release cycle.
High — the version-vs-tag guard fails when no tag exists yet
The guard in decideGeneration is the linchpin of the "never on main" design, but it only works once a release tag exists:
// scripts/create-changeset.mts:135
if (tagVersion == null) return { action: "generate", reason: "no release tag yet" };Today the repo has zero git tags (git tag -l is empty) but vinext is already published at 0.0.55 on npm (npm view vinext version → 0.0.55). Walk the first release cycle through:
- First push to
main→ no tags →latestTagVersionreturnsnull→releaseRangeStartfalls back tofirstCommit()and scans all history, generating a changeset listing the entire backlog. (I reproduced this:node scripts/create-changeset.mtson this branch generated a changeset with every conventional commit since the first commit.) That gives aminorbump →0.0.56. Probably tolerable as a one-time backfill, but worth being aware of. - Version PR merges →
package.jsonis now0.0.56, butchangeset publishhas not run yet on that same push (it runs in the nextchangesets/actioninvocation, which is the very push being processed). - On that post-merge push, the guard checks
0.0.56vslatestTagVersion. Because novinext@*orv*tag exists yet,tagVersion == null→generate, notskip. It then regenerates fromfirstCommit()again and re-opens a Version PR instead of letting publish run — exactly the failure the guard exists to prevent.
The guard's whole premise ("right after a Version PR merges, package.json is bumped but the tag isn't created yet") describes a window where tagVersion can legitimately be null (the first publish hasn't tagged anything). Treating null as unconditional generate defeats the guard in precisely that window.
Options:
- Seed a tag before enabling the workflow (e.g. create
vinext@0.0.55to match the published version), and document that bootstrapping requires an initial tag. The migration note already changes the tag scheme; this should be called out as a required setup step. - Or make the guard fall back to the published npm version (or the
package.jsonof the last release commit) when no tag exists, so the post-merge/pre-first-publish window is still suppressed.
At minimum, the PR's "first run" behavior should be validated against the no-tag state the repo is actually in, since the guard's unit tests only cover the tagVersion != null cases.
Medium — stale release-pr.yml / .mjs references in docs and comments
Several references point at files that don't exist on this branch (the workflow is release.yml and the scripts are .mts):
.changeset/README.md:16—scripts/create-changeset.mjs.changeset/README.md:17—.github/workflows/release-pr.yml.github/workflows/release.yml:4—create-changeset.mjs.github/workflows/release.yml:12—scripts/version.mjs
These will mislead anyone debugging the release flow. Suggest a sweep to .mts / release.yml.
Low — version.mts git() leaks fatal: noise to CI logs
create-changeset.mts deliberately pipes stderr so expected probe failures (missing tag) don't spam logs:
// scripts/create-changeset.mts:165
stdio: ["ignore", "pipe", "pipe"],But version.mts:166-168 uses the default stdio, so the same probe-style calls (e.g. tagRefFor → rev-parse --verify on a missing scoped tag, reached via collectReleaseCommits/releaseRangeStart) will print fatal: lines. Worth aligning the two git() helpers, or extracting a shared one in create-changeset.mts and importing it.
Low — resolveContributors makes one gh api call per commit
scripts/version.mts:192-208 loops over every SHA in the release range and issues a separate gh api repos/.../commits/<sha> call. For a release with many commits that's N serial round-trips and N units against the API rate limit. The GitHub "list commits" / "compare" / associated-PR APIs can return author logins in far fewer calls. Not blocking (it degrades gracefully on failure), but worth a follow-up if release ranges get large.
Nit — test count
The PR body says "Unit tests for both scripts: 35/35", but vp test run scripts/create-changeset.test.ts scripts/version.test.ts reports 32 passing here. Minor, just update the description (or the count drifted as tests were trimmed during review).
Nothing here blocks the design, which is good. The one I'd really want resolved before this goes live is the no-tag bootstrap behavior, since the repo is currently in exactly that state and the first release cycle is the one most likely to misfire.
|
Review posted to PR #1753. Summary of what I found: High — no-tag bootstrap window breaks the guard. The repo currently has zero git tags but Medium — stale references. Low — Low — Nit — PR says 35 tests, actual is 32. The design itself is sound and well-tested; the no-tag bootstrap is the one I'd want resolved before going live. |
- Fix stale references to deleted files: .mjs → .mts and release-pr.yml → release.yml in .changeset/README.md and the release.yml header comment. - resolveContributors: one paginated `gh api compare` call instead of one `gh api commits/<sha>` per commit (N round-trips → 1). This also removes version.mts's local git() helper, so no more `fatal:` probe noise from it. No behavior change to the changelog/contributor output (verified via dry run: same 4 contributors resolved). vp check clean; 32 unit tests pass. The bot's "no git tags" bootstrap concern does not apply: tags v0.0.10..v0.0.55 exist and CI checks out with fetch-depth: 0, so the guard resolves 0.0.55.
|
Thanks for the review — addressed: High — no-tag bootstrap: This does not apply to the real repo. The premise (" Medium — stale Low — Low — one Nit — test count: PR body updated 35 → 32 (count drifted as tests were trimmed during review).
|
|
/bigbonk review |
There was a problem hiding this comment.
/bigbonk review — PR #1753
I re-reviewed independently, focusing on the disputed "no-tag bootstrap" finding plus a fresh pass over what the workflow consolidation actually removed. Net: the design is sound and well-tested, the prior High finding is not a real-repo issue, but the workflow collapse dropped a pre-publish CI gate that's worth a deliberate decision, and there are two reproducibility nits.
Resolved — the "no-tag bootstrap" High finding does not apply to this repo
I verified the author's rebuttal directly rather than taking it on faith:
git ls-remote --tags originshowsv0.0.6 … v0.0.55(50 tags);npm view vinext version→0.0.55.release.ymlchecks out withfetch-depth: 0, which fetches tags.- With tags present,
releaseRangeStart("vinext")returnsv0.0.55, notfirstCommit(). Runningnode scripts/create-changeset.mtsthen diffsv0.0.55..HEADand emits only the unreleased commits — no all-history backfill. - The guard transitions correctly through the publish window: post-merge
package.json 0.0.56 > tag 0.0.55 → skip → publish; aftervinext@0.0.56is tagged,0.0.56 == 0.0.56 → generate. I checked the mixed legacy-v/ scoped-vinext@sort andtagRefForfallback (vinext@0.0.55absent → falls back tov0.0.55) — both correct.
The earlier reproduction ("scans all history") only happens in a tagless/shallow checkout, which is exactly the state of the GitHub Action runner, not the real release workflow. The previous reviewer ran against that artifact. No change needed for correctness here. (A latestTagVersion → npm version fallback would still be cheap defense-in-depth if a future runner ever fetches shallowly, but it's optional.)
The stale-ref, git() stderr, and N-call contributor fixes from the prior round all check out in the current tree.
Medium — the consolidated release.yml dropped the pre-publish CI gate
The deleted publish.yml gated publishing behind a real CI run:
ci:
needs: guard
uses: ./.github/workflows/ci.yml # Check + Vitest + Playwright
publish:
needs: [guard, ci]The new release.yml has no Check/Vitest/Playwright gate and no explicit vp run build step before changeset publish. The package ships files: ["dist"] with no committed dist, so the build only happens via the prepack script (cp README + vp run build) that pnpm publish fires.
Two things to weigh:
- No test gate before publish. Branch protection means code on
mainalready passed CI at merge time, but publish runs on a separate push (the Version PR merge) and never re-runs tests. Ifmainis ever red at the moment a Version PR merges, this publishes a broken build. The old flow explicitly guarded against that. If that's an acceptable tradeoff (rely on merge-time CI + provenance), fine — but it should be a conscious decision, not a silent drop. - Build relies entirely on
prepackfiring underchangeset publish. This is already on your "needs live-CI validation" list; calling it out as load-bearing: ifprepackdoesn't run (wrong cwd, PM quirk), you publish an emptydist. Worth a post-publish smoke check (e.g.npm pack --dry-run/ install the tarball) the first time.
Low — @changesets/cli is pinned as a devDependency but invoked via vp dlx (unpinned)
package.json adds "@changesets/cli": "2.31.0" and a "changeset": "changeset" script, but the CI paths bypass it:
release.yml:50→publish: vp dlx @changesets/cli publishversion.mts:206→execFileSync("vp", ["dlx", "@changesets/cli", "version"])
vp dlx resolves the latest published @changesets/cli at run time, so CI runs a different (floating) version than the one pinned in the lockfile and than .changeset/config.json's @changesets/config@3.1.1 schema. That's an avoidable reproducibility gap and a wasted download when the dep is already installed. Prefer the installed binary (vp exec changeset version / pnpm exec changeset publish, or pnpm changeset …) so the pinned 2.31.0 is what actually runs in both local and CI.
Nit — dead branch in affectedPackages
discoverPublishablePackages only ever returns packages/* keys, so the dir === "." / prefix === "" branch (create-changeset.mts:97-98) is unreachable for the real caller. Harmless (it's an exported pure util with tests), just noting it as no-longer-needed since the apps/examples scan was removed.
Confirmed locally: 32/32 script tests pass; vp check clean on the four script files. The publish-path items (CI gate, prepack build, OIDC) are the ones that can only be truly proven in the first live run — the Medium is the one I'd want an explicit decision on before merge.
|
Review posted to PR #1753. SummaryI verified the disputed finding directly and did a fresh pass over the workflow consolidation. Resolved — the "no-tag bootstrap" High finding is NOT a real-repo issue. The author's rebuttal is correct, confirmed by direct testing:
The earlier "scans all history" reproduction only happens in a tagless/shallow checkout (the CI runner's state), not the real release workflow. Prior stale-ref/stderr/contributor fixes also check out. New findings from my own pass:
Verified locally: 32/32 script tests pass, |
…anch Per second bonk review: - Invoke the pinned, installed @changesets/cli via `vp exec changeset` instead of `vp dlx @changesets/cli` (which fetches a floating latest at run time) — in release.yml publish and version.mts. Removes the reproducibility gap vs the lockfile-pinned 2.31.0. - affectedPackages: remove the unreachable `dir === "."` / empty-prefix branch now that discovery only returns `packages/*` keys. vp check clean; 32 unit tests pass.
|
Thanks for the independent re-review and confirming the bootstrap finding was an artifact of the tagless runner. Addressed the new pass: Medium — dropped pre-publish CI gate (deliberate decision): Keeping the single-workflow design and relying on the Version PR's required checks rather than re-adding an explicit gate. Rationale: publish runs on the merged Version PR, whose only diff is Low — floating Nit — dead
|
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
/bigbonk re-review — PR #1753
Re-reviewed the current tree (f751d93) after the last round of fixes. Verified prior findings are resolved and did a fresh pass. The design is sound and well-tested; nothing here blocks merge.
Verified resolved
@changesets/clinow runs pinned, not floating. Both call sites usevp exec changeset(release.yml:51,version.mts:207). I confirmedvp exec changeset --version→2.31.0after a fullvp install. (The binary is absent in a partial checkout, but.github/actions/setuprunsrun-install: true, so CI has it.)- Stale
.mjs/release-pr.ymlrefs fixed in.changeset/README.mdand therelease.ymlheader. git()stderr noise gone —version.mtsno longer defines its owngit(); it reuses the stderr-piping helpers fromcreate-changeset.mts.- Contributor resolution is one paginated
gh api comparecall (version.mts:181-198), not N per-SHA. - Dead
affectedPackages/dir === "."branch removed. - No-tag bootstrap is a non-issue for the real repo.
git ls-remote --tags originshows 50 legacyv0.0.6…v0.0.55tags;release.ymlchecks outfetch-depth: 0, solatestTagVersion("vinext")resolves0.0.55and the guard works. The tagless reproduction is only the CI-runner artifact. - Test count: 32/32 confirmed locally; PR body now matches.
vp checkclean on all four script files.
New (non-blocking) observations
Low — contributor name fallback produces malformed mentions. version.mts:190 uses .commits[].author.login // .commit.author.name. When a commit's GitHub author is null (unlinked email), the fallback is the raw git display name (with spaces), which dedupeSortLogins then renders as - @Full Name in the Contributors list — a broken @-mention. Degrades gracefully (no crash), but consider dropping non-login fallbacks (or only keeping entries matching /^[a-zA-Z0-9-]+$/) so the list contains valid handles only.
Low — vp dlx @changesets/cli still suggested in docs. .changeset/README.md:25 recommends vp dlx @changesets/cli for manually authoring changesets. This is the same floating-version pattern that was fixed in CI; since the dep is now pinned and installed, prefer suggesting pnpm changeset / vp exec changeset for consistency. Docs-only, harmless.
Note — publish runs without an explicit pre-publish test gate (acknowledged tradeoff). release.yml publishes on the Version-PR-merge push and relies on (a) the merge-time required checks on that PR and (b) ci.yml's own push-to-main run, which fires concurrently in a separate concurrency group. There's no ordering guarantee between that CI run and publish, but since the Version PR's only diff is package.json + CHANGELOG.md (no source), the published code is byte-identical to what passed CI at merge. Reasonable and documented; flagging only so it stays a conscious decision. The prepack-driven dist build remains the right thing to smoke-check on the first live release (already on the author's validation list).
Nothing here needs to block. The items that can only be proven in the first live run (OIDC publish, prepack build producing a non-empty dist, auto-*.md consumed-then-discarded) are correctly called out in the PR body.
|
Review posted to PR #1753. SummaryI re-reviewed the current tree (
New non-blocking notes:
Nothing blocks merge. The OIDC publish, |
Per third bonk review: - resolveContributors: `.author.login // empty` (was `// .commit.author.name`), so commits with an unlinked email contribute no entry instead of a raw git display name. dedupeSortLogins now keeps only `[a-zA-Z0-9-]+` shapes, which also subsumes the previous `[bot]` filter — no more broken `- @Full Name` mentions. - .changeset/README.md: suggest `vp exec changeset` instead of the floating `vp dlx @changesets/cli` for manual changeset authoring. vp check clean; 32 unit tests pass; dry run resolves the same valid handles.
|
Thanks — addressed both new Low items: Malformed Floating Pre-publish gate: acknowledged and kept as a conscious decision — relying on the Version PR's merge-time required checks (its diff is metadata-only, so the published code is identical to what passed CI). The
|
#1760) changesets/action builds the GitHub Release body via getChangelogEntry(), which slices from the `## <version>` heading to the next *same-depth* (`##`) heading. A `## Contributors` (h2) was acting as that boundary and getting excluded from the release notes — present in CHANGELOG.md but dropped from the GitHub Release. Demote to `### Contributors` (h3) so it stays inside the extracted body. The in-file boundary logic (/^##\s+\d/) is unaffected. Follow-up to #1753 (merged before this fix landed).
What & why
Replaces the manual
workflow_dispatch(human-picks-the-bump) release with a commit-driven Changesets flow in one workflow. Changesets handles as much as possible; the only custom code is a script that auto-creates changesets from Conventional Commits (plus a small contributors-list step).Auto-generated changeset files never live on
main— written to the CI working tree, consumed bychangesets/action, discarded. Manually authored changesets still work.One workflow:
release.ymlOn push to
main:create-changeset.mjswrites.changeset/auto-*.mdto the working tree.changesets/actiondoes everything else — maintains the rolling "Version Packages" PR while changesets exist, and when none remain (the PR just merged) it publishes viachangeset publishwith OIDC trusted publishing + provenance (id-token: write,NPM_CONFIG_PROVENANCE), and creates the git tag + GitHub Release.The guard (why one workflow is enough)
create-changeset.mjsregenerates the unreleased set from<lastTag>..HEADeach run, but skips whenpackage.jsonversion > the latest tag (release merged, awaiting publish). That empty working tree is exactly what makeschangesets/actionswitch from "maintain PR" to "publish" — so no separate publish workflow or guard job is needed.Custom surface (2 scripts)
scripts/create-changeset.mjs— Conventional Commit → bump (feat→minor,fix/perf/revert→patch,!/BREAKING CHANGE→major, else skip), changed paths → affected package(s), aggregate to highest bump. Multi-package ready (independent versions).scripts/version.mjs—changeset version, then appends a deduped## Contributorslist to each bumped package'sCHANGELOG.md(idempotent; same content flows to the GitHub Release).Removed
publish.ymlentirely (guard job, manual bump, manual OIDC/tag/GitHub-release/Google-Chat steps, AI-generated notes) —changesets/actionnow owns publish + tags + releases.Migration note
packages/vinext/package.jsonversion0.0.5 → 0.0.55(was stale; versions were driven bynpm view). Changesets makespackage.jsonthe source of truth.Testing
vp check(format + lint + types) clean.Needs validation in a live CI run (cannot be tested locally)
changeset publish: trusted publishing + provenance work throughchangeset publish/pnpm publishwithout a token (highest-risk item — the old flow usedvp pm publish --provenancedirectly).auto-*.mdchangeset does not leak into the Version PR ormain(created → consumed → gone within the action step).changeset version's CHANGELOG format matches whatinsertContributorsexpects, so the## Contributorsblock lands correctly.gh apicontributor resolution authenticates with the passedGITHUB_TOKEN(degrades gracefully otherwise).🤖 Generated with Claude Code