Files
sbox-public/engine/Tools/SboxBuild/Pipeline.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

542 lines
16 KiB
C#

using System.Diagnostics;
using System.Text;
using Facepunch.Steps;
using static Facepunch.Constants;
namespace Facepunch.Pipelines;
internal class Pipeline
{
private readonly List<Step> steps = new();
private readonly Dictionary<Step, bool> continueOnFailure = new();
private readonly Dictionary<Step, ExitCode> stepResults = new();
private readonly string name;
private readonly bool enableSlackNotifications;
private readonly string slackWebhookUrl;
private Platform platform = Platform.Create();
// Track execution times
private readonly Stopwatch pipelineStopwatch = new();
// GitHub API limits
private const int MAX_GITHUB_FIELD_LENGTH = 65535;
private const int SUMMARY_LINE_COUNT = 32;
public Pipeline( string name, bool enableSlackNotifications = false, string slackWebhookUrl = null )
{
this.name = name;
this.enableSlackNotifications = enableSlackNotifications;
// Use provided webhook URL or try to get from environment
this.slackWebhookUrl = slackWebhookUrl;
}
/// <summary>
/// Register a step to be run as part of this pipeline
/// </summary>
/// <param name="step">The step to register</param>
/// <param name="continueOnFailure">Whether to continue if this step fails</param>
public void RegisterStep( Step step, bool continueOnFailure = false )
{
steps.Add( step );
this.continueOnFailure[step] = continueOnFailure;
}
/// <summary>
/// Run the pipeline with all registered steps
/// </summary>
/// <returns>Success if all steps succeeded, Failure otherwise</returns>
public ExitCode Run()
{
Log.Info( $"Running pipeline: {name}" );
// Start the pipeline stopwatch
pipelineStopwatch.Start();
bool pipelineStopped = false;
// Run all steps
foreach ( var step in steps )
{
if ( pipelineStopped )
{
// Skip running the step if the pipeline has been stopped
continue;
}
var result = RunStep( step );
// If the step failed and we shouldn't continue on failure, stop the pipeline
if ( result != ExitCode.Success && !continueOnFailure[step] )
{
Log.Warning( $"Pipeline {name} is stopping due to step failure after {FormatElapsedTime( pipelineStopwatch.Elapsed )}" );
pipelineStopped = true;
}
}
// Stop the pipeline stopwatch
pipelineStopwatch.Stop();
// Log total execution time
Log.Info( $"Pipeline '{name}' completed in {FormatElapsedTime( pipelineStopwatch.Elapsed )}" );
// Check if any steps failed
return stepResults.Any( x => x.Value == ExitCode.Failure ) ? ExitCode.Failure : ExitCode.Success;
}
/// <summary>
/// Format a timespan as minutes and seconds
/// </summary>
private string FormatElapsedTime( TimeSpan elapsed )
{
if ( elapsed.TotalHours >= 1 )
{
return $"{(int)elapsed.TotalHours}h {elapsed.Minutes}m {elapsed.Seconds}s";
}
else if ( elapsed.TotalMinutes >= 1 )
{
return $"{elapsed.Minutes}m {elapsed.Seconds}s";
}
else
{
return $"{elapsed.Seconds}s";
}
}
/// <summary>
/// Run a single step and record its result
/// </summary>
/// <param name="step">The step to run</param>
/// <returns>The result of running the step</returns>
private ExitCode RunStep( Step step )
{
// Post "in_progress" check run for this step
if ( Utility.IsCi() )
{
// Get logs URL for in-progress message
string logsUrl = GitHub.GetWorkflowLogsUrl();
string inProgressSummary = $"{step.Name} is running...";
// Add logs link if available
if ( !string.IsNullOrEmpty( logsUrl ) )
{
inProgressSummary += $"\n\n[▶ View live logs]({logsUrl})";
}
GitHub.PostCheckRun(
new GitHub.CheckRunParams
{
Name = $"{step.Name} ({platform.PlatformID})",
Status = "in_progress",
Title = $"{step.Name} is running...",
Summary = inProgressSummary
}
);
}
// Set up console output capture
var originalOut = Console.Out;
var originalError = Console.Error;
ConsoleOutputCapture outputCapture = new ConsoleOutputCapture( originalOut, 2000 );
ConsoleOutputCapture errorCapture = new ConsoleOutputCapture( originalError, 2000 );
// Log this before running the step
// we dont want to capture this in the output
if ( Utility.IsCi() ) Log.Info( $"::group::{step.Name}" );
// Redirect console output
Console.SetOut( outputCapture );
Console.SetError( errorCapture );
// Start timing the step
var stepStopwatch = Stopwatch.StartNew();
ExitCode stepResult;
try
{
// Run the step
stepResult = step.Run();
stepResults[step] = stepResult;
}
finally
{
// Stop timing
stepStopwatch.Stop();
// Restore original console writers
Console.SetOut( originalOut );
Console.SetError( originalError );
// Log this after restoring the console output
if ( Utility.IsCi() ) Log.Info( $"::endgroup::" );
}
// Send Slack notification if enabled and step failed
if ( enableSlackNotifications && stepResult == ExitCode.Failure && !string.IsNullOrEmpty( slackWebhookUrl ) )
{
SendSlackNotification( step, stepResult, stepStopwatch.Elapsed, errorCapture.GetCapturedOutput() );
}
// Post "completed" check run with the result and console output
if ( Utility.IsCi() )
{
string conclusion = stepResult == ExitCode.Success ? "success" : "failure";
// Create summary including limited console output
StringBuilder summaryBuilder = new StringBuilder();
summaryBuilder.AppendLine( $"{(stepResult == ExitCode.Success ? "" : "")} '{step.Name}' {(stepResult == ExitCode.Success ? "completed successfully" : "failed with error code " + stepResult)}" );
summaryBuilder.AppendLine( $"⏱️ Execution time: {FormatElapsedTime( stepStopwatch.Elapsed )}" );
// Add up to SUMMARY_LINE_COUNT lines of error output to the summary if it's different from standard output
string errorOutput = errorCapture.GetCapturedOutput();
string standardOutput = outputCapture.GetCapturedOutput();
bool hasUniqueErrorOutput = errorCapture.HasContent && errorOutput != standardOutput;
if ( hasUniqueErrorOutput )
{
summaryBuilder.AppendLine( $"\n### Error Output (last {SUMMARY_LINE_COUNT} lines)" );
summaryBuilder.AppendLine( "```" );
summaryBuilder.AppendLine( errorCapture.GetLastLines( SUMMARY_LINE_COUNT ) );
summaryBuilder.AppendLine( "```" );
}
// Add up to SUMMARY_LINE_COUNT lines of console output to the summary
if ( outputCapture.HasContent )
{
summaryBuilder.AppendLine( $"\n### Console Output (last {SUMMARY_LINE_COUNT} lines)" );
summaryBuilder.AppendLine( "```" );
summaryBuilder.AppendLine( outputCapture.GetLastLines( SUMMARY_LINE_COUNT ) );
summaryBuilder.AppendLine( "```" );
}
// Get logs URL
string logsUrl = GitHub.GetWorkflowLogsUrl();
string viewLogsLink = string.IsNullOrEmpty( logsUrl ) ? "" : $"[▶ View complete logs]({logsUrl})";
summaryBuilder.AppendLine( $"\nMore logs in the details below or {viewLogsLink}." );
// Create details with full console output (up to limit)
StringBuilder detailsBuilder = new StringBuilder();
detailsBuilder.AppendLine( $"## Step: {step.Name}" );
detailsBuilder.AppendLine( $"* Exit code: {(int)stepResult}" );
detailsBuilder.AppendLine( $"* Status: {(stepResult == ExitCode.Success ? "Success" : "Failure")}" );
detailsBuilder.AppendLine( $"* Execution time: {FormatElapsedTime( stepStopwatch.Elapsed )}" );
detailsBuilder.AppendLine( $"* Run date: {DateTime.UtcNow.ToString( "yyyy-MM-dd HH:mm:ss" )} UTC" );
// A more accurate estimate of overhead for section headers, code blocks, truncation message, etc.
const int OUTPUT_SECTION_RESERVED_CHAR_COUNT = 250; // ~50 chars for headers, ~50 for markdown, ~50 for truncation message, ~100 for view logs link
const string TRUNCATION_MESSAGE = "... Output truncated (showing only most recent content) ...\n";
// Add error output first (with priority)
if ( hasUniqueErrorOutput )
{
// Reserve space for the error section
int availableSpace = MAX_GITHUB_FIELD_LENGTH - detailsBuilder.Length - OUTPUT_SECTION_RESERVED_CHAR_COUNT;
bool needsTruncation = errorOutput.Length > availableSpace;
// Add error output section
detailsBuilder.AppendLine( needsTruncation ? "\n## Error Output (Truncated)" : "\n## Error Output" );
detailsBuilder.AppendLine( "```" );
if ( needsTruncation )
{
// Truncate from the beginning to keep the most recent output
detailsBuilder.AppendLine( TRUNCATION_MESSAGE +
errorOutput.Substring( errorOutput.Length - availableSpace ) );
detailsBuilder.AppendLine( "```" );
detailsBuilder.AppendLine();
detailsBuilder.AppendLine( viewLogsLink );
}
else
{
detailsBuilder.AppendLine( errorOutput );
detailsBuilder.AppendLine( "```" );
}
}
// Add console output if we have enough space left
if ( outputCapture.HasContent )
{
int availableSpace = MAX_GITHUB_FIELD_LENGTH - detailsBuilder.Length - OUTPUT_SECTION_RESERVED_CHAR_COUNT;
bool needsTruncation = standardOutput.Length > availableSpace;
detailsBuilder.AppendLine( needsTruncation ? "\n## Console Output (Truncated)" : "\n## Console Output" );
detailsBuilder.AppendLine( "```" );
if ( needsTruncation )
{
// Truncate from the beginning to keep the most recent output
detailsBuilder.AppendLine( TRUNCATION_MESSAGE +
standardOutput.Substring( standardOutput.Length - availableSpace ) );
detailsBuilder.AppendLine( "```" );
detailsBuilder.AppendLine();
detailsBuilder.AppendLine( viewLogsLink );
}
else
{
detailsBuilder.AppendLine( standardOutput );
detailsBuilder.AppendLine( "```" );
}
}
// Ensure we don't exceed GitHub's character limits
string summary = summaryBuilder.ToString().Substring( 0, Math.Min( MAX_GITHUB_FIELD_LENGTH, summaryBuilder.Length ) );
string details = detailsBuilder.ToString().Substring( 0, Math.Min( MAX_GITHUB_FIELD_LENGTH, detailsBuilder.Length ) );
GitHub.PostCheckRun(
new GitHub.CheckRunParams
{
Name = $"{step.Name} ({platform.PlatformID})",
Status = "completed",
Conclusion = conclusion,
Title = $"{step.Name}",
Summary = summary,
Details = details
}
);
}
return stepResult;
}
/// <summary>
/// Send a notification to Slack about a step failure using Slack Block Kit
/// </summary>
private void SendSlackNotification( Step step, ExitCode result, TimeSpan executionTime, string errorOutput )
{
try
{
// Get workflow info
string repository = Environment.GetEnvironmentVariable( "GITHUB_REPOSITORY" ) ?? "unknown repository";
string workflow = Environment.GetEnvironmentVariable( "GITHUB_WORKFLOW" ) ?? "unknown workflow";
string branch = Environment.GetEnvironmentVariable( "GITHUB_REF_NAME" ) ?? "unknown branch";
string logsUrl = GitHub.GetWorkflowLogsUrl();
string runId = Environment.GetEnvironmentVariable( "GITHUB_RUN_ID" ) ?? "unknown";
string commitSha = Environment.GetEnvironmentVariable( "GITHUB_SHA" ) ?? "unknown";
string actor = Environment.GetEnvironmentVariable( "GITHUB_ACTOR" ) ?? "unknown";
// Short SHA for display
string shortSha = commitSha.Length > 7 ? commitSha.Substring( 0, 7 ) : commitSha;
// Get direct link to check run if available
string checkRunUrl = null;
if ( GitHub.TryGetCheckRunId( step.Name, out long checkRunId ) )
{
// Direct link to the specific check run
checkRunUrl = $"https://github.com/{repository}/runs/{checkRunId}";
}
else if ( !string.IsNullOrEmpty( repository ) && !string.IsNullOrEmpty( runId ) )
{
// Fallback to checks tab if specific ID not available
checkRunUrl = $"https://github.com/{repository}/actions/runs/{runId}/checks";
}
// Create commit URL if possible
string commitUrl = null;
if ( !string.IsNullOrEmpty( repository ) && commitSha != "unknown" )
{
commitUrl = $"https://github.com/{repository}/commit/{commitSha}";
}
// Simple fallback text for notifications and clients that don't support blocks
string fallbackText = $":x: Pipeline Step Failed: {step.Name} in {repository} ({branch})";
// Create blocks for the message
var blocks = new object[]
{
// Header with emoji and title
new {
type = "header",
text = new {
type = "plain_text",
text = "🚨 Pipeline Step Failed",
emoji = true
}
},
// Divider after header
new {
type = "divider"
},
// Main section with context
new {
type = "section",
fields = new object[]
{
new {
type = "mrkdwn",
text = $"*Repository:*\n{repository}"
},
new {
type = "mrkdwn",
text = $"*Pipeline:*\n{name}"
},
new {
type = "mrkdwn",
text = $"*Step:*\n{step.Name}"
},
new {
type = "mrkdwn",
text = $"*Duration:*\n{FormatElapsedTime(executionTime)}"
},
new {
type = "mrkdwn",
text = $"*Branch:*\n{branch}"
},
new {
type = "mrkdwn",
text = $"*Triggered by:*\n@{actor}"
}
}
},
// Actions (buttons) for links
new {
type = "actions",
elements = CreateLinkButtons(logsUrl, checkRunUrl, commitUrl, shortSha)
}
};
// Send the notification with a call to action for the check run button
Slack.SendMessage( slackWebhookUrl, new Slack.MessageParams(
Text: fallbackText,
Username: "GitHub Actions",
IconEmoji: ":github:",
Blocks: blocks
) );
}
catch ( Exception ex )
{
// Log the error but don't fail the pipeline
Console.WriteLine( $"Error sending Slack notification: {ex.Message}" );
}
}
/// <summary>
/// Helper method to create link buttons for the Slack message
/// </summary>
private object[] CreateLinkButtons( string logsUrl, string checkRunUrl, string commitUrl, string shortSha )
{
var buttons = new List<object>();
// Check run button is now primary since it's the most direct link to the failure
if ( !string.IsNullOrEmpty( checkRunUrl ) )
{
buttons.Add( new
{
type = "button",
text = new
{
type = "plain_text",
text = "View Failure",
emoji = true
},
url = checkRunUrl,
style = "primary" // Make this the primary button
} );
}
if ( !string.IsNullOrEmpty( logsUrl ) )
{
buttons.Add( new
{
type = "button",
text = new
{
type = "plain_text",
text = "View Logs",
emoji = true
},
url = logsUrl
} );
}
if ( !string.IsNullOrEmpty( commitUrl ) )
{
buttons.Add( new
{
type = "button",
text = new
{
type = "plain_text",
text = $"Commit {shortSha}",
emoji = true
},
url = commitUrl
} );
}
return buttons.ToArray();
}
}
/// <summary>
/// Builder pattern for creating pipeline with steps
/// </summary>
internal class PipelineBuilder
{
private readonly List<Step> steps = new();
private readonly Dictionary<Step, bool> continueOnFailure = new();
private readonly string name;
private bool enableSlackNotifications = false;
private string slackWebhookUrl = null;
public PipelineBuilder( string name )
{
this.name = name;
}
/// <summary>
/// Add a step to the pipeline
/// </summary>
/// <param name="step">The step to add</param>
/// <param name="continueOnFailure">Whether to continue if this step fails</param>
/// <returns>The builder for chaining</returns>
public PipelineBuilder AddStep( Step step, bool continueOnFailure = false )
{
steps.Add( step );
this.continueOnFailure[step] = continueOnFailure;
return this;
}
public PipelineBuilder AddStepGroup( string groupName, IEnumerable<Step> groupSteps, bool continueOnFailure = false )
{
var group = new StepGroup( groupName, groupSteps.ToList(), continueOnFailure );
steps.Add( group );
this.continueOnFailure[group] = continueOnFailure;
return this;
}
/// <summary>
/// Enable Slack notifications for this pipeline
/// </summary>
/// <param name="webhookUrl">Optional webhook URL (defaults to SLACK_WEBHOOK_URL environment variable)</param>
/// <returns>The builder for chaining</returns>
public PipelineBuilder WithSlackNotifications( string webhookUrl = null )
{
enableSlackNotifications = true;
slackWebhookUrl = webhookUrl;
return this;
}
/// <summary>
/// Build the pipeline with all registered steps
/// </summary>
/// <returns>A pipeline with the configured steps</returns>
public Pipeline Build()
{
var pipeline = new Pipeline( name, enableSlackNotifications, slackWebhookUrl );
// Register all steps with the pipeline
foreach ( var step in steps )
{
pipeline.RegisterStep( step, continueOnFailure[step] );
}
return pipeline;
}
}