Skip to content
ConsoleMonster

CI integration

Collapsible log groups, annotations and job summaries on GitHub Actions, Azure Pipelines and GitLab CI, with readable output in a terminal.

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

CommandGitHub ActionsAzure PipelinesGitLab CITerminal
Use-ConsoleGroup::group:: and ::endgroup::##[group] and ##[endgroup]A collapsed sectionA muted heading
Write-ConsoleAnnotationAn error, warning or notice annotation on the file and lineAn error or warning issue; a notice is an ordinary lineA status lineA status line, with the file linked where the terminal supports links
Add-ConsoleJobSummaryAppends to the step summaryUploads a Markdown summary fileReturns the MarkdownReturns 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, Warning or Notice, a -Message, and optionally -Title, -File, -Line and -Column. Text is escaped for the provider, so a message cannot inject a workflow command. A relative -File resolves 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-ConsoleRun puts each sequential item's work and result in its own group. With -Parallel, each item's result is grouped as it completes.
  • Invoke-ConsolePester annotates each failure with its source position and adds the results table and the failures to the GitHub or Azure job summary. -NoJobSummary keeps the annotations and skips the summary. GitLab has no annotation or summary API, so failures stay ordinary log lines.
  • Enable-ConsoleFormatting annotates 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.

CI=true 100×3024-bitFull
Install-Module ConsoleMonster -Scope CurrentUser
PowerShellthreads-dashboard.ps1View on GitHub
# 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

C#Program.csView on GitHub
// 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.

C# fluentProgram.cs
// 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-ConsoleRun and the per-item view that groups itself in CI.
  • Testing: check output in tests, including Pester runs.

Edit this page on GitHub