Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e0feeca
chore(changelog): add placeholder entry for #423
credfeto Oct 15, 2026
2513672
refactor(discord): replace Discord.Net bot login with a Discord webhook
credfeto Oct 15, 2026
5a31f39
test(discord): share logged-text helper across Discord tests
credfeto Oct 15, 2026
ca91c2c
fix(discord): only accept http(s) embed URLs
credfeto Oct 15, 2026
ce0d31f
test(discord): use a fake clock in webhook rate-limit tests
credfeto Oct 15, 2026
ff79dee
test(discord): use CapturingLogger instead of custom LoggedText helper
credfeto Oct 15, 2026
c5a13eb
test(github): share PublishedEmbed helper across GitHub publisher tests
credfeto Oct 15, 2026
839bde7
test(discord): extract MockDiscordWebhookClientSendThrows helper
credfeto Oct 15, 2026
f4ff73b
test(discord): share title-only embed factory across Discord tests
credfeto Oct 15, 2026
8523b80
refactor(discord): collapse webhook retry loop into a single post/res…
credfeto Oct 15, 2026
ef39f7a
fix(publish): pass Discord webhook URL instead of removed bot token s…
credfeto Oct 15, 2026
9f4f73a
refactor(discord): use platform US spelling for colour identifiers
credfeto Oct 15, 2026
89787fe
refactor(github): use platform US spelling for status colour mapping
credfeto Oct 15, 2026
6427b27
refactor(discord): remove inter-message delay from BotService
credfeto Oct 15, 2026
54e50dc
refactor(discord): rename BotService to DiscordMessageSender
credfeto Oct 15, 2026
26d831f
docs(changelog): describe Discord webhook replacement for #423
credfeto Oct 15, 2026
f61d9c2
refactor(discord): drop unused Global from RateLimitResponse
credfeto Oct 15, 2026
b006eee
refactor(discord): remove unreachable webhook not-configured path
credfeto Oct 15, 2026
ec102c9
docs(changelog): correct Discord startup/health entry for #423
credfeto Oct 15, 2026
96ac6f5
refactor(cloudformation): build ARN and status fields once in GetFields
credfeto Oct 15, 2026
422d706
refactor(discord): handle webhook 429s in the HttpClient Polly policy
credfeto Oct 15, 2026
fd897bc
docs(readme): move Discord webhook setting into the Configuration table
credfeto Oct 15, 2026
df00774
refactor(discord): run DiscordMessageSender as a BackgroundService
credfeto Oct 15, 2026
cfaf779
refactor(discord): drop non-http(s) embed URLs once in EmbedMapper
credfeto Oct 15, 2026
c14fa57
refactor(startup): drop the always-printed Discord webhook startup line
credfeto Oct 15, 2026
2ffa02d
test(discord): drop redundant configuration parameter from CreateBot
credfeto Oct 15, 2026
3747f56
refactor(discord): let DiscordMessageSender own send failure handling
credfeto Oct 15, 2026
3b13b64
refactor(discord): inline no-op Polly onRetryAsync callback
credfeto Oct 15, 2026
2222db0
fix(discord): enforce Discord embed length limits
credfeto Oct 16, 2026
1ddbc55
docs(changelog): note Discord embed limits and first-line commit titles
credfeto Oct 16, 2026
ea83991
fix(discord): honour Retry-After within the webhook timeout budget
credfeto Oct 16, 2026
9a86ed4
fix(discord): log Discord's response body when a webhook post is reje…
credfeto Oct 16, 2026
8181b46
fix(discord): replace blank embed field text so Discord accepts the m…
credfeto Oct 16, 2026
c6111f7
fix(discord): time out and retry each webhook attempt separately
credfeto Oct 16, 2026
d9509f2
fix(discord): log each webhook retry
credfeto Oct 16, 2026
b5831b3
fix(discord): retry only transient HTTP status codes
credfeto Oct 16, 2026
28145f8
fix(discord): leave headroom between the webhook timeout and retry bu…
credfeto Oct 16, 2026
21531fa
fix(discord): log only the exception type when a publish finally fails
credfeto Oct 16, 2026
daf82ef
chore(coverage): record branch coverage for #423
credfeto Oct 19, 2026
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,15 @@ Please ADD ALL Changes to the UNRELEASED SECTION and not a specific release
- CloudFormation template restructured into the shared format, with parameters only where the deploy workflow passes them and all other settings in a Settings mapping (#427)
- ECS application logs in /ecs/<ApplicationName>/ are kept for 30 days instead of never expiring, using the same LogRetentionInDays setting as the API Gateway access logs (#421)
- The ECS service is now named after the application, so it can be found by name in the DiscordBots cluster (#421)
- Discord notifications are sent through a channel webhook (Discord:WebhookUrl / DISCORD__WEBHOOKURL) instead of a Discord.Net bot login (#423)
- Startup fails if the Discord webhook URL is missing or not an absolute https URL (#423)
- Discord notifications are truncated to Discord's embed length limits instead of being rejected by Discord when a title, description, field or total length is exceeded (#423)
- Push notifications show only the first line of each commit message instead of the full message (#423)
### Deprecated
### Removed
- Removed NuGet audit suppressions for Scriban 6.2.0 vulnerabilities now that the transitive dependency has been dropped
- TaskExecutionRole CloudFormation parameter (#419)
- Discord.Net dependency, release channel support and the Discord:Token, Discord:Server, Discord:Channel and Discord:ReleaseChannel settings (#423)
### Deployment Changes
- ECS service deployment circuit breaker with rollback, so tasks that fail to start (e.g. a missing secret key) fail the deploy quickly (#419)
- ECS tasks now read their secrets at start-up using the ecsTaskExecutionRole execution role, which must have secretsmanager:GetSecretValue on the BuildBot secret (#419)
Expand All @@ -99,6 +104,7 @@ Please ADD ALL Changes to the UNRELEASED SECTION and not a specific release
- This deploy replaces the ECS service, target group and listener rule (#421)
- Payloads over 10 MB are rejected by API Gateway (#421)
- The deploy user needs logs:PutRetentionPolicy on the /ecs/* log group, because the stack now sets a 30-day retention on it (#421)
- The BuildBot Secrets Manager secret must contain a DISCORD_WEBHOOK key (an https Discord webhook URL) before deploying; the DISCORD_TOKEN, DISCORD_SERVER, DISCORD_CHANNEL and DISCORD_RELEASE_CHANNEL keys are no longer used (#423)

<!--
Releases that have at least been deployed to staging, BUT NOT necessarily released to live. Changes should be moved from [Unreleased] into here as they are merged into the appropriate release branch
Expand Down
6 changes: 3 additions & 3 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ Generated by the AI Coverage phase. Do not edit by hand.
| --- | --- |
| BuildBot | n/a (no instrumented lines) |
| BuildBot.CloudFormation | 100.0% |
| BuildBot.Discord | 96.8% |
| BuildBot.Discord | 100.0% |
| BuildBot.GitHub | 100.0% |
| BuildBot.Health | 100.0% |
| BuildBot.Json | 100.0% |
| BuildBot.ServiceModel | 100.0% |
| BuildBot.Watchtower | 100.0% |
| **Overall (.NET)** | **99.2%** |
| **Overall (.NET)** | **100.0%** |

## Node

Expand All @@ -30,4 +30,4 @@ excluded

---

Captured at commit `5838d6adf7b290fa6688493d7b506ecb2b56ab84` on 2026-10-16.
Captured at commit `21531fa8f8a0eaaa7c7981d2b08325401cbd5f8a` on 2026-10-19.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# chiseled-extra is needed over chiseled for Discord
# chiseled-extra is needed over chiseled for ICU, as globalization invariant mode is disabled
Comment thread
credfeto marked this conversation as resolved.
#FROM mcr.microsoft.com/dotnet/runtime-deps:9.0-noble-chiseled-extra
FROM mcr.microsoft.com/dotnet/runtime-deps:11.0-resolute-chiseled-extra

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ FunFair's Build Discord Bot
| Setting | Environment variable | Type | Default | Description |
|------------------------|-------------------------|--------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `GitHub:WebhookSecret` | `GITHUB__WEBHOOKSECRET` | string | (empty) | Shared secret configured on the GitHub webhook, used to verify the HMAC-SHA256 `X-Hub-Signature-256` header on `/github/*` requests. Requests with a missing or invalid signature are rejected with 401. When empty, signatures are not verified and a warning is logged at startup. In AWS it is read from the Secrets Manager key `GITHUB_WEBHOOK_SECRET`. |
| `Discord:WebhookUrl` | `DISCORD__WEBHOOKURL` | URL | (empty) | Discord channel webhook URL that build, deployment and container messages are posted to. Required: startup fails if it is empty or not an absolute `https` URL. The URL contains the webhook token, so treat it as a secret. In AWS it is read from the Secrets Manager key `DISCORD_WEBHOOK`. |

## Deployment

Expand Down
55 changes: 2 additions & 53 deletions cloudformation.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,61 +94,10 @@
],
"Secrets": [
{
"Name": "DISCORD__TOKEN",
"Name": "DISCORD__WEBHOOKURL",
"ValueFrom": {
"Fn::Sub": [
"${SecretsManagerArn}:DISCORD_TOKEN::",
{
"SecretsManagerArn": {
"Fn::FindInMap": [
"Settings",
"Values",
"SecretsManagerArn"
]
}
}
]
}
},
{
"Name": "DISCORD__SERVER",
"ValueFrom": {
"Fn::Sub": [
"${SecretsManagerArn}:DISCORD_SERVER::",
{
"SecretsManagerArn": {
"Fn::FindInMap": [
"Settings",
"Values",
"SecretsManagerArn"
]
}
}
]
}
},
{
"Name": "DISCORD__CHANNEL",
"ValueFrom": {
"Fn::Sub": [
"${SecretsManagerArn}:DISCORD_CHANNEL::",
{
"SecretsManagerArn": {
"Fn::FindInMap": [
"Settings",
"Values",
"SecretsManagerArn"
]
}
}
]
}
},
{
"Name": "DISCORD__RELEASE_CHANNEL",
"ValueFrom": {
"Fn::Sub": [
"${SecretsManagerArn}:DISCORD_RELEASE_CHANNEL::",
"${SecretsManagerArn}:DISCORD_WEBHOOK::",
{
"SecretsManagerArn": {
"Fn::FindInMap": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BuildBot.CloudFormation;
Expand Down Expand Up @@ -215,4 +216,97 @@ CloudFormationMessageReceivedNotificationHandler handler

await mediator.Received(1).Publish(Arg.Any<BotMessage>(), Arg.Any<CancellationToken>());
}

private static DiscordEmbed PublishedEmbed(IMediator mediator)
{
return mediator
.ReceivedCalls()
.Select(static call => call.GetArguments()[0])
.OfType<BotMessage>()
.Single()
.Message;
}

private async Task<DiscordEmbed> PublishAsync(Deployment deployment, StackDetails? stackDetails)
{
(
_,
ICloudFormationDeploymentExtractor extractor,
IAwsCloudFormation aws,
IMediator mediator,
CloudFormationMessageReceivedNotificationHandler handler
) = this.CreateHandler();

extractor.ExtractDeploymentProperties(Arg.Any<CloudFormationMessageReceived>()).Returns(deployment);
aws.GetStackDetailsAsync(Arg.Any<Deployment>(), Arg.Any<CancellationToken>()).Returns(stackDetails);

await handler.Handle(notification: MakeNotification(VALID_ARN), cancellationToken: this.CancellationToken());

return PublishedEmbed(mediator);
}

[Fact]
public async Task Handle_WithoutStackDetails_PublishesExpectedEmbed()
{
DiscordEmbed embed = await this.PublishAsync(deployment: MakeDeployment(), stackDetails: null);

Assert.Equal(expected: "MyProject was deployed ", actual: embed.Title);
Assert.Equal(
expected: new Uri(
"https://eu-west-1.console.aws.amazon.com/cloudformation/home?region=eu-west-1#/stacks/stackinfo?stackId=arn:aws:cf:eu-west-1:123:stack/MyStack/x"
),
actual: embed.Url
);
Assert.Null(embed.Description);
Assert.Equal(expected: 0x2ECC71u, actual: embed.Color?.Value);
Assert.Equal(
expected:
[
new DiscordEmbedField(Name: "ARN", Value: "arn:aws:cf:eu-west-1:123:stack/MyStack/x"),
new DiscordEmbedField(Name: "Status", Value: "UPDATE_COMPLETE"),
],
actual: embed.Fields
);
}

[Fact]
public async Task Handle_WithFailedDeploymentAndStackDetails_PublishesExpectedEmbed()
{
Deployment deployment = MakeDeployment() with { Status = "UPDATE_FAILED", Success = false };

DiscordEmbed embed = await this.PublishAsync(
deployment: deployment,
stackDetails: new StackDetails(Description: "A deployment", Version: "2.0.0")
);

Assert.Equal(expected: "MyProject (2.0.0) failed to deploy", actual: embed.Title);
Assert.Equal(expected: "A deployment", actual: embed.Description);
Assert.Equal(expected: 0xE74C3Cu, actual: embed.Color?.Value);
Assert.Equal(
expected:
[
new DiscordEmbedField(Name: "ARN", Value: "arn:aws:cf:eu-west-1:123:stack/MyStack/x"),
new DiscordEmbedField(Name: "Status", Value: "UPDATE_FAILED"),
new DiscordEmbedField(Name: "Version", Value: "2.0.0"),
],
actual: embed.Fields
);
}

[Fact]
public async Task Handle_WithStackDetailsButNoVersion_OmitsVersionField()
{
DiscordEmbed embed = await this.PublishAsync(
deployment: MakeDeployment(),
stackDetails: new StackDetails(Description: "A deployment", Version: null)
);

Assert.Equal(expected: "MyProject was deployed ", actual: embed.Title);
Assert.Equal(expected: "A deployment", actual: embed.Description);
Assert.Equal(
expected: ["ARN", "Status"],
actual: embed.Fields.Select(static f => f.Name),
comparer: StringComparer.Ordinal
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using BuildBot.CloudFormation.Models;
using BuildBot.CloudFormation.Publishers.LoggingExtensions;
using BuildBot.Discord.Models;
using Discord;
using Mediator;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -67,29 +66,22 @@ public async ValueTask Handle(CloudFormationMessageReceived notification, Cancel
);

this._logger.BuildingMessage(deployment.Value);
EmbedBuilder embed = BuildStatusMessage(deployment: deployment.Value, stackDetails: stackDetails);
DiscordEmbed embed = BuildStatusMessage(deployment: deployment.Value, stackDetails: stackDetails);

this._logger.PublishingMessage(deployment.Value);

await this._mediator.Publish(new BotMessage(embed), cancellationToken: cancellationToken);
}

private static EmbedBuilder BuildStatusMessage(in Deployment deployment, StackDetails? stackDetails)
private static DiscordEmbed BuildStatusMessage(in Deployment deployment, StackDetails? stackDetails)
{
EmbedBuilder builder = new EmbedBuilder()
.WithTitle(BuildTitle(deployment: deployment, stackDetails: stackDetails))
.WithUrl(BuildStackUrl(deployment).ToString())
.WithColor(deployment.Success ? Color.Green : Color.Red)
.WithFields(GetFields(deployment));

if (stackDetails is not null)
{
builder
.WithDescription(stackDetails.Value.Description)
.AddField(name: "Version", value: stackDetails.Value.Version);
}

return builder;
return new(
Title: BuildTitle(deployment: deployment, stackDetails: stackDetails),
Url: BuildStackUrl(deployment),
Description: stackDetails?.Description,
Color: deployment.Success ? DiscordColor.Green : DiscordColor.Red,
Fields: GetFields(deployment: deployment, stackDetails: stackDetails)
);
}

private static string BuildTitle(in Deployment deployment, StackDetails? stackDetails)
Expand All @@ -113,18 +105,26 @@ private static Uri BuildStackUrl(in Deployment deployment)
);
}

private static IReadOnlyList<EmbedFieldBuilder> GetFields(in Deployment deployment)
private static IReadOnlyList<DiscordEmbedField> GetFields(in Deployment deployment, StackDetails? stackDetails)
{
DiscordEmbedField[] fields = [AddArnEmbed(deployment), AddStatusEmbed(deployment)];
string? version = stackDetails?.Version;

return string.IsNullOrWhiteSpace(version) ? fields : [.. fields, AddVersionEmbed(version)];
}

private static DiscordEmbedField AddArnEmbed(in Deployment deployment)
{
return [AddArnEmbed(deployment), AddStatusEmbed(deployment)];
return new(Name: "ARN", Value: deployment.Arn);
}

private static EmbedFieldBuilder AddArnEmbed(in Deployment deployment)
private static DiscordEmbedField AddStatusEmbed(in Deployment deployment)
{
return new EmbedFieldBuilder().WithName("ARN").WithValue(deployment.Arn);
return new(Name: "Status", Value: deployment.Status);
}

private static EmbedFieldBuilder AddStatusEmbed(in Deployment deployment)
private static DiscordEmbedField AddVersionEmbed(string version)
{
return new EmbedFieldBuilder().WithName("Status").WithValue(deployment.Status);
return new(Name: "Version", Value: version);
}
}
42 changes: 13 additions & 29 deletions src/BuildBot.Discord.Tests/DependencyInjectionTests.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
using BuildBot.Discord;
using System;
using BuildBot.Discord.Models;
using BuildBot.Discord.Services;
using BuildBot.ServiceModel.ComponentStatus;
using Discord;
using FunFair.Test.Common;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
Expand All @@ -12,11 +11,8 @@ namespace BuildBot.Discord.Tests;

public sealed class DependencyInjectionTests : DependencyInjectionTestsBase
{
private static readonly DiscordBotConfiguration TestConfig = new(
token: "test-token",
server: "test-server",
channel: "test-channel",
releaseChannel: "test-release-channel"
private static readonly DiscordBotConfiguration TestConfig = DiscordBotConfiguration.FromWebhookUrl(
"https://example.com/webhook"
);

public DependencyInjectionTests(ITestOutputHelper output)
Expand All @@ -40,44 +36,32 @@ public void DiscordBotMustBeRegisteredAsIComponentStatus()
}

[Fact]
public void BotMessageChannelMustBeRegistered()
{
this.RequireService<IMessageChannel<BotMessage>>();
}

[Fact]
public void BotReleaseMessageChannelMustBeRegistered()
public void DiscordWebhookClientMustBeRegistered()
{
this.RequireService<IMessageChannel<BotReleaseMessage>>();
this.RequireService<IDiscordWebhookClient>();
}

[Fact]
public void BotServiceMustBeRegisteredAsIHostedService()
public void TimeProviderMustBeRegistered()
{
this.RequireService<IHostedService>();
this.RequireService<TimeProvider>();
}

[Fact]
public void DiscordRawClientMustBeRegistered()
public void ConfigurationMustBeRegistered()
{
this.RequireService<IDiscordRawClient>();
this.RequireService<DiscordBotConfiguration>();
}

[Fact]
public void DiscordRawClient_WhenDisconnected_LoginStateIsLoggedOut()
public void BotMessageChannelMustBeRegistered()
{
IDiscordRawClient client = this.GetService<IDiscordRawClient>();
Assert.Equal(expected: LoginState.LoggedOut, actual: client.LoginState);
this.RequireService<IMessageChannel<BotMessage>>();
}

[Fact]
public void FindChannel_WhenClientIsDisconnected_ReturnsNull()
public void DiscordMessageSenderMustBeRegisteredAsIHostedService()
{
IDiscordRawClient client = this.GetService<IDiscordRawClient>();
IDiscordChannel? channel = client.FindChannel(
serverName: "nonexistent-server",
channelName: "nonexistent-channel"
);
Assert.Null(channel);
this.RequireService<IHostedService>();
}
}
Loading
Loading