Skip to content

feat(cli): add --prerelease flag for --version AUTO generation - #17306

Open
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1785443206-prerelease-flag
Open

feat(cli): add --prerelease flag for --version AUTO generation#17306
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1785443206-prerelease-flag

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

Linear ticket: Refs

fern generate --version AUTO --prerelease rc keeps an SDK on a prerelease line instead of stamping a stable version each run, so several merges can accumulate before one stable publish.

Today incrementVersion discards the AI-selected bump whenever the current version already has any prerelease tag (4.0.0-rc.1 + MAJOR → 4.0.0-rc.2), and from a stable version it never attaches a prerelease at all. The new option makes the bump decide the core, and the identifier carry a counter:

1.5.5      + PATCH -> 1.5.6-rc.0     # bump the core, counter starts at 0
1.5.6-rc.0 + PATCH -> 1.5.6-rc.1     # pending core already covers the bump
1.5.6-rc.1 + MINOR -> 1.6.0-rc.0     # bump outranks pending core: re-anchor, reset counter
1.6.0-rc.0 + PATCH -> 1.6.0-rc.1     # escalation is one-way
1.6.0-rc.1 + MAJOR -> 2.0.0-rc.0
release            -> --version 2.0.0 (explicit, unchanged)

Every transition is strictly increasing under semver precedence: the counter only resets when the core moves up, and a prerelease of a higher core outranks any prerelease of a lower one. "Outranks" is decided from the pending core's shape — a pending x.y.z with z != 0 is a patch line, so a MINOR re-anchors it; y != 0 || z != 0 means a MAJOR re-anchors it. Switching identifiers keeps the pending core only when that still moves the version forward (1.6.0-beta.3 + rc1.6.0-rc.0); a switch that would go backwards re-anchors the core instead (1.6.0-rc.3 + beta1.6.1-beta.0 for PATCH).

NO_CHANGE still short-circuits, and behavior with no --prerelease is untouched (existing prerelease lines keep advancing their counter, ignoring the bump).

Changes Made

  • VersionUtils: incrementVersion(version, bump, { prerelease }) plus isValidPrereleaseIdentifier and applyPrereleaseIdentifier helpers. Identifiers must be alphanumeric and start with a letter — numeric-leading identifiers would be parsed by semver as counters and wouldn't round-trip.
  • cli.ts: --prerelease <identifier> option, rejected with a config error unless --version AUTO is also passed, and rejected for malformed identifiers.
  • Threaded through generateAPIWorkspacesgenerateAPIWorkspace → both generation paths: local (runLocalGenerationForWorkspacerunGeneratorLocalTaskHandler) and remote (runRemoteGenerationForAPIWorkspacerunRemoteGenerationForGeneratorcreateAndStartJob).
  • AutoVersionStepConfig.prerelease, honored by AutoVersionStep in the replay, non-replay and chunked-consolidation paths; first generation on a fresh repo stamps 0.0.1-<identifier>.0.
  • On the prerelease path an unparsable stored version is rethrown as CliError, so it fails loudly instead of being absorbed by LocalTaskHandler's "new SDK repo" fallback and silently restarting at 0.0.1.
  • Follow-up needed for the cloud path: the identifier is included in the createJobV3 payload, but Fiddle must forward it into AutoVersionStepConfig before it takes effect on remote generation. --local works end-to-end with this PR.

Testing

  • Unit tests added/updated — packages/generator-cli/src/__test__/VersionUtils.test.ts covers stable→prerelease transitions per bump level, counter advancement, re-anchoring on escalation, identifier switching in both directions, v prefixes, build metadata, NO_CHANGE, unchanged no-flag behavior, and invalid identifiers/versions.
  • pnpm turbo run test --filter @fern-api/generator-cli --filter @fern-api/local-workspace-runner --filter @fern-api/cli — 1276 tests passing.
  • Manual testing of the flag validation against the built dev CLI:
    $ fern generate --prerelease rc
    The --prerelease flag can only be used with --version AUTO.
    $ fern generate --version 1.2.3 --prerelease rc
    The --prerelease flag can only be used with --version AUTO.
    $ fern generate --version AUTO --prerelease 0rc
    Invalid --prerelease identifier "0rc". Expected an alphanumeric identifier starting with a letter, such as "rc".
    
  • pnpm turbo run compile for the four touched packages, plus pnpm lint:biome --fix, pnpm format:fix, pnpm check:fix.

Link to Devin session: https://app.devin.ai/sessions/de0fb13aee134af3a6d24cb48e2040e8


Open in Devin Review

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot changed the title feat: add --prerelease flag for --version AUTO generation feat(cli): add --prerelease flag for --version AUTO generation Jul 30, 2026

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the changes — everything looks good. No issues found.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

context,
generatorGroup,
version,
prerelease,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Prerelease label is dropped for cloud generation runs

The prerelease label chosen on the command line is never handed to the per-generator worker for cloud runs (missing prerelease in the generateOne({...}) call at packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForAPIWorkspace.ts:153-190), so it always arrives empty downstream.
Impact: Even once the server side supports it, cloud generations will silently ignore the requested prerelease label.

Plumbing gap between the workspace-level function and the per-generator worker

runRemoteGenerationForAPIWorkspace accepts prerelease (...:34, ...:68-69) and generateOne declares it (...:222, ...:260-261) and forwards it to runRemoteGenerationForGenerator (...:350), but the object literal passed to generateOne inside the generatorGroup.generators.map(...) block never includes prerelease. Because the property is optional, TypeScript does not flag it, so prerelease is undefined for every remote generator and the value never reaches createAndStartJob's createJobV3 payload.

Prompt for agents
In packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForAPIWorkspace.ts, the top-level function accepts a new `prerelease` option and the internal `generateOne` helper declares and forwards it, but the call site that constructs `generateOne({...})` inside the `generatorGroup.generators.map(...)` loop omits `prerelease`. Since the field is optional, this compiles but means the flag never reaches `runRemoteGenerationForGenerator`/`createAndStartJob`. Add `prerelease` to that argument object alongside `version`.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2e9b591prerelease is now passed in the generateOne({...}) object literal. As noted in the description, the value still needs Fiddle to forward it into AutoVersionStepConfig before it changes remote behavior.

Comment on lines +248 to +250
if (parsed.prerelease[0] !== identifier) {
return `${core}-${identifier}.0`;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Version can move backwards when the prerelease label is changed

An in-progress prerelease version is rewritten with a brand-new counter of zero while keeping the same release numbers (return ${core}-${identifier}.0`` at packages/generator-cli/src/autoversion/VersionUtils.ts:249) even when the new label sorts below the old one, so the next generated version can be lower than the one already published.
Impact: Switching the label (for example from rc to beta) produces a version that publishing tools reject or that ships as an apparent downgrade.

Identifier-switch branch ignores ordering of the old and new prerelease identifiers

incrementPrerelease handles a label change by keeping the pending core and resetting the counter: 1.6.0-beta.3 + rc1.6.0-rc.0 (increasing, since beta < rc). The reverse direction is not guarded: 1.6.0-rc.3 + beta1.6.0-beta.0, which is less than 1.6.0-rc.3 under semver precedence, contradicting the documented invariant at packages/generator-cli/src/autoversion/VersionUtils.ts:229 ("Every transition is monotonically increasing under semver precedence"). A fix would compare the candidate with the current version (e.g. semver.gt) and, when not greater, either bump the core or continue the counter instead of resetting it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2e9b591 — the identifier-switch branch now only keeps the pending core when semver.gt(switched, version); otherwise it falls through to re-anchoring the core (1.6.0-rc.3 + beta -> 1.6.1-beta.0 for PATCH, 1.7.0-beta.0 for MINOR, 2.0.0-beta.0 for MAJOR). Covered by a new test case.

Comment on lines +651 to +654
private initialVersion(mappedMagicVersion: string): string {
const version = mappedMagicVersion.startsWith("v") ? "v0.0.1" : "0.0.1";
return this.prerelease != null ? applyPrereleaseIdentifier(version, this.prerelease) : version;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 First version stamped on a brand new repo can lose the prerelease label

The starting version used when the previous version cannot be determined is built without the requested prerelease label (mappedMagicVersion.startsWith("v") ? "v0.0.1" : "0.0.1" at packages/cli/generation/local-generation/local-workspace-runner/src/LocalTaskHandler.ts:557), so that run publishes a stable version instead of staying on the prerelease line.
Impact: A run that was asked to stay on a prerelease line can stamp a plain stable version instead.

One of three initial-version sites was not migrated to the new helper

The PR introduces LocalTaskHandler.initialVersion() (...:651-654), which appends the identifier via applyPrereleaseIdentifier, and uses it at ...:274 and ...:318. The third initial-version site — the catch (error) { if (error instanceof AutoVersioningException) ... } fallback at ...:550-557 — still constructs "0.0.1" / "v0.0.1" inline, so the --prerelease identifier is dropped on that path.

Prompt for agents
packages/cli/generation/local-generation/local-workspace-runner/src/LocalTaskHandler.ts introduces a private `initialVersion(mappedMagicVersion)` helper that appends the `--prerelease` identifier, and uses it in the two 'new SDK repository' paths (around lines 274 and 318). The third initial-version fallback — inside the `catch` block that handles `AutoVersioningException` (around line 557) — still hardcodes `mappedMagicVersion.startsWith("v") ? "v0.0.1" : "0.0.1"`, so runs with `--prerelease` stamp a stable version there. Switch that site to the helper as well.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2e9b591 — the AutoVersioningException fallback site now uses initialVersion(mappedMagicVersion) too.

Comment on lines +661 to +663
if (this.prerelease != null) {
return incrementVersion(version, versionBump, { prerelease: this.prerelease });
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A malformed stored version silently resets the SDK to 0.0.1 when a prerelease label is used

When a prerelease label is requested, a version string that cannot be parsed now raises the same kind of error the surrounding code treats as "no previous version found" (incrementVersion(version, versionBump, { prerelease: this.prerelease }) at packages/cli/generation/local-generation/local-workspace-runner/src/LocalTaskHandler.ts:662), so generation quietly restarts numbering at 0.0.1 instead of failing.
Impact: An SDK with an unparsable recorded version is silently republished as 0.0.1, a large version downgrade, instead of surfacing an error.

Error-type change flips a hard failure into the initial-version fallback

Without --prerelease, LocalTaskHandler.incrementVersion throws a CliError for an unparsable version (...:668-669); CliError is not an AutoVersioningException, so the outer catch at ...:549-565 rethrows and the run fails loudly. With --prerelease, the call delegates to @fern-api/generator-cli's incrementVersion, which throws AutoVersioningException("Invalid semantic version format: ...") (packages/generator-cli/src/autoversion/VersionUtils.ts:184-186). All incrementVersion calls (...:350, ...:458, ...:506, ...:528) sit inside that try block, so the exception is absorbed by the AutoVersioningException branch and the run returns version 0.0.1. Previous versions can come from .fern/metadata.json or git tags, which are not guaranteed to be strict semver.

Suggested change
if (this.prerelease != null) {
return incrementVersion(version, versionBump, { prerelease: this.prerelease });
}
if (this.prerelease != null) {
try {
return incrementVersion(version, versionBump, { prerelease: this.prerelease });
} catch (error) {
throw new CliError({
message: `Failed to increment version: ${version} (${extractErrorMessage(error)})`,
code: CliError.Code.VersionError
});
}
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2e9b591 — the delegated call is wrapped and rethrown as CliError with Code.VersionError, so an unparsable stored version fails loudly instead of being absorbed by the initial-version fallback.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-30T05:00:19Z).

Fixture main PR Delta
docs 271.4s (n=5) 208.8s (35 versions) -62.6s (-23.1%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-07-30T05:00:19Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-30 20:57 UTC

@github-actions

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-30T05:00:19Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 77s (n=5) N/A 84s +7s (+9.1%)
go-sdk square 143s (n=5) 310s (n=5) 101s -42s (-29.4%)
java-sdk square 225s (n=5) 279s (n=5) 195s -30s (-13.3%)
php-sdk square 63s (n=5) N/A 64s +1s (+1.6%)
python-sdk square 135s (n=5) 253s (n=5) 171s +36s (+26.7%)
ruby-sdk-v2 square 109s (n=5) 125s (n=5) 81s -28s (-25.7%)
rust-sdk square 213s (n=5) 182s (n=5) 225s +12s (+5.6%)
swift-sdk square 78s (n=5) 445s (n=5) 72s -6s (-7.7%)
ts-sdk square 177s (n=5) 182s (n=5) 121s -56s (-31.6%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-07-30T05:00:19Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-30 20:58 UTC

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