Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
21 changes: 21 additions & 0 deletions packages/cli/cli-logger/src/TtyAwareLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import chalk from "chalk";
import IS_CI from "is-ci";
import ora, { Ora } from "ora";

import {
areLoggerAnnotationsSuppressed,
renderGithubAnnotationFromLog,
shouldEmitGithubAnnotations
} from "./githubAnnotations.js";
import { Log } from "./Log.js";

interface Task {
Expand Down Expand Up @@ -114,6 +119,9 @@ export class TtyAwareLogger {
this.writeStdout(content);
}
};
// Cache the env-var check + the suppression flag once per call. Both are stable for the
// duration of this batch; checking each iteration would just be noise.
const emitAnnotations = shouldEmitGithubAnnotations() && !areLoggerAnnotationsSuppressed();
for (const log of logs) {
const content = formatLog(log, { includeDebugInfo });
const omitOnTTY = log.omitOnTTY ?? false;
Expand All @@ -122,6 +130,19 @@ export class TtyAwareLogger {
} else if (!omitOnTTY) {
write(this.clear() + content + this.lastPaint);
}
// Emit a GitHub Actions workflow command alongside the normal log when the runner is
// GitHub Actions. The runner parses these from the step's stdout and renders them as
// inline annotations on the run/PR — surfacing errors and warnings the user would
// otherwise have to scroll the raw log to find. Gated purely on `GITHUB_ACTIONS=true`
// (not on `isTTY`) so it also works if someone forces the env var locally for testing.
// Commands that emit their own structured annotations (e.g. `fern automations
// generate`) suppress this hook to avoid duplicates — see `setLoggerAnnotationsSuppressed`.
if (emitAnnotations) {
const annotation = renderGithubAnnotationFromLog(log);
if (annotation != null) {
this.stdout.write(annotation);
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { PassThrough } from "node:stream";
import { LogLevel } from "@fern-api/logger";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { withSuppressedLoggerAnnotations } from "../githubAnnotations.js";
import { TtyAwareLogger } from "../TtyAwareLogger.js";

const ENV_KEYS = ["GITHUB_ACTIONS"] as const;

/**
* Integration tests for the GHA annotation hook in `TtyAwareLogger.log`. The hook lives in the
* hot path of every CLI log line, so we want explicit coverage of the env-var gate, the
* suppression flag, the level filter, and the omit-on-TTY filter.
*/
describe("TtyAwareLogger GitHub Actions annotations", () => {
const saved: Record<(typeof ENV_KEYS)[number], string | undefined> = { GITHUB_ACTIONS: undefined };

let stdout: TestStream;
let stderr: TestStream;
let logger: TtyAwareLogger;

beforeEach(() => {
for (const key of ENV_KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
stdout = makeStream();
stderr = makeStream();
logger = new TtyAwareLogger(stdout.stream, stderr.stream);
});

afterEach(() => {
logger.finish();
for (const key of ENV_KEYS) {
if (saved[key] == null) {
delete process.env[key];
} else {
process.env[key] = saved[key];
}
}
});

it("emits no annotation lines when GITHUB_ACTIONS is unset", () => {
delete process.env.GITHUB_ACTIONS;
logger.log([{ level: LogLevel.Error, parts: ["boom"], time: new Date() }]);
expect(stdout.read()).not.toContain("::error::");
expect(stderr.read()).not.toContain("::error::");
});

it("emits ::error:: on stdout when GITHUB_ACTIONS=true and level is Error", () => {
process.env.GITHUB_ACTIONS = "true";
logger.log([{ level: LogLevel.Error, parts: ["boom"], time: new Date() }]);
expect(stdout.read()).toContain("::error::boom\n");
});

it("emits ::warning:: on stdout for warn-level logs", () => {
process.env.GITHUB_ACTIONS = "true";
logger.log([{ level: LogLevel.Warn, parts: ["careful"], time: new Date() }]);
expect(stdout.read()).toContain("::warning::careful\n");
});

it("does not emit annotations for info / debug / trace levels", () => {
process.env.GITHUB_ACTIONS = "true";
logger.log([
{ level: LogLevel.Info, parts: ["x"], time: new Date() },
{ level: LogLevel.Debug, parts: ["y"], time: new Date() },
{ level: LogLevel.Trace, parts: ["z"], time: new Date() }
]);
const out = stdout.read();
expect(out).not.toContain("::error");
expect(out).not.toContain("::warning");
});

it("suppresses annotations inside withSuppressedLoggerAnnotations(...)", async () => {
// `fern automations generate` runs its body inside `withSuppressedLoggerAnnotations` while
// it emits its own structured annotations from the GeneratorRunCollector; the generic
// logger hook must stay quiet so the user doesn't see two annotations per failure.
process.env.GITHUB_ACTIONS = "true";
await withSuppressedLoggerAnnotations(async () => {
logger.log([{ level: LogLevel.Error, parts: ["boom"], time: new Date() }]);
});
expect(stdout.read()).not.toContain("::error::");
});

it("re-emits annotations after the suppression scope exits", async () => {
process.env.GITHUB_ACTIONS = "true";
await withSuppressedLoggerAnnotations(async () => {
logger.log([{ level: LogLevel.Error, parts: ["first"], time: new Date() }]);
});
logger.log([{ level: LogLevel.Error, parts: ["second"], time: new Date() }]);
const out = stdout.read();
expect(out).not.toContain("::error::first");
expect(out).toContain("::error::second\n");
});

it("skips annotations for omitOnTTY: true logs even at error level", () => {
// The CLI uses `omitOnTTY: true` for status-only lines like "Failed." that exist for
// non-TTY readability. They aren't the actual error and shouldn't burn an annotation.
process.env.GITHUB_ACTIONS = "true";
logger.log([{ level: LogLevel.Error, parts: ["Failed."], time: new Date(), omitOnTTY: true }]);
expect(stdout.read()).not.toContain("::error::");
});

it("uses the log prefix as the annotation title (with ANSI stripped)", () => {
process.env.GITHUB_ACTIONS = "true";
// Hardcoded ANSI escapes so the test doesn't depend on chalk's TTY-detection behavior.
const prefix = `[workspace-foo] `;
logger.log([{ level: LogLevel.Error, parts: ["boom"], time: new Date(), prefix }]);
expect(stdout.read()).toContain("::error title=[workspace-foo]::boom\n");
});
});

interface TestStream {
stream: NodeJS.WriteStream;
read: () => string;
}

function makeStream(): TestStream {
const buffers: Buffer[] = [];
const passthrough = new PassThrough();
passthrough.on("data", (chunk: Buffer) => buffers.push(chunk));
// The TtyAwareLogger only reads `isTTY` and `write`; cast is enough for our purposes.
const stream = passthrough as unknown as NodeJS.WriteStream;
(stream as { isTTY?: boolean }).isTTY = false;
return {
stream,
read: () => Buffer.concat(buffers).toString("utf8")
};
}
196 changes: 196 additions & 0 deletions packages/cli/cli-logger/src/__test__/githubAnnotations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { LogLevel } from "@fern-api/logger";
import { afterEach, describe, expect, it } from "vitest";

import {
areLoggerAnnotationsSuppressed,
renderGithubAnnotation,
renderGithubAnnotationFromLog,
shouldEmitGithubAnnotations,
withSuppressedLoggerAnnotations
} from "../githubAnnotations.js";
import { Log } from "../Log.js";

function makeLog(level: LogLevel, parts: string[], extras: Partial<Log> = {}): Log {
return { level, parts, time: new Date(0), ...extras };
}

describe("shouldEmitGithubAnnotations", () => {
const original = process.env.GITHUB_ACTIONS;
afterEach(() => {
if (original === undefined) {
delete process.env.GITHUB_ACTIONS;
} else {
process.env.GITHUB_ACTIONS = original;
}
});

it("returns true when GITHUB_ACTIONS=true", () => {
process.env.GITHUB_ACTIONS = "true";
expect(shouldEmitGithubAnnotations()).toBe(true);
});

it("returns false when GITHUB_ACTIONS is unset", () => {
delete process.env.GITHUB_ACTIONS;
expect(shouldEmitGithubAnnotations()).toBe(false);
});

it("returns false for any value other than the literal string 'true'", () => {
// GitHub Actions sets the value to the literal string "true"; other CI providers may
// set similarly named vars to "1" or "yes", and we don't want to misinterpret those.
process.env.GITHUB_ACTIONS = "1";
expect(shouldEmitGithubAnnotations()).toBe(false);
});
});

describe("withSuppressedLoggerAnnotations", () => {
it("suppresses annotations during the body and restores afterward", async () => {
expect(areLoggerAnnotationsSuppressed()).toBe(false);
await withSuppressedLoggerAnnotations(async () => {
expect(areLoggerAnnotationsSuppressed()).toBe(true);
});
expect(areLoggerAnnotationsSuppressed()).toBe(false);
});

it("restores the previous state even if the body throws", async () => {
// The whole reason this is a scoped runner instead of a `set(true)` / `set(false)` pair
// is that automations generate's body can throw (e.g. an unexpected error escapes
// `runTask`); the flag must not leak into subsequent commands or tests.
expect(areLoggerAnnotationsSuppressed()).toBe(false);
await expect(
withSuppressedLoggerAnnotations(async () => {
expect(areLoggerAnnotationsSuppressed()).toBe(true);
throw new Error("body failed");
})
).rejects.toThrow("body failed");
expect(areLoggerAnnotationsSuppressed()).toBe(false);
});

it("preserves the previous suppressed state on nested usage (re-entrant safety)", async () => {
// If two automations runs are layered (unlikely in production but possible in tests
// sharing a process), the inner restoration must not flip the outer's state to false.
await withSuppressedLoggerAnnotations(async () => {
expect(areLoggerAnnotationsSuppressed()).toBe(true);
await withSuppressedLoggerAnnotations(async () => {
expect(areLoggerAnnotationsSuppressed()).toBe(true);
});
// Inner finally should restore to the outer's state (true), not the original (false).
expect(areLoggerAnnotationsSuppressed()).toBe(true);
});
expect(areLoggerAnnotationsSuppressed()).toBe(false);
});
});

describe("renderGithubAnnotation", () => {
it("renders a bare ::error:: command with no properties", () => {
expect(renderGithubAnnotation("error", "boom")).toBe("::error::boom\n");
});

it("renders ::warning:: level", () => {
expect(renderGithubAnnotation("warning", "deprecated")).toBe("::warning::deprecated\n");
});

it("formats file/line/title properties in reading order (file, line, title)", () => {
const out = renderGithubAnnotation("error", "boom", {
file: "fern/generators.yml",
line: 42,
title: "python-sdk failed"
});
expect(out).toBe("::error file=fern/generators.yml,line=42,title=python-sdk failed::boom\n");
});

it("escapes commas, colons, and CR/LF in property values", () => {
// Property syntax uses `,` to separate properties and `:` to terminate the property list,
// so any property value containing them would corrupt the parse. GHA's escape is `%XX`.
// (The `=` between key and value is not ambiguous in values — GHA parses property strings
// by splitting on the first `=` per pair, so `=` does not need escaping.)
const out = renderGithubAnnotation("error", "boom", {
title: "url=http://x:8080/a,b\r\nnext"
});
expect(out).toBe("::error title=url=http%3A//x%3A8080/a%2Cb%0D%0Anext::boom\n");
});

it("escapes a literal '%' to '%25' before applying other escapes", () => {
// If we didn't escape `%` first, a value containing `%0A` literally would survive
// round-tripping (the `%` would stay raw and produce `%0A`), making the value
// ambiguous with our newline encoding. Escaping `%` to `%25` first prevents that.
expect(renderGithubAnnotation("error", "boom", { title: "100%" })).toBe("::error title=100%25::boom\n");
});

it("strips ANSI from property values", () => {
// chalk-colored prefixes shouldn't appear as escape codes in property values either.
const titleWithColor = `workspace-a`;
const out = renderGithubAnnotation("error", "boom", { title: titleWithColor });
expect(out).toBe("::error title=workspace-a::boom\n");
});

it("omits empty title and file properties (an empty title= would render blank)", () => {
expect(renderGithubAnnotation("error", "boom", { title: "", file: "" })).toBe("::error::boom\n");
});

it("returns undefined when the body is empty after sanitization", () => {
expect(renderGithubAnnotation("error", "")).toBeUndefined();
expect(renderGithubAnnotation("error", "\n\n")).toBeUndefined();
});

it("encodes newlines in the body as %0A so multi-line errors stay one workflow command", () => {
expect(renderGithubAnnotation("error", "line one\nline two")).toBe("::error::line one%0Aline two\n");
});

it("trims trailing newlines from the body before encoding", () => {
expect(renderGithubAnnotation("error", "boom\n\n")).toBe("::error::boom\n");
});

it("normalizes CRLF and drops bare CRs", () => {
expect(renderGithubAnnotation("error", "line one\r\nline two\rstill line two")).toBe(
"::error::line one%0Aline twostill line two\n"
);
});

it("strips ANSI escape sequences from the body", () => {
// Hardcoded ANSI bytes — we can't rely on chalk's runtime detection in the test env, since
// chalk strips colors when stdout isn't a TTY (which it isn't under vitest).
const body = `boom`;
expect(renderGithubAnnotation("error", body)).toBe("::error::boom\n");
});
});

describe("renderGithubAnnotationFromLog", () => {
it("renders an ::error:: annotation for an error log", () => {
const log = makeLog(LogLevel.Error, ["generator failed:", "boom"]);
expect(renderGithubAnnotationFromLog(log)).toBe("::error::generator failed: boom\n");
});

it("renders a ::warning:: annotation for a warn log", () => {
const log = makeLog(LogLevel.Warn, ["deprecated config option"]);
expect(renderGithubAnnotationFromLog(log)).toBe("::warning::deprecated config option\n");
});

it("returns undefined for info / debug / trace levels", () => {
for (const level of [LogLevel.Info, LogLevel.Debug, LogLevel.Trace]) {
expect(renderGithubAnnotationFromLog(makeLog(level, ["noisy"]))).toBeUndefined();
}
});

it("returns undefined when omitOnTTY is true (status-only logs that aren't real failures)", () => {
// The CLI emits per-task status lines like LogLevel.Error "Failed." with omitOnTTY: true
// for non-TTY readability. Those aren't real errors and shouldn't burn an annotation slot.
const log = makeLog(LogLevel.Error, ["Failed."], { omitOnTTY: true });
expect(renderGithubAnnotationFromLog(log)).toBeUndefined();
});

it("uses log.prefix as the annotation title (with ANSI stripped and whitespace trimmed)", () => {
// Logger prefixes look like `[workspace-foo] ` (color + padding).
const log = makeLog(LogLevel.Error, ["boom"], { prefix: `[workspace-foo] ` });
expect(renderGithubAnnotationFromLog(log)).toBe("::error title=[workspace-foo]::boom\n");
});

it("omits the title when prefix is empty after sanitization", () => {
const log = makeLog(LogLevel.Error, ["boom"], { prefix: " " });
expect(renderGithubAnnotationFromLog(log)).toBe("::error::boom\n");
});

it("returns undefined when the body is empty", () => {
expect(renderGithubAnnotationFromLog(makeLog(LogLevel.Error, []))).toBeUndefined();
expect(renderGithubAnnotationFromLog(makeLog(LogLevel.Error, ["", ""]))).toBeUndefined();
});
});
Loading
Loading