From 8701ed66a39771608c8c6d0f2db6c05fdffb6d61 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:36:20 +0200 Subject: [PATCH 01/26] Add base Linux database compose stack --- docker/docker-compose.yml | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docker/docker-compose.yml diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4ebf7f5 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,79 @@ +services: + sqlexpress: + platform: linux/amd64 + image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 + mem_limit: 2g + environment: + ACCEPT_EULA: "Y" + MSSQL_PID: Express + MSSQL_SA_PASSWORD: Password1 + healthcheck: + test: + - CMD-SHELL + - /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -Q "SELECT 1" || exit 1 + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + volumes: + - sqlserver-data:/var/opt/mssql + + mysql: + platform: linux/amd64 + image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_ROOT_PASSWORD: Password1 + MYSQL_DATABASE: NEventStore + MYSQL_USER: sa + MYSQL_PASSWORD: Password1 + healthcheck: + test: + - CMD-SHELL + - mysqladmin ping -h localhost -uroot -p"$$MYSQL_ROOT_PASSWORD" --silent + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + volumes: + - mysql-data:/var/lib/mysql + + postgres: + platform: linux/amd64 + image: postgres:15-alpine + environment: + POSTGRES_PASSWORD: Password1 + POSTGRES_USER: sa + POSTGRES_DB: NEventStore + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" + interval: 10s + timeout: 5s + retries: 30 + start_period: 10s + volumes: + - postgres-data:/var/lib/postgresql/data + + oracle: + platform: linux/amd64 + image: gvenzl/oracle-xe:21-slim + environment: + ORACLE_PASSWORD: Password1 + healthcheck: + test: + - CMD + - healthcheck.sh + interval: 15s + timeout: 10s + retries: 40 + start_period: 60s + volumes: + - oracle-data:/opt/oracle/oradata + +volumes: + sqlserver-data: + mysql-data: + postgres-data: + oracle-data: From 859afb7e155b94c14346e44e62068a44700e8e85 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:36:29 +0200 Subject: [PATCH 02/26] Add dynamic port compose override --- docker/docker-compose.dynamic.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker/docker-compose.dynamic.yml diff --git a/docker/docker-compose.dynamic.yml b/docker/docker-compose.dynamic.yml new file mode 100644 index 0000000..f50d9c1 --- /dev/null +++ b/docker/docker-compose.dynamic.yml @@ -0,0 +1,16 @@ +services: + sqlexpress: + ports: + - "127.0.0.1::1433" + + mysql: + ports: + - "127.0.0.1::3306" + + postgres: + ports: + - "127.0.0.1::5432" + + oracle: + ports: + - "127.0.0.1::1521" From 2214cfe8cce3bd41a716cc10d8a4d3925ae25167 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:36:35 +0200 Subject: [PATCH 03/26] Add debug port compose override --- docker/docker-compose.debug.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker/docker-compose.debug.yml diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml new file mode 100644 index 0000000..1619f6d --- /dev/null +++ b/docker/docker-compose.debug.yml @@ -0,0 +1,16 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:50001:1433" + + mysql: + ports: + - "127.0.0.1:50003:3306" + + postgres: + ports: + - "127.0.0.1:50004:5432" + + oracle: + ports: + - "127.0.0.1:50005:1521" From ef6f711541b0a6db11a0bbb13a4a2de30d3b31a6 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:36:43 +0200 Subject: [PATCH 04/26] Add test port compose override --- docker/docker-compose.test.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker/docker-compose.test.yml diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml new file mode 100644 index 0000000..d934711 --- /dev/null +++ b/docker/docker-compose.test.yml @@ -0,0 +1,16 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:51001:1433" + + mysql: + ports: + - "127.0.0.1:51003:3306" + + postgres: + ports: + - "127.0.0.1:51004:5432" + + oracle: + ports: + - "127.0.0.1:51005:1521" From 203322b8cb462dbcb4dfb5264214fd992a9a15c9 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:37:05 +0200 Subject: [PATCH 05/26] Add Bash environment start script --- docker/start-environment.sh | 110 ++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docker/start-environment.sh diff --git a/docker/start-environment.sh b/docker/start-environment.sh new file mode 100644 index 0000000..5b6a844 --- /dev/null +++ b/docker/start-environment.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "$script_dir/.." && pwd)" +project_prefix="neventstore-sql" +maximum_environment_name_length=48 + +normalize_environment_name() { + local value="$1" + local normalized + + normalized="$(printf '%s' "$value" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + + if [[ -z "$normalized" ]]; then + echo "Environment name '$value' does not contain any supported characters." >&2 + return 1 + fi + + normalized="${normalized:0:$maximum_environment_name_length}" + printf '%s' "${normalized%-}" +} + +get_assigned_port() { + local service="$1" + local container_port="$2" + local endpoint + local port + + endpoint="$("${compose_command[@]}" port "$service" "$container_port" | tail -n 1)" + port="${endpoint##*:}" + + if [[ ! "$port" =~ ^[0-9]+$ ]]; then + echo "Unable to determine the host port for $service:$container_port from '$endpoint'." >&2 + return 1 + fi + + printf '%s' "$port" +} + +if [[ $# -gt 1 ]]; then + echo "Only one environment name may be provided." >&2 + exit 2 +fi + +raw_environment_name="${1:-$(basename "$repository_root")}" +environment_name="$(normalize_environment_name "$raw_environment_name")" + +case "$environment_name" in + debug|test) + port_mode="$environment_name" + ;; + *) + port_mode="dynamic" + ;; +esac + +project_name="$project_prefix-$environment_name" +base_compose_file="$script_dir/docker-compose.yml" +override_compose_file="$script_dir/docker-compose.$port_mode.yml" +env_file="$repository_root/.env" + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose v2 is required. Ensure 'docker compose' is available." >&2 + exit 1 +fi + +compose_command=( + docker compose + --project-name "$project_name" + --file "$base_compose_file" + --file "$override_compose_file" +) + +echo "Starting '$project_name' using $port_mode ports..." +"${compose_command[@]}" up --detach --wait + +"${compose_command[@]}" exec -T sqlexpress \ + /opt/mssql-tools/bin/sqlcmd \ + -S localhost \ + -U sa \ + -P Password1 \ + -Q "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];" + +sqlserver_port="$(get_assigned_port sqlexpress 1433)" +mysql_port="$(get_assigned_port mysql 3306)" +postgres_port="$(get_assigned_port postgres 5432)" +oracle_port="$(get_assigned_port oracle 1521)" + +temporary_env_file="$env_file.tmp" +cat > "$temporary_env_file" < Date: Sat, 11 Jul 2026 12:37:19 +0200 Subject: [PATCH 06/26] Add Bash environment stop script --- docker/stop-environment.sh | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docker/stop-environment.sh diff --git a/docker/stop-environment.sh b/docker/stop-environment.sh new file mode 100644 index 0000000..f47e3df --- /dev/null +++ b/docker/stop-environment.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "$script_dir/.." && pwd)" +project_prefix="neventstore-sql" +maximum_environment_name_length=48 +remove_data=false +raw_environment_name="" + +normalize_environment_name() { + local value="$1" + local normalized + + normalized="$(printf '%s' "$value" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + + if [[ -z "$normalized" ]]; then + echo "Environment name '$value' does not contain any supported characters." >&2 + return 1 + fi + + normalized="${normalized:0:$maximum_environment_name_length}" + printf '%s' "${normalized%-}" +} + +for argument in "$@"; do + case "$argument" in + --remove-data) + remove_data=true + ;; + -*) + echo "Unknown option: $argument" >&2 + exit 2 + ;; + *) + if [[ -n "$raw_environment_name" ]]; then + echo "Only one environment name may be provided." >&2 + exit 2 + fi + raw_environment_name="$argument" + ;; + esac +done + +if [[ -z "$raw_environment_name" ]]; then + raw_environment_name="$(basename "$repository_root")" +fi + +environment_name="$(normalize_environment_name "$raw_environment_name")" + +case "$environment_name" in + debug|test) + port_mode="$environment_name" + ;; + *) + port_mode="dynamic" + ;; +esac + +project_name="$project_prefix-$environment_name" +compose_command=( + docker compose + --project-name "$project_name" + --file "$script_dir/docker-compose.yml" + --file "$script_dir/docker-compose.$port_mode.yml" +) + +down_arguments=(down) +if [[ "$remove_data" == true ]]; then + down_arguments+=(--volumes) +fi + +echo "Stopping '$project_name'..." +"${compose_command[@]}" "${down_arguments[@]}" From df4e49b6bc99c9fb4f85e744b042ddcaa817dc2e Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:37:36 +0200 Subject: [PATCH 07/26] Add PowerShell environment start script --- docker/start-environment.ps1 | 130 +++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docker/start-environment.ps1 diff --git a/docker/start-environment.ps1 b/docker/start-environment.ps1 new file mode 100644 index 0000000..15263a3 --- /dev/null +++ b/docker/start-environment.ps1 @@ -0,0 +1,130 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string] $EnvironmentName +) + +if ($args.Count -gt 0) { + throw 'Only one environment name may be provided.' +} + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = Split-Path -Parent $scriptDirectory +$projectPrefix = 'neventstore-sql' +$maximumEnvironmentNameLength = 48 + +function Normalize-EnvironmentName { + param([Parameter(Mandatory)][string] $Value) + + $normalized = $Value.ToLowerInvariant() + $normalized = [Regex]::Replace($normalized, '[^a-z0-9]+', '-') + $normalized = $normalized.Trim('-') + + if ([string]::IsNullOrWhiteSpace($normalized)) { + throw "Environment name '$Value' does not contain any supported characters." + } + + if ($normalized.Length -gt $maximumEnvironmentNameLength) { + $normalized = $normalized.Substring(0, $maximumEnvironmentNameLength).TrimEnd('-') + } + + return $normalized +} + +function Invoke-DockerCompose { + param( + [Parameter(Mandatory)][string[]] $Arguments, + [switch] $CaptureOutput + ) + + $allArguments = $script:composeArguments + $Arguments + if ($CaptureOutput) { + $output = & docker @allArguments + } else { + & docker @allArguments + } + + if ($LASTEXITCODE -ne 0) { + throw "Docker Compose failed with exit code $LASTEXITCODE." + } + + if ($CaptureOutput) { + return $output + } +} + +function Get-AssignedPort { + param( + [Parameter(Mandatory)][string] $Service, + [Parameter(Mandatory)][int] $ContainerPort + ) + + $endpoint = (Invoke-DockerCompose -Arguments @('port', $Service, $ContainerPort.ToString()) -CaptureOutput | Select-Object -Last 1).Trim() + $match = [Regex]::Match($endpoint, ':(\d+)$') + + if (-not $match.Success) { + throw "Unable to determine the host port for ${Service}:${ContainerPort} from '$endpoint'." + } + + return $match.Groups[1].Value +} + +if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + $EnvironmentName = Split-Path -Leaf $repositoryRoot +} + +$normalizedEnvironmentName = Normalize-EnvironmentName $EnvironmentName +$portMode = if ($normalizedEnvironmentName -in @('debug', 'test')) { $normalizedEnvironmentName } else { 'dynamic' } +$projectName = "$projectPrefix-$normalizedEnvironmentName" +$baseComposeFile = Join-Path $scriptDirectory 'docker-compose.yml' +$overrideComposeFile = Join-Path $scriptDirectory "docker-compose.$portMode.yml" +$envFile = Join-Path $repositoryRoot '.env' +$script:composeArguments = @( + 'compose', + '--project-name', $projectName, + '--file', $baseComposeFile, + '--file', $overrideComposeFile +) + +& docker compose version *> $null +if ($LASTEXITCODE -ne 0) { + throw "Docker Compose v2 is required. Ensure 'docker compose' is available." +} + +Write-Host "Starting '$projectName' using $portMode ports..." +Invoke-DockerCompose -Arguments @('up', '--detach', '--wait') + +Invoke-DockerCompose -Arguments @( + 'exec', '-T', 'sqlexpress', + '/opt/mssql-tools/bin/sqlcmd', + '-S', 'localhost', + '-U', 'sa', + '-P', 'Password1', + '-Q', "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];" +) + +$sqlServerPort = Get-AssignedPort -Service 'sqlexpress' -ContainerPort 1433 +$mySqlPort = Get-AssignedPort -Service 'mysql' -ContainerPort 3306 +$postgreSqlPort = Get-AssignedPort -Service 'postgres' -ContainerPort 5432 +$oraclePort = Get-AssignedPort -Service 'oracle' -ContainerPort 1521 + +$connectionStrings = @( + "# Generated by docker/start-environment.ps1 for environment '$normalizedEnvironmentName'.", + "NEventStore.MsSql=Server=127.0.0.1,$sqlServerPort;Database=NEventStore;User Id=sa;Password=Password1;TrustServerCertificate=True;", + "NEventStore.MySql=Server=127.0.0.1;Port=$mySqlPort;Database=NEventStore;Uid=sa;Pwd=Password1;AutoEnlist=false;", + "NEventStore.PostgreSql=Server=127.0.0.1;Port=$postgreSqlPort;Database=NEventStore;Uid=sa;Pwd=Password1;Enlist=false;", + "NEventStore.Oracle=Data Source=127.0.0.1:$oraclePort/XE;User Id=system;Password=Password1;Persist Security Info=True;" +) + +$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText($envFile, ($connectionStrings -join [Environment]::NewLine) + [Environment]::NewLine, $utf8WithoutBom) + +Write-Host +Write-Host "Environment: $normalizedEnvironmentName" +Write-Host "Compose project: $projectName" +Write-Host "Connection strings written to: $envFile" +Write-Host +$connectionStrings | Select-Object -Skip 1 | ForEach-Object { Write-Host $_ } From 3679facc6c7be0dfd5fb8b64e0faf164b7e98228 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:37:49 +0200 Subject: [PATCH 08/26] Add PowerShell environment stop script --- docker/stop-environment.ps1 | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docker/stop-environment.ps1 diff --git a/docker/stop-environment.ps1 b/docker/stop-environment.ps1 new file mode 100644 index 0000000..8ff62eb --- /dev/null +++ b/docker/stop-environment.ps1 @@ -0,0 +1,62 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = Split-Path -Parent $scriptDirectory +$projectPrefix = 'neventstore-sql' +$maximumEnvironmentNameLength = 48 +$removeData = $false +$environmentName = $null + +function Normalize-EnvironmentName { + param([Parameter(Mandatory)][string] $Value) + + $normalized = $Value.ToLowerInvariant() + $normalized = [Regex]::Replace($normalized, '[^a-z0-9]+', '-') + $normalized = $normalized.Trim('-') + + if ([string]::IsNullOrWhiteSpace($normalized)) { + throw "Environment name '$Value' does not contain any supported characters." + } + + if ($normalized.Length -gt $maximumEnvironmentNameLength) { + $normalized = $normalized.Substring(0, $maximumEnvironmentNameLength).TrimEnd('-') + } + + return $normalized +} + +foreach ($argument in $args) { + switch ($argument) { + '--remove-data' { $removeData = $true; continue } + '-RemoveData' { $removeData = $true; continue } + default { + if ($argument.StartsWith('-')) { throw "Unknown option: $argument" } + if ($null -ne $environmentName) { throw 'Only one environment name may be provided.' } + $environmentName = $argument + } + } +} + +if ([string]::IsNullOrWhiteSpace($environmentName)) { + $environmentName = Split-Path -Leaf $repositoryRoot +} + +$normalizedEnvironmentName = Normalize-EnvironmentName $environmentName +$portMode = if ($normalizedEnvironmentName -in @('debug', 'test')) { $normalizedEnvironmentName } else { 'dynamic' } +$projectName = "$projectPrefix-$normalizedEnvironmentName" +$composeArguments = @( + 'compose', + '--project-name', $projectName, + '--file', (Join-Path $scriptDirectory 'docker-compose.yml'), + '--file', (Join-Path $scriptDirectory "docker-compose.$portMode.yml"), + 'down' +) + +if ($removeData) { $composeArguments += '--volumes' } + +Write-Host "Stopping '$projectName'..." +& docker @composeArguments +if ($LASTEXITCODE -ne 0) { + throw "Docker Compose failed with exit code $LASTEXITCODE." +} From 5242af81075070fed367ca126141692df66a2c49 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:38:08 +0200 Subject: [PATCH 09/26] Load test connection strings from local dotenv file --- .../DotEnvFile.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/NEventStore.Persistence.Sql.Tests/DotEnvFile.cs diff --git a/src/NEventStore.Persistence.Sql.Tests/DotEnvFile.cs b/src/NEventStore.Persistence.Sql.Tests/DotEnvFile.cs new file mode 100644 index 0000000..f812f1c --- /dev/null +++ b/src/NEventStore.Persistence.Sql.Tests/DotEnvFile.cs @@ -0,0 +1,60 @@ +namespace NEventStore.Persistence.Sql.Tests +{ + internal static class DotEnvFile + { + private static readonly object SyncRoot = new object(); + private static bool _loaded; + + public static void Load() + { + if (_loaded) return; + + lock (SyncRoot) + { + if (_loaded) return; + + var envFile = FindEnvFile(Environment.CurrentDirectory) + ?? FindEnvFile(AppContext.BaseDirectory); + + if (envFile != null) Load(envFile); + _loaded = true; + } + } + + private static string? FindEnvFile(string startDirectory) + { + var directory = new DirectoryInfo(startDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, ".env"); + if (File.Exists(candidate)) return candidate; + directory = directory.Parent; + } + return null; + } + + private static void Load(string path) + { + foreach (var sourceLine in File.ReadLines(path)) + { + var line = sourceLine.Trim(); + if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue; + if (line.StartsWith("export ", StringComparison.Ordinal)) line = line.Substring("export ".Length).TrimStart(); + + var separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0) continue; + + var key = line.Substring(0, separatorIndex).Trim(); + if (key.Length == 0 || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(key, EnvironmentVariableTarget.Process))) continue; + + var value = line.Substring(separatorIndex + 1).Trim(); + if (value.Length >= 2 && ((value[0] == '"' && value[value.Length - 1] == '"') || (value[0] == '\'' && value[value.Length - 1] == '\''))) + { + value = value.Substring(1, value.Length - 2); + } + + Environment.SetEnvironmentVariable(key, value, EnvironmentVariableTarget.Process); + } + } + } +} From e08be446272b700011378e1ee0e1338e00c3de0f Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:38:31 +0200 Subject: [PATCH 10/26] Load dotenv values before resolving test connections --- .../EnvironmentConnectionFactory.cs | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs b/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs index 5deeb4e..645a88a 100644 --- a/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs +++ b/src/NEventStore.Persistence.Sql.Tests/EnvironmentConnectionFactory.cs @@ -40,18 +40,7 @@ public Type GetDbProviderFactoryType() private DbConnection OpenInternal() { - var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); - if (connectionString == null) - { - string message = - string.Format( - "Failed to get '{0}' environment variable. Please ensure " + - "you have correctly setup the connection string environment variables. Refer to the " + - "NEventStore wiki for details.", - _envVarKey); - throw new InvalidOperationException(message); - } - connectionString = connectionString.TrimStart('"').TrimEnd('"'); + var connectionString = GetConnectionString(); var connection = _dbProviderFactory.CreateConnection(); Debug.Assert(connection != null, "connection == null"); connection!.ConnectionString = connectionString; @@ -68,18 +57,7 @@ private DbConnection OpenInternal() private async Task OpenInternalAsync(CancellationToken cancellationToken) { - var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); - if (connectionString == null) - { - string message = - string.Format( - "Failed to get '{0}' environment variable. Please ensure " + - "you have correctly setup the connection string environment variables. Refer to the " + - "NEventStore wiki for details.", - _envVarKey); - throw new InvalidOperationException(message); - } - connectionString = connectionString.TrimStart('"').TrimEnd('"'); + var connectionString = GetConnectionString(); var connection = _dbProviderFactory.CreateConnection(); Debug.Assert(connection != null, "connection == null"); connection!.ConnectionString = connectionString; @@ -93,5 +71,24 @@ private async Task OpenInternalAsync(CancellationToken cancellatio } return connection; } + + private string GetConnectionString() + { + DotEnvFile.Load(); + + var connectionString = Environment.GetEnvironmentVariable(_envVarKey, EnvironmentVariableTarget.Process); + if (connectionString == null) + { + string message = + string.Format( + "Failed to get '{0}' environment variable. Please ensure " + + "you have correctly setup the connection string environment variables or run " + + "docker/start-environment for this worktree.", + _envVarKey); + throw new InvalidOperationException(message); + } + + return connectionString.TrimStart('"').TrimEnd('"'); + } } -} \ No newline at end of file +} From 88f4d7995bdcabd9daeee9b47884874fa483d0bd Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:38:46 +0200 Subject: [PATCH 11/26] Ignore generated local connection settings --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b48498b..994403f 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,5 @@ UpgradeLog*.XML # Custom artifacts/ -.tokensave/ \ No newline at end of file +.tokensave/ +.env From e4b6e6ffeb8345ebd60164cdf18d2cb33d5a93af Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:38:51 +0200 Subject: [PATCH 12/26] Remove legacy platform-specific start script --- docker/DockerComposeUp.ps1 | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 docker/DockerComposeUp.ps1 diff --git a/docker/DockerComposeUp.ps1 b/docker/DockerComposeUp.ps1 deleted file mode 100644 index d10ddc3..0000000 --- a/docker/DockerComposeUp.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -$platform = $args[0] -if (!$platform) { - Write-Host "Missing Platform: windows | linux" - exit(1) -} - -Write-Host "Run the CI environment" - -docker-compose -f docker-compose.ci.$platform.db.yml -p nesci up --detach - -Write-Host "Creating Databases" - -Start-Sleep -Seconds 20 - -if ($platform -eq "linux") { -docker exec nesci-sqlexpress-1 /opt/mssql-tools/bin/sqlcmd -l 60 -S localhost -U sa -P Password1 -Q "CREATE DATABASE NEventStore" -} - -if ($platform -eq "windows") { -docker exec nesci-sqlexpress-1 sqlcmd -l 60 -S localhost -U sa -P Password1 -Q "CREATE DATABASE NEventStore" -} From 5f623d71a09e36aa101a37559439be1b475e22f4 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:38:56 +0200 Subject: [PATCH 13/26] Remove legacy platform-specific stop script --- docker/DockerComposeDown.ps1 | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 docker/DockerComposeDown.ps1 diff --git a/docker/DockerComposeDown.ps1 b/docker/DockerComposeDown.ps1 deleted file mode 100644 index 37c8f3f..0000000 --- a/docker/DockerComposeDown.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -$platform = $args[0] -if (!$platform) { - Write-Host "Missing Platform: windows | linux" - exit(1) -} - -if ($platform -eq "linux") { - # there's a bug on stopping/terminating LCOW - # https://github.com/moby/moby/issues/37919 - # workaround is to kill the containers manually - - #Write-Host "LCOW stopping bug, hack: killing the containers (https://github.com/moby/moby/issues/37919)." - #docker kill -s 9 nesci-sqlexpress-1 - #docker kill -s 9 nesci-mongo-1 - #docker kill -s 9 nesci-mysql-1 - #docker kill -s 9 nesci-postgres-1 -} -# -v "removes all the volumes, there's no need to do it manually" -docker-compose -f docker-compose.ci.$platform.db.yml -p nesci down -v - -# remove unneeded volumes, so we start clear every time -#Write-Host "Removing volumes:" -#docker volume rm nesci-h8-sqldata-${platform}_ci -#docker volume rm nesci-h8_mongodata-${platform}_ci \ No newline at end of file From 8e0feaa36f8ad76e62c140f0783f18ff156cd29f Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:39:02 +0200 Subject: [PATCH 14/26] Remove legacy fixed-port Linux compose file --- docker/docker-compose.ci.linux.db.yml | 70 --------------------------- 1 file changed, 70 deletions(-) delete mode 100644 docker/docker-compose.ci.linux.db.yml diff --git a/docker/docker-compose.ci.linux.db.yml b/docker/docker-compose.ci.linux.db.yml deleted file mode 100644 index 873c1f8..0000000 --- a/docker/docker-compose.ci.linux.db.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Root Docker Compose file to run the tests on the development machine using some pre-initialized databases -# It must be used with powershell actions that mount the databases files. - -services: - sqlexpress: - container_name: nesci-sqlexpress-1 - platform: linux - image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 - mem_limit: 2000m - environment: - - SA_PASSWORD=Password1 - - ACCEPT_EULA=Y - - MSSQL_PID=Express - ports: - - "50001:1433" -# volumes: -# - C:\Temp/NEventStore:/data -# - nes_sqldata_linux_ci:/var/opt/mssql - -# mongo: -# container_name: nesci-mongo-1 -# platform: linux -# image: mongo #@sha256:52c3314bee611f91d37b9b1bc0cc2755b1388f2de5b396b441f3fe94bef6c56c -# ports: -# - "50002:27017" -## volumes: -## - nes_mongodata_linux_ci:/data/db -## - nes_mongoconfig_linux_ci:/data/configdb - - mysql: - container_name: nesci-mysql-1 - platform: linux - image: mysql:8.0 #5.7 - command: --default-authentication-plugin=mysql_native_password - restart: always - environment: - MYSQL_ROOT_PASSWORD: Password1 - MYSQL_DATABASE: NEventStore - MYSQL_USER: sa - MYSQL_PASSWORD: Password1 - ports: - - "50003:3306" - - postgres: - container_name: nesci-postgres-1 - platform: linux - image: postgres - restart: always - environment: - POSTGRES_PASSWORD: Password1 - POSTGRES_USER: sa - POSTGRES_DB: NEventStore - ports: - - "50004:5432" - - oracle: - container_name: nesci-oracle-1 - platform: linux - image: gvenzl/oracle-xe - restart: always - environment: - - ORACLE_ALLOW_REMOTE=true - - ORACLE_PASSWORD=Password1 - ports: - - "50005:1521" - -#volumes: -# nes_sqldata_linux_ci: -# nes_mongodata_linux_ci: -# nes_mongoconfig_linux_ci: \ No newline at end of file From 75088402fc3c36b0b8f96662208a6f460dea1936 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:39:09 +0200 Subject: [PATCH 15/26] Remove unsupported Windows-container compose file --- docker/docker-compose.ci.windows.db.yml | 72 ------------------------- 1 file changed, 72 deletions(-) delete mode 100644 docker/docker-compose.ci.windows.db.yml diff --git a/docker/docker-compose.ci.windows.db.yml b/docker/docker-compose.ci.windows.db.yml deleted file mode 100644 index ead22a6..0000000 --- a/docker/docker-compose.ci.windows.db.yml +++ /dev/null @@ -1,72 +0,0 @@ -# Root Docker Compose file to run the tests on the development machine using some pre-initialized databases -# It must be used with powershell actions that mount the databases files. - -services: - sqlexpress: - container_name: nesci-sqlexpress-1 - platform: windows - image: christianacca/mssql-server-windows-express - environment: - - SA_PASSWORD=Password1 - - ACCEPT_EULA=Y - ports: - - "50001:1433" -# volumes: -# - "C:\\Temp\\NEventStore:c:\\data" -# - "nes_sqldata_windows_ci:c:\\sql\\userdbs\\data" -# - "nes_sqllog_windows_ci:c:\\sql\\userdbs\\log" -# - "nes_sqlbackup_windows_ci:c:\\sql\\backup" - -# mongo: -# container_name: nesci-mongo-1 -# platform: windows -# image: mongo -# ports: -# - "50002:27017" -## volumes: -## - nes_mongodata_windows_ci:/data/db -## - nes_mongoconfig_windows_ci:/data/configdb - - mysql: - container_name: nesci-mysql-1 - platform: windows - image: mysql:8.0 #5.7 - command: --default-authentication-plugin=mysql_native_password - restart: always - environment: - MYSQL_ROOT_PASSWORD: Password1 - MYSQL_DATABASE: NEventStore - MYSQL_USER: sa - MYSQL_PASSWORD: Password1 - ports: - - "50003:3306" - - postgres: - container_name: nesci-postgres-1 - platform: windows - image: postgres - restart: always - environment: - POSTGRES_PASSWORD: Password1 - POSTGRES_USER: sa - POSTGRES_DB: NEventStore - ports: - - "50004:5432" - - oracle: - container_name: nesci-oracle-1 - platform: linux - image: gvenzl/oracle-xe - restart: always - environment: - - ORACLE_ALLOW_REMOTE=true - - ORACLE_PASSWORD=Password1 - ports: - - "50005:1521" - -#volumes: -# nes_sqldata_windows_ci: -# nes_sqllog_windows_ci: -# nes_sqlbackup_windows_ci: -# nes_mongodata_windows_ci: -# nes_mongoconfig_windows_ci: \ No newline at end of file From f3f39f8220cbd09cd21fe8ed385d58c538ffc7cd Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:39:22 +0200 Subject: [PATCH 16/26] Update VS Code Docker environment tasks --- .vscode/tasks.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bc28cf8..1c889a7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Docker Up", "options": { - "cwd": "${workspaceFolder}/docker/" + "cwd": "${workspaceFolder}" }, "type": "shell", "command": "powershell", @@ -12,8 +12,8 @@ "-ExecutionPolicy", "Bypass", "-File", - "${workspaceFolder}/docker/DockerComposeUp.ps1", - "linux" + "${workspaceFolder}/docker/start-environment.ps1", + "debug" ], "presentation": { "echo": true, @@ -26,7 +26,7 @@ { "label": "Docker Down", "options": { - "cwd": "${workspaceFolder}/docker/" + "cwd": "${workspaceFolder}" }, "type": "shell", "command": "powershell", @@ -34,8 +34,8 @@ "-ExecutionPolicy", "Bypass", "-File", - "${workspaceFolder}/docker/DockerComposeDown.ps1", - "linux" + "${workspaceFolder}/docker/stop-environment.ps1", + "debug" ], "presentation": { "echo": true, @@ -56,4 +56,4 @@ "label": "dotnet: build" } ] -} \ No newline at end of file +} From 5b4384c5e54ec633ff02a17b07008d301f6637cf Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:39:43 +0200 Subject: [PATCH 17/26] Document isolated database environments --- docker/Readme.md | 94 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 11 deletions(-) diff --git a/docker/Readme.md b/docker/Readme.md index 4e5a5c9..6dac83e 100644 --- a/docker/Readme.md +++ b/docker/Readme.md @@ -1,18 +1,90 @@ -# Docker +# Docker database environments -Docker must be configured to use LCOW in Windows OS. +The scripts in this directory start and stop the complete Linux-container database stack used by the SQL persistence tests. -Run the following scripts to start and stop all the Database engines: +Each Git worktree owns one Docker Compose project: +```text +worktree -> environment name -> Compose project -> all database services ``` -DockerComposeUp.ps1 -DockerComposeDown.ps1 + +The Compose project name is `neventstore-sql-`. Containers, networks, and named volumes are isolated by that project name. + +## Start an environment + +Run the platform-specific script from any directory: + +```powershell +./docker/start-environment.ps1 +``` + +```bash +./docker/start-environment.sh +``` + +Without an argument, the environment name is derived from the worktree directory. The name is normalized and dynamic host ports are assigned automatically. + +An explicit dynamic environment can be started with: + +```bash +./docker/start-environment.sh issue-142 +``` + +Two reserved environment names use predictable ports: + +```bash +./docker/start-environment.sh debug +./docker/start-environment.sh test +``` + +| Database | Debug | Test | +| --- | ---: | ---: | +| SQL Server | 50001 | 51001 | +| MySQL | 50003 | 51003 | +| PostgreSQL | 50004 | 51004 | +| Oracle | 50005 | 51005 | + +The start script waits for the services to become healthy, creates the SQL Server `NEventStore` database when necessary, retrieves the assigned ports, and writes the resulting connection strings to the repository-root `.env` file. + +The generated `.env` file is excluded from Git. Test projects load it automatically when the corresponding process environment variable is not already set. + +## Stop an environment + +Data is preserved by default: + +```bash +./docker/stop-environment.sh issue-142 +``` + +Remove only the selected environment's named volumes with: + +```bash +./docker/stop-environment.sh issue-142 --remove-data +``` + +The PowerShell scripts accept the same environment names and behavior: + +```powershell +./docker/stop-environment.ps1 issue-142 --remove-data +``` + +## Agent usage + +Agents should normally use a dynamic environment derived from their worktree or task: + +```bash +./docker/start-environment.sh issue-142 +dotnet test ./src/NEventStore.Persistence.Sql.Core.sln +./docker/stop-environment.sh issue-142 ``` -## Docker Environment Variables +Agents should not use `debug` or `test` unless explicitly instructed because those names reserve predictable ports for human workflows. + +## Compose files + +- `docker-compose.yml` defines the Linux database services, health checks, and named volumes. +- `docker-compose.dynamic.yml` publishes each service on an automatically assigned local port. +- `docker-compose.debug.yml` publishes the fixed debug ports. +- `docker-compose.test.yml` publishes the fixed test ports. -NEventStore.MsSql="Server=localhost,50001;Database=NEventStore;User Id=sa;Password=Password1;TrustServerCertificate=True;" -NEventStore.MongoDB="mongodb://localhost:50002/NEventStore" -NEventStore.MySql="Server=localhost;Port=50003;Database=NEventStore;Uid=sa;Pwd=Password1;AutoEnlist=false;" -NEventStore.PostgreSql="Server=localhost;Port=50004;Database=NEventStore;Uid=sa;Pwd=Password1;Enlist=false;" -NEventStore.Oracle="Data Source=localhost:50005/XE;User Id=system;Password=Password1;Persist Security Info=True;" +The Compose files intentionally do not define `container_name`; Compose derives resource names from the project name. From 7b298048bfeb2c565988646546739db55e585bb4 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:40:03 +0200 Subject: [PATCH 18/26] Document dotenv-based local test setup --- README.md | 51 +++++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 99a00d9..990c829 100644 --- a/README.md +++ b/README.md @@ -54,20 +54,32 @@ git submodule update To build the project locally on a Windows Machine: -- Optional: update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating relase packages). +- Optional: update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating release packages). - Open a Powershell console in Administrative mode and run the build script `build.ps1` in the root of the repository. ## How to Run Unit Tests (locally) -- Install Database engines or use Docker to run them in a container (you can use the scripts in `./docker` folder). -- Define the following environment variables: +Start the complete Linux-container database environment with one of the scripts under `docker/`: - ``` - NEventStore.MsSql="Server=localhost,50001;Database=NEventStore;User Id=sa;Password=Password1;TrustServerCertificate=True;" - NEventStore.MySql="Server=localhost;Port=50003;Database=NEventStore;Uid=sa;Pwd=Password1;AutoEnlist=false;" - NEventStore.PostgreSql="Server=localhost;Port=50004;Database=NEventStore;Uid=sa;Pwd=Password1;Enlist=false;" - NEventStore.Oracle="Data Source=localhost:50005/XE;User Id=system;Password=Password1;Persist Security Info=True;" - ``` +```powershell +./docker/start-environment.ps1 debug +``` + +```bash +./docker/start-environment.sh debug +``` + +The scripts generate a repository-root `.env` file containing the database connection strings. The test projects load that file automatically unless the corresponding process environment variables are already defined. + +Dynamic environment names may be used for parallel worktrees and agents: + +```bash +./docker/start-environment.sh issue-142 +dotnet test ./src/NEventStore.Persistence.Sql.Core.sln +./docker/stop-environment.sh issue-142 +``` + +See [`docker/Readme.md`](docker/Readme.md) for fixed ports, data removal, and environment isolation details. ## How to contribute @@ -81,19 +93,19 @@ This repository uses GitFlow to develop, if you are not familiar with GitFlow yo ### Installing and configuring Git Flow -Probably the most straightforward way to install GitFlow on your machine is installing [Git Command Line](https://git-for-windows.github.io/), then install the [Visual Studio Plugin for Git-Flow](https://visualstudiogallery.msdn.microsoft.com/27f6d087-9b6f-46b0-b236-d72907b54683). This plugin is accessible from the **Team Explorer** menu and allows you to install GitFlow extension directly from Visual Studio with a simple click. The installer installs standard GitFlow extension both for command line and for Visual Studio Plugin. +Probably the most straightforward way to install GitFlow on your machine is installing [Git Command Line](https://git-for-windows.github.io/), then install the [Visual Studio Plugin for Git-Flow](https://visualstudiogallery.msdn.microsoft.com/27f6d087-9b6f-46b0-b236-d72907b54683). This plugin is accessible from the **Team Explorer** menu and allows you to install GitFlow extension directly from Visual Studio. The installer installs standard GitFlow extension both for command line and for Visual Studio Plugin. Once installed you can use GitFlow right from Visual Studio or from Command line, which one you prefer. ### Build machine and GitVersion -Build machine uses [GitVersion](https://github.com/GitTools/GitVersion) to manage automatic versioning of assemblies and Nuget Packages. You need to be aware that there are a rule that does not allow you to directly commit on master, or the build will fail. +Build machine uses GitVersion to manage automatic versioning of assemblies and Nuget Packages. You need to be aware that there are a rule that does not allow you to directly commit on master, or the build will fail. -A commit on master can be done only following the [Git-Flow](http://nvie.com/posts/a-successful-git-branching-model/) model, as a result of a new release coming from develop, or with an hotfix. +A commit on master can be done only following the Git-Flow model, as a result of a new release coming from develop, or with an hotfix. ### Quick Info for NEventstore projects -Just clone the repository and from command line checkout develop branch with +Just clone the repository and from command line checkout develop branch with ``` git checkout develop @@ -109,17 +121,8 @@ You can leave all values as default. Now your repository is GitFlow enabled. ### Note on Nuget version on Nuspec -Remember to update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating relase packages). +Remember to update `.\src\.nuget\NEventStore.Persistence.Sql.nuspec` file if needed (before creating release packages). The .nuspec file is needed because the new `dotnet pack` command has problems dealing with ProjectReferences, submodules get the wrong version number. -While we are on develop branch, (suppose we just bumped major number so the driver version number is 6.0.0-unstablexxxx), we need to declare that this persistence driver depends from a version greater than the latest published. If the latest version of NEventStore 5.x.x wave iw 5.4.0 we need to declare this package dependency as - -(5.4, 7) - -This means, that we need a NEventStore greater than the latest published, but lesser than the next main version. This allows version 6.0.0-unstable of NEventStore to satisfy the dependency. We remember that prerelease package are considered minor than the stable package. Es. - -5.4.0 -5.4.1 -6.0.0-unstable00001 -6.0.0 +While we are on develop branch, suppose we just bumped major number so the driver version number is 6.0.0-unstablexxxx. We need to declare that this persistence driver depends from a version greater than the latest published. If the latest version of NEventStore 5.x.x wave is 5.4.0 we need to declare that dependency as `(5.4, 7)`. From 0d1f91e0e219f87d3d78cd571743bcee58952e4c Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:55:02 +0200 Subject: [PATCH 19/26] Add fixed CI database ports --- docker/docker-compose.ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker/docker-compose.ci.yml diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml new file mode 100644 index 0000000..7c39fb5 --- /dev/null +++ b/docker/docker-compose.ci.yml @@ -0,0 +1,16 @@ +services: + sqlexpress: + ports: + - "127.0.0.1:52001:1433" + + mysql: + ports: + - "127.0.0.1:52003:3306" + + postgres: + ports: + - "127.0.0.1:52004:5432" + + oracle: + ports: + - "127.0.0.1:52005:1521" From 10a2e2a69edc25851a2e76896f426c7147fefa63 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:56:12 +0200 Subject: [PATCH 20/26] Reserve the CI environment in Bash startup --- docker/start-environment.sh | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/docker/start-environment.sh b/docker/start-environment.sh index 5b6a844..d1173d2 100644 --- a/docker/start-environment.sh +++ b/docker/start-environment.sh @@ -49,7 +49,7 @@ raw_environment_name="${1:-$(basename "$repository_root")}" environment_name="$(normalize_environment_name "$raw_environment_name")" case "$environment_name" in - debug|test) + debug|test|ci) port_mode="$environment_name" ;; *) @@ -77,11 +77,13 @@ compose_command=( echo "Starting '$project_name' using $port_mode ports..." "${compose_command[@]}" up --detach --wait +database_password="$("${compose_command[@]}" exec -T sqlexpress printenv MSSQL_SA_PASSWORD | tr -d '\r')" + "${compose_command[@]}" exec -T sqlexpress \ /opt/mssql-tools/bin/sqlcmd \ -S localhost \ -U sa \ - -P Password1 \ + -P "$database_password" \ -Q "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];" sqlserver_port="$(get_assigned_port sqlexpress 1433)" @@ -92,10 +94,10 @@ oracle_port="$(get_assigned_port oracle 1521)" temporary_env_file="$env_file.tmp" cat > "$temporary_env_file" < Date: Sat, 11 Jul 2026 12:56:40 +0200 Subject: [PATCH 21/26] Reserve the CI environment in PowerShell startup --- docker/start-environment.ps1 | 103 +++++++---------------------------- 1 file changed, 21 insertions(+), 82 deletions(-) diff --git a/docker/start-environment.ps1 b/docker/start-environment.ps1 index 15263a3..4669499 100644 --- a/docker/start-environment.ps1 +++ b/docker/start-environment.ps1 @@ -4,10 +4,7 @@ param( [string] $EnvironmentName ) -if ($args.Count -gt 0) { - throw 'Only one environment name may be provided.' -} - +if ($args.Count -gt 0) { throw 'Only one environment name may be provided.' } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -18,113 +15,55 @@ $maximumEnvironmentNameLength = 48 function Normalize-EnvironmentName { param([Parameter(Mandatory)][string] $Value) - - $normalized = $Value.ToLowerInvariant() - $normalized = [Regex]::Replace($normalized, '[^a-z0-9]+', '-') - $normalized = $normalized.Trim('-') - - if ([string]::IsNullOrWhiteSpace($normalized)) { - throw "Environment name '$Value' does not contain any supported characters." - } - - if ($normalized.Length -gt $maximumEnvironmentNameLength) { - $normalized = $normalized.Substring(0, $maximumEnvironmentNameLength).TrimEnd('-') - } - + $normalized = [Regex]::Replace($Value.ToLowerInvariant(), '[^a-z0-9]+', '-').Trim('-') + if ([string]::IsNullOrWhiteSpace($normalized)) { throw "Environment name '$Value' does not contain any supported characters." } + if ($normalized.Length -gt $maximumEnvironmentNameLength) { $normalized = $normalized.Substring(0, $maximumEnvironmentNameLength).TrimEnd('-') } return $normalized } function Invoke-DockerCompose { - param( - [Parameter(Mandatory)][string[]] $Arguments, - [switch] $CaptureOutput - ) - + param([Parameter(Mandatory)][string[]] $Arguments, [switch] $CaptureOutput) $allArguments = $script:composeArguments + $Arguments - if ($CaptureOutput) { - $output = & docker @allArguments - } else { - & docker @allArguments - } - - if ($LASTEXITCODE -ne 0) { - throw "Docker Compose failed with exit code $LASTEXITCODE." - } - - if ($CaptureOutput) { - return $output - } + if ($CaptureOutput) { $output = & docker @allArguments } else { & docker @allArguments } + if ($LASTEXITCODE -ne 0) { throw "Docker Compose failed with exit code $LASTEXITCODE." } + if ($CaptureOutput) { return $output } } function Get-AssignedPort { - param( - [Parameter(Mandatory)][string] $Service, - [Parameter(Mandatory)][int] $ContainerPort - ) - + param([Parameter(Mandatory)][string] $Service, [Parameter(Mandatory)][int] $ContainerPort) $endpoint = (Invoke-DockerCompose -Arguments @('port', $Service, $ContainerPort.ToString()) -CaptureOutput | Select-Object -Last 1).Trim() $match = [Regex]::Match($endpoint, ':(\d+)$') - - if (-not $match.Success) { - throw "Unable to determine the host port for ${Service}:${ContainerPort} from '$endpoint'." - } - + if (-not $match.Success) { throw "Unable to determine the host port for ${Service}:${ContainerPort} from '$endpoint'." } return $match.Groups[1].Value } -if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { - $EnvironmentName = Split-Path -Leaf $repositoryRoot -} - +if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { $EnvironmentName = Split-Path -Leaf $repositoryRoot } $normalizedEnvironmentName = Normalize-EnvironmentName $EnvironmentName -$portMode = if ($normalizedEnvironmentName -in @('debug', 'test')) { $normalizedEnvironmentName } else { 'dynamic' } +$portMode = if ($normalizedEnvironmentName -in @('debug', 'test', 'ci')) { $normalizedEnvironmentName } else { 'dynamic' } $projectName = "$projectPrefix-$normalizedEnvironmentName" -$baseComposeFile = Join-Path $scriptDirectory 'docker-compose.yml' -$overrideComposeFile = Join-Path $scriptDirectory "docker-compose.$portMode.yml" $envFile = Join-Path $repositoryRoot '.env' -$script:composeArguments = @( - 'compose', - '--project-name', $projectName, - '--file', $baseComposeFile, - '--file', $overrideComposeFile -) +$script:composeArguments = @('compose', '--project-name', $projectName, '--file', (Join-Path $scriptDirectory 'docker-compose.yml'), '--file', (Join-Path $scriptDirectory "docker-compose.$portMode.yml")) & docker compose version *> $null -if ($LASTEXITCODE -ne 0) { - throw "Docker Compose v2 is required. Ensure 'docker compose' is available." -} +if ($LASTEXITCODE -ne 0) { throw "Docker Compose v2 is required. Ensure 'docker compose' is available." } Write-Host "Starting '$projectName' using $portMode ports..." Invoke-DockerCompose -Arguments @('up', '--detach', '--wait') - -Invoke-DockerCompose -Arguments @( - 'exec', '-T', 'sqlexpress', - '/opt/mssql-tools/bin/sqlcmd', - '-S', 'localhost', - '-U', 'sa', - '-P', 'Password1', - '-Q', "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];" -) +$databasePassword = (Invoke-DockerCompose -Arguments @('exec', '-T', 'sqlexpress', 'printenv', 'MSSQL_SA_PASSWORD') -CaptureOutput | Select-Object -Last 1).Trim() +Invoke-DockerCompose -Arguments @('exec', '-T', 'sqlexpress', '/opt/mssql-tools/bin/sqlcmd', '-S', 'localhost', '-U', 'sa', '-P', $databasePassword, '-Q', "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];") $sqlServerPort = Get-AssignedPort -Service 'sqlexpress' -ContainerPort 1433 $mySqlPort = Get-AssignedPort -Service 'mysql' -ContainerPort 3306 $postgreSqlPort = Get-AssignedPort -Service 'postgres' -ContainerPort 5432 $oraclePort = Get-AssignedPort -Service 'oracle' -ContainerPort 1521 - $connectionStrings = @( "# Generated by docker/start-environment.ps1 for environment '$normalizedEnvironmentName'.", - "NEventStore.MsSql=Server=127.0.0.1,$sqlServerPort;Database=NEventStore;User Id=sa;Password=Password1;TrustServerCertificate=True;", - "NEventStore.MySql=Server=127.0.0.1;Port=$mySqlPort;Database=NEventStore;Uid=sa;Pwd=Password1;AutoEnlist=false;", - "NEventStore.PostgreSql=Server=127.0.0.1;Port=$postgreSqlPort;Database=NEventStore;Uid=sa;Pwd=Password1;Enlist=false;", - "NEventStore.Oracle=Data Source=127.0.0.1:$oraclePort/XE;User Id=system;Password=Password1;Persist Security Info=True;" + "NEventStore.MsSql=Server=127.0.0.1,$sqlServerPort;Database=NEventStore;User Id=sa;Password=$databasePassword;TrustServerCertificate=True;", + "NEventStore.MySql=Server=127.0.0.1;Port=$mySqlPort;Database=NEventStore;Uid=sa;Pwd=$databasePassword;AutoEnlist=false;", + "NEventStore.PostgreSql=Server=127.0.0.1;Port=$postgreSqlPort;Database=NEventStore;Uid=sa;Pwd=$databasePassword;Enlist=false;", + "NEventStore.Oracle=Data Source=127.0.0.1:$oraclePort/XE;User Id=system;Password=$databasePassword;Persist Security Info=True;" ) - -$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false) -[System.IO.File]::WriteAllText($envFile, ($connectionStrings -join [Environment]::NewLine) + [Environment]::NewLine, $utf8WithoutBom) - -Write-Host +[System.IO.File]::WriteAllText($envFile, ($connectionStrings -join [Environment]::NewLine) + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) Write-Host "Environment: $normalizedEnvironmentName" Write-Host "Compose project: $projectName" Write-Host "Connection strings written to: $envFile" -Write-Host -$connectionStrings | Select-Object -Skip 1 | ForEach-Object { Write-Host $_ } From 3a66d58ef86d3c802283e6159d16233e0ab50cad Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:56:55 +0200 Subject: [PATCH 22/26] Reserve the CI environment in Bash shutdown --- docker/stop-environment.sh | 47 ++++++++------------------------------ 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/docker/stop-environment.sh b/docker/stop-environment.sh index f47e3df..6f7ec80 100644 --- a/docker/stop-environment.sh +++ b/docker/stop-environment.sh @@ -11,66 +11,37 @@ raw_environment_name="" normalize_environment_name() { local value="$1" local normalized - - normalized="$(printf '%s' "$value" \ - | tr '[:upper:]' '[:lower:]' \ - | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" - + normalized="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" if [[ -z "$normalized" ]]; then echo "Environment name '$value' does not contain any supported characters." >&2 return 1 fi - normalized="${normalized:0:$maximum_environment_name_length}" printf '%s' "${normalized%-}" } for argument in "$@"; do case "$argument" in - --remove-data) - remove_data=true - ;; - -*) - echo "Unknown option: $argument" >&2 - exit 2 - ;; + --remove-data) remove_data=true ;; + -*) echo "Unknown option: $argument" >&2; exit 2 ;; *) - if [[ -n "$raw_environment_name" ]]; then - echo "Only one environment name may be provided." >&2 - exit 2 - fi + if [[ -n "$raw_environment_name" ]]; then echo "Only one environment name may be provided." >&2; exit 2; fi raw_environment_name="$argument" ;; esac done -if [[ -z "$raw_environment_name" ]]; then - raw_environment_name="$(basename "$repository_root")" -fi - +if [[ -z "$raw_environment_name" ]]; then raw_environment_name="$(basename "$repository_root")"; fi environment_name="$(normalize_environment_name "$raw_environment_name")" - case "$environment_name" in - debug|test) - port_mode="$environment_name" - ;; - *) - port_mode="dynamic" - ;; + debug|test|ci) port_mode="$environment_name" ;; + *) port_mode="dynamic" ;; esac project_name="$project_prefix-$environment_name" -compose_command=( - docker compose - --project-name "$project_name" - --file "$script_dir/docker-compose.yml" - --file "$script_dir/docker-compose.$port_mode.yml" -) - +compose_command=(docker compose --project-name "$project_name" --file "$script_dir/docker-compose.yml" --file "$script_dir/docker-compose.$port_mode.yml") down_arguments=(down) -if [[ "$remove_data" == true ]]; then - down_arguments+=(--volumes) -fi +if [[ "$remove_data" == true ]]; then down_arguments+=(--volumes); fi echo "Stopping '$project_name'..." "${compose_command[@]}" "${down_arguments[@]}" From 8b6cc74d1802733080e49e58235eb5d86114471b Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:57:06 +0200 Subject: [PATCH 23/26] Reserve the CI environment in PowerShell shutdown --- docker/stop-environment.ps1 | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/docker/stop-environment.ps1 b/docker/stop-environment.ps1 index 8ff62eb..e0efd55 100644 --- a/docker/stop-environment.ps1 +++ b/docker/stop-environment.ps1 @@ -10,19 +10,9 @@ $environmentName = $null function Normalize-EnvironmentName { param([Parameter(Mandatory)][string] $Value) - - $normalized = $Value.ToLowerInvariant() - $normalized = [Regex]::Replace($normalized, '[^a-z0-9]+', '-') - $normalized = $normalized.Trim('-') - - if ([string]::IsNullOrWhiteSpace($normalized)) { - throw "Environment name '$Value' does not contain any supported characters." - } - - if ($normalized.Length -gt $maximumEnvironmentNameLength) { - $normalized = $normalized.Substring(0, $maximumEnvironmentNameLength).TrimEnd('-') - } - + $normalized = [Regex]::Replace($Value.ToLowerInvariant(), '[^a-z0-9]+', '-').Trim('-') + if ([string]::IsNullOrWhiteSpace($normalized)) { throw "Environment name '$Value' does not contain any supported characters." } + if ($normalized.Length -gt $maximumEnvironmentNameLength) { $normalized = $normalized.Substring(0, $maximumEnvironmentNameLength).TrimEnd('-') } return $normalized } @@ -38,25 +28,13 @@ foreach ($argument in $args) { } } -if ([string]::IsNullOrWhiteSpace($environmentName)) { - $environmentName = Split-Path -Leaf $repositoryRoot -} - +if ([string]::IsNullOrWhiteSpace($environmentName)) { $environmentName = Split-Path -Leaf $repositoryRoot } $normalizedEnvironmentName = Normalize-EnvironmentName $environmentName -$portMode = if ($normalizedEnvironmentName -in @('debug', 'test')) { $normalizedEnvironmentName } else { 'dynamic' } +$portMode = if ($normalizedEnvironmentName -in @('debug', 'test', 'ci')) { $normalizedEnvironmentName } else { 'dynamic' } $projectName = "$projectPrefix-$normalizedEnvironmentName" -$composeArguments = @( - 'compose', - '--project-name', $projectName, - '--file', (Join-Path $scriptDirectory 'docker-compose.yml'), - '--file', (Join-Path $scriptDirectory "docker-compose.$portMode.yml"), - 'down' -) - +$composeArguments = @('compose', '--project-name', $projectName, '--file', (Join-Path $scriptDirectory 'docker-compose.yml'), '--file', (Join-Path $scriptDirectory "docker-compose.$portMode.yml"), 'down') if ($removeData) { $composeArguments += '--volumes' } Write-Host "Stopping '$projectName'..." & docker @composeArguments -if ($LASTEXITCODE -ne 0) { - throw "Docker Compose failed with exit code $LASTEXITCODE." -} +if ($LASTEXITCODE -ne 0) { throw "Docker Compose failed with exit code $LASTEXITCODE." } From cabf6e0fbb55562a11df917c5a2a9034b495da48 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:57:23 +0200 Subject: [PATCH 24/26] Run GitHub Actions through the CI environment --- .github/workflows/ci.yml | 121 ++++----------------------------------- 1 file changed, 10 insertions(+), 111 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30d4290..a690b45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,6 @@ concurrency: cancel-in-progress: true jobs: - # Keep the Windows build separate so we still validate the .NET Framework target - # and the packaging prerequisites that only exist on Windows runners. build: name: Build (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -57,20 +55,14 @@ jobs: run: dotnet restore ./src/NEventStore.Persistence.Sql.Core.sln --verbosity m - name: Run GitVersion and patch assembly info - id: gitversion shell: pwsh - working-directory: ${{ github.workspace }} run: | - $gitVersion = dotnet tool run dotnet-gitversion /targetpath "${{ github.workspace }}" /output json /updateAssemblyInfo | ConvertFrom-Json + dotnet tool run dotnet-gitversion /targetpath "${{ github.workspace }}" /output json /updateAssemblyInfo | Out-Null dotnet tool run dotnet-gitversion /targetpath "${{ github.workspace }}/dependencies/NEventStore" /updateAssemblyInfo | Out-Null - "semver=$($gitVersion.SemVer)" >> $env:GITHUB_OUTPUT - name: Build solution run: dotnet build ./src/NEventStore.Persistence.Sql.Core.sln -c Release --no-restore /p:ContinuousIntegrationBuild=True - # Tests run on Linux with database services (PostgreSQL, MySQL, SQLite). - # Each database provider runs independently to isolate failures and validate - # database-specific persistence logic. test-sql-databases: needs: build name: Test (Linux, ${{ matrix.tfm }}) @@ -78,77 +70,7 @@ jobs: strategy: fail-fast: false matrix: - # Run each modern TFM independently so failures are isolated and easy to read. - tfm: - - net8.0 - - net9.0 - - net10.0 - - services: - sqlexpress: - image: mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04 - env: - SA_PASSWORD: Password12! - ACCEPT_EULA: Y - MSSQL_PID: Express - options: >- - --health-cmd "/opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P 'Password12!' -Q 'SELECT 1'" - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 1433:1433 - - postgresql: - image: postgres:15-alpine - env: - POSTGRES_DB: NEventStore - POSTGRES_USER: postgres - POSTGRES_PASSWORD: Password12! - options: >- - --health-cmd pg_isready - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 5432:5432 - - mysql: - image: mysql:8 - env: - MYSQL_DATABASE: NEventStore - MYSQL_ROOT_PASSWORD: Password12! - options: >- - --health-cmd "mysqladmin ping -h localhost" - --health-interval 20s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 3306:3306 - - oracle: - image: gvenzl/oracle-xe - env: - ORACLE_ALLOW_REMOTE: true - ORACLE_PASSWORD: Password12! - options: >- - --health-cmd "sqlplus -L -S / as sysdba @/dev/null" - --health-interval 25s - --health-timeout 10s - --health-retries 10 - --health-start-period 30s - ports: - - 1521:1521 - - env: - NEventStore.MsSql: Server=localhost;Database=NEventStore;User Id=SA;Password=Password12!;TrustServerCertificate=True; - NEventStore.PostgreSql: Server=localhost;Database=NEventStore;Uid=postgres;Pwd=Password12!;Enlist=false; - NEventStore.MySql: Server=localhost;Database=NEventStore;Uid=root;Pwd=Password12!;AutoEnlist=false; - NEventStore.Sqlite: Data Source=:memory:;Cache=Shared; - NEventStore.Oracle: Data Source=localhost:1521/XE;User Id=system;Password=Password12!;Persist Security Info=True; + tfm: [net8.0, net9.0, net10.0] steps: - name: Checkout @@ -174,46 +96,23 @@ jobs: restore-keys: | nuget-${{ runner.os }}- - - name: Create NEventStore database once SQL Server is healthy + - name: Start CI database environment shell: bash - run: | - set -euo pipefail - - # Find the SQL Server service container by ancestor image (more reliable in GH Actions) - container_id="$(/usr/bin/docker ps --filter "ancestor=mcr.microsoft.com/mssql/server:2022-RTM-GDR1-ubuntu-20.04" --format "{{.ID}}" | head -n 1)" - if [ -z "$container_id" ]; then - echo "SQL Server service container not found." - /usr/bin/docker ps --format "{{.ID}} {{.Image}} {{.Names}}" || true - exit 1 - fi - - # Wait until container reports healthy - for i in $(seq 1 60); do - health_status="$(/usr/bin/docker inspect --format='{{.State.Health.Status}}' "$container_id" 2>/dev/null || echo starting)" - if [ "$health_status" = "healthy" ]; then - echo "SQL Server container healthy" - break - fi - echo "Waiting for SQL Server to be healthy: $health_status ($i/60)" - sleep 2 - done - - if [ "$health_status" != "healthy" ]; then - echo "SQL Server container is not healthy. Logs follow (last 200 lines):" - /usr/bin/docker logs "$container_id" --tail 200 || true - exit 1 - fi - - # Create database using sqlcmd inside the container - /usr/bin/docker exec "$container_id" /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P "Password12!" -Q "IF DB_ID(N'NEventStore') IS NULL CREATE DATABASE [NEventStore];" + run: ./docker/start-environment.sh ci - name: Run tests for ${{ matrix.tfm }} run: dotnet test ./src/NEventStore.Persistence.Sql.Core.sln -c Release -f ${{ matrix.tfm }} --logger "trx;LogFileName=test-results-${{ matrix.tfm }}.trx" - name: Upload test results + if: always() uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.tfm }} path: "**/test-results-${{ matrix.tfm }}.trx" if-no-files-found: ignore retention-days: 14 + + - name: Stop CI database environment + if: always() + shell: bash + run: ./docker/stop-environment.sh ci --remove-data From bda9e00e055895e9aa6f573385b8ee4ffb997fb2 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:57:38 +0200 Subject: [PATCH 25/26] Invoke CI environment scripts through Bash --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a690b45..d507810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: - name: Start CI database environment shell: bash - run: ./docker/start-environment.sh ci + run: bash ./docker/start-environment.sh ci - name: Run tests for ${{ matrix.tfm }} run: dotnet test ./src/NEventStore.Persistence.Sql.Core.sln -c Release -f ${{ matrix.tfm }} --logger "trx;LogFileName=test-results-${{ matrix.tfm }}.trx" @@ -115,4 +115,4 @@ jobs: - name: Stop CI database environment if: always() shell: bash - run: ./docker/stop-environment.sh ci --remove-data + run: bash ./docker/stop-environment.sh ci --remove-data From 36f95fa88486f79cb0ad7e8d58ebb5001b85b5e5 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Sat, 11 Jul 2026 12:57:51 +0200 Subject: [PATCH 26/26] Document the reserved CI environment --- docker/Readme.md | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/docker/Readme.md b/docker/Readme.md index 6dac83e..68a7577 100644 --- a/docker/Readme.md +++ b/docker/Readme.md @@ -19,7 +19,7 @@ Run the platform-specific script from any directory: ``` ```bash -./docker/start-environment.sh +bash ./docker/start-environment.sh ``` Without an argument, the environment name is derived from the worktree directory. The name is normalized and dynamic host ports are assigned automatically. @@ -27,22 +27,23 @@ Without an argument, the environment name is derived from the worktree directory An explicit dynamic environment can be started with: ```bash -./docker/start-environment.sh issue-142 +bash ./docker/start-environment.sh issue-142 ``` -Two reserved environment names use predictable ports: +Three reserved environment names use predictable ports: ```bash -./docker/start-environment.sh debug -./docker/start-environment.sh test +bash ./docker/start-environment.sh debug +bash ./docker/start-environment.sh test +bash ./docker/start-environment.sh ci ``` -| Database | Debug | Test | -| --- | ---: | ---: | -| SQL Server | 50001 | 51001 | -| MySQL | 50003 | 51003 | -| PostgreSQL | 50004 | 51004 | -| Oracle | 50005 | 51005 | +| Database | Debug | Test | CI | +| --- | ---: | ---: | ---: | +| SQL Server | 50001 | 51001 | 52001 | +| MySQL | 50003 | 51003 | 52003 | +| PostgreSQL | 50004 | 51004 | 52004 | +| Oracle | 50005 | 51005 | 52005 | The start script waits for the services to become healthy, creates the SQL Server `NEventStore` database when necessary, retrieves the assigned ports, and writes the resulting connection strings to the repository-root `.env` file. @@ -53,13 +54,13 @@ The generated `.env` file is excluded from Git. Test projects load it automatica Data is preserved by default: ```bash -./docker/stop-environment.sh issue-142 +bash ./docker/stop-environment.sh issue-142 ``` Remove only the selected environment's named volumes with: ```bash -./docker/stop-environment.sh issue-142 --remove-data +bash ./docker/stop-environment.sh issue-142 --remove-data ``` The PowerShell scripts accept the same environment names and behavior: @@ -68,17 +69,25 @@ The PowerShell scripts accept the same environment names and behavior: ./docker/stop-environment.ps1 issue-142 --remove-data ``` -## Agent usage +## Agent and CI usage Agents should normally use a dynamic environment derived from their worktree or task: ```bash -./docker/start-environment.sh issue-142 +bash ./docker/start-environment.sh issue-142 dotnet test ./src/NEventStore.Persistence.Sql.Core.sln -./docker/stop-environment.sh issue-142 +bash ./docker/stop-environment.sh issue-142 ``` -Agents should not use `debug` or `test` unless explicitly instructed because those names reserve predictable ports for human workflows. +Agents should not use `debug`, `test`, or `ci` unless explicitly instructed. `debug` and `test` reserve predictable ports for human workflows; `ci` is reserved for GitHub Actions. + +GitHub Actions starts the same stack through: + +```bash +bash ./docker/start-environment.sh ci +``` + +Each test-matrix job runs on its own hosted runner, so the fixed CI ports do not collide between jobs. The workflow stops the stack with `--remove-data` in an `always()` cleanup step. ## Compose files @@ -86,5 +95,6 @@ Agents should not use `debug` or `test` unless explicitly instructed because tho - `docker-compose.dynamic.yml` publishes each service on an automatically assigned local port. - `docker-compose.debug.yml` publishes the fixed debug ports. - `docker-compose.test.yml` publishes the fixed test ports. +- `docker-compose.ci.yml` publishes the fixed GitHub Actions CI ports. The Compose files intentionally do not define `container_name`; Compose derives resource names from the project name.