diff --git a/crates/turborepo-ci/src/lib.rs b/crates/turborepo-ci/src/lib.rs index 36bb361554a8a..ef8aeb24b7b5f 100644 --- a/crates/turborepo-ci/src/lib.rs +++ b/crates/turborepo-ci/src/lib.rs @@ -93,6 +93,15 @@ impl Vendor { } } +pub fn github_header_footer(package: Option<&str>, task: &str) -> (String, String) { + let header = if let Some(package) = package { + format!("::group::{package}:{task}\n") + } else { + format!("::group::{task}\n") + }; + (header, "::endgroup::\n".to_string()) +} + #[cfg(test)] mod tests { use tracing::info; diff --git a/crates/turborepo-lib/src/run/mod.rs b/crates/turborepo-lib/src/run/mod.rs index 337cf816b2503..f5b0281a82198 100644 --- a/crates/turborepo-lib/src/run/mod.rs +++ b/crates/turborepo-lib/src/run/mod.rs @@ -18,7 +18,7 @@ pub use cache::{RunCache, TaskCache}; use chrono::{DateTime, Local}; use itertools::Itertools; use rayon::iter::ParallelBridge; -use tracing::{debug, info}; +use tracing::debug; use turbopath::AbsoluteSystemPathBuf; use turborepo_analytics::{start_analytics, AnalyticsHandle, AnalyticsSender}; use turborepo_api_client::{APIAuth, APIClient}; @@ -423,8 +423,13 @@ impl<'a> Run<'a> { // We hit some error, it shouldn't be exit code 0 .unwrap_or(if errors.is_empty() { 0 } else { 1 }); + let error_prefix = if opts.run_opts.is_github_actions { + "::error::" + } else { + "" + }; for err in &errors { - writeln!(std::io::stderr(), "{err}").ok(); + writeln!(std::io::stderr(), "{error_prefix}{err}").ok(); } visitor diff --git a/crates/turborepo-lib/src/task_graph/visitor.rs b/crates/turborepo-lib/src/task_graph/visitor.rs index 73124143f3a72..1063332ed763f 100644 --- a/crates/turborepo-lib/src/task_graph/visitor.rs +++ b/crates/turborepo-lib/src/task_graph/visitor.rs @@ -16,6 +16,7 @@ use tokio::{ }; use tracing::{debug, error, Span}; use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; +use turborepo_ci::github_header_footer; use turborepo_env::{EnvironmentVariableMap, ResolvedEnvMode}; use turborepo_repository::{ package_graph::{PackageGraph, WorkspaceName, ROOT_PKG_NAME}, @@ -242,9 +243,9 @@ impl<'a> Visitor<'a> { execution_env, ); + let output_client = self.output_client(&info); let tracker = self.run_tracker.track_task(info.clone().into_owned()); let spaces_client = self.run_tracker.spaces_task_client(); - let output_client = self.output_client(); let parent_span = Span::current(); tasks.push(tokio::spawn(async move { @@ -337,7 +338,7 @@ impl<'a> Visitor<'a> { OutputSink::new(out, err) } - fn output_client(&self) -> OutputClient { + fn output_client(&self, task_id: &TaskId) -> OutputClient { let behavior = match self.opts.run_opts.log_order { crate::opts::ResolvedLogOrder::Stream if self.run_tracker.spaces_enabled() => { turborepo_ui::OutputClientBehavior::InMemoryBuffer @@ -347,7 +348,18 @@ impl<'a> Visitor<'a> { } crate::opts::ResolvedLogOrder::Grouped => turborepo_ui::OutputClientBehavior::Grouped, }; - self.sink.logger(behavior) + + let mut logger = self.sink.logger(behavior); + if self.opts.run_opts.is_github_actions { + let package = if self.opts.run_opts.single_package { + None + } else { + Some(task_id.package()) + }; + let (header, footer) = github_header_footer(package, task_id.task()); + logger.with_header_footer(Some(header), Some(footer)); + } + logger } fn prefix<'b>(&self, task_id: &'b TaskId) -> Cow<'b, str> { @@ -385,8 +397,8 @@ impl<'a> Visitor<'a> { .with_warn_prefix(prefix); if is_github_actions { prefixed_ui = prefixed_ui - .with_error_prefix(Style::new().apply_to("[ERROR]".to_string())) - .with_warn_prefix(Style::new().apply_to("[WARN]".to_string())); + .with_error_prefix(Style::new().apply_to("[ERROR] ".to_string())) + .with_warn_prefix(Style::new().apply_to("[WARN] ".to_string())); } prefixed_ui } diff --git a/crates/turborepo-ui/src/output.rs b/crates/turborepo-ui/src/output.rs index 26c36130281fc..837c11041aa40 100644 --- a/crates/turborepo-ui/src/output.rs +++ b/crates/turborepo-ui/src/output.rs @@ -22,6 +22,8 @@ pub struct OutputClient { // Any locals held across an await must implement Sync and RwLock lets us achieve this buffer: Option>>>, writers: Arc>>, + header: Option, + footer: Option, } pub struct OutputWriter<'a, W> { @@ -80,11 +82,18 @@ impl OutputSink { behavior, buffer, writers, + header: None, + footer: None, } } } impl OutputClient { + pub fn with_header_footer(&mut self, header: Option, footer: Option) { + self.header = header; + self.footer = footer; + } + /// A writer that will write to the underlying sink's out writer according /// to this client's behavior. pub fn stdout(&self) -> OutputWriter { @@ -112,6 +121,8 @@ impl OutputClient { behavior, buffer, writers, + header, + footer, } = self; let buffers = buffer.map(|cell| cell.into_inner().expect("lock poisoned")); @@ -122,6 +133,9 @@ impl OutputClient { // We hold the mutex until we write all of the bytes associated for the client // to ensure that the bytes aren't interspersed. let mut writers = writers.lock().expect("lock poisoned"); + if let Some(prefix) = header { + writers.out.write_all(prefix.as_bytes())?; + } for SinkBytes { buffer, destination, @@ -133,6 +147,9 @@ impl OutputClient { }; writer.write_all(buffer)?; } + if let Some(suffix) = footer { + writers.out.write_all(suffix.as_bytes())?; + } } Ok(buffers.map(|buffers| { diff --git a/turborepo-tests/integration/tests/ordered/github.t b/turborepo-tests/integration/tests/ordered/github.t index f8aa7a406a8f2..f9123c0fdfd85 100644 --- a/turborepo-tests/integration/tests/ordered/github.t +++ b/turborepo-tests/integration/tests/ordered/github.t @@ -68,9 +68,9 @@ Verify that errors are grouped properly npm ERR! Error: command failed npm ERR! in workspace: util npm ERR\! at location: (.*)/packages/util (re) - \[ERROR\] command finished with error: command \((.*)/packages/util\) npm run fail exited \(1\) (re) + \[ERROR\] command finished with error: command \((.*)/packages/util\) (.*)npm run fail exited \(1\) (re) ::endgroup:: - ::error::util#fail: command \(.*/packages/util\) npm run fail exited \(1\) (re) + ::error::util#fail: command \(.*/packages/util\) (.*)npm run fail exited \(1\) (re) Tasks: 0 successful, 1 total Cached: 0 cached, 1 total