From 49e249f498e952faec6160798b46c38f2ab0567b Mon Sep 17 00:00:00 2001
From: Michael Grosse Huelsewiesche <mihuelsewiesche@twilio.com>
Date: Thu, 7 May 2026 17:12:31 -0400
Subject: [PATCH 1/3] Add e2e-cli for E2E testing

---
 e2e-cli/Program.cs      | 263 ++++++++++++++++++++++++++++++++++++++++
 e2e-cli/README.md       | 116 ++++++++++++++++++
 e2e-cli/e2e-cli.csproj  |  16 +++
 e2e-cli/e2e-config.json |   7 ++
 e2e-cli/run-e2e.sh      |  34 ++++++
 5 files changed, 436 insertions(+)
 create mode 100644 e2e-cli/Program.cs
 create mode 100644 e2e-cli/README.md
 create mode 100644 e2e-cli/e2e-cli.csproj
 create mode 100644 e2e-cli/e2e-config.json
 create mode 100755 e2e-cli/run-e2e.sh

diff --git a/e2e-cli/Program.cs b/e2e-cli/Program.cs
new file mode 100644
index 0000000..a09cd45
--- /dev/null
+++ b/e2e-cli/Program.cs
@@ -0,0 +1,263 @@
+// e2e-cli: End-to-end test CLI for Analytics-CSharp
+// Reads --input <json> from args, sends events via the SDK, outputs JSON result to stdout.
+// Debug/info logs go to stderr.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using Segment.Analytics;
+using Segment.Analytics.Utilities;
+using Segment.Serialization;
+using JsonUtility = Segment.Serialization.JsonUtility;
+
+// ── Argument parsing ────────────────────────────────────────────────────────
+string? inputJson = null;
+for (int i = 0; i < args.Length - 1; i++)
+{
+    if (args[i] == "--input")
+    {
+        inputJson = args[i + 1];
+        break;
+    }
+}
+
+if (inputJson == null)
+{
+    Console.Error.WriteLine("[e2e-cli] ERROR: --input <json> argument is required");
+    Console.WriteLine("{\"success\":false,\"error\":\"--input argument is required\"}");
+    Environment.Exit(1);
+}
+
+// ── Parse the input JSON ─────────────────────────────────────────────────────
+JsonDocument doc;
+try
+{
+    doc = JsonDocument.Parse(inputJson);
+}
+catch (Exception ex)
+{
+    Console.Error.WriteLine($"[e2e-cli] ERROR: Failed to parse --input JSON: {ex.Message}");
+    Console.WriteLine($"{{\"success\":false,\"error\":\"Failed to parse input JSON: {Escape(ex.Message)}\"}}");
+    Environment.Exit(1);
+    return; // unreachable, satisfies compiler
+}
+
+JsonElement root = doc.RootElement;
+
+string writeKey = root.GetProperty("writeKey").GetString()
+    ?? throw new InvalidOperationException("writeKey is required");
+
+string? apiHost = root.TryGetProperty("apiHost", out var apiHostEl) ? apiHostEl.GetString() : null;
+
+// config block (optional)
+int flushAt = 15;
+int flushInterval = 10; // seconds
+if (root.TryGetProperty("config", out var configEl))
+{
+    if (configEl.TryGetProperty("flushAt", out var fa)) flushAt = fa.GetInt32();
+    if (configEl.TryGetProperty("flushInterval", out var fi))
+    {
+        // input is in ms; SDK expects seconds
+        int fiMs = fi.GetInt32();
+        flushInterval = Math.Max(1, fiMs / 1000);
+    }
+}
+
+// ── Error handler ────────────────────────────────────────────────────────────
+var errors = new List<string>();
+var errorHandler = new CapturingErrorHandler(errors);
+
+// ── Build configuration ──────────────────────────────────────────────────────
+var configBuilder = new Configuration(
+    writeKey,
+    flushAt: flushAt,
+    flushInterval: flushInterval,
+    analyticsErrorHandler: errorHandler,
+    storageProvider: new InMemoryStorageProvider(),
+    apiHost: apiHost
+);
+
+Console.Error.WriteLine($"[e2e-cli] Initialising analytics (writeKey={writeKey[..Math.Min(8, writeKey.Length)]}…, apiHost={apiHost ?? "default"})");
+
+var analytics = new Analytics(configBuilder);
+
+// ── Process sequences ────────────────────────────────────────────────────────
+int totalEvents = 0;
+
+if (root.TryGetProperty("sequences", out var sequencesEl))
+{
+    foreach (var sequence in sequencesEl.EnumerateArray())
+    {
+        int delayMs = sequence.TryGetProperty("delayMs", out var delayEl) ? delayEl.GetInt32() : 0;
+        if (delayMs > 0)
+        {
+            Console.Error.WriteLine($"[e2e-cli] Waiting {delayMs}ms before next sequence");
+            Thread.Sleep(delayMs);
+        }
+
+        if (!sequence.TryGetProperty("events", out var eventsEl)) continue;
+
+        foreach (var ev in eventsEl.EnumerateArray())
+        {
+            string eventType = ev.GetProperty("type").GetString()?.ToLowerInvariant() ?? "";
+            string? userId = ev.TryGetProperty("userId", out var uidEl) ? uidEl.GetString() : null;
+
+            Console.Error.WriteLine($"[e2e-cli] Sending event type={eventType} userId={userId ?? "(none)"}");
+
+            switch (eventType)
+            {
+                case "identify":
+                {
+                    JsonObject? traits = GetJsonObject(ev, "traits");
+                    if (userId != null)
+                        analytics.Identify(userId, traits);
+                    else
+                        analytics.Identify(traits ?? new JsonObject());
+                    break;
+                }
+
+                case "track":
+                {
+                    // Set userId state first if provided
+                    if (userId != null && analytics.UserId() != userId)
+                        analytics.Identify(userId);
+
+                    string eventName = ev.TryGetProperty("event", out var enEl)
+                        ? enEl.GetString() ?? "Unknown"
+                        : "Unknown";
+                    JsonObject? properties = GetJsonObject(ev, "properties");
+                    analytics.Track(eventName, properties);
+                    break;
+                }
+
+                case "page":
+                {
+                    if (userId != null && analytics.UserId() != userId)
+                        analytics.Identify(userId);
+
+                    string title = ev.TryGetProperty("name", out var nameEl)
+                        ? nameEl.GetString() ?? ""
+                        : "";
+                    string category = ev.TryGetProperty("category", out var catEl)
+                        ? catEl.GetString() ?? ""
+                        : "";
+                    JsonObject? properties = GetJsonObject(ev, "properties");
+                    analytics.Page(title, properties, category);
+                    break;
+                }
+
+                case "screen":
+                {
+                    if (userId != null && analytics.UserId() != userId)
+                        analytics.Identify(userId);
+
+                    string title = ev.TryGetProperty("name", out var nameEl)
+                        ? nameEl.GetString() ?? ""
+                        : "";
+                    string category = ev.TryGetProperty("category", out var catEl)
+                        ? catEl.GetString() ?? ""
+                        : "";
+                    JsonObject? properties = GetJsonObject(ev, "properties");
+                    analytics.Screen(title, properties, category);
+                    break;
+                }
+
+                case "alias":
+                {
+                    // For alias: previousId becomes the current userId state, newId is the alias target.
+                    // The SDK Alias(newId) uses _userInfo._userId as previousId.
+                    string? previousId = ev.TryGetProperty("previousId", out var prevEl)
+                        ? prevEl.GetString()
+                        : null;
+                    string newId = userId ?? (ev.TryGetProperty("newId", out var newIdEl)
+                        ? newIdEl.GetString() ?? ""
+                        : "");
+
+                    if (previousId != null && analytics.UserId() != previousId)
+                        analytics.Identify(previousId);
+
+                    analytics.Alias(newId);
+                    break;
+                }
+
+                case "group":
+                {
+                    if (userId != null && analytics.UserId() != userId)
+                        analytics.Identify(userId);
+
+                    string groupId = ev.TryGetProperty("groupId", out var gidEl)
+                        ? gidEl.GetString() ?? ""
+                        : "";
+                    JsonObject? traits = GetJsonObject(ev, "traits");
+                    analytics.Group(groupId, traits);
+                    break;
+                }
+
+                default:
+                    Console.Error.WriteLine($"[e2e-cli] WARNING: Unknown event type '{eventType}', skipping");
+                    continue;
+            }
+
+            totalEvents++;
+        }
+    }
+}
+
+Console.Error.WriteLine($"[e2e-cli] Flushing {totalEvents} event(s)…");
+analytics.Flush();
+
+// Give the async pipeline time to upload
+Thread.Sleep(5000);
+
+// ── Output result ─────────────────────────────────────────────────────────────
+bool success = errors.Count == 0;
+if (success)
+{
+    Console.WriteLine($"{{\"success\":true,\"sentBatches\":1}}");
+    Environment.Exit(0);
+}
+else
+{
+    string combinedErrors = string.Join("; ", errors);
+    Console.WriteLine($"{{\"success\":false,\"sentBatches\":0,\"error\":\"{Escape(combinedErrors)}\"}}");
+    Environment.Exit(1);
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+static JsonObject? GetJsonObject(JsonElement parent, string key)
+{
+    if (!parent.TryGetProperty(key, out var el) || el.ValueKind == JsonValueKind.Null)
+        return null;
+
+    // Serialise the JsonElement back to a JSON string, then parse with Segment's JsonUtility
+    string json = el.GetRawText();
+    try
+    {
+        return JsonUtility.FromJson<JsonObject>(json);
+    }
+    catch
+    {
+        return null;
+    }
+}
+
+static string Escape(string s) =>
+    s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "");
+
+// ── Error handler implementation ──────────────────────────────────────────────
+
+class CapturingErrorHandler : IAnalyticsErrorHandler
+{
+    private readonly List<string> _errors;
+
+    public CapturingErrorHandler(List<string> errors) => _errors = errors;
+
+    public void OnExceptionThrown(Exception e)
+    {
+        string msg = e.Message;
+        Console.Error.WriteLine($"[e2e-cli] SDK ERROR: {msg}");
+        _errors.Add(msg);
+    }
+}
diff --git a/e2e-cli/README.md b/e2e-cli/README.md
new file mode 100644
index 0000000..77f6fe6
--- /dev/null
+++ b/e2e-cli/README.md
@@ -0,0 +1,116 @@
+# Analytics-CSharp e2e-cli
+
+A small CLI tool used by the [sdk-e2e-tests](https://github.com/segmentio/sdk-e2e-tests) framework to run end-to-end tests against the Analytics-CSharp SDK.
+
+## Prerequisites
+
+- .NET 6 SDK or later
+- Node.js 18+ (only needed when running the full test suite via `run-e2e.sh`)
+
+## Build
+
+```bash
+cd e2e-cli
+dotnet build -c Release -o build
+```
+
+## Usage
+
+```bash
+dotnet build/e2e-cli.dll --input '<json>'
+```
+
+The CLI reads a single `--input` argument containing a JSON string that describes the SDK configuration and event sequences to send.
+
+### Input JSON format
+
+```json
+{
+  "writeKey": "YOUR_WRITE_KEY",
+  "apiHost": "https://api.segment.io/v1",
+  "sequences": [
+    {
+      "delayMs": 0,
+      "events": [
+        {"type": "identify", "userId": "user-1", "traits": {"name": "Alice"}},
+        {"type": "track",    "userId": "user-1", "event": "Button Clicked", "properties": {"button": "signup"}},
+        {"type": "page",     "userId": "user-1", "name": "Home", "category": "Nav"},
+        {"type": "screen",   "userId": "user-1", "name": "Main"},
+        {"type": "alias",    "userId": "new-id", "previousId": "old-id"},
+        {"type": "group",    "userId": "user-1", "groupId": "group-1", "traits": {"plan": "pro"}}
+      ]
+    }
+  ],
+  "config": {
+    "flushAt": 15,
+    "flushInterval": 1000,
+    "maxRetries": 3,
+    "timeout": 10
+  }
+}
+```
+
+#### Top-level fields
+
+| Field      | Type   | Required | Description |
+|------------|--------|----------|-------------|
+| `writeKey` | string | yes      | Segment source write key |
+| `apiHost`  | string | no       | Override the Segment API host (e.g. a local proxy) |
+| `sequences`| array  | yes      | Ordered list of event sequences |
+| `config`   | object | no       | SDK tuning parameters (see below) |
+
+#### `config` fields
+
+| Field           | Type | Default | Description |
+|-----------------|------|---------|-------------|
+| `flushAt`       | int  | 15      | Flush after this many events |
+| `flushInterval` | int  | 10000   | Flush interval in **milliseconds** |
+| `maxRetries`    | int  | 3       | (informational, not yet wired to SDK) |
+| `timeout`       | int  | 10      | (informational) |
+
+#### Event fields
+
+All events share a `type` field. Additional fields per type:
+
+| Type       | Required fields          | Optional fields |
+|------------|--------------------------|-----------------|
+| `identify` | `userId`                 | `traits` (object) |
+| `track`    | `userId`, `event`        | `properties` (object) |
+| `page`     | `userId`, `name`         | `category`, `properties` |
+| `screen`   | `userId`, `name`         | `category`, `properties` |
+| `alias`    | `userId` (new id), `previousId` | — |
+| `group`    | `userId`, `groupId`      | `traits` (object) |
+
+### Output JSON format
+
+Written to **stdout** on the last line:
+
+```json
+{"success": true, "sentBatches": 1}
+```
+
+On failure:
+
+```json
+{"success": false, "sentBatches": 0, "error": "description of the error"}
+```
+
+The process exits with code `0` on success and `1` on failure.
+
+Debug information is written to **stderr**.
+
+## Running the full E2E test suite
+
+```bash
+# Clone the test framework next to this repo
+git clone https://github.com/segmentio/sdk-e2e-tests ../sdk-e2e-tests
+
+# Run all suites defined in e2e-config.json
+./e2e-cli/run-e2e.sh
+
+# Pass extra arguments to run-tests.sh
+./e2e-cli/run-e2e.sh --suite basic
+
+# Use a custom test-framework location
+E2E_TESTS_DIR=/path/to/sdk-e2e-tests ./e2e-cli/run-e2e.sh
+```
diff --git a/e2e-cli/e2e-cli.csproj b/e2e-cli/e2e-cli.csproj
new file mode 100644
index 0000000..930b1f4
--- /dev/null
+++ b/e2e-cli/e2e-cli.csproj
@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net6.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <AssemblyName>e2e-cli</AssemblyName>
+    <RootNamespace>E2eCli</RootNamespace>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="../Analytics-CSharp/Analytics-CSharp.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json
new file mode 100644
index 0000000..47dd750
--- /dev/null
+++ b/e2e-cli/e2e-config.json
@@ -0,0 +1,7 @@
+{
+  "sdk": "csharp",
+  "test_suites": "basic,retry",
+  "auto_settings": false,
+  "patch": null,
+  "env": {}
+}
diff --git a/e2e-cli/run-e2e.sh b/e2e-cli/run-e2e.sh
new file mode 100755
index 0000000..fbfb64e
--- /dev/null
+++ b/e2e-cli/run-e2e.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+#
+# Run E2E tests for Analytics-CSharp
+#
+# Prerequisites: Node.js 18+ and .NET 6+
+#
+# Usage:
+#   ./run-e2e.sh [extra args passed to run-tests.sh]
+#
+# Override sdk-e2e-tests location:
+#   E2E_TESTS_DIR=../my-e2e-tests ./run-e2e.sh
+#
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SDK_ROOT="$SCRIPT_DIR/.."
+E2E_DIR="${E2E_TESTS_DIR:-$SDK_ROOT/../sdk-e2e-tests}"
+
+echo "=== Building Analytics-CSharp e2e-cli ==="
+echo "Using dotnet: $(dotnet --version)"
+
+# Build the e2e-cli
+cd "$SCRIPT_DIR"
+dotnet build -c Release -o build
+
+echo ""
+
+# Run tests
+cd "$E2E_DIR"
+./scripts/run-tests.sh \
+    --sdk-dir "$SCRIPT_DIR" \
+    --cli "dotnet $SCRIPT_DIR/build/e2e-cli.dll" \
+    "$@"

From bbc1fcf14cef83946689bb39d5be1e267c0d399f Mon Sep 17 00:00:00 2001
From: Michael Grosse Huelsewiesche <mihuelsewiesche@twilio.com>
Date: Fri, 8 May 2026 15:16:24 -0400
Subject: [PATCH 2/3] Fix e2e-cli: target net10.0, pass cdnHost, override
 SegmentURL scheme

---
 e2e-cli/Program.cs     | 57 +++++++++++++++++++++++++++++++++++++++---
 e2e-cli/e2e-cli.csproj |  2 +-
 2 files changed, 55 insertions(+), 4 deletions(-)

diff --git a/e2e-cli/Program.cs b/e2e-cli/Program.cs
index a09cd45..c2f1c31 100644
--- a/e2e-cli/Program.cs
+++ b/e2e-cli/Program.cs
@@ -43,12 +43,13 @@
     return; // unreachable, satisfies compiler
 }
 
-JsonElement root = doc.RootElement;
+System.Text.Json.JsonElement root = doc.RootElement;
 
 string writeKey = root.GetProperty("writeKey").GetString()
     ?? throw new InvalidOperationException("writeKey is required");
 
 string? apiHost = root.TryGetProperty("apiHost", out var apiHostEl) ? apiHostEl.GetString() : null;
+string? cdnHost = root.TryGetProperty("cdnHost", out var cdnHostEl) ? cdnHostEl.GetString() : apiHost;
 
 // config block (optional)
 int flushAt = 15;
@@ -69,13 +70,42 @@
 var errorHandler = new CapturingErrorHandler(errors);
 
 // ── Build configuration ──────────────────────────────────────────────────────
+
+// Determine scheme from apiHost so we can override SegmentURL for http:// targets
+// (the SDK always prepends "https://" by default).
+// Determine scheme and strip it — the SDK prepends scheme via SegmentURL,
+// which we override in PlainHttpClient to respect http:// targets.
+string scheme = "https://";
+string? rawApiHost = apiHost;
+string? rawCdnHost = cdnHost;
+
+if (apiHost != null && apiHost.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
+{
+    scheme = "http://";
+    rawApiHost = apiHost.Substring("http://".Length).TrimEnd('/');
+}
+else if (apiHost != null && apiHost.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+{
+    rawApiHost = apiHost.Substring("https://".Length).TrimEnd('/');
+}
+
+// Strip scheme from cdnHost too (same PlainHttpClient handles it)
+if (cdnHost != null && cdnHost.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
+    rawCdnHost = cdnHost.Substring("http://".Length).TrimEnd('/');
+else if (cdnHost != null && cdnHost.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+    rawCdnHost = cdnHost.Substring("https://".Length).TrimEnd('/');
+
+var httpClientProvider = new PlainHttpClientProvider(scheme);
+
 var configBuilder = new Configuration(
     writeKey,
     flushAt: flushAt,
     flushInterval: flushInterval,
     analyticsErrorHandler: errorHandler,
     storageProvider: new InMemoryStorageProvider(),
-    apiHost: apiHost
+    apiHost: rawApiHost,
+    cdnHost: rawCdnHost,
+    httpClientProvider: httpClientProvider
 );
 
 Console.Error.WriteLine($"[e2e-cli] Initialising analytics (writeKey={writeKey[..Math.Min(8, writeKey.Length)]}…, apiHost={apiHost ?? "default"})");
@@ -226,7 +256,7 @@
 
 // ── Helpers ───────────────────────────────────────────────────────────────────
 
-static JsonObject? GetJsonObject(JsonElement parent, string key)
+static JsonObject? GetJsonObject(System.Text.Json.JsonElement parent, string key)
 {
     if (!parent.TryGetProperty(key, out var el) || el.ValueKind == JsonValueKind.Null)
         return null;
@@ -246,6 +276,27 @@
 static string Escape(string s) =>
     s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "");
 
+// ── HTTP client that respects the scheme (http:// vs https://) ───────────────
+
+class PlainHttpClient : Segment.Analytics.Utilities.DefaultHTTPClient
+{
+    private readonly string _scheme;
+    public PlainHttpClient(string apiKey, string scheme, string? apiHost, string? cdnHost)
+        : base(apiKey, apiHost, cdnHost) => _scheme = scheme;
+
+    public override string SegmentURL(string host, string path) => _scheme + host + path;
+}
+
+class PlainHttpClientProvider : Segment.Analytics.Utilities.IHTTPClientProvider
+{
+    private readonly string _scheme;
+    public PlainHttpClientProvider(string scheme) => _scheme = scheme;
+
+    public Segment.Analytics.Utilities.HTTPClient CreateHTTPClient(
+        string apiKey, string? apiHost = null, string? cdnHost = null)
+        => new PlainHttpClient(apiKey, _scheme, apiHost, cdnHost);
+}
+
 // ── Error handler implementation ──────────────────────────────────────────────
 
 class CapturingErrorHandler : IAnalyticsErrorHandler
diff --git a/e2e-cli/e2e-cli.csproj b/e2e-cli/e2e-cli.csproj
index 930b1f4..8ea4c6e 100644
--- a/e2e-cli/e2e-cli.csproj
+++ b/e2e-cli/e2e-cli.csproj
@@ -2,7 +2,7 @@
 
   <PropertyGroup>
     <OutputType>Exe</OutputType>
-    <TargetFramework>net6.0</TargetFramework>
+    <TargetFramework>net10.0</TargetFramework>
     <Nullable>enable</Nullable>
     <ImplicitUsings>enable</ImplicitUsings>
     <AssemblyName>e2e-cli</AssemblyName>

From 2b856d98341683417d6af7e0b02c114277c507c9 Mon Sep 17 00:00:00 2001
From: Michael Grosse Huelsewiesche <mihuelsewiesche@twilio.com>
Date: Fri, 8 May 2026 15:35:49 -0400
Subject: [PATCH 3/3] Add e2e workflows and restrict test suites to basic

---
 .github/workflows/e2e-tests.yml       | 61 +++++++++++++++++++++++++++
 .github/workflows/publish-e2e-cli.yml | 34 +++++++++++++++
 e2e-cli/e2e-config.json               |  2 +-
 3 files changed, 96 insertions(+), 1 deletion(-)
 create mode 100644 .github/workflows/e2e-tests.yml
 create mode 100644 .github/workflows/publish-e2e-cli.yml

diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
new file mode 100644
index 0000000..a24a172
--- /dev/null
+++ b/.github/workflows/e2e-tests.yml
@@ -0,0 +1,61 @@
+name: E2E Tests
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+  workflow_dispatch:
+    inputs:
+      e2e_tests_ref:
+        description: 'Branch or ref of sdk-e2e-tests to use'
+        required: false
+        default: 'main'
+
+jobs:
+  e2e-tests:
+    if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout SDK
+        uses: actions/checkout@v4
+        with:
+          path: sdk
+
+      - name: Checkout sdk-e2e-tests
+        uses: actions/checkout@v4
+        with:
+          repository: segmentio/sdk-e2e-tests
+          ref: ${{ inputs.e2e_tests_ref || 'main' }}
+          token: ${{ secrets.E2E_TESTS_TOKEN }}
+          path: sdk-e2e-tests
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: '10.0.x'
+
+      - name: Setup Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '20'
+
+      - name: Build e2e-cli
+        working-directory: sdk/e2e-cli
+        run: dotnet build -c Release -o build
+
+      - name: Run E2E tests
+        working-directory: sdk-e2e-tests
+        run: |
+          ./scripts/run-tests.sh \
+            --sdk-dir "${{ github.workspace }}/sdk/e2e-cli" \
+            --cli "dotnet ${{ github.workspace }}/sdk/e2e-cli/build/e2e-cli.dll"
+
+      - name: Upload test results
+        if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: e2e-test-results
+          path: sdk-e2e-tests/test-results/
+          if-no-files-found: ignore
diff --git a/.github/workflows/publish-e2e-cli.yml b/.github/workflows/publish-e2e-cli.yml
new file mode 100644
index 0000000..3581f5d
--- /dev/null
+++ b/.github/workflows/publish-e2e-cli.yml
@@ -0,0 +1,34 @@
+name: Publish E2E CLI
+
+on:
+  push:
+    branches: [main]
+    paths:
+      - 'e2e-cli/**'
+      - 'Analytics-CSharp/**'
+  schedule:
+    - cron: '0 0 1 * *'
+  workflow_dispatch:
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout SDK
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: '10.0.x'
+
+      - name: Build e2e-cli
+        working-directory: e2e-cli
+        run: dotnet build -c Release -o build
+
+      - name: Upload CLI artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: e2e-cli-csharp
+          path: e2e-cli/build/
+          retention-days: 90
diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json
index 47dd750..eea1cca 100644
--- a/e2e-cli/e2e-config.json
+++ b/e2e-cli/e2e-config.json
@@ -1,6 +1,6 @@
 {
   "sdk": "csharp",
-  "test_suites": "basic,retry",
+  "test_suites": "basic",
   "auto_settings": false,
   "patch": null,
   "env": {}
