Skip to content
Open
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
42 changes: 42 additions & 0 deletions src/bun_core/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,48 @@ pub fn is_github_action() -> bool {
false
}

/// Per-child filter: which relayed lines the GitHub Actions runner must see
/// at column 0. Group markers stay prefixed (concurrent children would leave
/// them unpaired), and so does the `::stop-commands::<token>` window.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[derive(Default)]
pub struct GithubCommandRelay {
stop_token: std::cell::Cell<Option<Box<[u8]>>>,
}

impl GithubCommandRelay {
const BARE_COMMANDS: [&[u8]; 5] = [
b"::error",
b"::warning",
b"::notice",
b"::debug",
b"::add-mask",
];

/// Whether `line` must go out at column 0.
pub fn is_bare_line(&self, line: &[u8]) -> bool {
let line = line.trim_ascii();
if let Some(token) = self.stop_token.take() {
let resumes = line.len() == token.len() + 4
&& line.starts_with(b"::")
&& line.ends_with(b"::")
&& line[2..line.len() - 2] == *token;
if !resumes {
self.stop_token.set(Some(token));
}
return false;
}
if let Some(token) = line.strip_prefix(b"::stop-commands::") {
if !token.is_empty() {
self.stop_token.set(Some(token.into()));
}
return false;
}
Self::BARE_COMMANDS.iter().any(|cmd| {
line.starts_with(cmd) && matches!(line.get(cmd.len()), Some(b' ') | Some(b':'))
})
}
}

pub fn is_ai_agent() -> bool {
static VALUE: AtomicBool = AtomicBool::new(false);
static ONCE: std::sync::Once = std::sync::Once::new();
Expand Down
49 changes: 28 additions & 21 deletions src/runtime/cli/filter_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub(crate) struct ProcessHandle<'a> {
/// cannot finish it twice.
finished: bool,
buffer: Vec<u8>,
github_relay: Output::GithubCommandRelay,

process: Option<ProcessInfo>,
options: SpawnOptions,
Expand Down Expand Up @@ -343,6 +344,26 @@ impl<'a> State<'a> {
self.remaining_scripts == 0
}

/// Writes one child line with the `<package> <script>: ` prefix.
fn write_prefixed_line(
draw_buf: &mut Vec<u8>,
handle: &ProcessHandle<'a>,
line: &[u8],
) -> crate::Result<()> {
if Output::is_github_action() && handle.github_relay.is_bare_line(line) {
draw_buf.extend_from_slice(line);
return Ok(());
}
write!(
draw_buf,
"{} {}: {}",
bstr::BStr::new(&handle.config.package_name),
bstr::BStr::new(&handle.config.script_name),
bstr::BStr::new(line),
)?;
Ok(())
}

fn read_chunk(&mut self, handle: &mut ProcessHandle<'a>, chunk: &[u8]) -> crate::Result<()> {
if self.pretty_output {
handle.buffer.extend_from_slice(chunk);
Expand All @@ -355,13 +376,7 @@ impl<'a> State<'a> {
let i = i as usize;
handle.buffer.extend_from_slice(&content[0..i + 1]);
content = &content[i + 1..];
write!(
&mut self.draw_buf,
"{} {}: {}",
bstr::BStr::new(&handle.config.package_name),
bstr::BStr::new(&handle.config.script_name),
bstr::BStr::new(&handle.buffer),
)?;
Self::write_prefixed_line(&mut self.draw_buf, handle, &handle.buffer)?;
handle.buffer.clear();
} else {
handle.buffer.extend_from_slice(content);
Expand All @@ -371,13 +386,7 @@ impl<'a> State<'a> {
while let Some(i) = strings::index_of_char(content, b'\n') {
let i = i as usize;
let line = &content[0..i + 1];
write!(
&mut self.draw_buf,
"{} {}: {}",
bstr::BStr::new(&handle.config.package_name),
bstr::BStr::new(&handle.config.script_name),
bstr::BStr::new(line),
)?;
Self::write_prefixed_line(&mut self.draw_buf, handle, line)?;
content = &content[i + 1..];
}
if !content.is_empty() {
Expand Down Expand Up @@ -422,12 +431,8 @@ impl<'a> State<'a> {
self.draw_buf.clear();
// flush any remaining buffer
if !handle.buffer.is_empty() {
writeln!(
&mut self.draw_buf,
"{}: {}",
bstr::BStr::new(&handle.config.package_name),
bstr::BStr::new(&handle.buffer),
)?;
Self::write_prefixed_line(&mut self.draw_buf, handle, &handle.buffer)?;
self.draw_buf.push(b'\n');
handle.buffer.clear();
}
// print exit status
Expand Down Expand Up @@ -961,7 +966,8 @@ pub(crate) fn run_scripts_with_filter(
remaining_scripts: 0,
draw_buf: Vec::new(),
last_lines_written: 0,
pretty_output: {
// The redraw renderer never writes a line at column 0.
pretty_output: !Output::is_github_action() && {
Comment thread
robobun marked this conversation as resolved.
Resolved
#[cfg(windows)]
{
windows_is_terminal() && Output::enable_ansi_colors_stdout()
Expand Down Expand Up @@ -994,6 +1000,7 @@ pub(crate) fn run_scripts_with_filter(
stdout: BufferedReader::init::<ProcessHandle>(),
stderr: BufferedReader::init::<ProcessHandle>(),
buffer: Vec::new(),
github_relay: Output::GithubCommandRelay::default(),
remaining_fds: 0,
finished: false,
process: None,
Expand Down
9 changes: 7 additions & 2 deletions src/runtime/cli/multi_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ pub(crate) struct ProcessHandle<'a> {
config: &'a ScriptConfig,
state: *const State<'a>,
color_idx: usize,
github_relay: Output::GithubCommandRelay,

stdout_reader: PipeReader<'a>,
stderr_reader: PipeReader<'a>,
Expand Down Expand Up @@ -390,7 +391,9 @@ impl<'a> State<'a> {
line: &[u8],
writer: &mut OutputWriter,
) -> Result<(), Error> {
self.write_prefix(handle, writer)?;
if !(Output::is_github_action() && handle.github_relay.is_bare_line(line)) {
self.write_prefix(handle, writer)?;
}
writer.write_all(line)?;
Ok(())
}
Expand Down Expand Up @@ -456,7 +459,8 @@ impl<'a> State<'a> {
// raw-ptr-based throughout this file).
let handle_ptr = std::ptr::from_mut::<ProcessHandle>(handle);
// SAFETY: handle_ptr is live for this call; flush_pipe_buffer reads only
// `config`/`color_idx` from `handle` and writes only `pipe.line_buffer`.
// `config`/`color_idx`/`github_relay` (a `Cell`) from `handle` and
// writes only `pipe.line_buffer`.
Comment thread
robobun marked this conversation as resolved.
Resolved
unsafe {
self.flush_pipe_buffer(&*handle_ptr, &mut (*handle_ptr).stdout_reader)?;
self.flush_pipe_buffer(&*handle_ptr, &mut (*handle_ptr).stderr_reader)?;
Expand Down Expand Up @@ -1158,6 +1162,7 @@ pub(crate) fn run(ctx: &mut Command::ContextData) -> Result<core::convert::Infal
state: &raw const state,
config,
color_idx,
github_relay: Output::GithubCommandRelay::default(),
stdout_reader: PipeReader::new(false),
stderr_reader: PipeReader::new(true),
process: None,
Expand Down
109 changes: 109 additions & 0 deletions test/cli/run/filter-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1311,3 +1311,112 @@ describe("auto-discovered bunfig.toml [run] section", () => {
expect(r.exitCode).toBe(1);
});
});

// The GitHub Actions runner only parses a workflow command at column 0. The
// stateless commands must reach it bare. Group markers stay prefixed because
// concurrent packages interleave and bare markers would be unpaired. Text
// between ::stop-commands::<token> and ::<token>:: stays prefixed too.
describe.concurrent("GitHub Actions annotations", () => {
const lines = [
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
"::group::a.test.ts:",
"plain line",
"::error file=a.test.ts,line=2,col=30,title=boom::Expected 2",
"::warning ::careful",
"::notice::note",
"::debug::dbg",
"::add-mask::s3cret",
" ::error::indented",
"::stop-commands::tok",
"::error::suppressed",
"::tok::",
"::error::resumed",
"::endgroup::",
];
const printer = `for (const l of ${JSON.stringify(lines)}) console.log(l); process.stdout.write("::error ::tail");`;

function workspace(name: string) {
return tempDir(name, {
packages: {
dep0: {
"package.json": JSON.stringify({ name: "dep0", scripts: { script: `${bunExe()} -e '${printer}'` } }),
},
},
"package.json": JSON.stringify({ name: "ws", workspaces: ["packages/*"] }),
});
}

async function run(dir: string, args: string[], env: Record<string, string | undefined> = {}) {
await using proc = Bun.spawn({
cwd: dir,
cmd: [bunExe(), ...args],
env: { ...bunEnv, GITHUB_ACTIONS: "true", GITHUB_WORKSPACE: undefined, ...env },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { exitCode, stdout, stderr };
}

test.each([
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
["bun run --filter", ["run", "--filter", "dep0", "script"]],
["bun run --workspaces", ["run", "--workspaces", "script"]],
])("%s writes annotation commands at column 0", async (_, args) => {
using dir = workspace("filter-gha-annotations");
const r = await run(String(dir), args);
expect(r.stdout.split("\n")).toEqual([
"dep0 script: ::group::a.test.ts:",
"dep0 script: plain line",
"::error file=a.test.ts,line=2,col=30,title=boom::Expected 2",
"::warning ::careful",
"::notice::note",
"::debug::dbg",
"::add-mask::s3cret",
" ::error::indented",
"dep0 script: ::stop-commands::tok",
"dep0 script: ::error::suppressed",
"dep0 script: ::tok::",
"::error::resumed",
"dep0 script: ::endgroup::",
"::error ::tail",
"dep0 script: Exited with code 0",
"",
]);
expect(r.exitCode).toBe(0);
});

test("FORCE_COLOR does not select the redraw renderer under GitHub Actions", async () => {
using dir = workspace("filter-gha-force-color");
const r = await run(String(dir), ["run", "--filter", "dep0", "script"], { FORCE_COLOR: "1", NO_COLOR: "0" });
expect(r.stdout).toMatch(/^::error file=a\.test\.ts,line=2,col=30,title=boom::Expected 2$/m);
expect(r.stdout).toMatch(/^dep0 script: ::group::a\.test\.ts:$/m);
expect(r.stdout).not.toContain("\x1b[");
expect(r.exitCode).toBe(0);
});

test("annotation lines stay prefixed outside GitHub Actions", async () => {
using dir = workspace("filter-gha-off");
const r = await run(String(dir), ["run", "--filter", "dep0", "script"], { GITHUB_ACTIONS: "false" });
expect(r.stdout).toMatch(/^dep0 script: ::error file=a\.test\.ts,/m);
expect(r.stdout).toMatch(/^dep0 script: ::error ::tail$/m);
expect(r.stdout).not.toMatch(/^::error/m);
expect(r.exitCode).toBe(0);
});

test("a failing bun test in a workspace package produces a bare ::error annotation", async () => {
using dir = tempDir("filter-gha-bun-test", {
packages: {
dep0: {
"a.test.ts": `import { test, expect } from "bun:test";\ntest("ok", () => { expect(1).toBe(2); });\n`,
"package.json": JSON.stringify({ name: "dep0", scripts: { test: `${bunExe()} test` } }),
},
},
"package.json": JSON.stringify({ name: "ws", workspaces: ["packages/*"] }),
});
const r = await run(String(dir), ["run", "--filter", "dep0", "test"]);
const out = r.stdout + r.stderr;
expect(out).toMatch(/^::error file=a\.test\.ts,line=2,col=30,/m);
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
expect(out).toMatch(/^dep0 test: ::group::a\.test\.ts:$/m);
expect(out).toMatch(/^dep0 test: ::endgroup::$/m);
expect(r.exitCode).toBe(1);
});
});
70 changes: 70 additions & 0 deletions test/cli/run/multi-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,76 @@ describe.concurrent("unusual output", () => {
});
});

// ─── GITHUB ACTIONS ANNOTATIONS ─────────────────────────────────────────────

// The GitHub Actions runner only parses a workflow command at column 0. The
// stateless commands must reach it bare. Group markers stay prefixed because
// parallel scripts interleave and bare markers would be unpaired. Text
// between ::stop-commands::<token> and ::<token>:: stays prefixed too.
describe.concurrent("GitHub Actions annotations", () => {
const lines = [
"::group::a.test.ts:",
"plain line",
"::error file=a.test.ts,line=2,col=30,title=boom::Expected 2",
"::warning ::careful",
"::notice::note",
"::debug::dbg",
"::add-mask::s3cret",
" ::error::indented",
"::stop-commands::tok",
"::error::suppressed",
"::tok::",
"::error::resumed",
"::endgroup::",
];
const printer = `for (const l of ${JSON.stringify(lines)}) console.log(l); process.stderr.write("::error ::tail");`;
const pkg = JSON.stringify({ scripts: { gha: `${bunExe()} -e '${printer}'` } });

test.each([["--parallel"], ["--sequential"]])("%s writes annotation commands at column 0", async flag => {
using dir = tempDir("mr-gha-annotations", { "package.json": pkg });
const r = await runMulti(["run", flag, "gha"], String(dir), { GITHUB_ACTIONS: "true" });
expect(r.stdout.split("\n")).toEqual([
"gha | ::group::a.test.ts:",
"gha | plain line",
"::error file=a.test.ts,line=2,col=30,title=boom::Expected 2",
"::warning ::careful",
"::notice::note",
"::debug::dbg",
"::add-mask::s3cret",
" ::error::indented",
"gha | ::stop-commands::tok",
"gha | ::error::suppressed",
"gha | ::tok::",
"::error::resumed",
"gha | ::endgroup::",
"",
]);
expect(r.stderr).toMatch(/^::error ::tail$/m);
expectDone(r.stderr, "gha");
expect(r.exitCode).toBe(0);
});

test("FORCE_COLOR does not put a color escape before an annotation", async () => {
using dir = tempDir("mr-gha-color", { "package.json": pkg });
const r = await runMulti(["run", "--parallel", "gha"], String(dir), {
GITHUB_ACTIONS: "true",
FORCE_COLOR: "1",
NO_COLOR: undefined,
});
expect(r.stdout).toMatch(/^::error file=a\.test\.ts,line=2,col=30,title=boom::Expected 2$/m);
expect(r.stdout).toMatch(/^\x1b\[\d+mgha\x1b\[0m \| ::group::a\.test\.ts:$/m);
expect(r.exitCode).toBe(0);
});

test("annotation lines stay prefixed outside GitHub Actions", async () => {
using dir = tempDir("mr-gha-off", { "package.json": pkg });
const r = await runMulti(["run", "--parallel", "gha"], String(dir), { GITHUB_ACTIONS: "false" });
expectPrefixed(r.stdout, "gha", "::error file=a.test.ts,");
expect(r.stdout).not.toMatch(/^::error/m);
expect(r.exitCode).toBe(0);
});
});

// ─── SEQUENTIAL: DONE STATUS BETWEEN SCRIPTS ───────────────────────────────

describe.concurrent("sequential: status messages between scripts", () => {
Expand Down
Loading