mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-01-06 05:18:26 -05:00
This commit imports the C# engine code and game files, excluding C++ source code. [Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
542 lines
16 KiB
C#
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;
|
|
}
|
|
}
|