diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3b3a5c..a4b62bad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,8 @@ ## HEAD (Unreleased) -**(none)** - ---- +- feat: Strip ansi control characters from GitHub step Summary and PR comment + ([#1312](https://github.com/pulumi/actions/pull/1312)) ## 6.4.0 (2025-07-16) diff --git a/package.json b/package.json index 65175f0e..f77d8e24 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@actions/tool-cache": "^2.0.1", "@pulumi/pulumi": "3.165.0", "actions-parsers": "^1.0.2", - "ansi-to-html": "^0.7.2", "dedent": "^0.7.0", "envalid": "^7.3.1", "got": "^11.8.6", diff --git a/src/libs/__tests__/pr.test.ts b/src/libs/__tests__/pr.test.ts index b4203916..2ad9e49e 100644 --- a/src/libs/__tests__/pr.test.ts +++ b/src/libs/__tests__/pr.test.ts @@ -50,12 +50,12 @@ describe('pr.ts', () => { await handlePullRequestMessage(defaultOptions, projectName, 'test'); expect(createComment).toHaveBeenCalledWith({ - body: '#### :tropical_drink: `preview` on myFirstProject/staging\n\n
\nPulumi report\n\n\n
\ntest\n
\n\n
', + body: `#### :tropical_drink: \`preview\` on ${projectName}/${defaultOptions.stackName}\n\n
\nPulumi report\n\n\n
\ntest\n
\n\n
`, issue_number: 123, }); }); - it('should convert ansi control character to html and add to pull request message', async () => { + it('should convert ansi control character to plain text and add to pull request message', async () => { // @ts-ignore gh.context = { payload: { @@ -69,7 +69,7 @@ describe('pr.ts', () => { await handlePullRequestMessage(defaultOptions, projectName, '\x1b[30mblack\x1b[37mwhite'); expect(createComment).toHaveBeenCalledWith({ - body: '#### :tropical_drink: `preview` on myFirstProject/staging\n\n
\nPulumi report\n\n\n
\nblackwhite\n
\n\n
', + body: `#### :tropical_drink: \`preview\` on ${projectName}/${defaultOptions.stackName}\n\n
\nPulumi report\n\n\n
\nblackwhite\n
\n\n
`, issue_number: 123, }); }); @@ -93,7 +93,7 @@ describe('pr.ts', () => { await handlePullRequestMessage(options, projectName, 'test'); expect(createComment).toHaveBeenCalledWith({ - body: '#### :tropical_drink: `preview` on myFirstProject/staging\n\n
\nPulumi report\n\n\n
\ntest\n
\n\n
', + body: `#### :tropical_drink: \`preview\` on ${projectName}/${options.stackName}\n\n
\nPulumi report\n\n\n
\ntest\n
\n\n
`, issue_number: 87, }); }); @@ -120,7 +120,7 @@ describe('pr.ts', () => { await handlePullRequestMessage(options, projectName, 'test'); expect(createComment).toHaveBeenCalledWith({ - body: '#### :tropical_drink: `preview` on myFirstProject/staging\n\n
\nPulumi report\n\n\n
\ntest\n
\n\n
', + body: `#### :tropical_drink: \`preview\` on ${projectName}/${options.stackName}\n\n
\nPulumi report\n\n\n
\ntest\n
\n\n
`, issue_number: 87, }); }); @@ -158,15 +158,14 @@ describe('pr.ts', () => { }, }, }; - const alwaysIncludeSummaryOptions = { - command: 'preview', - stackName: 'staging', + + const options: Config = { + ...defaultOptions, alwaysIncludeSummary: true, - options: {}, - } as Config; + }; await handlePullRequestMessage( - alwaysIncludeSummaryOptions, + options, projectName, 'a'.repeat(65_000) + '\n' + 'this is at the end and should be in the output', ); @@ -182,17 +181,16 @@ describe('pr.ts', () => { // @ts-ignore gh.context = { repo: {} }; - const options = { - command: 'preview', - stackName: 'staging', + const options: Config = { + ...defaultOptions, commentOnPrNumber: 123, editCommentOnPr: true, - } as Config; + }; await handlePullRequestMessage(options, projectName, 'test'); expect(updateComment).toHaveBeenCalledWith({ comment_id: 2, - body: '#### :tropical_drink: `preview` on myFirstProject/staging\n\n
\nPulumi report\n\n\n
\ntest\n
\n\n
', + body: `#### :tropical_drink: \`preview\` on ${projectName}/${options.stackName}\n\n
\nPulumi report\n\n\n
\ntest\n
\n\n
`, }); }); diff --git a/src/libs/__tests__/summary.test.ts b/src/libs/__tests__/summary.test.ts new file mode 100644 index 00000000..828bd0c2 --- /dev/null +++ b/src/libs/__tests__/summary.test.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs' +import path from 'path' +import * as core from '@actions/core'; +import { SUMMARY_ENV_VAR } from '@actions/core/lib/summary' +import { Config } from '../../config'; +import { handleSummaryMessage } from '../summary'; + +const testDirectoryPath = path.join(__dirname, 'test') +const testFilePath = path.join(testDirectoryPath, 'test-summary.md') + +async function getSummary(): Promise { + const file = await fs.promises.readFile(testFilePath, {encoding: 'utf8'}); + return file.replace(/\r\n/g, '\n'); +} + +const projectName = 'myFirstProject'; +const defaultOptions = { + command: 'preview', + stackName: 'staging', + options: {}, +} as Config; + +describe('summary.ts', () => { + beforeEach(async () => { + process.env[SUMMARY_ENV_VAR] = testFilePath; + await fs.promises.mkdir(testDirectoryPath, {recursive: true}); + await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}); + core.summary.emptyBuffer(); + }) + + afterAll(async () => { + await fs.promises.unlink(testFilePath); + }) + + it('throws if summary env var is undefined', async () => { + process.env[SUMMARY_ENV_VAR] = undefined; + const write = core.summary.addRaw('123').write(); + await expect(write).rejects.toThrow(); + }) + + it('throws if summary file does not exist', async () => { + await fs.promises.unlink(testFilePath); + const write = core.summary.addRaw('123').write(); + await expect(write).rejects.toThrow(); + }) + + it('should add only heading with empty code block to summary', async () => { + const message = ''; + const expected = `

Pulumi ${projectName}/${defaultOptions.stackName} results

\n
\n`; + + await handleSummaryMessage(defaultOptions, projectName, message); + const summary = await getSummary() + + expect(summary).toBe(expected); + }) + + it('should convert ansi control character to plain text and add to summary', async () => { + const message = '\x1b[30mblack\x1b[37mwhite'; + const expected = `

Pulumi ${projectName}/${defaultOptions.stackName} results

\n
blackwhite
\n`; + + await handleSummaryMessage(defaultOptions, projectName, message); + const summary = await getSummary() + + expect(summary).toBe(expected); + }) + + it('should trim the output when the output is larger than 1 MiB', async () => { + const message = 'this is at the begining and should not be in the output' + 'a'.repeat(1_048_576) + 'this is at the end and should be in the output'; + + await handleSummaryMessage(defaultOptions, projectName, message); + const summary = await getSummary() + + expect(Buffer.byteLength(summary, 'utf8')).toBeLessThan(1_048_576); + expect(summary).toContain('this is at the begining and should not be in the output') + expect(summary).toContain('The output was too long and trimmed.'); + expect(summary).not.toContain('this is at the end and should be in the output'); + expect(summary).not.toContain('The output was too long and trimmed from the front.'); + }) + + it('should trim the output from front when the output is larger than 1 MiB and config is set', async () => { + const message = 'this is at the begining and should not be in the output' + 'a'.repeat(1_048_576) + 'this is at the end and should be in the output'; + + const options: Config = { + ...defaultOptions, + alwaysIncludeSummary: true, + }; + + await handleSummaryMessage(options, projectName, message); + const summary = await getSummary() + + expect(Buffer.byteLength(summary, 'utf8')).toBeLessThan(1_048_576); + expect(summary).toContain('this is at the end and should be in the output'); + expect(summary).toContain('The output was too long and trimmed from the front.'); + expect(summary).not.toContain('this is at the begining and should not be in the output') + expect(summary).not.toContain('The output was too long and trimmed.'); + }) + + it('should replace first leading space with non-breaking space character to preserve the formatting', async () => { + const message = +`begin + some leading spaces in this line +`; + + const expected = `

Pulumi ${projectName}/${defaultOptions.stackName} results

\n
begin\n      some leading spaces in this line\n
\n`; + + await handleSummaryMessage(defaultOptions, projectName, message); + const summary = await getSummary() + + expect(summary).toBe(expected); + }) + +}) diff --git a/src/libs/__tests__/utils.test.ts b/src/libs/__tests__/utils.test.ts index e3353477..bcae36a7 100644 --- a/src/libs/__tests__/utils.test.ts +++ b/src/libs/__tests__/utils.test.ts @@ -1,4 +1,4 @@ -import { parseSemicolorToArray } from '../utils'; +import { parseSemicolorToArray, stripAnsiControlCodes } from '../utils'; describe('utils.ts', () => { it('should parse semicolon to array', () => { @@ -9,3 +9,12 @@ describe('utils.ts', () => { expect(inputArray[1]).toBe('world'); }); }); + +describe('utils.ts', () => { + it('should parse semicolon to array', () => { + + const output = stripAnsiControlCodes('\x1b[30mblack\x1b[37mwhite'); + + expect(output).toBe('blackwhite'); + }); +}); diff --git a/src/libs/pr.ts b/src/libs/pr.ts index fc09cc33..a2a64e6c 100644 --- a/src/libs/pr.ts +++ b/src/libs/pr.ts @@ -1,47 +1,41 @@ import * as core from '@actions/core'; import { context, getOctokit } from '@actions/github'; -import AnsiToHtml from 'ansi-to-html'; import dedent from 'dedent'; import invariant from 'ts-invariant'; import { Config } from '../config'; +import { stripAnsiControlCodes } from './utils'; -function ansiToHtml( +function trimOutputByCharacters( message: string, maxLength: number, alwaysIncludeSummary: boolean, ): [string, boolean] { /** - * Converts an ansi string to html by for example removing color escape characters. - * message: ansi string to convert - * maxLength: Maximum number of characters of final message incl. HTML tags + * Trim message to maxLength + * message: string to trim + * maxLength: Maximum number of characters of final message * alwaysIncludeSummary: if true, trim message from front (if trimming is needed), otherwise from end * - * return message as html and information if message was trimmed because of length + * return message and information if message was trimmed */ - const convert = new AnsiToHtml(); let trimmed = false; - let htmlBody: string = convert.toHtml(message); + // Check if message exceeds max characters + if (message.length > maxLength) { - // Check if htmlBody exceeds max characters - if (htmlBody.length > maxLength) { - - // trim input message by number of exceeded characters from front or back as configured - const dif: number = htmlBody.length - maxLength; + // Trim input message by number of exceeded characters from front or back as configured + const dif: number = message.length - maxLength; if (alwaysIncludeSummary) { - message = message.substring(dif, htmlBody.length); + message = message.substring(dif, message.length); } else { message = message.substring(0, message.length - dif); } trimmed = true; - - // convert trimmed message to html - htmlBody = convert.toHtml(message); } - return [htmlBody, trimmed]; + return [message, trimmed]; } function extractViewLiveLink(output: string) { @@ -72,14 +66,17 @@ export async function handlePullRequestMessage( alwaysIncludeSummary, } = config; - // GitHub limits comment characters to 65535, use lower max to keep buffer for variable values + // Remove ANSI symbols from output because they are not supported in GitHub PR message + output = stripAnsiControlCodes(output); + + // GitHub limits PR comment characters to 65_535, use lower max to keep buffer for variable values const MAX_CHARACTER_COMMENT = 64_000; const heading = `#### :tropical_drink: \`${command}\` on ${projectName}/${stackName}`; const summary = 'Pulumi report'; - const [htmlBody, trimmed]: [string, boolean] = ansiToHtml(output, MAX_CHARACTER_COMMENT, alwaysIncludeSummary); + const [message, trimmed]: [string, boolean] = trimOutputByCharacters(output, MAX_CHARACTER_COMMENT, alwaysIncludeSummary); const viewLiveLink = extractViewLiveLink(output); @@ -94,7 +91,7 @@ export async function handlePullRequestMessage( : '' }
-    ${htmlBody}
+    ${message}
     
${trimmed && !alwaysIncludeSummary ? ':warning: **Warn**: The output was too long and trimmed.' diff --git a/src/libs/summary.ts b/src/libs/summary.ts new file mode 100644 index 00000000..3ce77e3c --- /dev/null +++ b/src/libs/summary.ts @@ -0,0 +1,74 @@ +import * as core from '@actions/core'; +import { Config } from '../config'; +import { stripAnsiControlCodes } from './utils'; + +function trimOutputByBytes( + message: string, + maxSize: number, + alwaysIncludeSummary: boolean, +): [string, boolean] { + /** + * Trim message to maxSize in bytes + * message: string to trim + * maxSize: Maximum number of bytes of final message + * alwaysIncludeSummary: if true, trim message from front (if trimming is needed), otherwise from end + * + * return message and information if message was trimmed + */ + let trimmed = false; + + const messageSize = Buffer.byteLength(message, 'utf8'); + + // Check if message exceeds max size + if (messageSize > maxSize) { + + // Trim input message by number of exceeded bytes from front or back as configured + const dif: number = messageSize - maxSize; + + if (alwaysIncludeSummary) { + message = Buffer.from(message).subarray(dif, messageSize).toString() + } else { + message = Buffer.from(message).subarray(0, messageSize - dif).toString() + } + + trimmed = true; + } + + return [message, trimmed]; +} + +export async function handleSummaryMessage( + config: Config, + projectName: string, + output: string, +): Promise { + const { + stackName, + alwaysIncludeSummary, + } = config; + + // Remove ANSI symbols from output because they are not supported in GitHub step Summary + output = stripAnsiControlCodes(output); + + // Replace the first leading space in each line with a non-breaking space character to preserve the formatting + const regex_space = RegExp(`^[ ]`, 'gm'); + output = output.replace(regex_space, ' '); + + // GitHub limits step Summary to 1 MiB (1_048_576 bytes), use lower max to keep buffer for variable values + const MAX_SUMMARY_SIZE_BYTES = 1_000_000; + + const [message, trimmed]: [string, boolean] = trimOutputByBytes(output, MAX_SUMMARY_SIZE_BYTES, alwaysIncludeSummary); + + let heading = `Pulumi ${projectName}/${stackName} results`; + + if (trimmed && alwaysIncludeSummary) { + heading += ' :warning: **Warn**: The output was too long and trimmed from the front.'; + } else if (trimmed && !alwaysIncludeSummary) { + heading += ' :warning: **Warn**: The output was too long and trimmed.'; + } + + await core.summary + .addHeading(heading) + .addCodeBlock(message, "diff") + .write(); +} diff --git a/src/libs/utils.ts b/src/libs/utils.ts index a150cdbf..ee23c234 100644 --- a/src/libs/utils.ts +++ b/src/libs/utils.ts @@ -13,3 +13,8 @@ export function parseSemicolorToArray(input?: string[]): undefined | string[] { [], ); } + +export function stripAnsiControlCodes(text: string): string { + const regex_ansi = RegExp(`\x1B(?:[@-Z\\-_]|[[0-?]*[ -/]*[@-~])`, 'g'); + return text.replace(regex_ansi, ''); +} diff --git a/src/main.ts b/src/main.ts index 3b44db1b..15c8cacb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,7 @@ import { import { environmentVariables } from './libs/envs'; import { handlePullRequestMessage } from './libs/pr'; import * as pulumiCli from './libs/pulumi-cli'; +import { handleSummaryMessage } from './libs/summary'; import { login } from './login'; const main = async () => { @@ -160,10 +161,7 @@ const runAction = async (config: Config): Promise => { } if (config.commentOnSummary) { - await core.summary - .addHeading(`Pulumi ${config.stackName} results`) - .addCodeBlock(stdout, "diff") - .write(); + handleSummaryMessage(config, projectName, stdout) } }