Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
72,309 changes: 71,478 additions & 831 deletions example-action/dist/index.js

Large diffs are not rendered by default.

72,340 changes: 71,498 additions & 842 deletions generate/dist/index.js

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions generate/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import {
WrapperError,
getOptionalInput,
getOrCreateRunId,
getRequiredFernToken,
injectFernToken,
instrumentAction,
isPostPhase,
markMainPhaseStarted,
Expand Down Expand Up @@ -42,13 +44,22 @@ runAction(async () => {

await instrumentAction("generate", async () => {
const inputs = parseInputs();
injectFernToken(inputs.fernToken);
getOrCreateRunId();

const cli = await resolveFernCli("auto");
const userArgs = buildGenerateArgs(inputs);

await exec.exec(cli.command, [...cli.leadingArgs, "automations", "generate", ...userArgs], {
env: { ...process.env, FERN_TOKEN: inputs.fernToken },
});
try {
await exec.exec(cli.command, [...cli.leadingArgs, "automations", "generate", ...userArgs], {
env: { ...process.env, FERN_TOKEN: inputs.fernToken },
});
} catch (err) {
throw new WrapperError({
errorCode: "CLI_AUTOMATIONS_GENERATE_FAILED",
message: err instanceof Error ? err.message : String(err),
originalError: err,
});
}
});
});
5 changes: 4 additions & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1",
"@actions/io": "^1.1.3"
"@actions/io": "^1.1.3",
"@sentry/node": "^8.0.0",
"find-up": "^5.0.0",
"posthog-node": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
Expand Down
8 changes: 7 additions & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
export * from "./types.js";
export * from "./run-id.js";
export * from "./telemetry.js";
export * from "./post-phase.js";
export * from "./fern-cli.js";
export * from "./install-cli.js";
export * from "./project-config.js";
export * from "./telemetry/index.js";

import * as core from "@actions/core";
import { flushTelemetry } from "./telemetry/telemetry.js";
import type { Repository } from "./types.js";

/**
Expand Down Expand Up @@ -48,13 +50,17 @@ export function getOptionalInput(name: string): string | undefined {

/**
* Wraps action execution with top-level error handling and core.setFailed.
* Flushes the PostHog and Sentry SDK buffers before exiting so events
* emitted by `instrumentAction` are not lost on failure.
*/
export async function runAction(fn: () => Promise<void>): Promise<void> {
try {
await fn();
await flushTelemetry();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
core.setFailed(message);
await flushTelemetry();
process.exit(1);
}
}
Expand Down
47 changes: 34 additions & 13 deletions packages/shared/src/install-cli.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import * as io from "@actions/io";
import { WrapperError } from "./telemetry/errors.js";

/**
* Installs the Fern CLI globally via npm. Mirrors the npm-install branch of
* the setup-cli action. Throws if npm/node are missing.
* the setup-cli action. Throws `WrapperError("cli_install_failure", ...)`
* if npm/node are missing or the install fails — the wrapper's top-level
* catch translates that into a `wrapper_failed` event with cause
* `cli_install_failure`.
*
* For 'auto' or 'latest', installs `fern-api` (the CLI then handles version
* redirection at runtime via fern.config.json). For any other value, pins to
Expand All @@ -16,24 +20,41 @@ import * as io from "@actions/io";
export async function installFernCli(version: string): Promise<void> {
const npm = await io.which("npm", false);
if (!npm) {
throw new Error("npm is not available. Please add a Node.js setup step before this action.");
throw new WrapperError({
errorCode: "CLI_INSTALL_NPM_MISSING",
message: "npm is not available. Please add a Node.js setup step before this action.",
});
}
const node = await io.which("node", false);
if (!node) {
throw new Error("node is not available. Please add a Node.js setup step before this action.");
throw new WrapperError({
errorCode: "CLI_INSTALL_NODE_MISSING",
message: "node is not available. Please add a Node.js setup step before this action.",
});
}

const pkg = version === "latest" || version === "auto" ? "fern-api" : `fern-api@${version}`;
await exec.exec("npm", ["install", "-g", pkg]);
try {
await exec.exec("npm", ["install", "-g", pkg]);

let stdout = "";
await exec.exec("fern", ["--version"], {
env: { ...process.env, FERN_NO_VERSION_REDIRECTION: "true" },
listeners: {
stdout: (data) => {
stdout += data.toString();
let stdout = "";
await exec.exec("fern", ["--version"], {
env: { ...process.env, FERN_NO_VERSION_REDIRECTION: "true" },
listeners: {
stdout: (data) => {
stdout += data.toString();
},
},
},
});
core.info(`Installed Fern CLI version ${stdout.trim()}`);
});
core.info(`Installed Fern CLI version ${stdout.trim()}`);
} catch (err) {
if (err instanceof WrapperError) {
throw err;
}
throw new WrapperError({
errorCode: "CLI_INSTALL_NPM_FAILED",
message: err instanceof Error ? err.message : String(err),
originalError: err,
});
}
}
88 changes: 88 additions & 0 deletions packages/shared/src/project-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ProjectConfig } from "./project-config.js";

function makeIsolatedTempCwd(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "fern-actions-project-config-test-"));
}

describe("ProjectConfig.tryLoad", () => {
let originalCwd: string;
let tmpCwd: string;

beforeEach(() => {
originalCwd = process.cwd();
tmpCwd = makeIsolatedTempCwd();
process.chdir(tmpCwd);
});

afterEach(() => {
process.chdir(originalCwd);
fs.rmSync(tmpCwd, { recursive: true, force: true });
});

it("loads organization and version from fern/fern.config.json in cwd", () => {
fs.mkdirSync(path.join(tmpCwd, "fern"));
fs.writeFileSync(
path.join(tmpCwd, "fern", "fern.config.json"),
JSON.stringify({ organization: "square-bank", version: "0.42.0" })
);

const config = ProjectConfig.tryLoad();
expect(config).toBeDefined();
expect(config?.organization).toBe("square-bank");
expect(config?.version).toBe("0.42.0");
expect(config?.absolutePath).toBe(
path.join(fs.realpathSync(tmpCwd), "fern", "fern.config.json")
);
expect(config?.rawConfig).toEqual({ organization: "square-bank", version: "0.42.0" });
});

it("walks up from cwd to locate fern/fern.config.json in an ancestor", () => {
fs.mkdirSync(path.join(tmpCwd, "fern"));
fs.writeFileSync(
path.join(tmpCwd, "fern", "fern.config.json"),
JSON.stringify({ organization: "acme", version: "1.0.0" })
);
const nested = path.join(tmpCwd, "subdir", "deeper");
fs.mkdirSync(nested, { recursive: true });
process.chdir(nested);

expect(ProjectConfig.tryLoad()?.organization).toBe("acme");
});

it("returns undefined when no fern/ ancestor exists", () => {
expect(ProjectConfig.tryLoad()).toBeUndefined();
});

it("returns undefined when fern/ exists but fern.config.json is missing", () => {
fs.mkdirSync(path.join(tmpCwd, "fern"));
expect(ProjectConfig.tryLoad()).toBeUndefined();
});

it("returns undefined when fern.config.json is unreadable JSON", () => {
fs.mkdirSync(path.join(tmpCwd, "fern"));
fs.writeFileSync(path.join(tmpCwd, "fern", "fern.config.json"), "not json {");
expect(ProjectConfig.tryLoad()).toBeUndefined();
});

it("returns undefined when the parsed shape is missing organization", () => {
fs.mkdirSync(path.join(tmpCwd, "fern"));
fs.writeFileSync(
path.join(tmpCwd, "fern", "fern.config.json"),
JSON.stringify({ version: "0.0.1" })
);
expect(ProjectConfig.tryLoad()).toBeUndefined();
});

it("returns undefined when the parsed shape is missing version", () => {
fs.mkdirSync(path.join(tmpCwd, "fern"));
fs.writeFileSync(
path.join(tmpCwd, "fern", "fern.config.json"),
JSON.stringify({ organization: "acme" })
);
expect(ProjectConfig.tryLoad()).toBeUndefined();
});
});
74 changes: 74 additions & 0 deletions packages/shared/src/project-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as fs from "node:fs";
import * as path from "node:path";
import findUp from "find-up";

const FERN_DIRECTORY = "fern";
const PROJECT_CONFIG_FILENAME = "fern.config.json";

/**
* Raw on-disk shape of `fern.config.json`. Mirrors `ProjectConfigSchema` in
* `@fern-api/configuration` (the CLI's strict zod schema).
*/
export interface ProjectConfigSchema {
organization: string;
version: string;
}

/**
* In-memory representation of the customer's `fern.config.json`. Mirrors the
* CLI's `ProjectConfig` interface and its loader (`getFernDirectory` +
* `loadProjectConfig`), so the wrapper resolves the same `organization` and
* `version` the CLI itself sees.
*
* Synchronous and best-effort: `tryLoad` returns `undefined` rather than
* throwing when the file is missing or malformed, since the wrapper uses
* this for telemetry enrichment, not as a hard pre-flight check.
*/
export class ProjectConfig {
readonly absolutePath: string;
readonly rawConfig: ProjectConfigSchema;
readonly organization: string;
readonly version: string;

private constructor(absolutePath: string, rawConfig: ProjectConfigSchema) {
this.absolutePath = absolutePath;
this.rawConfig = rawConfig;
this.organization = rawConfig.organization;
this.version = rawConfig.version;
}

/**
* Walks up from cwd to the first ancestor containing a `fern/` directory,
* then reads `fern.config.json` from inside it. Returns `undefined` when no
* `fern/` ancestor exists, the config file is missing, JSON parsing fails,
* or the parsed value doesn't match `ProjectConfigSchema`.
*/
static tryLoad(): ProjectConfig | undefined {
const fernDir = findUp.sync(FERN_DIRECTORY, { type: "directory" });
if (fernDir == null) {
return undefined;
}
const configPath = path.join(fernDir, PROJECT_CONFIG_FILENAME);
if (!findUp.sync.exists(configPath)) {
return undefined;
}
try {
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8")) as unknown;
if (!isProjectConfigSchema(parsed)) {
return undefined;
}
return new ProjectConfig(configPath, parsed);
} catch {
return undefined;
}
}
}

function isProjectConfigSchema(value: unknown): value is ProjectConfigSchema {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { organization?: unknown }).organization === "string" &&
typeof (value as { version?: unknown }).version === "string"
);
}
4 changes: 2 additions & 2 deletions packages/shared/src/run-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ describe("getGithubRunId", () => {
expect(getGithubRunId()).toBe("12345678");
});

it("returns empty string when GITHUB_RUN_ID is not set", () => {
it("returns undefined when GITHUB_RUN_ID is not set", () => {
// biome-ignore lint/performance/noDelete: process.env coerces to string, delete is required to unset
delete process.env.GITHUB_RUN_ID;
expect(getGithubRunId()).toBe("");
expect(getGithubRunId()).toBeUndefined();
});
});
20 changes: 18 additions & 2 deletions packages/shared/src/run-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ export function getOrCreateRunId(): string {
* Returns the GITHUB_RUN_ID for cross-referencing with FERN_RUN_ID in
* telemetry events and Sentry tags.
*/
export function getGithubRunId(): string {
return process.env.GITHUB_RUN_ID ?? "";
export function getGithubRunId(): string | undefined {
return process.env.GITHUB_RUN_ID ?? undefined;
}

/**
* Returns the click-through URL for the current GitHub Actions run, derived
* from the runner-provided GITHUB_SERVER_URL, GITHUB_REPOSITORY, and
* GITHUB_RUN_ID env vars. Returns an empty string when invoked off-runner
* so callers can spread the result into payloads without conditional logic.
*/
export function getGithubRunUrl(): string | undefined {
const serverUrl = process.env.GITHUB_SERVER_URL;
const repository = process.env.GITHUB_REPOSITORY;
const runId = process.env.GITHUB_RUN_ID;
if (!serverUrl || !repository || !runId) {
return undefined;
}
return `${serverUrl}/${repository}/actions/runs/${runId}`;
}
Loading