diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/ArtifactCache.cs b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/ArtifactCache.cs new file mode 100644 index 000000000..7a0eb6379 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/ArtifactCache.cs @@ -0,0 +1,140 @@ +using System.Text; + +namespace StellaOps.Workflow.ArtifactExporter; + +/// +/// Content-addressed cache logic for rendered workflow artifacts. The hash of +/// the canonical definition JSON is embedded as an XML comment at the top of +/// the generated SVG; on subsequent builds the exporter compares that marker +/// against the current entry's hash to decide whether to re-render. +/// +/// +/// Extracted into a public static class so the behavior can be unit-tested. +/// Exporter top-level code calls these helpers; there is no hidden state. +/// +public static class ArtifactCache +{ + // Bump when the renderer's output format changes — invalidates all caches. + public const string RendererCacheVersion = "1"; + + public const string HashMarkerPrefix = "stellaops-render-hash:"; + + /// + /// Produce the composite cache key stored alongside rendered SVGs. + /// Combines the canonical content hash with the renderer version so + /// a renderer change invalidates previously cached artifacts. + /// + public static string ExpectedHash(string contentHash) + => $"{contentHash}:v{RendererCacheVersion}"; + + /// + /// Insert the hash marker as an XML comment immediately after the XML + /// declaration (if present), so the resulting SVG remains well-formed. + /// + public static string InjectHashMarker(string svg, string hash) + { + var marker = $""; + if (svg.StartsWith("", StringComparison.Ordinal); + if (endOfDecl >= 0) + { + return svg.Insert(endOfDecl + 2, "\n" + marker); + } + } + return marker + "\n" + svg; + } + + /// + /// Read the cached hash from an existing SVG file. Returns null if the + /// file doesn't exist, can't be read, or contains no marker. + /// + public static string? ReadCachedHash(string svgPath) + { + if (!File.Exists(svgPath)) return null; + try + { + // The marker is always within the first few hundred bytes. + using var stream = File.OpenRead(svgPath); + var buffer = new byte[2048]; + var read = stream.Read(buffer, 0, buffer.Length); + var head = Encoding.UTF8.GetString(buffer, 0, read); + + var open = $"", start, StringComparison.Ordinal); + return end > start ? head[start..end] : null; + } + catch + { + return null; + } + } + + /// + /// Decide whether rendering can be skipped for a given workflow entry. + /// Returns true when the stamped SVG matches the expected hash and — if + /// PNG output is requested — the PNG also exists. + /// + public static bool IsCacheHit(string svgPath, string? pngPath, string contentHash) + { + if (ReadCachedHash(svgPath) != ExpectedHash(contentHash)) + { + return false; + } + // pngPath is null when the caller passed --skip-png; otherwise the PNG + // must exist for a cache hit, because a missing PNG would mean the + // previous render was partial. + return pngPath is null || File.Exists(pngPath); + } + + /// + /// Atomic text write — writes to {path}.tmp then renames to {path}. If the + /// write is interrupted (cancellation, process kill), the final path is + /// either fully replaced or untouched, never half-written. Without this, + /// a partial hash-stamped SVG could cause a false cache hit. + /// + public static async Task WriteTextAtomicAsync(string path, string content, CancellationToken ct) + { + var tmp = path + ".tmp"; + try + { + await File.WriteAllTextAsync(tmp, content, ct); + File.Move(tmp, path, overwrite: true); + } + catch + { + try { File.Delete(tmp); } catch { } + throw; + } + } + + /// + /// Build a single dictionary of class-name -> source-file path by scanning + /// the source root once. Skips obj/bin output. O(n) vs O(n*m) per-entry scans. + /// + public static Dictionary BuildSourceIndex(string sourceRoot) + { + var sep = Path.DirectorySeparatorChar; + var objFragment = $"{sep}obj{sep}"; + var binFragment = $"{sep}bin{sep}"; + + var index = new Dictionary(StringComparer.Ordinal); + foreach (var file in Directory.EnumerateFiles(sourceRoot, "*Workflow.cs", SearchOption.AllDirectories)) + { + if (file.Contains(objFragment, StringComparison.Ordinal) || + file.Contains(binFragment, StringComparison.Ordinal)) + { + continue; + } + + var className = Path.GetFileNameWithoutExtension(file); + // First writer wins — multiple matches would be ambiguous, mirror previous behavior. + index.TryAdd(className, file); + } + + return index; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/Program.cs b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/Program.cs new file mode 100644 index 000000000..d8a4828aa --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/Program.cs @@ -0,0 +1,525 @@ +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Runtime.Loader; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +using StellaOps.ElkSharp; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.ArtifactExporter; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Rendering; +using StellaOps.Workflow.Renderer.ElkSharp; +using StellaOps.Workflow.Renderer.Svg; + +// --------------------------------------------------------------------------- +// StellaOps.Workflow.ArtifactExporter +// +// Post-build tool that reads the Roslyn-generated _BundledCanonicalWorkflowRegistry +// from a compiled plugin DLL and writes sibling artifacts next to each workflow +// source file: +// {ClassName}.definition.json (canonical JSON — authoritative, sync) +// {ClassName}.definition.svg (rendered graph — hash-stamped, cacheable) +// {ClassName}.definition.png (rasterized — optional, graceful fail) +// +// Renders run in parallel with per-item progress output. When the canonical +// content hash matches a previously stamped SVG (and the PNG still exists), +// the render is skipped. Bump RendererCacheVersion to invalidate all caches. +// --------------------------------------------------------------------------- + +var options = ExporterOptions.Parse(args); + +// Latest-wins coordinator: when a second build starts while a first is still +// running for the same plugin, the newer build preempts the older — it signals +// via the sentinel PID file, the older notices and cancels its render loop, +// and the newer proceeds. Different plugins don't interact (separate sentinels). +using var coordinator = new LatestWinsCoordinator(options.AssemblyPath); +var cancellationToken = coordinator.CancellationToken; + +using var resolver = new PluginAssemblyResolver(options.AssemblyPath); +var assembly = resolver.Load(); + +var entries = ReadBundledRegistry(assembly); +if (entries.Count == 0) +{ + Console.Error.WriteLine("[warn] No bundled workflows found in registry."); + return 0; +} + +Console.WriteLine($"Found {entries.Count} bundled workflow(s) in {Path.GetFileName(options.AssemblyPath)}"); + +var TolerantJsonOptions = BuildTolerantJsonOptions(); + +// Preload *Workflow.cs index once — avoids O(n*m) directory scans per entry. +var sourceIndex = ArtifactCache.BuildSourceIndex(options.SourceRoot); + +var jsonCount = 0; +var svgCount = 0; +var pngCount = 0; +var skippedCount = 0; +var warnCount = 0; +var processedCount = 0; + +var parallelOptions = new ParallelOptions +{ + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount), + CancellationToken = cancellationToken, +}; + +var startedAt = DateTime.UtcNow; + +try +{ + await Parallel.ForEachAsync(entries, parallelOptions, async (entry, ct) => +{ + var n = Interlocked.Increment(ref processedCount); + + if (string.IsNullOrEmpty(entry.CanonicalDefinitionJson)) + { + return; + } + + var className = entry.WorkflowName + "Workflow"; + if (!sourceIndex.TryGetValue(className, out var sourceFile)) + { + Console.Error.WriteLine($"[{n}/{entries.Count}] [warn] {className}.cs not found under {options.SourceRoot}"); + Interlocked.Increment(ref warnCount); + return; + } + + var sourceDir = Path.GetDirectoryName(sourceFile)!; + var baseName = Path.GetFileNameWithoutExtension(sourceFile); + + // 1. JSON (always write — cheap and authoritative) + var jsonPath = Path.Combine(sourceDir, $"{baseName}.definition.json"); + await ArtifactCache.WriteTextAtomicAsync(jsonPath, entry.CanonicalDefinitionJson, ct); + Interlocked.Increment(ref jsonCount); + + if (options.SkipSvg) + { + Console.WriteLine($"[{n}/{entries.Count}] {className} — json"); + return; + } + + // 2. Hash-based cache check — skip render if inputs + renderer unchanged. + var svgPath = Path.Combine(sourceDir, $"{baseName}.definition.svg"); + var pngPath = Path.Combine(sourceDir, $"{baseName}.definition.png"); + + if (ArtifactCache.IsCacheHit(svgPath, options.SkipPng ? null : pngPath, entry.ContentHash)) + { + Console.WriteLine($"[{n}/{entries.Count}] {className} — cached"); + Interlocked.Increment(ref skippedCount); + return; + } + + // 3. Render (fresh instances per iteration — no shared state across threads). + try + { + var definition = JsonSerializer.Deserialize( + entry.CanonicalDefinitionJson, + TolerantJsonOptions); + + if (definition is null) + { + return; + } + + definition = EnsureRenderDefaults(definition); + var descriptor = new WorkflowDefinitionDescriptor + { + WorkflowName = entry.WorkflowName, + WorkflowVersion = entry.WorkflowVersion, + DisplayName = definition.DisplayName ?? entry.WorkflowName, + }; + var runtimeDef = new WorkflowRuntimeDefinition + { + Registration = new WorkflowRegistration + { + WorkflowType = typeof(object), + StartRequestType = typeof(object), + Definition = descriptor, + BindStartRequest = _ => new object(), + ExtractBusinessReference = _ => null, + }, + Descriptor = descriptor, + ExecutionKind = WorkflowRuntimeExecutionKind.Declarative, + CanonicalDefinition = definition, + }; + + var graphCompiler = new WorkflowRenderGraphCompiler(); + var layoutEngine = new ElkSharpWorkflowRenderLayoutEngine(new ElkSharpLayeredLayoutEngine()); + var svgRenderer = new WorkflowRenderSvgRenderer(); + + var renderGraph = graphCompiler.Compile(runtimeDef); + var layoutResult = await layoutEngine.LayoutAsync(renderGraph, cancellationToken: ct); + var svgDoc = svgRenderer.Render(layoutResult, $"{entry.WorkflowName} v{entry.WorkflowVersion}"); + + var stampedSvg = ArtifactCache.InjectHashMarker(svgDoc.Svg, ArtifactCache.ExpectedHash(entry.ContentHash)); + await ArtifactCache.WriteTextAtomicAsync(svgPath, stampedSvg, ct); + Interlocked.Increment(ref svgCount); + + var status = "svg"; + + if (!options.SkipPng) + { + try + { + var stampedDoc = svgDoc with { Svg = stampedSvg }; + var pngExporter = new WorkflowRenderPngExporter(); + var tmpPng = pngPath + ".tmp"; + try + { + await pngExporter.ExportAsync(stampedDoc, tmpPng, cancellationToken: ct); + File.Move(tmpPng, pngPath, overwrite: true); + } + catch + { + try { File.Delete(tmpPng); } catch { } + throw; + } + Interlocked.Increment(ref pngCount); + status = "svg+png"; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + Console.Error.WriteLine($"[{n}/{entries.Count}] [warn] PNG export failed for {className}: {ex.Message}"); + Interlocked.Increment(ref warnCount); + } + } + + Console.WriteLine($"[{n}/{entries.Count}] {className} — {status}"); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + Console.Error.WriteLine($"[{n}/{entries.Count}] [warn] Rendering failed for {className}: {ex.Message}"); + Interlocked.Increment(ref warnCount); + } +}); +} +catch (OperationCanceledException) +{ + // Preempted by a newer build — exit quickly so the successor's WaitForExit + // returns and the new render proceeds. + var elapsedCancel = DateTime.UtcNow - startedAt; + Console.WriteLine( + $"[preempt] exited early after {processedCount}/{entries.Count} entries in {elapsedCancel.TotalSeconds:F1}s"); + return 0; +} + +var elapsed = DateTime.UtcNow - startedAt; +Console.WriteLine( + $"Exported: {jsonCount} JSON, {svgCount} SVG, {pngCount} PNG " + + $"({skippedCount} cached, {warnCount} warning(s)) in {elapsed.TotalSeconds:F1}s " + + $"(x{parallelOptions.MaxDegreeOfParallelism} parallel)"); +return 0; + +// --------------------------------------------------------------------------- +// JSON deserialization — tolerant options + placeholder synthesis +// +// The Roslyn analyzer can emit JSON that omits members marked `required` in the +// contracts (e.g. WorkflowStartDeclaration.InitializeStateExpression) when it +// hits DSL patterns it doesn't yet understand. Tolerating this here — rather +// than relaxing the runtime contract — lets the exporter render whatever the +// generator *did* manage to extract while keeping the runtime invariants intact. +// --------------------------------------------------------------------------- + +static JsonSerializerOptions BuildTolerantJsonOptions() +{ + var resolver = new DefaultJsonTypeInfoResolver(); + resolver.Modifiers.Add(typeInfo => + { + foreach (var prop in typeInfo.Properties) + { + // Suppress required-member enforcement during artifact export. + prop.IsRequired = false; + } + }); + + return new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = resolver, + }; +} + +static WorkflowCanonicalDefinition EnsureRenderDefaults(WorkflowCanonicalDefinition definition) +{ + // If the analyzer failed to emit Start, supply a null-expression placeholder so + // the graph compiler doesn't NRE. The alternative is to drop the render entirely, + // but a partial SVG is more useful feedback than nothing. + if (definition.Start is null) + { + definition = definition with + { + Start = new WorkflowStartDeclaration + { + InitializeStateExpression = new WorkflowNullExpressionDefinition(), + }, + }; + } + else if (definition.Start.InitializeStateExpression is null) + { + definition = definition with + { + Start = definition.Start with + { + InitializeStateExpression = new WorkflowNullExpressionDefinition(), + }, + }; + } + + return definition; +} + +// --------------------------------------------------------------------------- +// Registry reflection +// --------------------------------------------------------------------------- + +static IReadOnlyList ReadBundledRegistry(Assembly assembly) +{ + var entries = new List(); + + var registryType = assembly.GetType("StellaOps.Workflow.Generated._BundledCanonicalWorkflowRegistry"); + if (registryType is null) + { + return entries; + } + + var entriesField = registryType.GetField("Entries", BindingFlags.Public | BindingFlags.Static); + if (entriesField?.GetValue(null) is not System.Collections.IEnumerable entriesCollection) + { + return entries; + } + + foreach (var entryObj in entriesCollection) + { + if (entryObj is null) continue; + var entryType = entryObj.GetType(); + + var name = entryType.GetProperty("WorkflowName")?.GetValue(entryObj) as string ?? ""; + var version = entryType.GetProperty("WorkflowVersion")?.GetValue(entryObj) as string ?? ""; + var json = entryType.GetProperty("CanonicalDefinitionJson")?.GetValue(entryObj) as string ?? ""; + var hash = entryType.GetProperty("CanonicalContentHash")?.GetValue(entryObj) as string ?? ""; + + if (!string.IsNullOrEmpty(name)) + { + entries.Add(new BundledWorkflowEntry(name, version, json, hash)); + } + } + + return entries; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +file sealed record BundledWorkflowEntry( + string WorkflowName, + string WorkflowVersion, + string CanonicalDefinitionJson, + string ContentHash); + +file sealed record ExporterOptions +{ + public required string AssemblyPath { get; init; } + public required string SourceRoot { get; init; } + public bool SkipSvg { get; init; } + public bool SkipPng { get; init; } + + public static ExporterOptions Parse(string[] args) + { + string? assemblyPath = null; + string? sourceRoot = null; + var skipSvg = false; + var skipPng = false; + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--assembly": assemblyPath = args[++i]; break; + case "--source-root": sourceRoot = args[++i]; break; + case "--skip-svg": skipSvg = true; break; + case "--skip-png": skipPng = true; break; + } + } + + if (string.IsNullOrWhiteSpace(assemblyPath)) + throw new InvalidOperationException("--assembly is required."); + if (string.IsNullOrWhiteSpace(sourceRoot)) + throw new InvalidOperationException("--source-root is required."); + + return new ExporterOptions + { + AssemblyPath = Path.GetFullPath(assemblyPath), + SourceRoot = Path.GetFullPath(sourceRoot), + SkipSvg = skipSvg, + SkipPng = skipPng, + }; + } +} + +// --------------------------------------------------------------------------- +// Latest-wins cross-process coordinator. +// +// Protocol: +// - A per-plugin PID sentinel file sits in %TEMP%. +// - On startup we read the previous PID (if any), overwrite the sentinel with +// our own PID, and if the previous process is still alive we wait for it +// to exit (it will notice the PID change and cancel its own run). If it +// hasn't exited within a grace window we kill it. +// - While running, a watcher task polls the sentinel; if a newer process +// overwrites it with its own PID, we trigger cancellation and exit. +// +// Different plugins use different sentinel files, so they don't interact. +// --------------------------------------------------------------------------- + +file sealed class LatestWinsCoordinator : IDisposable +{ + private readonly string sentinelPath; + private readonly string pluginName; + private readonly CancellationTokenSource cts = new(); + private readonly CancellationTokenSource watcherStop = new(); + private readonly Task watcherTask; + + public CancellationToken CancellationToken => cts.Token; + + public LatestWinsCoordinator(string assemblyPath) + { + pluginName = Path.GetFileNameWithoutExtension(assemblyPath); + sentinelPath = Path.Combine(Path.GetTempPath(), $"StellaOpsArtifactExporter-{pluginName}.pid"); + + var prevPid = ReadPid(); + + // Overwrite sentinel with our PID — this signals the predecessor. + WriteOwnPid(); + + // Wait for the predecessor to exit so we don't race on the same output files. + if (prevPid is int pid && pid != Environment.ProcessId) + { + WaitForPredecessor(pid); + // Predecessor may have deleted the sentinel on exit; re-stamp ours. + WriteOwnPid(); + } + + watcherTask = Task.Run(WatchForNewerOwner); + } + + private void WaitForPredecessor(int pid) + { + Process prev; + try { prev = Process.GetProcessById(pid); } + catch (ArgumentException) { return; /* already gone */ } + + if (prev.HasExited) return; + + Console.WriteLine($"[preempt] newer build starting; stopping previous exporter pid={pid} for {pluginName}"); + + // Short graceful window — predecessor's watcher polls at 200ms so it + // typically observes the takeover almost immediately. If in-flight renders + // (ELK layout, SVG/PNG) keep it busy past the window, force-kill so the + // new build doesn't stall. + const int gracefulExitMs = 2000; + if (!prev.WaitForExit(gracefulExitMs)) + { + Console.WriteLine($"[preempt] previous exporter pid={pid} still busy after {gracefulExitMs}ms - killing"); + try { prev.Kill(entireProcessTree: true); prev.WaitForExit(TimeSpan.FromSeconds(2)); } + catch { /* best effort */ } + } + } + + private async Task WatchForNewerOwner() + { + var myPid = Environment.ProcessId; + while (!watcherStop.IsCancellationRequested) + { + var pid = ReadPid(); + if (pid.HasValue && pid.Value != myPid) + { + Console.WriteLine($"[preempt] newer build (pid={pid}) took over — cancelling this run"); + cts.Cancel(); + return; + } + + try { await Task.Delay(100, watcherStop.Token); } + catch (OperationCanceledException) { return; } + } + } + + private int? ReadPid() + { + try + { + if (!File.Exists(sentinelPath)) return null; + var text = File.ReadAllText(sentinelPath).Trim(); + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) ? pid : null; + } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + } + + private void WriteOwnPid() + { + try + { + File.WriteAllText(sentinelPath, Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + } + catch (IOException) { /* best effort — a concurrent writer is acceptable here */ } + } + + public void Dispose() + { + watcherStop.Cancel(); + try { watcherTask.Wait(TimeSpan.FromSeconds(1)); } catch { } + + // Only delete if we still own the sentinel — avoid racing a successor. + if (ReadPid() == Environment.ProcessId) + { + try { File.Delete(sentinelPath); } catch { } + } + + cts.Dispose(); + watcherStop.Dispose(); + } +} + +// --------------------------------------------------------------------------- +// Assembly resolver (proven pattern from the old CanonicalValidator tool) +// --------------------------------------------------------------------------- + +file sealed class PluginAssemblyResolver : IDisposable +{ + private readonly string targetPath; + private readonly AssemblyDependencyResolver resolver; + + public PluginAssemblyResolver(string assemblyPath) + { + targetPath = Path.GetFullPath(assemblyPath); + resolver = new AssemblyDependencyResolver(assemblyPath); + AssemblyLoadContext.Default.Resolving += Resolve; + } + + public Assembly Load() + { + var existing = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(a.Location, targetPath, StringComparison.OrdinalIgnoreCase)); + return existing ?? AssemblyLoadContext.Default.LoadFromAssemblyPath(targetPath); + } + + public void Dispose() => AssemblyLoadContext.Default.Resolving -= Resolve; + + private Assembly? Resolve(AssemblyLoadContext context, AssemblyName name) + { + var path = resolver.ResolveAssemblyToPath(name); + if (path is null) return null; + + var existing = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(a.Location, path, StringComparison.OrdinalIgnoreCase)); + return existing ?? context.LoadFromAssemblyPath(path); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.csproj new file mode 100644 index 000000000..76071bd56 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.targets b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.targets new file mode 100644 index 000000000..add81bfe4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.ArtifactExporter/StellaOps.Workflow.ArtifactExporter.targets @@ -0,0 +1,70 @@ + + + + + <_StellaOpsWorkflowToolsRoot>$(MSBuildThisFileDirectory) + <_StellaOpsAnalyzerCsproj>$(_StellaOpsWorkflowToolsRoot)..\StellaOps.Workflow.Analyzer\StellaOps.Workflow.Analyzer.csproj + <_StellaOpsAnalyzerDll>$(_StellaOpsWorkflowToolsRoot)..\StellaOps.Workflow.Analyzer\bin\$(Configuration)\netstandard2.0\StellaOps.Workflow.Analyzer.dll + <_StellaOpsExporterCsproj>$(_StellaOpsWorkflowToolsRoot)StellaOps.Workflow.ArtifactExporter.csproj + <_StellaOpsExporterDll>$(_StellaOpsWorkflowToolsRoot)bin\$(Configuration)\net10.0\StellaOps.Workflow.ArtifactExporter.dll + + + + + + + + + + + + + + + + + + + + + + + + + <_ExporterArgs>--assembly "$(TargetPath)" --source-root "$(MSBuildProjectDirectory)" + <_ExporterArgs Condition="'$(StellaOpsWorkflowSkipSvg)' == 'true'">$(_ExporterArgs) --skip-svg + <_ExporterArgs Condition="'$(StellaOpsWorkflowSkipPng)' == 'true'">$(_ExporterArgs) --skip-png + + + + + +