From 4bfcb7f601133e470b458ac02a5b264cc72d71da Mon Sep 17 00:00:00 2001 From: Vivek JM Date: Wed, 13 May 2026 12:23:32 +0530 Subject: [PATCH 1/2] Group align-deps logs on CI --- .changeset/group-align-deps-ci.md | 5 ++++ packages/align-deps/src/ci.ts | 34 ++++++++++++++++++++++++++ packages/align-deps/src/cli.ts | 9 +++++++ packages/align-deps/test/ci.test.ts | 38 +++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 .changeset/group-align-deps-ci.md create mode 100644 packages/align-deps/src/ci.ts create mode 100644 packages/align-deps/test/ci.test.ts diff --git a/.changeset/group-align-deps-ci.md b/.changeset/group-align-deps-ci.md new file mode 100644 index 0000000000..7d6d6b631f --- /dev/null +++ b/.changeset/group-align-deps-ci.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/align-deps": patch +--- + +Group align-deps package output in GitHub Actions and Azure DevOps logs. diff --git a/packages/align-deps/src/ci.ts b/packages/align-deps/src/ci.ts new file mode 100644 index 0000000000..f4776c4c82 --- /dev/null +++ b/packages/align-deps/src/ci.ts @@ -0,0 +1,34 @@ +type LogGroup = { + end: string; + start: string; +}; + +type Env = NodeJS.ProcessEnv; + +function escapeGitHubCommand(value: string): string { + return value + .replace(/%/g, "%25") + .replace(/\r/g, "%0D") + .replace(/\n/g, "%0A"); +} + +export function logGroupFor( + title: string, + env: Env = process.env +): LogGroup | undefined { + if (env["GITHUB_ACTIONS"] === "true") { + return { + start: `::group::${escapeGitHubCommand(title)}`, + end: "::endgroup::", + }; + } + + if (env["TF_BUILD"] === "True") { + return { + start: `##[group]${title}`, + end: "##[endgroup]", + }; + } + + return undefined; +} diff --git a/packages/align-deps/src/cli.ts b/packages/align-deps/src/cli.ts index c8067647b0..da47c7b5fd 100644 --- a/packages/align-deps/src/cli.ts +++ b/packages/align-deps/src/cli.ts @@ -9,6 +9,7 @@ import { } from "@rnx-kit/tools-workspaces"; import * as fs from "node:fs"; import * as path from "node:path"; +import { logGroupFor } from "./ci.ts"; import { makeCheckCommand } from "./commands/check.ts"; import { makeExportCatalogsCommand } from "./commands/exportCatalogs.ts"; import { makeInitializeCommand } from "./commands/initialize.ts"; @@ -266,7 +267,11 @@ export async function cli({ packages, ...args }: Args): Promise { // disk only when everything is in order for the target package. Packages with // invalid or missing configurations are skipped. const errors = manifests.reduce((errors, manifest) => { + const logGroup = logGroupFor(manifest); try { + if (logGroup) { + console.log(logGroup.start); + } const result = command(manifest); printError(manifest, result); if (result !== "success" && result !== "excluded") { @@ -279,6 +284,10 @@ export async function cli({ packages, ...args }: Args): Promise { } throw e; + } finally { + if (logGroup) { + console.log(logGroup.end); + } } return errors; }, 0); diff --git a/packages/align-deps/test/ci.test.ts b/packages/align-deps/test/ci.test.ts new file mode 100644 index 0000000000..bebae1a81b --- /dev/null +++ b/packages/align-deps/test/ci.test.ts @@ -0,0 +1,38 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { logGroupFor } from "../src/ci.ts"; + +describe("logGroupFor()", () => { + it("returns GitHub Actions group markers", () => { + deepEqual( + logGroupFor("packages/app/package.json", { GITHUB_ACTIONS: "true" }), + { + start: "::group::packages/app/package.json", + end: "::endgroup::", + } + ); + }); + + it("escapes GitHub Actions command values", () => { + deepEqual( + logGroupFor("packages/%app\r\n/package.json", { + GITHUB_ACTIONS: "true", + }), + { + start: "::group::packages/%25app%0D%0A/package.json", + end: "::endgroup::", + } + ); + }); + + it("returns Azure DevOps group markers", () => { + deepEqual(logGroupFor("packages/app/package.json", { TF_BUILD: "True" }), { + start: "##[group]packages/app/package.json", + end: "##[endgroup]", + }); + }); + + it("does not group local output", () => { + equal(logGroupFor("packages/app/package.json", {}), undefined); + }); +}); From 782a8357626503c34adb078df2a176228a4cdea6 Mon Sep 17 00:00:00 2001 From: Vivek JM Date: Wed, 13 May 2026 13:41:19 +0530 Subject: [PATCH 2/2] Address CI log grouping review feedback --- packages/align-deps/src/ci.ts | 81 ++++++++++++++----- packages/align-deps/src/cli.ts | 38 ++++----- packages/align-deps/test/ci.test.ts | 119 +++++++++++++++++++++++----- 3 files changed, 176 insertions(+), 62 deletions(-) diff --git a/packages/align-deps/src/ci.ts b/packages/align-deps/src/ci.ts index f4776c4c82..08115668be 100644 --- a/packages/align-deps/src/ci.ts +++ b/packages/align-deps/src/ci.ts @@ -1,34 +1,79 @@ -type LogGroup = { - end: string; - start: string; +type GroupMarkers = { + beginGroup: (title: string) => void; + endGroup: () => void; }; -type Env = NodeJS.ProcessEnv; +type Output = Pick; -function escapeGitHubCommand(value: string): string { - return value - .replace(/%/g, "%25") - .replace(/\r/g, "%0D") - .replace(/\n/g, "%0A"); +function noop() { + return undefined; } -export function logGroupFor( - title: string, - env: Env = process.env -): LogGroup | undefined { +export function getGroupMarkers( + log: typeof console.log = console.log, + env = process.env +): GroupMarkers { if (env["GITHUB_ACTIONS"] === "true") { return { - start: `::group::${escapeGitHubCommand(title)}`, - end: "::endgroup::", + beginGroup: (title) => log(`::group::${encodeURI(title)}`), + endGroup: () => log("::endgroup::"), }; } if (env["TF_BUILD"] === "True") { return { - start: `##[group]${title}`, - end: "##[endgroup]", + beginGroup: (title) => log(`##[group]${title}`), + endGroup: () => log("##[endgroup]"), }; } - return undefined; + return { + beginGroup: noop, + endGroup: noop, + }; +} + +export function withLogGroup( + title: string, + fn: () => T, + output: Output = console, + env = process.env +): T { + const originalLog = output.log; + const originalWarn = output.warn; + const originalError = output.error; + const { beginGroup, endGroup } = getGroupMarkers(originalLog, env); + let groupStarted = false; + + const begin = () => { + if (!groupStarted) { + beginGroup(title); + groupStarted = true; + } + }; + + output.log = (...args) => { + begin(); + originalLog(...args); + }; + output.warn = (...args) => { + begin(); + originalWarn(...args); + }; + output.error = (...args) => { + begin(); + originalError(...args); + }; + + try { + return fn(); + } finally { + output.log = originalLog; + output.warn = originalWarn; + output.error = originalError; + + if (groupStarted) { + endGroup(); + } + } } diff --git a/packages/align-deps/src/cli.ts b/packages/align-deps/src/cli.ts index da47c7b5fd..812504d19a 100644 --- a/packages/align-deps/src/cli.ts +++ b/packages/align-deps/src/cli.ts @@ -9,7 +9,7 @@ import { } from "@rnx-kit/tools-workspaces"; import * as fs from "node:fs"; import * as path from "node:path"; -import { logGroupFor } from "./ci.ts"; +import { withLogGroup } from "./ci.ts"; import { makeCheckCommand } from "./commands/check.ts"; import { makeExportCatalogsCommand } from "./commands/exportCatalogs.ts"; import { makeInitializeCommand } from "./commands/initialize.ts"; @@ -267,29 +267,23 @@ export async function cli({ packages, ...args }: Args): Promise { // disk only when everything is in order for the target package. Packages with // invalid or missing configurations are skipped. const errors = manifests.reduce((errors, manifest) => { - const logGroup = logGroupFor(manifest); - try { - if (logGroup) { - console.log(logGroup.start); - } - const result = command(manifest); - printError(manifest, result); - if (result !== "success" && result !== "excluded") { - return errors + 1; - } - } catch (e) { - if (hasProperty(e, "message")) { - error(`${manifest}: ${e.message}`); - return errors + 1; - } + return withLogGroup(manifest, () => { + try { + const result = command(manifest); + printError(manifest, result); + if (result !== "success" && result !== "excluded") { + return errors + 1; + } + } catch (e) { + if (hasProperty(e, "message")) { + error(`${manifest}: ${e.message}`); + return errors + 1; + } - throw e; - } finally { - if (logGroup) { - console.log(logGroup.end); + throw e; } - } - return errors; + return errors; + }); }, 0); process.exitCode = errors; diff --git a/packages/align-deps/test/ci.test.ts b/packages/align-deps/test/ci.test.ts index bebae1a81b..b93dc985e2 100644 --- a/packages/align-deps/test/ci.test.ts +++ b/packages/align-deps/test/ci.test.ts @@ -1,38 +1,113 @@ import { deepEqual, equal } from "node:assert/strict"; import { describe, it } from "node:test"; -import { logGroupFor } from "../src/ci.ts"; +import { getGroupMarkers, withLogGroup } from "../src/ci.ts"; -describe("logGroupFor()", () => { +describe("getGroupMarkers()", () => { it("returns GitHub Actions group markers", () => { - deepEqual( - logGroupFor("packages/app/package.json", { GITHUB_ACTIONS: "true" }), - { - start: "::group::packages/app/package.json", - end: "::endgroup::", - } + const output: string[] = []; + const { beginGroup, endGroup } = getGroupMarkers( + (message) => output.push(message), + { GITHUB_ACTIONS: "true" } ); + + beginGroup("packages/app/package.json"); + endGroup(); + + deepEqual(output, ["::group::packages/app/package.json", "::endgroup::"]); }); - it("escapes GitHub Actions command values", () => { - deepEqual( - logGroupFor("packages/%app\r\n/package.json", { - GITHUB_ACTIONS: "true", - }), - { - start: "::group::packages/%25app%0D%0A/package.json", - end: "::endgroup::", - } + it("encodes GitHub Actions group titles", () => { + const output: string[] = []; + const { beginGroup, endGroup } = getGroupMarkers( + (message) => output.push(message), + { GITHUB_ACTIONS: "true" } ); + + beginGroup("packages/%app\r\n/package.json"); + endGroup(); + + deepEqual(output, [ + "::group::packages/%25app%0D%0A/package.json", + "::endgroup::", + ]); }); it("returns Azure DevOps group markers", () => { - deepEqual(logGroupFor("packages/app/package.json", { TF_BUILD: "True" }), { - start: "##[group]packages/app/package.json", - end: "##[endgroup]", - }); + const output: string[] = []; + const { beginGroup, endGroup } = getGroupMarkers( + (message) => output.push(message), + { TF_BUILD: "True" } + ); + + beginGroup("packages/app/package.json"); + endGroup(); + + deepEqual(output, ["##[group]packages/app/package.json", "##[endgroup]"]); }); it("does not group local output", () => { - equal(logGroupFor("packages/app/package.json", {}), undefined); + const output: string[] = []; + const { beginGroup, endGroup } = getGroupMarkers( + (message) => output.push(message), + {} + ); + + beginGroup("packages/app/package.json"); + endGroup(); + + deepEqual(output, []); + }); +}); + +describe("withLogGroup()", () => { + function createConsole() { + const output: string[] = []; + + return { + output, + console: { + log: (...args: unknown[]) => output.push(args.join(" ")), + warn: (...args: unknown[]) => output.push(args.join(" ")), + error: (...args: unknown[]) => output.push(args.join(" ")), + }, + }; + } + + it("does not emit an empty group", () => { + const { console, output } = createConsole(); + + withLogGroup("packages/app/package.json", () => undefined, console, { + GITHUB_ACTIONS: "true", + }); + + deepEqual(output, []); + }); + + it("groups the first emitted log line", () => { + const { console, output } = createConsole(); + + withLogGroup( + "packages/app/package.json", + () => console.log("hello"), + console, + { GITHUB_ACTIONS: "true" } + ); + + deepEqual(output, [ + "::group::packages/app/package.json", + "hello", + "::endgroup::", + ]); + }); + + it("restores the console methods", () => { + const { console } = createConsole(); + const log = console.log; + + withLogGroup("packages/app/package.json", () => undefined, console, { + GITHUB_ACTIONS: "true", + }); + + equal(console.log, log); }); });