Skip to content

Commit d00b1f5

Browse files
fix(cli): fall back to .fern/metadata.json then git tags for auto-versioning (#14719)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 952cbd2 commit d00b1f5

7 files changed

Lines changed: 1474 additions & 8 deletions

File tree

packages/cli/cli/versions.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
2+
- version: 4.67.1
3+
changelogEntry:
4+
- summary: |
5+
Fall back to git tags for auto-versioning when the magic version is not
6+
embedded in any generated source file. This fixes auto-versioning for
7+
SDKs like Swift that use git tags for versioning (via SPM) rather than
8+
a version field in source code.
9+
type: fix
10+
createdAt: "2026-04-10"
11+
irVersion: 66
212

313
- version: 4.67.0
414
changelogEntry:
@@ -39,7 +49,6 @@
3949
type: fix
4050
createdAt: "2026-04-10"
4151
irVersion: 66
42-
4352
- version: 4.65.2
4453
changelogEntry:
4554
- summary: |
@@ -164,7 +173,6 @@
164173
type: fix
165174
createdAt: "2026-04-07"
166175
irVersion: 66
167-
168176
- version: 4.62.4
169177
changelogEntry:
170178
- summary: |

packages/cli/generation/local-generation/local-workspace-runner/src/AutoVersioningService.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,27 @@ import { TaskContext } from "@fern-api/task-context";
33
import { existsSync } from "fs";
44
import { readdir, readFile, stat, writeFile } from "fs/promises";
55
import { extname, join } from "path";
6+
import semver from "semver";
67

78
/**
89
* Exception thrown when automatic semantic versioning fails due to inability
910
* to extract or process version information.
1011
*/
1112
export class AutoVersioningException extends Error {
12-
constructor(message: string, cause?: Error) {
13+
/**
14+
* True when the magic version was not found at all in the diff,
15+
* indicating a generator that doesn't embed versions in source files
16+
* (e.g., Swift uses git tags via SPM). Only this case should fall back
17+
* to reading git tags.
18+
*/
19+
public readonly magicVersionAbsent: boolean;
20+
21+
constructor(message: string, options?: { cause?: Error; magicVersionAbsent?: boolean }) {
1322
super(message);
1423
this.name = "AutoVersioningException";
15-
if (cause) {
16-
this.cause = cause;
24+
this.magicVersionAbsent = options?.magicVersionAbsent ?? false;
25+
if (options?.cause) {
26+
this.cause = options.cause;
1727
}
1828
}
1929
}
@@ -242,7 +252,8 @@ export class AutoVersioningService {
242252

243253
throw new AutoVersioningException(
244254
"Failed to extract version from diff. This may indicate the version file format is not supported for" +
245-
" auto-versioning, or the placeholder version was not found in any added lines."
255+
" auto-versioning, or the placeholder version was not found in any added lines.",
256+
{ magicVersionAbsent: true }
246257
);
247258
}
248259

@@ -749,6 +760,58 @@ export class AutoVersioningService {
749760
return /\/v\d+(?=\/|"\s*$|$)/.test(minusContent) || /\/v\d+(?=\/|"\s*$|$)/.test(plusContent);
750761
}
751762

763+
/**
764+
* Gets the latest semantic version from git tags in the given repository.
765+
* Used as a fallback when the magic version is not embedded in any generated file
766+
* (e.g., Swift SDKs which use git tags for versioning via SPM, not a version file).
767+
*
768+
* @param workingDirectory The git repository directory
769+
* @return The latest semver version from git tags, or undefined if no valid tags found
770+
*/
771+
public async getLatestVersionFromGitTags(workingDirectory: string): Promise<string | undefined> {
772+
try {
773+
// Use ls-remote to query tags from the remote directly.
774+
// This works even with shallow clones (--depth 1) which don't fetch
775+
// tags pointing to commits outside the shallow boundary.
776+
const result = await loggingExeca(this.logger, "git", ["ls-remote", "--tags", "origin"], {
777+
cwd: workingDirectory,
778+
doNotPipeOutput: true
779+
});
780+
const output = result.stdout;
781+
if (!output || output.trim().length === 0) {
782+
this.logger.info("No git tags found in repository");
783+
return undefined;
784+
}
785+
// ls-remote output format: "<sha>\trefs/tags/<tagname>"
786+
// Some tags have ^{} suffix for annotated tag dereferences — skip those.
787+
const tags: string[] = [];
788+
for (const line of output.split("\n")) {
789+
const trimmed = line.trim();
790+
if (trimmed.length === 0 || trimmed.includes("^{}")) {
791+
continue;
792+
}
793+
const match = trimmed.match(/refs\/tags\/(.+)$/);
794+
if (match?.[1]) {
795+
tags.push(match[1]);
796+
}
797+
}
798+
// Use semver library for sorting instead of git's versioncmp,
799+
// which disagrees with semver for pre-release tags (git sorts
800+
// v1.0.0-beta after v1.0.0, but semver says v1.0.0 > v1.0.0-beta).
801+
const validTags = tags.filter((tag) => semver.valid(tag) != null).sort((a, b) => semver.rcompare(a, b));
802+
if (validTags.length > 0) {
803+
const latest = validTags[0];
804+
this.logger.info(`Found latest version from git tags: ${latest}`);
805+
return latest;
806+
}
807+
this.logger.info("No valid semver tags found in repository");
808+
return undefined;
809+
} catch (e) {
810+
this.logger.warn(`Failed to read git tags: ${e}`);
811+
return undefined;
812+
}
813+
}
814+
752815
/**
753816
* Replaces all occurrences of the magic version with the final version in generated files.
754817
*

packages/cli/generation/local-generation/local-workspace-runner/src/LocalTaskHandler.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,37 @@ export class LocalTaskHandler {
205205
throw new Error("Version is required for auto versioning");
206206
}
207207

208-
const previousVersion = autoVersioningService.extractPreviousVersion(diffContent, this.version);
208+
let previousVersion: string | undefined;
209+
try {
210+
previousVersion = autoVersioningService.extractPreviousVersion(diffContent, this.version);
211+
} catch (e) {
212+
if (!(e instanceof AutoVersioningException) || !e.magicVersionAbsent) {
213+
throw e;
214+
}
215+
// Magic version not found in diff — fall back to .fern/metadata.json then git tags.
216+
// This happens for generators that don't embed versions in files (e.g., Swift
217+
// uses git tags for versioning via SPM, not a version field in Package.swift).
218+
this.context.logger.info(`Magic version not found in diff, trying fallbacks: ${e}`);
219+
previousVersion = await this.getVersionFromLocalMetadata();
220+
if (previousVersion == null) {
221+
const tagVersion = await autoVersioningService.getLatestVersionFromGitTags(
222+
this.absolutePathToLocalOutput
223+
);
224+
previousVersion = this.normalizeVersionPrefix(tagVersion);
225+
}
226+
if (previousVersion == null) {
227+
this.context.logger.info("No git tags found — treating as new SDK repository");
228+
const initialVersion = this.version?.startsWith("v") ? "v0.0.1" : "0.0.1";
229+
const commitMessage = this.isWhitelabel
230+
? "Initial SDK generation"
231+
: "Initial SDK generation\n\n🌿 Generated with Fern";
232+
return {
233+
version: initialVersion,
234+
commitMessage
235+
};
236+
}
237+
this.context.logger.debug(`Previous version from fallback: ${previousVersion}`);
238+
}
209239
const cleanedDiff = autoVersioningService.cleanDiffForAI(diffContent, this.version);
210240

211241
const rawDiffSizeKB = formatSizeKB(diffContent.length);
@@ -218,6 +248,22 @@ export class LocalTaskHandler {
218248
`Cleaned diff size: ${cleanedDiffSizeKB}KB (${cleanedDiff.length} chars), ${cleanedFileCount} files remaining`
219249
);
220250

251+
// If no previous version from diff (e.g., Version.swift is a new file in an existing SDK),
252+
// try .fern/metadata.json first, then git tags before falling back to initial version
253+
if (previousVersion == null) {
254+
previousVersion = await this.getVersionFromLocalMetadata();
255+
if (previousVersion == null) {
256+
const rawTagVersion = await autoVersioningService.getLatestVersionFromGitTags(
257+
this.absolutePathToLocalOutput
258+
);
259+
const normalizedTag = this.normalizeVersionPrefix(rawTagVersion);
260+
if (normalizedTag != null) {
261+
this.context.logger.info(`No previous version from diff; using git tag: ${normalizedTag}`);
262+
previousVersion = normalizedTag;
263+
}
264+
}
265+
}
266+
221267
// Handle new SDK repository with no previous version
222268
if (previousVersion == null) {
223269
this.context.logger.info(
@@ -573,6 +619,58 @@ export class LocalTaskHandler {
573619
return version.startsWith("v") ? `v${newVersion}` : newVersion;
574620
}
575621

622+
/**
623+
* Reads the SDK version from the *previously committed* `.fern/metadata.json`.
624+
* Uses `git show HEAD:.fern/metadata.json` instead of reading from the filesystem
625+
* because by the time auto-versioning runs, the generated files have already been
626+
* copied over — the on-disk metadata.json contains the magic placeholder version,
627+
* not the real previous version.
628+
* Returns undefined if the file doesn't exist in HEAD (older SDKs) or can't be parsed.
629+
*/
630+
private async getVersionFromLocalMetadata(): Promise<string | undefined> {
631+
try {
632+
const result = await loggingExeca(this.context.logger, "git", ["show", "HEAD:.fern/metadata.json"], {
633+
cwd: this.absolutePathToLocalOutput,
634+
doNotPipeOutput: true,
635+
reject: false
636+
});
637+
if (result.exitCode !== 0) {
638+
this.context.logger.debug(".fern/metadata.json not found in HEAD commit");
639+
return undefined;
640+
}
641+
const metadata = JSON.parse(result.stdout) as { sdkVersion?: string };
642+
if (metadata.sdkVersion != null) {
643+
const normalized = this.normalizeVersionPrefix(metadata.sdkVersion);
644+
this.context.logger.info(`Found version from .fern/metadata.json (HEAD): ${normalized}`);
645+
return normalized;
646+
}
647+
this.context.logger.debug(".fern/metadata.json found in HEAD but no sdkVersion field");
648+
return undefined;
649+
} catch (error) {
650+
this.context.logger.debug(`Failed to read .fern/metadata.json from HEAD: ${error}`);
651+
return undefined;
652+
}
653+
}
654+
655+
/**
656+
* Normalizes a version string's `v` prefix to match the convention used by
657+
* the magic version (`this.version`). Git tags may use `v1.2.3` while the
658+
* magic version is `0.0.0-fern-placeholder` (no prefix) or vice-versa.
659+
* Without normalization the mismatch propagates into `replaceMagicVersion`
660+
* and can produce invalid versions in package manifests (e.g. `v1.3.0` in
661+
* a `package.json` that expects bare semver).
662+
*/
663+
private normalizeVersionPrefix(version: string | undefined): string | undefined {
664+
if (version == null) {
665+
return undefined;
666+
}
667+
const stripped = version.startsWith("v") ? version.slice(1) : version;
668+
if (this.version?.startsWith("v")) {
669+
return `v${stripped}`;
670+
}
671+
return stripped;
672+
}
673+
576674
/**
577675
* Gets the BAML client registry for AI analysis.
578676
* This method is adapted from sdkDiffCommand.ts but needs project configuration.

packages/cli/generation/local-generation/local-workspace-runner/src/__test__/AutoVersioningService.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2967,3 +2967,147 @@ describe("Python PEP 440 magic version", () => {
29672967
expect(cleaned).not.toContain("pyproject.toml");
29682968
});
29692969
});
2970+
2971+
describe("getLatestVersionFromGitTags", () => {
2972+
// Helper to create a temporary bare git repo that can serve as a remote,
2973+
// and a clone of it, so `git ls-remote --tags origin` works.
2974+
async function createRepoWithTags(tags: string[]): Promise<{ cloneDir: string; bareDir: string }> {
2975+
const bareDir = await fs.mkdtemp(path.join(require("os").tmpdir(), "test-bare-"));
2976+
const cloneDir = await fs.mkdtemp(path.join(require("os").tmpdir(), "test-clone-"));
2977+
2978+
// Create bare repo with explicit main branch
2979+
await runCommand(["git", "init", "--bare", "--initial-branch=main", bareDir], bareDir);
2980+
2981+
// Clone it
2982+
await runCommand(["git", "clone", bareDir, cloneDir], cloneDir);
2983+
2984+
// Configure git user for commits
2985+
await runCommand(["git", "config", "user.email", "test@test.com"], cloneDir);
2986+
await runCommand(["git", "config", "user.name", "Test"], cloneDir);
2987+
2988+
// Create an initial commit so we have something to tag
2989+
const filePath = path.join(cloneDir, "README.md");
2990+
await fs.writeFile(filePath, "# Test\n");
2991+
await runCommand(["git", "add", "."], cloneDir);
2992+
await runCommand(["git", "commit", "-m", "Initial commit"], cloneDir);
2993+
await runCommand(["git", "push", "-u", "origin", "main"], cloneDir);
2994+
2995+
// Create tags
2996+
for (const tag of tags) {
2997+
await runCommand(["git", "tag", tag], cloneDir);
2998+
}
2999+
// Push all tags
3000+
if (tags.length > 0) {
3001+
await runCommand(["git", "push", "origin", "--tags"], cloneDir);
3002+
}
3003+
3004+
return { cloneDir, bareDir };
3005+
}
3006+
3007+
async function cleanupDirs(...dirs: string[]): Promise<void> {
3008+
for (const dir of dirs) {
3009+
await fs.rm(dir, { recursive: true, force: true });
3010+
}
3011+
}
3012+
3013+
it("returns latest semver tag from repo with multiple versions", async () => {
3014+
const { cloneDir, bareDir } = await createRepoWithTags(["v1.0.0", "v1.1.0", "v2.0.0", "v1.5.3"]);
3015+
try {
3016+
const service = new AutoVersioningService({ logger: mockLogger });
3017+
const result = await service.getLatestVersionFromGitTags(cloneDir);
3018+
expect(result).toBe("v2.0.0");
3019+
} finally {
3020+
await cleanupDirs(cloneDir, bareDir);
3021+
}
3022+
});
3023+
3024+
it("returns undefined when repo has no tags", async () => {
3025+
const { cloneDir, bareDir } = await createRepoWithTags([]);
3026+
try {
3027+
const service = new AutoVersioningService({ logger: mockLogger });
3028+
const result = await service.getLatestVersionFromGitTags(cloneDir);
3029+
expect(result).toBeUndefined();
3030+
} finally {
3031+
await cleanupDirs(cloneDir, bareDir);
3032+
}
3033+
});
3034+
3035+
it("filters out non-semver tags", async () => {
3036+
const { cloneDir, bareDir } = await createRepoWithTags(["latest", "release-candidate", "build-123", "v1.2.3"]);
3037+
try {
3038+
const service = new AutoVersioningService({ logger: mockLogger });
3039+
const result = await service.getLatestVersionFromGitTags(cloneDir);
3040+
expect(result).toBe("v1.2.3");
3041+
} finally {
3042+
await cleanupDirs(cloneDir, bareDir);
3043+
}
3044+
});
3045+
3046+
it("returns undefined when all tags are non-semver", async () => {
3047+
const { cloneDir, bareDir } = await createRepoWithTags(["latest", "nightly", "release-candidate"]);
3048+
try {
3049+
const service = new AutoVersioningService({ logger: mockLogger });
3050+
const result = await service.getLatestVersionFromGitTags(cloneDir);
3051+
expect(result).toBeUndefined();
3052+
} finally {
3053+
await cleanupDirs(cloneDir, bareDir);
3054+
}
3055+
});
3056+
3057+
it("handles tags without v prefix", async () => {
3058+
const { cloneDir, bareDir } = await createRepoWithTags(["1.0.0", "1.1.0", "2.0.0"]);
3059+
try {
3060+
const service = new AutoVersioningService({ logger: mockLogger });
3061+
const result = await service.getLatestVersionFromGitTags(cloneDir);
3062+
expect(result).toBe("2.0.0");
3063+
} finally {
3064+
await cleanupDirs(cloneDir, bareDir);
3065+
}
3066+
});
3067+
3068+
it("handles mixed v-prefixed and bare semver tags", async () => {
3069+
const { cloneDir, bareDir } = await createRepoWithTags(["v1.0.0", "2.0.0", "v1.5.0"]);
3070+
try {
3071+
const service = new AutoVersioningService({ logger: mockLogger });
3072+
const result = await service.getLatestVersionFromGitTags(cloneDir);
3073+
expect(result).toBe("2.0.0");
3074+
} finally {
3075+
await cleanupDirs(cloneDir, bareDir);
3076+
}
3077+
});
3078+
3079+
it("handles pre-release versions correctly (semver ordering)", async () => {
3080+
const { cloneDir, bareDir } = await createRepoWithTags(["v1.0.0-beta.1", "v1.0.0", "v1.0.0-alpha.1"]);
3081+
try {
3082+
const service = new AutoVersioningService({ logger: mockLogger });
3083+
const result = await service.getLatestVersionFromGitTags(cloneDir);
3084+
// semver: v1.0.0 > v1.0.0-beta.1 > v1.0.0-alpha.1
3085+
expect(result).toBe("v1.0.0");
3086+
} finally {
3087+
await cleanupDirs(cloneDir, bareDir);
3088+
}
3089+
});
3090+
3091+
it("returns undefined for non-git directory", async () => {
3092+
const tmpDir = await fs.mkdtemp(path.join(require("os").tmpdir(), "test-nogit-"));
3093+
try {
3094+
const service = new AutoVersioningService({ logger: mockLogger });
3095+
const result = await service.getLatestVersionFromGitTags(tmpDir);
3096+
// Should not throw — returns undefined gracefully
3097+
expect(result).toBeUndefined();
3098+
} finally {
3099+
await cleanupDirs(tmpDir);
3100+
}
3101+
});
3102+
3103+
it("handles single tag correctly", async () => {
3104+
const { cloneDir, bareDir } = await createRepoWithTags(["v0.1.0"]);
3105+
try {
3106+
const service = new AutoVersioningService({ logger: mockLogger });
3107+
const result = await service.getLatestVersionFromGitTags(cloneDir);
3108+
expect(result).toBe("v0.1.0");
3109+
} finally {
3110+
await cleanupDirs(cloneDir, bareDir);
3111+
}
3112+
});
3113+
});

0 commit comments

Comments
 (0)