Skip to content

feat(codemods): resolve UNRELEASED migration versions at promotion - #3077

Draft
mfal wants to merge 2 commits into
nextfrom
feat/migration-unreleased-placeholder-2890
Draft

feat(codemods): resolve UNRELEASED migration versions at promotion#3077
mfal wants to merge 2 commits into
nextfrom
feat/migration-unreleased-placeholder-2890

Conversation

@mfal

@mfal mfal commented Sep 2, 2026

Copy link
Copy Markdown
Member

What this does

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 >=x.y.0-next.N range names a channel no latest consumer ever finds themselves in.

Author side: write the literal UNRELEASED instead.

/prepare-release side: 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.md still carries ## From version … headings. It does not: that file is now generated from packages/codemods/src/migrations/<id>/entry.md, and the version comes from the entry's since: frontmatter (rendered as a bold Since x fact line). Only packages/ext-bridge/MIGRATION.md still uses the heading shape, and it is hand-written.

So the placeholder lives in each layer's source, never in a generated file:

Layer Placeholder Resolved to
packages/codemods/src/migrations/<id>/entry.md since: UNRELEASED since: x.y.0, then pnpm nx build codemods regenerates the guide
hand-written packages/*/MIGRATION.md ## From version `UNRELEASED` to `UNRELEASED` ## From version `<current>` to `>=x.y.0`, sections collapsed

The script detects the AUTO-GENERATED marker 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) and packages/codemods/src/migrations/*/entry.md (the since: field, i.e. the real source of packages/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-bridge directly, components through its generator.

2. Hotfix path — the issue's preference, documented as next-only. No rewrite added to publish.yml. It stays a curation step, not a CI step: publish.yml versions, builds and publishes, and would otherwise have to mutate source files, run a generator and commit the result inside the publish job. A fix: author on main also 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 main forward-merges into next and would be resolved by the next promotion, under a version far later than the one it shipped in. The convention is documented as next-only in every place an author passes through, and pnpm release:check-unreleased makes it one command to check. A base-branch-aware guard in commit-guard.yml would mechanize it — deliberately left out of scope.

The guard

pnpm release:check-unreleased scans every globbed guide and entry, prints path:line for each surviving UNRELEASED, and exits 1. /prepare-release runs 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 existing release-relevance-lib / version-contract-lib guards.
  • .github/scripts/migration-unreleased.mjs — the IO shell (--check, --current/--target), wired as pnpm release:{check,resolve}-unreleased.
  • packages/codemods/src/catalog/unreleased.ts — the runtime half. A placeholder reaches consumers through X.Y.0-next.N, and semver throws on the literal, so every comparison routes 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) — the same placeholder, two opposite answers, both documented at the call site.

Verification

node --test .github/scripts/*.test.mjs        # 66 pass (19 new)
pnpm nx test:unit codemods                    # 317 pass (31 files)
pnpm nx test:compile codemods                 # clean
pnpm nx build codemods                        # no generated-file drift
pnpm lint                                     # 0 errors, format:check clean

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 real ext-bridge/MIGRATION.md that must not reformat a byte. One test asserts the .mjs literal and the TypeScript unreleasedSince agree, 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, confirmed pnpm nx build codemods does 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 two ext-bridge sections collapsed under one heading. Reverted afterwards; git status showed no generated drift.

eslint.config.js gains URL: "readonly" for .github/scripts/**/*.mjs — the block declared only process and console, and new URL(…, import.meta.url) is how these scripts reach repo files.

Docs

packages/codemods/AGENTS.md (the author-facing home, with the next-only rule and why guessing fails), docs/release-workflow.md, CONTRIBUTE.md invariant 2, root AGENTS.md, and /prepare-release itself.

Why featnext

The bulk is repo tooling, but this changes the runtime behavior of a published package: @mittwald/flow-codemods now tolerates and orders an UNRELEASED entry (src/catalog/, src/cli/list.ts). That is additive published behavior, so feat is the honest type, and the routing guard sends a feat: to next. It also fits the substance: the placeholder is only meaningful on the next line, and a promotion runs /prepare-release from a checkout built off next, so the script is present when it is needed.

fixes #2890

🤖 Generated with Claude Code

@mfal mfal self-assigned this Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

Type URL
docs pr-3077.docs.review.flow-components.de
storybook pr-3077.storybook.review.flow-components.de

Images:

  • docs: ghcr.io/mittwald/flow/docs:pr-3077
  • storybook: ghcr.io/mittwald/flow/storybook:pr-3077

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
mfal requested a review from ins0 September 3, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant