Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Please ADD ALL Changes to the UNRELEASED SECTION and not a specific release
- CloudFormation task settings now live in Mappings so every deploy applies them, reducing ECS task memory from 4 GB to 1 GB
- On a new stack, ECS tasks no longer fail to start with a 'log group does not exist' error, and their final logs are kept when the stack is deleted, because the ECS application log group is now created before the tasks and removed after them (#421)
- Status notifications now pick one branch consistently for the embed title and Branch field (prefer release/*, then main, else first) - #434
- Added a build-time --self-test switch run in the Docker image so a binary that cannot start fails the build
### Changed
- Dependencies - Updated NSubstitute.Analyzers.CSharp to 1.0.17
- Switched to use minimal APIs
Expand Down
4 changes: 2 additions & 2 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Generated by the AI Coverage phase. Do not edit by hand.

| Project | Line Coverage |
| --- | --- |
| BuildBot | n/a (no instrumented lines) |
| BuildBot | 100.0% |
| BuildBot.CloudFormation | 100.0% |
| BuildBot.Discord | 100.0% |
| BuildBot.GitHub | 100.0% |
Expand All @@ -30,4 +30,4 @@ excluded

---

Captured at commit `21531fa8f8a0eaaa7c7981d2b08325401cbd5f8a` on 2026-10-19.
Captured at commit `6d122751d6f5850bbf9742ae0af63f0ee496d620` on 2026-11-13.
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ WORKDIR /usr/src/app
COPY BuildBot .
COPY appsettings.json .

# Fail the image build, rather than production, if the binary cannot start in this runtime base
RUN [ "/usr/src/app/BuildBot", "--self-test" ]

EXPOSE 8080
ENTRYPOINT [ "/usr/src/app/BuildBot" ]

Expand Down
50 changes: 50 additions & 0 deletions src/BuildBot.Tests/ApplicationConfigLocatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Threading.Tasks;
using BuildBot.Helpers;
using FunFair.Test.Common;
using Xunit;

namespace BuildBot.Tests;

public sealed class ApplicationConfigLocatorTests : TestBase
{
[Fact]
public async Task UsesTheBaseDirectoryWhenItHoldsTheAppSettingsAsync()
{
using (TestConfigFolder configFolder = new())
{
await configFolder.WriteAppSettingsAsync(
discordWebhookUrl: "",
cancellationToken: this.CancellationToken()
);

string path = ApplicationConfigLocator.LookupConfigurationFilesPath(
configFolder.FullName + Path.DirectorySeparatorChar
);

Assert.Equal(expected: configFolder.FullName, actual: path);
}
}

[Fact]
public void FallsBackToTheCurrentDirectoryWhenTheBaseDirectoryHasNoAppSettings()
{
using (TestConfigFolder configFolder = new())
{
string path = ApplicationConfigLocator.LookupConfigurationFilesPath(
configFolder.FullName + Path.DirectorySeparatorChar
);

Assert.Equal(expected: Environment.CurrentDirectory, actual: path);
}
}

[Fact]
public void FallsBackToTheCurrentDirectoryWhenTheBaseDirectoryHasNoParent()
{
string path = ApplicationConfigLocator.LookupConfigurationFilesPath(Path.DirectorySeparatorChar.ToString());

Assert.Equal(expected: Environment.CurrentDirectory, actual: path);
}
}
1 change: 1 addition & 0 deletions src/BuildBot.Tests/BuildBot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<Import Project="$(SolutionDir)UnitTests.props" Condition="Exists('$(SolutionDir)UnitTests.props')" />
<ItemGroup>
<ProjectReference Include="../BuildBot.Json/BuildBot.Json.csproj" />
<ProjectReference Include="../BuildBot/BuildBot.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FunFair.Test.Common" Version="6.4.6.2749" IncludeAssets="compile;runtime" />
Expand Down
104 changes: 104 additions & 0 deletions src/BuildBot.Tests/ProgramTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using FunFair.Test.Common;
using Xunit;

namespace BuildBot.Tests;

[Collection(ThreadPoolCollection.NAME)]
public sealed class ProgramTests : TestBase
{
private static readonly Uri PingUri = new("http://127.0.0.1:8080/ping");

[Fact]
public async Task SelfTestSwitchExitsWithoutStartingServerAsync()
{
int exitCode = await Program.Main(["--self-test"]);

Assert.Equal(expected: 0, actual: exitCode);
}

[Fact]
public async Task HealthCheckSwitchFailsWhenNothingIsListeningAsync()
{
int exitCode = await Program.Main(["--health-check", $"http://127.0.0.1:{GetUnusedPort()}/ping"]);

Assert.NotEqual(expected: 0, actual: exitCode);
}

[Fact]
public async Task ServerExitsWithErrorWhenStartupFailsAsync()
{
using (TestConfigFolder contentRoot = new())
{
string missingFolder = Path.Combine(path1: contentRoot.FullName, path2: "missing");

int exitCode = await Program.Main(["--contentRoot", missingFolder]);

Assert.Equal(expected: 1, actual: exitCode);
}
}

[Fact]
public async Task ServerRunsUntilCancelledAsync()
{
using (TestConfigFolder configFolder = new())
{
await configFolder.WriteAppSettingsAsync(
discordWebhookUrl: TestConfigFolder.DISCORD_WEBHOOK_URL,
cancellationToken: this.CancellationToken()
);

using (
CancellationTokenSource stop = CancellationTokenSource.CreateLinkedTokenSource(this.CancellationToken())
)
{
Task<int> server = Program
.RunServerAsync(args: [], configPath: configFolder.FullName, cancellationToken: stop.Token)
.AsTask();

await WaitUntilListeningAsync(server: server, cancellationToken: this.CancellationToken());

await stop.CancelAsync();

Assert.Equal(expected: 0, actual: await server);
}
}
}

private static int GetUnusedPort()
{
using (TcpListener listener = new(localaddr: IPAddress.Loopback, port: 0))
{
listener.Start();

return ((IPEndPoint)listener.LocalEndpoint).Port;
}
}

private static async ValueTask WaitUntilListeningAsync(Task server, CancellationToken cancellationToken)
{
using (HttpClient client = new())
{
while (!server.IsCompleted)
{
try
{
using (await client.GetAsync(requestUri: PingUri, cancellationToken: cancellationToken))
{
return;
}
}
catch (HttpRequestException)
{
await Task.Delay(millisecondsDelay: 100, cancellationToken: cancellationToken);
}
}
}
}
}
77 changes: 77 additions & 0 deletions src/BuildBot.Tests/SelfTestTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;
using System.IO;
using System.Threading.Tasks;
using BuildBot.Helpers;
using FunFair.Test.Common;
using Xunit;

namespace BuildBot.Tests;

public sealed class SelfTestTests : TestBase
{
[Theory]
[InlineData("--self-test", true)]
[InlineData("--urls http://localhost --self-test", true)]
[InlineData("", false)]
[InlineData("--health-check http://127.0.0.1:8080/ping", false)]
[InlineData("--SELF-TEST", false)]
public static void DetectsSelfTestSwitch(string commandLine, bool expected)
{
string[] args = commandLine.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries);

Assert.Equal(expected: expected, actual: SelfTest.IsSelfTest(args));
}

[Fact]
public async Task SucceedsWhenStartupCompletesAsync()
{
using (TestConfigFolder configFolder = new())
{
await configFolder.WriteAppSettingsAsync(
discordWebhookUrl: "",
cancellationToken: this.CancellationToken()
);

(int exitCode, string output) = await this.RunSelfTestAsync(configFolder: configFolder);

Assert.Equal(expected: 0, actual: exitCode);
Assert.Equal(expected: "Self-test passed", actual: output);
}
}

[Fact]
public async Task FailsWithOneLineReasonWhenConfigurationIsMissingAsync()
{
using (TestConfigFolder configFolder = new())
{
(int exitCode, string output) = await this.RunSelfTestAsync(configFolder: configFolder);

Assert.Equal(expected: 1, actual: exitCode);
Assert.StartsWith(
expectedStartString: "Self-test failed: ",
actualString: output,
comparisonType: StringComparison.Ordinal
);
Assert.DoesNotContain(
expectedSubstring: "\n",
actualString: output,
comparisonType: StringComparison.Ordinal
);
}
}

private async ValueTask<(int ExitCode, string Output)> RunSelfTestAsync(TestConfigFolder configFolder)
{
await using (StringWriter output = new())
{
int exitCode = await SelfTest.RunAsync(
args: ["--self-test"],
configPath: configFolder.FullName,
output: output,
cancellationToken: this.CancellationToken()
);

return (exitCode, output.ToString().TrimEnd());
}
}
}
79 changes: 79 additions & 0 deletions src/BuildBot.Tests/ServerStartupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BuildBot.Helpers;
using BuildBot.Json;
using FunFair.Test.Common;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;

namespace BuildBot.Tests;

[Collection(ThreadPoolCollection.NAME)]
public sealed class ServerStartupTests : TestBase
{
[Theory]
[InlineData(0, 0, 1, 1, 1)]
[InlineData(0, 2, 1, 1, 2)]
[InlineData(2, 0, 1, 2, 1)]
[InlineData(2, 2, 1, 2, 2)]
public static void RaisesOnlyTheThreadPoolMinimumsBelowTheTarget(
int workerOffset,
int completionPortOffset,
int targetOffset,
int expectedWorkerOffset,
int expectedCompletionPortOffset
)
{
ThreadPool.GetMinThreads(out int originalWorker, out int originalCompletionPort);

// Offsets keep every value above the server's own minimum so a server started by another test cannot change them.
int baseline = Math.Max(val1: Math.Max(val1: originalWorker, val2: originalCompletionPort), val2: 32);

try
{
Assert.True(
ThreadPool.SetMinThreads(
workerThreads: baseline + workerOffset,
completionPortThreads: baseline + completionPortOffset
),
userMessage: "Could not set the thread pool minimums"
);

ServerStartup.SetThreads(baseline + targetOffset);

ThreadPool.GetMinThreads(out int worker, out int completionPort);
Assert.Equal(expected: baseline + expectedWorkerOffset, actual: worker);
Assert.Equal(expected: baseline + expectedCompletionPortOffset, actual: completionPort);
}
finally
{
ThreadPool.SetMinThreads(workerThreads: originalWorker, completionPortThreads: originalCompletionPort);
}
}

[Fact]
public async Task UsesTheApplicationSerializationContextForHttpJsonAsync()
{
using (TestConfigFolder configFolder = new())
{
await configFolder.WriteAppSettingsAsync(
discordWebhookUrl: TestConfigFolder.DISCORD_WEBHOOK_URL,
cancellationToken: this.CancellationToken()
);

await using (WebApplication app = ServerStartup.CreateApp(args: [], configPath: configFolder.FullName))
{
JsonOptions options = app.Services.GetRequiredService<IOptions<JsonOptions>>().Value;

Assert.Same(
expected: AppSerializationContext.Default,
actual: options.SerializerOptions.TypeInfoResolverChain[0]
);
}
}
}
}
40 changes: 40 additions & 0 deletions src/BuildBot.Tests/TestConfigFolder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace BuildBot.Tests;

internal sealed class TestConfigFolder : IDisposable
{
public const string DISCORD_WEBHOOK_URL = "https://discord.invalid/api/webhooks/test";

private readonly DirectoryInfo _folder;

public TestConfigFolder()
{
this._folder = Directory.CreateTempSubdirectory();
}

public string FullName => this._folder.FullName;

public void Dispose()
{
this._folder.Delete(recursive: true);
}

public Task WriteAppSettingsAsync(string discordWebhookUrl, CancellationToken cancellationToken)
{
return File.WriteAllTextAsync(
path: Path.Combine(path1: this.FullName, path2: "appsettings.json"),
contents: $$"""
{
"Discord": { "WebhookUrl": "{{discordWebhookUrl}}" },
"CloudFormation": { "TopicArn": "", "Aws": { "AccessKeyId": "", "SecretKey": "" } },
"GitHub": { "WebhookSecret": "" }
}
""",
cancellationToken: cancellationToken
);
}
}
Loading
Loading