feat(codemods): resolve UNRELEASED migration versions at promotion - #3077
Draft
mfal wants to merge 2 commits into
Draft
feat(codemods): resolve UNRELEASED migration versions at promotion#3077mfal wants to merge 2 commits into
mfal wants to merge 2 commits into
Conversation
Contributor
🚀 Preview DeploymentPreview environments are ready:
Images:
|
A migration entry authored in a `feat:` PR cannot name the version it applies to. The PR lands on `next` and is promoted later, in a bundle whose stable `x.y.0` depends on what else is promoted with it — so a guess is wrong as soon as the bundle changes, and a `-next.N` range names a channel no `latest` consumer finds themselves in. The author writes the literal `UNRELEASED` instead, and `/prepare-release` fills it in while it builds the release branch — the first moment both numbers are known. Two layers carry a version, and the placeholder sits in each one's source: - `packages/codemods/src/migrations/<id>/entry.md`, field `since`. `packages/components/MIGRATION.md` is generated from that catalogue, so the rewrite goes into the entry and `pnpm nx build codemods` regenerates the guide. The script detects the `AUTO-GENERATED` marker and refuses to write a generated file. - A hand-written `MIGRATION.md` (today only `ext-bridge`'s), keyed by `## From version <a> to >=<b>`. All placeholder sections of one release collapse into a single heading, bodies concatenated in document order at the position of the first — three promoted PRs each carrying an entry must not produce three identical headings. `pnpm release:check-unreleased` is the guard, and it hard-stops the promotion: a placeholder that survives into a published release leaves an entry with no version a reader can match against their own. The placeholder is `next`-only. A `fix:` on `main` is released by `publish.yml`, which never runs the command, and knows its version anyway. The rewrite is a curation step, not a CI step. Because a placeholder reaches consumers through `X.Y.0-next.N`, the CLI has to tolerate it: `semver` throws on the literal, so comparisons route through `compareSince`/`isUnreleased`. `selectEntries` always selects an unreleased entry (no version to gate on, and the prerelease channel exists to preview it); `list`'s catch-up marker never does (it has not shipped under any stable version). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mfal
force-pushed
the
feat/migration-unreleased-placeholder-2890
branch
from
September 2, 2026 11:53
26791ff to
609449a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
A migration entry authored in a
feat:PR cannot name the version it applies to. The PR lands onnextand is promoted later, in a bundle whose stablex.y.0depends on what else is promoted with it — so a guess is wrong as soon as the bundle changes, and a>=x.y.0-next.Nrange names a channel nolatestconsumer ever finds themselves in.Author side: write the literal
UNRELEASEDinstead./prepare-releaseside: resolve every placeholder in the graduation commit — the first moment both numbers are known — and collapse all placeholder sections of one release under a single heading, bodies concatenated in document order.The premise moved, the problem did not
The issue assumes
packages/components/MIGRATION.mdstill carries## From version …headings. It does not: that file is now generated frompackages/codemods/src/migrations/<id>/entry.md, and the version comes from the entry'ssince:frontmatter (rendered as a bold Sincexfact line). Onlypackages/ext-bridge/MIGRATION.mdstill uses the heading shape, and it is hand-written.So the placeholder lives in each layer's source, never in a generated file:
packages/codemods/src/migrations/<id>/entry.mdsince: UNRELEASEDsince: x.y.0, thenpnpm nx build codemodsregenerates the guidepackages/*/MIGRATION.md## From version `UNRELEASED` to `UNRELEASED`## From version `<current>` to `>=x.y.0`, sections collapsedThe script detects the
AUTO-GENERATEDmarker and refuses to write a generated guide (it still scans it in--check— that is where a leftover surfaces after regeneration).The two decisions
1. Which files — globbed, two patterns.
packages/*/MIGRATION.md(heading placeholders) andpackages/codemods/src/migrations/*/entry.md(thesince:field, i.e. the real source ofpackages/components/MIGRATION.md). Nothing is hardcoded, so a new package's guide is covered the day it is added. Both files the issue names are covered —ext-bridgedirectly,componentsthrough its generator.2. Hotfix path — the issue's preference, documented as
next-only. No rewrite added topublish.yml. It stays a curation step, not a CI step:publish.ymlversions, builds and publishes, and would otherwise have to mutate source files, run a generator and commit the result inside the publish job. Afix:author onmainalso still knows their version the way they always did (last published,>=next patch), so the placeholder buys nothing there.Residual risk, recorded rather than papered over: a placeholder that does land on
mainforward-merges intonextand would be resolved by the next promotion, under a version far later than the one it shipped in. The convention is documented asnext-only in every place an author passes through, andpnpm release:check-unreleasedmakes it one command to check. A base-branch-aware guard incommit-guard.ymlwould mechanize it — deliberately left out of scope.The guard
pnpm release:check-unreleasedscans every globbed guide and entry, printspath:linefor each survivingUNRELEASED, and exits 1./prepare-releaseruns it after the regeneration (Step 10) — before that it cannot see what the catalogue produced — and it is a hard stop on the promotion. Failing it in Step 5 is the normal state and is only reported, since those entries are exactly what Step 10 resolves.Layering
.github/scripts/migration-unreleased-lib.mjs— pure text, no IO: rewrite, collapse, frontmatter, findings. Sibling to the existingrelease-relevance-lib/version-contract-libguards..github/scripts/migration-unreleased.mjs— the IO shell (--check,--current/--target), wired aspnpm release:{check,resolve}-unreleased.packages/codemods/src/catalog/unreleased.ts— the runtime half. A placeholder reaches consumers throughX.Y.0-next.N, andsemverthrows on the literal, so every comparison routes throughcompareSince/isUnreleased.selectEntriesalways selects an unreleased entry (no version to gate on, and the prerelease channel exists to preview it);list's catch-up marker never does (it has not shipped under any stable version) — the same placeholder, two opposite answers, both documented at the call site.Verification
The 19 new lib tests cover the cases that matter: multiple placeholder sections collapsing into one (with the inter-section
---separators dropped), a file with no placeholder returned byte-identical, mixed placeholder + real headings, position of the collapsed section, idempotency, a##line inside fenced code, and a round-trip over the realext-bridge/MIGRATION.mdthat must not reformat a byte. One test asserts the.mjsliteral and the TypeScriptunreleasedSinceagree, since the string is spelled in two languages.Beyond the unit tests I ran the whole flow end-to-end: planted a placeholder entry plus two placeholder headings in
ext-bridge, confirmed the guard fails naming all three locations, confirmedpnpm nx build codemodsdoes not throw with an unresolved placeholder present (it sorts to the top and renders "ships in the next stable release"), then resolved → regenerated → guard green, with the twoext-bridgesections collapsed under one heading. Reverted afterwards;git statusshowed no generated drift.eslint.config.jsgainsURL: "readonly"for.github/scripts/**/*.mjs— the block declared onlyprocessandconsole, andnew URL(…, import.meta.url)is how these scripts reach repo files.Docs
packages/codemods/AGENTS.md(the author-facing home, with thenext-only rule and why guessing fails),docs/release-workflow.md,CONTRIBUTE.mdinvariant 2, rootAGENTS.md, and/prepare-releaseitself.Why
feat→nextThe bulk is repo tooling, but this changes the runtime behavior of a published package:
@mittwald/flow-codemodsnow tolerates and orders anUNRELEASEDentry (src/catalog/,src/cli/list.ts). That is additive published behavior, sofeatis the honest type, and the routing guard sends afeat:tonext. It also fits the substance: the placeholder is only meaningful on thenextline, and a promotion runs/prepare-releasefrom a checkout built offnext, so the script is present when it is needed.fixes #2890
🤖 Generated with Claude Code