diff --git a/Directory.Packages.props b/Directory.Packages.props
index ded4255..3d5dabf 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,6 +4,8 @@
false
+
+
diff --git a/IntelliTect.Multitool.Tests/CIDetectionTests.cs b/IntelliTect.Multitool.Tests/CIDetectionTests.cs
new file mode 100644
index 0000000..42b1e09
--- /dev/null
+++ b/IntelliTect.Multitool.Tests/CIDetectionTests.cs
@@ -0,0 +1,101 @@
+using System.IO;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+using System.Xml;
+using Microsoft.Build.Construction;
+using Microsoft.Build.Evaluation;
+using Xunit;
+
+namespace IntelliTect.Multitool.Tests;
+
+[Collection(MSBuildCollection.CollectionName)]
+public class CIDetectionTests
+{
+ private static readonly string TargetsPath = Path.Combine(
+ RepositoryPaths.GetDefaultRepoRoot(),
+ "IntelliTect.Multitool", "Build", "IntelliTect.Multitool.targets");
+
+ // Derived from the targets file itself — automatically stays in sync when new CI variables are added.
+ private static readonly Lazy> _ciVarNames = new(() =>
+ {
+ var doc = XDocument.Load(TargetsPath);
+ var conditions = doc.Descendants()
+ .Where(e => e.Name.LocalName == "CI" && e.Attribute("Condition") != null)
+ .Select(e => e.Attribute("Condition")!.Value);
+ var names = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (string condition in conditions)
+ foreach (Match m in Regex.Matches(condition, @"\$\((\w+)\)"))
+ names.Add(m.Groups[1].Value);
+ return names;
+ });
+
+ [Theory]
+ [InlineData("GITHUB_ACTIONS", "true")]
+ [InlineData("GITLAB_CI", "true")]
+ [InlineData("CIRCLECI", "true")]
+ [InlineData("CONTINUOUS_INTEGRATION", "true")]
+ [InlineData("TF_BUILD", "true")]
+ [InlineData("TEAMCITY_VERSION", "1.0")]
+ [InlineData("APPVEYOR", "True")]
+ [InlineData("BuildRunner", "MyGet")]
+ [InlineData("JENKINS_URL", "http://jenkins")]
+ [InlineData("TRAVIS", "true")]
+ [InlineData("BUDDY", "true")]
+ [InlineData("CODEBUILD_CI", "true")]
+ public void CiEnvVar_SetsCIPropertyToTrue(string envVar, string value)
+ {
+ string ci = EvaluateCIProperty(new Dictionary { [envVar] = value });
+ Assert.Equal("true", ci);
+ }
+
+ [Fact]
+ public void NoCiEnvVars_SetsCIPropertyToFalse()
+ {
+ string ci = EvaluateCIProperty([]);
+ Assert.Equal("false", ci);
+ }
+
+ [Fact]
+ public void CiAlreadyTrue_IsNotOverridden()
+ {
+ string ci = EvaluateCIProperty(new Dictionary { ["CI"] = "true" });
+ Assert.Equal("true", ci);
+ }
+
+ private static string EvaluateCIProperty(Dictionary overrides)
+ {
+ // Clear all CI-related variables parsed from the targets file so that any env vars
+ // set on the host runner (e.g. GITHUB_ACTIONS=true on GitHub Actions) don't leak in.
+ // Global properties override process env vars in MSBuild evaluation.
+ var globalProperties = _ciVarNames.Value
+ .ToDictionary(v => v, _ => "", StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in overrides)
+ globalProperties[key] = value;
+
+ // CI itself is not in _ciVarNames (it's the output property, not an input condition
+ // variable), but GitHub Actions sets CI=true in the OS environment. If it leaks in,
+ // the outer guard short-circuits and the
+ // entire detection block is skipped. Temporarily remove it from the process environment
+ // so MSBuild doesn't see it, unless the caller is explicitly testing the CI=true case.
+ string? savedCI = Environment.GetEnvironmentVariable("CI");
+ if (!overrides.ContainsKey("CI"))
+ Environment.SetEnvironmentVariable("CI", null);
+ try
+ {
+ using var collection = new ProjectCollection(globalProperties);
+ string xml = $"""
+
+
+
+ """;
+ using var reader = XmlReader.Create(new StringReader(xml));
+ ProjectRootElement rootElement = ProjectRootElement.Create(reader, collection);
+ var project = new Project(rootElement, null, null, collection);
+ return project.GetPropertyValue("CI");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("CI", savedCI);
+ }
+ }
+}
diff --git a/IntelliTect.Multitool.Tests/IntelliTect.Multitool.Tests.csproj b/IntelliTect.Multitool.Tests/IntelliTect.Multitool.Tests.csproj
index a734b6f..6474654 100644
--- a/IntelliTect.Multitool.Tests/IntelliTect.Multitool.Tests.csproj
+++ b/IntelliTect.Multitool.Tests/IntelliTect.Multitool.Tests.csproj
@@ -13,6 +13,8 @@
+
+
diff --git a/IntelliTect.Multitool.Tests/MSBuildFixture.cs b/IntelliTect.Multitool.Tests/MSBuildFixture.cs
new file mode 100644
index 0000000..08140a6
--- /dev/null
+++ b/IntelliTect.Multitool.Tests/MSBuildFixture.cs
@@ -0,0 +1,21 @@
+using Microsoft.Build.Locator;
+using Xunit;
+
+namespace IntelliTect.Multitool.Tests;
+
+[CollectionDefinition(CollectionName)]
+public class MSBuildCollection : ICollectionFixture
+{
+ public const string CollectionName = "MSBuild";
+}
+
+public class MSBuildFixture : IDisposable
+{
+ public MSBuildFixture()
+ {
+ if (!MSBuildLocator.IsRegistered)
+ MSBuildLocator.RegisterDefaults();
+ }
+
+ public void Dispose() { }
+}