This page shows how a build or test script gets the CI features its provider offers: collapsible sections in the log, errors and warnings pinned to a file and line, and a Markdown summary on the job page. The same script runs unchanged in a terminal, where the groups become headings and the annotations become status lines.
Import-Module ConsoleMonster
Use-ConsoleGroup 'Package' {
Compress-Archive -Path ./src/* -DestinationPath ./package.zip -Force
}
Use-ConsoleGroup 'Check configuration' {
Write-ConsoleAnnotation -Level Warning -Message 'Timeout is not set; using 30 s' -File ./build.ps1 -Line 12
}
$results = @(
[pscustomobject] @{ Check = 'Package'; Result = 'Passed' }
[pscustomobject] @{ Check = 'Configuration'; Result = 'Warning' }
)
$results | Add-ConsoleJobSummary
What each command does
| Command | GitHub Actions | Azure Pipelines | GitLab CI | Terminal |
|---|---|---|---|---|
Use-ConsoleGroup | ::group:: and ::endgroup:: | ##[group] and ##[endgroup] | A collapsed section | A muted heading |
Write-ConsoleAnnotation | An error, warning or notice annotation on the file and line | An error or warning issue; a notice is an ordinary line | A status line | A status line, with the file linked where the terminal supports links |
Add-ConsoleJobSummary | Appends to the step summary | Uploads a Markdown summary file | Returns the Markdown | Returns the Markdown |
- Groups nest, and they close even when the script block throws. The script block runs with the caller's variables and its output is passed through.
- Annotations take
-Level Error,WarningorNotice, a-Message, and optionally-Title,-File,-Lineand-Column. Text is escaped for the provider, so a message cannot inject a workflow command. A relative-Fileresolves from the current location; inside the checkout it becomes a checkout-relative path with forward slashes, so the annotation lands on the right line of the pull request. An annotation never changes the exit code or writes to PowerShell's error stream. - Job summaries take widgets or ordinary objects. Objects use the automatic view and several widgets are stacked, then the whole is exported as Markdown with semantic tables, records and trees. On GitHub and Azure nothing is returned; elsewhere the Markdown comes back, so a script can save it.
Choosing the provider
The provider is detected from GITHUB_ACTIONS, TF_BUILD or GITLAB_CI. CONSOLEMONSTER_CI overrides detection with github, azure, gitlab or none; none turns provider commands off, which keeps a log plain when a script runs inside another tool's CI step.
# .github/workflows/build.yml (excerpt)
- name: Build and test
shell: pwsh
run: |
Install-Module ConsoleMonster -Scope CurrentUser -Force
./build.ps1
Without changing the script
Several commands already use the provider when it is detected:
Invoke-ConsoleRunputs each sequential item's work and result in its own group. With-Parallel, each item's result is grouped as it completes.Invoke-ConsolePesterannotates each failure with its source position and adds the results table and the failures to the GitHub or Azure job summary.-NoJobSummarykeeps the annotations and skips the summary. GitLab has no annotation or summary API, so failures stay ordinary log lines.Enable-ConsoleFormattingannotates errors that have a script position, including terminating errors, through its Errors feature.- Live dashboards write whole, ordered lines instead of redrawing, with
[mm:ss]timestamps when CI is detected.
Automatic annotations stop at ten per run or output pipeline, followed by one line counting the rest, so a run with many failures does not flood the pull request. Annotations written with Write-ConsoleAnnotation have no limit.
The same script in CI
With CI detected, the same dashboard writes a whole line for each lane start and finish and each log entry instead of redrawing, in the order the updates were applied, then prints its final state.
[00:00] web12 done [00:00] web12 PASS: 4 passed, 0 failed, 0 warnings in 626ms [00:00] web04 started [00:00] web07 done [00:00] web07 PASS: 4 passed, 0 failed, 0 warnings in 703ms [00:00] web09 started [00:00] web02 started [00:00] web03 started [00:00] web11 started [00:00] web01 done [00:00] web01 PASS: 4 passed, 0 failed, 0 warnings in 799ms [00:00] web04 done [00:00] web04 PASS: 4 passed, 0 failed, 0 warnings in 793ms [00:00] web05 started [00:00] web06 started [00:00] web08 started [00:01] web09 started [00:01] web02 done [00:01] web02 PASS: 4 passed, 0 failed, 0 warnings in 892ms [00:01] web10 started [00:01] web08 done [00:01] web03 done [00:01] web08 PASS: 4 passed, 0 failed, 0 warnings in 915ms [00:01] web03 PASS: 4 passed, 0 failed, 0 warnings in 950ms [00:01] web09 done [00:01] web09 PASS: 4 passed, 0 failed, 0 warnings in 915ms [00:01] web11 done [00:01] web11 PASS: 4 passed, 0 failed, 0 warnings in 890ms [00:01] web06 done
Install-Module ConsoleMonster -Scope CurrentUser# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' -Content {
New-ConsolePanel -Title 'Servers' -HeaderRight '12 at once' -Content {
New-ConsoleLanes -WidgetId servers -Content {
foreach ($server in $servers) { @{ Key = $server; Total = 4 } }
}
}
New-ConsolePanel -Title 'Log' -Fill -Content { New-ConsoleLogRegion }
}
$servers | Invoke-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
param($Server)
foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
Update-Console -WidgetId servers -Key $Server -Status Running -Detail $step
Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
Update-Console -WidgetId servers -Key $Server -Result Pass
}
Write-Host "$Server done"
Update-Console -WidgetId servers -Key $Server -Complete
}Full program
Import-Module ConsoleMonster
# The same twelve servers, each runspace updating its own lane; Write-Host from any of them goes to the log panel.
$servers = 1..12 | ForEach-Object { 'web{0:d2}' -f $_ }
$dashboard = New-ConsoleDashboard -Title 'Patching the web farm' -Content {
New-ConsolePanel -Title 'Servers' -HeaderRight '12 at once' -Content {
New-ConsoleLanes -WidgetId servers -Content {
foreach ($server in $servers) { @{ Key = $server; Total = 4 } }
}
}
New-ConsolePanel -Title 'Log' -Fill -Content { New-ConsoleLogRegion }
}
$servers | Invoke-ConsoleDashboard $dashboard -Parallel -ThrottleLimit 12 {
param($Server)
foreach ($step in 'copying', 'stopping', 'swapping', 'starting') {
Update-Console -WidgetId servers -Key $Server -Status Running -Detail $step
Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 240)
Update-Console -WidgetId servers -Key $Server -Result Pass
}
Write-Host "$Server done"
Update-Console -WidgetId servers -Key $Server -Complete
}Run: $env:CI = 'true'; pwsh ./threads-dashboard.ps1; Remove-Item Env:CI
// The same twelve servers, each worker updating its own lane through a handle; Console.WriteLine from any of them
// goes to the log panel.
string[] servers = Enumerable.Range(1, 12).Select(n => $"web{n:00}").ToArray();
string[] steps = { "copying", "stopping", "swapping", "starting" };
Lanes lanes = new Lanes();
Dashboard dashboard = Ui.Dashboard().Title("Patching the web farm")
.Content(body => body
.Panel(new Panel(lanes, "Servers") { HeaderRight = "12 at once" })
.Add(new Panel(new LogRegion(), "Log").Fill()));
foreach (string server in servers)
{
lanes.Add(server, server, steps.Length);
}
using (LiveSession session = new LiveSession(dashboard))
{
await session.RunAsync(async (s, cancel) =>
{
LanesHandle handle = lanes.Handle(s);
try
{
await Parallel.ForEachAsync(
servers,
new ParallelOptions { MaxDegreeOfParallelism = 12, CancellationToken = cancel },
async (server, token) =>
{
foreach (string step in steps)
{
handle.Start(server, step);
await Task.Delay(Random.Shared.Next(40, 240), token);
handle.Advance(server, StatusKind.Pass);
}
Console.WriteLine(server + " done");
handle.Complete(server);
});
}
catch (OperationCanceledException)
{
// Ctrl+C: the session has already stopped the view; the workers just stop.
}
});
}Full program
using ConsoleMonster;
using ConsoleMonster.Layout;
using ConsoleMonster.Live;
using ConsoleMonster.Styling;
using ConsoleMonster.Widgets;
// The same twelve servers, each worker updating its own lane through a handle; Console.WriteLine from any of them
// goes to the log panel.
string[] servers = Enumerable.Range(1, 12).Select(n => $"web{n:00}").ToArray();
string[] steps = { "copying", "stopping", "swapping", "starting" };
Lanes lanes = new Lanes();
Dashboard dashboard = Ui.Dashboard().Title("Patching the web farm")
.Content(body => body
.Panel(new Panel(lanes, "Servers") { HeaderRight = "12 at once" })
.Add(new Panel(new LogRegion(), "Log").Fill()));
foreach (string server in servers)
{
lanes.Add(server, server, steps.Length);
}
using (LiveSession session = new LiveSession(dashboard))
{
await session.RunAsync(async (s, cancel) =>
{
LanesHandle handle = lanes.Handle(s);
try
{
await Parallel.ForEachAsync(
servers,
new ParallelOptions { MaxDegreeOfParallelism = 12, CancellationToken = cancel },
async (server, token) =>
{
foreach (string step in steps)
{
handle.Start(server, step);
await Task.Delay(Random.Shared.Next(40, 240), token);
handle.Advance(server, StatusKind.Pass);
}
Console.WriteLine(server + " done");
handle.Complete(server);
});
}
catch (OperationCanceledException)
{
// Ctrl+C: the session has already stopped the view; the workers just stop.
}
});
}The recording ran the PowerShell script. This C# program does the same with the library and compiles as shown; its timings are its own.
// The same twelve servers, each worker updating its own lane through a handle; Console.WriteLine from any of them
// goes to the log panel.
string[] servers = Enumerable.Range(1, 12).Select(n => $"web{n:00}").ToArray();
string[] steps = { "copying", "stopping", "swapping", "starting" };
Lanes lanes = Ui.Lanes();
Dashboard dashboard = Ui.Dashboard()
.Title("Patching the web farm")
.Content(body => body.Panel(Ui.Panel()
.Title("Servers")
.HeaderRight("12 at once")
.Content(panelContent =>
{
panelContent.Add(lanes);
})).Add(Ui.Panel().Title("Log").Content(panelContent =>
{
panelContent.LogRegion();
}).Fill()));
foreach (string server in servers)
{
lanes.Add(server, server, steps.Length);
}
using (LiveSession session = dashboard.CreateSession())
{
await session.RunAsync(async (s, cancel) =>
{
LanesHandle handle = lanes.Handle(s);
try
{
await Parallel.ForEachAsync(
servers,
new ParallelOptions { MaxDegreeOfParallelism = 12, CancellationToken = cancel },
async (server, token) =>
{
foreach (string step in steps)
{
handle.Start(server, step);
await Task.Delay(Random.Shared.Next(40, 240), token);
handle.Advance(server, StatusKind.Pass);
}
Console.WriteLine(server + " done");
handle.Complete(server);
});
}
catch (OperationCanceledException)
{
// Ctrl+C: the session has already stopped the view; the workers just stop.
}
});
}
Full program
using ConsoleMonster;
using ConsoleMonster.Layout;
using ConsoleMonster.Live;
using ConsoleMonster.Styling;
using ConsoleMonster.Widgets;
// The same twelve servers, each worker updating its own lane through a handle; Console.WriteLine from any of them
// goes to the log panel.
string[] servers = Enumerable.Range(1, 12).Select(n => $"web{n:00}").ToArray();
string[] steps = { "copying", "stopping", "swapping", "starting" };
Lanes lanes = Ui.Lanes();
Dashboard dashboard = Ui.Dashboard()
.Title("Patching the web farm")
.Content(body => body.Panel(Ui.Panel()
.Title("Servers")
.HeaderRight("12 at once")
.Content(panelContent =>
{
panelContent.Add(lanes);
})).Add(Ui.Panel().Title("Log").Content(panelContent =>
{
panelContent.LogRegion();
}).Fill()));
foreach (string server in servers)
{
lanes.Add(server, server, steps.Length);
}
using (LiveSession session = dashboard.CreateSession())
{
await session.RunAsync(async (s, cancel) =>
{
LanesHandle handle = lanes.Handle(s);
try
{
await Parallel.ForEachAsync(
servers,
new ParallelOptions { MaxDegreeOfParallelism = 12, CancellationToken = cancel },
async (server, token) =>
{
foreach (string step in steps)
{
handle.Start(server, step);
await Task.Delay(Random.Shared.Next(40, 240), token);
handle.Advance(server, StatusKind.Pass);
}
Console.WriteLine(server + " done");
handle.Complete(server);
});
}
catch (OperationCanceledException)
{
// Ctrl+C: the session has already stopped the view; the workers just stop.
}
});
}
The recording ran the PowerShell script. This C# program does the same with the library and compiles as shown; its timings are its own.
In C#
The static ContinuousIntegration class has the same three operations, with the same detection and fallbacks:
using (ContinuousIntegration.Group("Package")) {
Package();
}
ContinuousIntegration.Annotate(AnnotationLevel.Warning, "Timeout is not set; using 30 s", file: "build.ps1", line: 12);
ContinuousIntegration.AddSummary(resultsTable);
AddSummary takes Markdown text, or a widget that it exports with the Markdown provider from the ConsoleMonster.Markdown package. ContinuousIntegration.Provider reports the detected provider, and using (ContinuousIntegration.UseProvider(ContinuousIntegrationProvider.None)) { ... } overrides it for the code inside, which is how a test checks the plain output.
Next
- Hosts and CI: what ConsoleMonster detects in each host and CI system, and how to pin it.
- Live runs:
Invoke-ConsoleRunand the per-item view that groups itself in CI. - Testing: check output in tests, including Pester runs.