diff --git a/tools/pr-meta/src/__tests__/comment.test.ts b/tools/pr-meta/src/__tests__/comment.test.ts index 5facca7b4c9d89..d63714c7328eb2 100644 --- a/tools/pr-meta/src/__tests__/comment.test.ts +++ b/tools/pr-meta/src/__tests__/comment.test.ts @@ -953,6 +953,19 @@ describe( 'marker attributes', () => { expect( parseSections( merged )[ 0 ].runUrl ).toBe( runUrl ); } ); + it( 'keeps a run link with a query string', () => { + const runUrl = 'https://ci.example/runs/123?attempt=2'; + const merged = bodyOf( + mergeSection( + undefined, + { id: 'bundle-size', body: 'Size.', sha: HEAD, runUrl }, + HEAD + ) + ); + + expect( parseSections( merged )[ 0 ].runUrl ).toBe( runUrl ); + } ); + it.each( [ [ 'a space', 'https://example.com/a b' ], [ 'a closing bracket', 'https://example.com/a>b' ], diff --git a/tools/pr-meta/src/__tests__/core.test.ts b/tools/pr-meta/src/__tests__/core.test.ts new file mode 100644 index 00000000000000..5e1f5d30687476 --- /dev/null +++ b/tools/pr-meta/src/__tests__/core.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { infoVerbatim } from '../core.ts'; + +describe( 'infoVerbatim', () => { + afterEach( () => { + vi.restoreAllMocks(); + } ); + + it( 'stops workflow commands around the message and resumes them', () => { + const write = vi + .spyOn( process.stdout, 'write' ) + .mockImplementation( () => true ); + + infoVerbatim( '::add-mask::secret\n::warning::spoofed' ); + + const lines = write.mock.calls.map( ( [ chunk ] ) => String( chunk ) ); + const token = lines[ 0 ].match( /^::stop-commands::(.+)\n$/ )?.[ 1 ]; + + expect( token ).toBeTruthy(); + expect( lines[ 1 ] ).toBe( '::add-mask::secret\n::warning::spoofed\n' ); + expect( lines[ 2 ] ).toBe( `::${ token }::\n` ); + } ); + + it( 'uses a fresh token each time', () => { + const write = vi + .spyOn( process.stdout, 'write' ) + .mockImplementation( () => true ); + + infoVerbatim( 'one' ); + infoVerbatim( 'two' ); + + const starts = write.mock.calls + .map( ( [ chunk ] ) => String( chunk ) ) + .filter( ( line ) => line.startsWith( '::stop-commands::' ) ); + + expect( new Set( starts ).size ).toBe( 2 ); + } ); +} ); diff --git a/tools/pr-meta/src/comment.ts b/tools/pr-meta/src/comment.ts index abf2c92f6392b6..fbcfb0528caeee 100644 --- a/tools/pr-meta/src/comment.ts +++ b/tools/pr-meta/src/comment.ts @@ -161,7 +161,10 @@ function parseAttributes( const attributes: Pick< ParsedSection, 'sha' | 'runUrl' > = {}; for ( const pair of raw.trim().split( /\s+/ ).filter( Boolean ) ) { - const [ key, value ] = pair.split( '=' ); + /* Split at the first `=` only: a run link can carry a query string. */ + const separator = pair.indexOf( '=' ); + const key = separator < 0 ? pair : pair.slice( 0, separator ); + const value = separator < 0 ? undefined : pair.slice( separator + 1 ); if ( key === 'sha' ) { attributes.sha = safeSha( value ); } else if ( key === 'run' ) { diff --git a/tools/pr-meta/src/core.ts b/tools/pr-meta/src/core.ts index 9400e279107d38..ee285d18ddec2a 100644 --- a/tools/pr-meta/src/core.ts +++ b/tools/pr-meta/src/core.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; /* @@ -18,6 +19,18 @@ export function info( message: string ): void { process.stdout.write( `${ message }\n` ); } +/* Logs text that may hold `::` lines without the runner acting on them. */ +export function infoVerbatim( message: string ): void { + const token = randomUUID(); + + info( `::stop-commands::${ token }` ); + try { + info( message ); + } finally { + info( `::${ token }::` ); + } +} + function escapeData( value: string ): string { return value .replace( /%/g, '%25' ) diff --git a/tools/pr-meta/src/index.ts b/tools/pr-meta/src/index.ts index 905d3a446272ac..66ef75b90bbaa7 100644 --- a/tools/pr-meta/src/index.ts +++ b/tools/pr-meta/src/index.ts @@ -1,4 +1,4 @@ -import { info, warning } from './core.ts'; +import { info, infoVerbatim, warning } from './core.ts'; import { resolveBody, run } from './run.ts'; /* @@ -14,7 +14,7 @@ run().catch( ( error ) => { ); try { - info( resolveBody() ); + infoVerbatim( resolveBody() ); } catch { info( 'The section body could not be read either.' ); }