Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- summary: |
Add `fern automations upgrade` command that wraps `fern upgrade` and
`fern generator upgrade` into a single invocation with structured JSON
output (`--json`). Designed for consumption by the fern-upgrade GitHub
Action.
type: feat
- summary: |
Replace brittle hardcoded changelog URL map in `upgradeGenerator.ts` with
a regex-based derivation that supports all current and future generators.
type: fix
100 changes: 100 additions & 0 deletions packages/cli/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { addGeneratorCommands, addGetOrganizationCommand } from "./cliV2.js";
import { addGeneratorToWorkspaces } from "./commands/add-generator/addGeneratorToWorkspaces.js";
import { executeAutomationsGenerate } from "./commands/automations/generate/executeAutomationsGenerate.js";
import { listPreviewGroups } from "./commands/automations/listPreviewGroups.js";
import { executeAutomationsUpgrade } from "./commands/automations/upgrade/executeAutomationsUpgrade.js";
import { diff } from "./commands/diff/diff.js";
import { previewDocsWorkspace } from "./commands/docs-dev/devDocsWorkspace.js";
import { docsDiff } from "./commands/docs-diff/docsDiff.js";
Expand Down Expand Up @@ -2334,6 +2335,7 @@ function addAutomationsCommand(cli: Argv<GlobalCliOptions>, cliContext: CliConte
cli.command("automations", false, (yargs) => {
addAutomationsGenerateCommand(yargs, cliContext);
addAutomationsPreviewCommand(yargs, cliContext);
addAutomationsUpgradeCommand(yargs, cliContext);
return yargs.demandCommand();
});
}
Expand Down Expand Up @@ -2607,6 +2609,104 @@ function addAutomationsGenerateCommand(cli: Argv<GlobalCliOptions>, cliContext:
);
}

/**
* `fern automations upgrade`
*
* Wraps `fern upgrade` (CLI version) and `fern generator upgrade` (generator
* versions) into a single command that outputs structured JSON to stdout.
*
* Designed for consumption by the fern-upgrade GitHub Action, replacing the
* previous snapshot.js / diff.js approach with a single CLI invocation.
*
* Flags:
* --include-major Include major version bumps for generators (default: false).
* --json Output structured JSON to stdout (for machine consumption).
*
* JSON output format (--json):
* {
* "cli": { "from": "4.66.0", "to": "4.96.0", "upgraded": true },
* "generators": [
* {
* "name": "fernapi/fern-typescript-sdk",
* "group": "ts-sdk",
* "api": "api",
* "from": "3.63.4",
* "to": "3.65.5",
* "changelog": "https://buildwithfern.com/learn/sdks/generators/typescript/changelog",
* "migrationsApplied": 1
* }
* ],
* "skippedMajor": [{ "name": "...", "current": "0.28.0", "latest": "1.37.0" }],
* "alreadyUpToDate": [{ "name": "...", "version": "3.65.5" }]
* }
*
* Example GitHub Actions usage:
* - run: |
* UPGRADE_JSON=$(fern automations upgrade --json --include-major)
* echo "upgrade-json=$UPGRADE_JSON" >> "$GITHUB_OUTPUT"
* env:
* FERN_TOKEN: ${{ secrets.FERN_TOKEN }}
*/
function addAutomationsUpgradeCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext) {
cli.command(
"upgrade",
false, // hidden
(yargs) =>
yargs
.option("include-major", {
boolean: true,
default: false,
description:
"Whether to include major version upgrades for generators. " +
"When false (default), only minor/patch upgrades are applied."
})
.option("json", {
boolean: true,
default: false,
description: "Output results as JSON to stdout (for machine consumption)."
}),
async (argv) => {
cliContext.instrumentPostHogEvent({
command: "fern automations upgrade"
});

const result = await executeAutomationsUpgrade({
cliContext,
options: {
includeMajor: argv["include-major"]
}
});

if (argv.json) {
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else {
// Human-readable summary
const { cli: cliResult, generators, skippedMajor, alreadyUpToDate } = result;
if (cliResult.upgraded) {
cliContext.logger.info(`CLI: ${cliResult.from} -> ${cliResult.to}`);
} else {
cliContext.logger.info(`CLI: ${cliResult.from} (already up to date)`);
}
if (generators.length > 0) {
cliContext.logger.info(`Generators upgraded: ${generators.length}`);
for (const gen of generators) {
cliContext.logger.info(` ${gen.name}: ${gen.from} -> ${gen.to}`);
}
}
if (alreadyUpToDate.length > 0) {
cliContext.logger.info(`Generators already up to date: ${alreadyUpToDate.length}`);
}
if (skippedMajor.length > 0) {
cliContext.logger.info(`Major upgrades available (skipped): ${skippedMajor.length}`);
for (const skip of skippedMajor) {
cliContext.logger.info(` ${skip.name}: ${skip.current} -> ${skip.latest}`);
}
}
}
}
);
}

function addSdkPreviewCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext) {
cli.command(
"preview",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import {
addDefaultDockerOrgIfNotPresent,
FERN_DIRECTORY,
getFernDirectory,
getPathToGeneratorsConfiguration,
loadProjectConfig
} from "@fern-api/configuration-loader";
import { Project } from "@fern-api/project-loader";
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
import { writeFile } from "fs/promises";

import { CliContext } from "../../../cli-context/CliContext.js";
import { loadProjectAndRegisterWorkspacesWithContext } from "../../../cliCommons.js";
import { upgrade } from "../../upgrade/upgrade.js";
import { loadAndUpdateGenerators } from "../../upgrade/upgradeGenerator.js";

const CHANGELOG_BASE = "https://buildwithfern.com/learn/sdks/generators";

/**
* Derives the changelog URL from a generator name.
* Generator names follow the pattern "fernapi/fern-<language>-sdk[-variant]",
* and changelog pages live at buildwithfern.com/learn/sdks/generators/<language>/changelog.
*/
function getChangelogUrl(generatorName: string): string | undefined {
const match = generatorName.match(/^fernapi\/fern-([a-z]+)/);
if (!match?.[1]) {
return undefined;
}
return `${CHANGELOG_BASE}/${match[1]}/changelog`;
}

export interface AutomationsUpgradeOptions {
includeMajor: boolean;
}

interface CliUpgradeResult {
from: string;
to: string;
upgraded: boolean;
}

interface GeneratorUpgradeEntry {
name: string;
group: string;
api: string | null;
from: string;
to: string;
changelog: string | undefined;
migrationsApplied: number;
}

interface SkippedMajorEntry {
name: string;
current: string;
latest: string;
}

interface AlreadyUpToDateEntry {
name: string;
version: string;
}

export interface AutomationsUpgradeResult {
cli: CliUpgradeResult;
generators: GeneratorUpgradeEntry[];
skippedMajor: SkippedMajorEntry[];
alreadyUpToDate: AlreadyUpToDateEntry[];
}

/**
* Reads the current CLI version from fern.config.json before any upgrade runs.
*/
async function getCurrentCliVersion(cliContext: CliContext): Promise<string> {
const fernDirectory = await getFernDirectory();
if (fernDirectory == null) {
cliContext.failAndThrow(`Directory "${FERN_DIRECTORY}" not found.`, undefined, {
code: CliError.Code.ConfigError
});
}
const projectConfig = await cliContext.runTask((context) =>
loadProjectConfig({ directory: fernDirectory, context })
);
return projectConfig.version;
}

/**
* Top-level runner for `fern automations upgrade`.
*
* Orchestrates both `fern upgrade` (CLI version) and `fern generator upgrade`
* (generator versions), then outputs a structured JSON summary to stdout.
*
* The JSON output is designed for consumption by the fern-upgrade GitHub Action,
* replacing the previous snapshot.js / diff.js approach with a single CLI call.
*
* JSON output format (on --json):
* {
* "cli": { "from": "4.66.0", "to": "4.96.0", "upgraded": true },
* "generators": [
* {
* "name": "fernapi/fern-typescript-sdk",
* "group": "ts-sdk",
* "api": "api",
* "from": "3.63.4",
* "to": "3.65.5",
* "changelog": "https://...",
* "migrationsApplied": 1
* }
* ],
* "skippedMajor": [{ "name": "...", "current": "...", "latest": "..." }],
* "alreadyUpToDate": [{ "name": "...", "version": "..." }]
* }
*/
export async function executeAutomationsUpgrade({
cliContext,
options
}: {
cliContext: CliContext;
options: AutomationsUpgradeOptions;
}): Promise<AutomationsUpgradeResult> {
// 1. Capture the CLI version before upgrade
const cliVersionBefore = await getCurrentCliVersion(cliContext);

// 2. Run `fern upgrade --yes` to upgrade CLI version + run migrations
cliContext.logger.info("Running CLI upgrade...");
await upgrade({
cliContext,
includePreReleases: false,
targetVersion: undefined,
fromVersion: undefined,
yes: true
});

// Read the CLI version after upgrade
const cliVersionAfter = await getCurrentCliVersion(cliContext);
const cliUpgraded = cliVersionBefore !== cliVersionAfter;

if (cliUpgraded) {
cliContext.logger.info(`CLI upgraded: ${chalk.dim(cliVersionBefore)} -> ${chalk.green(cliVersionAfter)}`);
} else {
cliContext.logger.info("CLI already up to date.");
}

// 3. Load project and run generator upgrades across all workspaces
cliContext.logger.info("Running generator upgrades...");
const project = await loadProjectAndRegisterWorkspacesWithContext(cliContext, {
commandLineApiWorkspace: undefined,
defaultToAllApiWorkspaces: true
});

const generatorResults = await upgradeGeneratorsForAllWorkspaces({
cliContext,
project,
includeMajor: options.includeMajor
});

// 4. Build the structured result
const result: AutomationsUpgradeResult = {
cli: {
from: cliVersionBefore,
to: cliVersionAfter,
upgraded: cliUpgraded
},
generators: generatorResults.generators,
skippedMajor: generatorResults.skippedMajor,
alreadyUpToDate: generatorResults.alreadyUpToDate
};

return result;
}

/**
* Runs generator upgrades across all API workspaces in the project,
* collecting structured results instead of just logging to console.
*/
async function upgradeGeneratorsForAllWorkspaces({
cliContext,
project,
includeMajor
}: {
cliContext: CliContext;
project: Project;
includeMajor: boolean;
}): Promise<{
generators: GeneratorUpgradeEntry[];
skippedMajor: SkippedMajorEntry[];
alreadyUpToDate: AlreadyUpToDateEntry[];
}> {
const generators: GeneratorUpgradeEntry[] = [];
const skippedMajor: SkippedMajorEntry[] = [];
const alreadyUpToDate: AlreadyUpToDateEntry[] = [];

await Promise.all(
project.apiWorkspaces.map(async (workspace) => {
await cliContext.runTaskForWorkspace(workspace, async (context) => {
const result = await loadAndUpdateGenerators({
absolutePathToWorkspace: workspace.absoluteFilePath,
context,
generatorFilter: undefined,
groupFilter: undefined,
includeMajor,
skipAutoreleaseDisabled: false,
channel: undefined,
cliVersion: cliContext.environment.packageVersion
});

const absolutePathToGeneratorsConfiguration = await getPathToGeneratorsConfiguration({
absolutePathToWorkspace: workspace.absoluteFilePath
});

if (absolutePathToGeneratorsConfiguration != null && result.updatedConfiguration != null) {
await writeFile(absolutePathToGeneratorsConfiguration, result.updatedConfiguration);
}

for (const upgrade of result.appliedUpgrades) {
const normalizedName = addDefaultDockerOrgIfNotPresent(upgrade.generatorName);
generators.push({
name: normalizedName,
group: upgrade.groupName,
api: workspace.workspaceName ?? null,
from: upgrade.previousVersion,
to: upgrade.newVersion,
changelog: getChangelogUrl(normalizedName),
migrationsApplied: upgrade.migrationsApplied ?? 0
});
}

for (const skip of result.skippedMajorUpgrades) {
const normalizedName = addDefaultDockerOrgIfNotPresent(skip.generatorName);
skippedMajor.push({
name: normalizedName,
current: skip.currentVersion,
latest: skip.latestMajorVersion
});
}

for (const upToDate of result.alreadyUpToDate) {
const normalizedName = addDefaultDockerOrgIfNotPresent(upToDate.generatorName);
alreadyUpToDate.push({
name: normalizedName,
version: upToDate.version
});
}
});
})
);

return { generators, skippedMajor, alreadyUpToDate };
}
Loading
Loading