diff --git a/build.ln b/build.ln index 06cd06a..afa2528 100644 --- a/build.ln +++ b/build.ln @@ -1,52 +1,17 @@ { + "templates": [ + "dotnet" + ], "env": { "NUGET_SOURCE": "https://nexus.niclas-thobaben.de/repository/l--n.de/", "CONFIGURATION": "Release" }, "stages": [ - { - "name": "setup", - "env": { - "SOME_ENV_VAR": "Some text", - }, - "commands": [ - "SH echo Setting up build environment", - "SH set", - "SH rm -Rf .build" - ] - }, { "name": "prepare", "commands": [ - "SH dotnet restore", - "SH dotnet clean" + "dotnet prepare */*.csproj" ] - }, - { - "name": "build", - "commands": [ - "SH dotnet build -c $CONFIGURATION" - ] - }, - { - "name": "pack_and_publish", - "commands": [ - "SH dotnet pack ln.build -o .build -c $CONFIGURATION", - "SH dotnet pack ln.build.server -o .build -c $CONFIGURATION", - "SH dotnet publish ln.build.server -p:PublishTrimmed=true -p:PublishSingleFile=true -p:PublishReadyToRun=true --self-contained -r linux-x64 -c $CONFIGURATION -o .build/linux-x64", - "SH dotnet publish ln.build.server -p:PublishTrimmed=true -p:PublishSingleFile=true -p:PublishReadyToRun=false --self-contained -r win-x64 -c $CONFIGURATION -o .build/windows-x64" - ] - }, - { - "name": "push", - "commands": [ - "SH for NUPKG in .build/ln.build.*.nupkg; do dotnet nuget push $NUPKG -s $NUGET_SOURCE -k $NUGET_APIKEY; done", - "RELEASE .build/linux-x64/ln.build.server=ln.build.server-linux-amd64", - "RELEASE .build/windows-x64/ln.build.server.exe=ln.build.server-windows-x64.exe" - ], - "secrets": { - "NUGET_APIKEY": "https://nexus.niclas-thobaben.de" - } } ] } \ No newline at end of file diff --git a/ln.build.server/Program.cs b/ln.build.server/Program.cs index 05b1847..480e821 100644 --- a/ln.build.server/Program.cs +++ b/ln.build.server/Program.cs @@ -12,18 +12,41 @@ using Microsoft.VisualBasic; using ln.application; using System.IO; using ln.build.repositories.gitea; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using ln.build.semver; +using ln.build.pipeline; namespace ln.build.server { + public enum RunMode { + serv, + versioning, + build + } + class Program { static CIService CIService; + [StaticArgument( Option = 'm')] + static RunMode RunMode { get; set; } = RunMode.serv; + [StaticArgument( LongOption = "build")] - public static string BuildPath { get; set; } + static string BuildPath { get; set; } [StaticArgument( LongOption = "build-secret")] - public static string BuildSecret { get; set; } + static string BuildSecret { get; set; } + + [StaticArgument( LongOption = "versioning-provider")] + static string VersioningProviderName { get; set; } + + [StaticArgument( LongOption = "versioning-level")] + static SemVerLevels VersioningLevel { get; set;} = SemVerLevels.PATCH; + + [StaticArgument( LongOption = "versioning-source")] + static string VersioningSource { get; set;} + static void Main(string[] args) { @@ -35,19 +58,39 @@ namespace ln.build.server CIService.Initialize(); - if (BuildPath != null) + switch (RunMode) { - CIJob job = new CIJob(CIService,null, (BuildSecret != null) ? CIService.GetSecretStorage(BuildSecret) : null); - job.WorkingDirectory = BuildPath; - job.RunJob(); - } else { - CIService.Initialize(); - CIService.AddWebHookHandler("gitea", GiteaRepository.WebHookHandler); - CIService.Start(); + case RunMode.serv: + CIService.Initialize(); + CIService.AddWebHookHandler("gitea", GiteaRepository.WebHookHandler); + CIService.Start(); + break; + case RunMode.build: + CIJob job = new CIJob(CIService,null, (BuildSecret != null) ? CIService.GetSecretStorage(BuildSecret) : null); + job.WorkingDirectory = BuildPath; + job.RunJob(); + break; + case RunMode.versioning: + Versioning versioning = new Versioning(VersioningProviderName); + versioning.Sources = new string[]{ VersioningSource }; + + SemVersion version = versioning.GetCurrentVersion(null); + Logging.Log("INFO: found version {0}", version); + version.Increment(VersioningLevel); + Logging.Log("INFO: write back version {0}", version); + versioning.SetVersion(null, version); + break; } } + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .UseSystemd() + .ConfigureServices((hostContext, services) => + { + services.AddHostedService(); + }); } diff --git a/ln.build.server/ServiceWorker.cs b/ln.build.server/ServiceWorker.cs new file mode 100644 index 0000000..fa99ec2 --- /dev/null +++ b/ln.build.server/ServiceWorker.cs @@ -0,0 +1,16 @@ + + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +namespace ln.build +{ + public class ServiceWorker : BackgroundService + { + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + throw new System.NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/ln.build.server/ln.build.server.csproj b/ln.build.server/ln.build.server.csproj index a268d48..eb83084 100644 --- a/ln.build.server/ln.build.server.csproj +++ b/ln.build.server/ln.build.server.csproj @@ -6,7 +6,7 @@ - 0.4.2 + 0.4.3-ci Harald Wolff-Thobaben l--n.de A simple build server scheduling builds triggered via web-hooks @@ -15,11 +15,12 @@ - - - - - + + + + + + diff --git a/ln.build/CIJob.cs b/ln.build/CIJob.cs index b7f4d92..e84f976 100644 --- a/ln.build/CIJob.cs +++ b/ln.build/CIJob.cs @@ -1,20 +1,22 @@ using System; using System.Collections.Generic; using System.IO; -using System.Net.Http; -using System.Text; using ln.build.commands; using ln.build.pipeline; using ln.build.repositories; using ln.build.secrets; +using ln.build.semver; using ln.json; using ln.logging; using ln.threading; namespace ln.build { + public delegate void CIJobCompleted(CIJob cIJob); public class CIJob : PoolJob { + public event CIJobCompleted OnJobCompleted; + public string JobID { get; } = Guid.NewGuid().ToString("N"); public CIService CIService { get; } public Repository Repository { get; set; } @@ -115,6 +117,8 @@ namespace ln.build Logger.Log(LogLevel.ERROR, "CIJob failed at CloneRepository()"); } + OnJobCompleted?.Invoke(this); + Notify(); Cleanup(); } @@ -158,5 +162,30 @@ namespace ln.build Directory.Delete(WorkingDirectory, true); } + + public void PublishRelease(SemVerLevels releaseLevel) + { + if (BuildState == BuildState.SUCCESS) + { + if (PipeLine.Versioning != null) + { + Versioning versioning = PipeLine.Versioning; + SemVersion version = versioning.GetCurrentVersion(this); + + if (version.IsPreRelease) + { + SemVersion releaseVersion = new SemVersion(version); + releaseVersion.PreRelease = null; + + versioning.SetVersion(this, releaseVersion); + } else if (version.IsRelease) + { + + } + + } + } + } + } } diff --git a/ln.build/CIService.cs b/ln.build/CIService.cs index c4e6aa3..9f1a812 100644 --- a/ln.build/CIService.cs +++ b/ln.build/CIService.cs @@ -5,8 +5,11 @@ using System.IO; using System.Net; using System.Net.NetworkInformation; using ln.application; +using ln.build.commands; +using ln.build.pipeline; using ln.build.repositories; using ln.build.secrets; +using ln.build.semver; using ln.http; using ln.http.router; using ln.json; @@ -43,9 +46,7 @@ namespace ln.build TemplateRouter templateRouter; HTTPServer httpServer; - HashSet pipelines = new HashSet(); - public IEnumerable PipeLines => pipelines; - + public StageCommandContainer StageCommands { get; } = new StageCommandContainer(null,null); public CIService() { @@ -72,6 +73,8 @@ namespace ln.build Directory.CreateDirectory(ReportsDirectory); Directory.CreateDirectory(Path.Combine(BaseDirectory, "secrets")); + InitializeStageCommands(); + InitializeHttpServer(); } @@ -114,6 +117,21 @@ namespace ln.build } } + public void InitializeStageCommands() + { + StageCommands.AddCommand(CoreCommands.ShellCommand, "sh"); + + StageCommands.AddCommand(DotNetCommand.Prepare, "dotnet", "prepare" ); + StageCommands.AddCommand(DotNetCommand.Build, "dotnet", "build" ); + StageCommands.AddCommand(DotNetCommand.Test, "dotnet", "test" ); + StageCommands.AddCommand(DotNetCommand.Pack, "dotnet", "pack" ); + StageCommands.AddCommand(DotNetCommand.Push, "dotnet", "push" ); + StageCommands.AddCommand(DotNetCommand.Publish, "dotnet", "publish" ); + + StageCommands.AddCommand(DeployCommand.Deploy, "deploy" ); + StageCommands.AddCommand(DeployCommand.Release, "release" ); + } + public string GetJobURL(CIJob job) => string.Format("{0}/builds/{1}", BaseURL, job.JobID); public void Start() @@ -122,11 +140,6 @@ namespace ln.build httpServer.Start(); } - public void AddPipeline(PipeLine pipeLine) - { - pipelines.Add(pipeLine); - } - public void AddWebHookHandler(string name, Func webHookHandler) { hookRouter.AddSimpleRoute(String.Format("/{0}", name), (HttpRoutingContext context,HttpRequest request) => webHookHandler(this, request)); diff --git a/ln.build/PathHelper.cs b/ln.build/PathHelper.cs new file mode 100644 index 0000000..8a4edd9 --- /dev/null +++ b/ln.build/PathHelper.cs @@ -0,0 +1,43 @@ + + +using System; +using System.Collections.Generic; +using System.IO; +using ln.http.exceptions; + +namespace ln.build +{ + public static class PathHelper + { + + public static IEnumerable ResolvePattern(string pattern) => ResolvePattern(pattern, Environment.CurrentDirectory); + public static IEnumerable ResolvePattern(string pattern, string start) + { + string[] patternTokens = pattern.Split('/', StringSplitOptions.RemoveEmptyEntries); + List matches = new List(); + collect(patternTokens, 0, start, matches); + return matches; + } + + static void collect(string[] tokens,int depth,string currentPath, List matches) + { + if (depth < tokens.Length-1) + { + foreach (string dirname in Directory.GetDirectories(currentPath, tokens[depth])) + { + collect(tokens, depth + 1, dirname, matches); + } + } else if (depth == (tokens.Length - 1)) + { + foreach (string filename in Directory.GetFiles(currentPath, tokens[depth])) + { + matches.Add(filename); + } + } else + throw new Exception("serious bug"); + } + + + + } +} \ No newline at end of file diff --git a/ln.build/commands/CommandRunner.cs b/ln.build/commands/CommandRunner.cs index 255eb08..e43ece0 100644 --- a/ln.build/commands/CommandRunner.cs +++ b/ln.build/commands/CommandRunner.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using Jint.Parser.Ast; using ln.logging; namespace ln.build.commands @@ -33,9 +34,9 @@ namespace ln.build.commands TestExitCode = testExitCode; } - public void AddArgument(Argument argument) => arguments.Add(argument); - public void AddArguments(params Argument[] args) => arguments.AddRange(args); - + public CommandRunner AddArgument(Argument argument){ arguments.Add(argument); return this; } + public CommandRunner AddArguments(params Argument[] args) { arguments.AddRange(args); return this; } + public CommandRunner AddArguments(params string[] args) { arguments.AddRange(args.Select((string arg)=>(Argument)arg)); return this; } public string FindFileInPath(CommandEnvironment environment, string filename) { Logger.Log(LogLevel.DEBUG, "Looking up {0} in paths {1}", filename, environment.Get("PATH","")); @@ -116,7 +117,7 @@ namespace ln.build.commands public class Argument { - string value; + protected string value; public bool MaskValue { get; set; } protected Argument(){ } @@ -142,8 +143,10 @@ namespace ln.build.commands public Option(string optionArgument, string optionValue) : this(optionArgument, optionValue, false){ } public Option(string optionArgument, string optionValue, bool maskValue) { + value = optionValue; + OptionArgument = optionArgument; - GetValue = (e) => optionValue; + GetValue = (e) => value; MaskValue = maskValue; } public Option(string optionArgument, Func getOptionValue) : this(optionArgument, getOptionValue, false) { } diff --git a/ln.build/ln.build.csproj b/ln.build/ln.build.csproj index 5302074..996738a 100644 --- a/ln.build/ln.build.csproj +++ b/ln.build/ln.build.csproj @@ -5,7 +5,7 @@ - 0.4.2 + 0.4.3-ci Harald Wolff-Thobaben l--n.de A simple build server scheduling builds triggered via web-hooks @@ -15,16 +15,18 @@ + - - - - - - + + + + + + + diff --git a/ln.build/pipeline/CoreCommands.cs b/ln.build/pipeline/CoreCommands.cs new file mode 100644 index 0000000..13aa86d --- /dev/null +++ b/ln.build/pipeline/CoreCommands.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; +using System.Text; +using ln.build.commands; +using ln.logging; + +namespace ln.build.pipeline +{ + public static class CoreCommands + { + + public static void ShellCommand(Stage stage,params string[] arguments) + { + CommandRunner commandRunner = new CommandRunner("/bin/bash", "-c", string.Format("\"{0}\"", string.Join(' ', arguments))) { Throw = CRThrow.NEVER, }; + MemoryStream logStream = new MemoryStream(); + + int result = commandRunner.Run(stage.CommandEnvironment, logStream); + + stage.CommandEnvironment.Logger.Log(LogLevel.INFO, "command output:\n{0}", Encoding.UTF8.GetString(logStream.ToArray())); + stage.CommandEnvironment.Logger.Log(LogLevel.INFO, "command exit code: {0}", result); + + if (result != 0) + throw new Exception(String.Format("command exited with code {0} [0x{0:x}]", result)); + return; + } + + } +} \ No newline at end of file diff --git a/ln.build/pipeline/DefaultPipeLine.cs b/ln.build/pipeline/DefaultPipeLine.cs index dadcfa5..ac5eb3a 100644 --- a/ln.build/pipeline/DefaultPipeLine.cs +++ b/ln.build/pipeline/DefaultPipeLine.cs @@ -1,5 +1,9 @@ +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using ln.build.commands; +using ln.build.semver; using ln.json; using ln.logging; @@ -14,6 +18,11 @@ namespace ln.build.pipeline List stages = new List(); public IEnumerable Stages => stages; + public Versioning Versioning { get; set; } + + List loadedTemplates = new List(); + public IEnumerable LoadedTemplates => loadedTemplates; + public DefaultPipeLine(CIService ciService) { CIService = ciService; @@ -27,6 +36,29 @@ namespace ln.build.pipeline public void LoadJson(JSONObject jsonPipeLine) { + if (jsonPipeLine.ContainsKey("templates")) + { + foreach (JSONString jsonTemplateName in jsonPipeLine["templates"].Children) + { + if (!loadedTemplates.Contains(jsonTemplateName.Value)) + { + loadedTemplates.Add(jsonTemplateName.Value); + + string repoTemplate = Path.Combine(CommandEnvironment.WorkingDirectory, String.Format("{0}.ln", jsonTemplateName.Value)); + string systemTemplate = Path.Combine(CIService.ContextDirectory, "scripts", "pipeline", String.Format("{0}.ln", jsonTemplateName.Value)); + + if (File.Exists(repoTemplate)) + { + LoadJson(JSONParser.ParseFile(repoTemplate) as JSONObject); + } else if (File.Exists(systemTemplate)) + { + LoadJson(JSONParser.ParseFile(systemTemplate) as JSONObject); + } else + throw new FileNotFoundException(String.Format("{0}.ln", jsonTemplateName.Value)); + } + } + } + if (jsonPipeLine.ContainsKey("env")) CommandEnvironment.Apply(jsonPipeLine["env"] as JSONObject); @@ -35,12 +67,13 @@ namespace ln.build.pipeline JSONArray jsonStages = jsonPipeLine["stages"] as JSONArray; foreach (JSONObject jsonStage in jsonStages.Children) { - Stage stage = new Stage(this); + Stage stage = GetStage(jsonStage["name"].ToNative().ToString()); stage.LoadJson(jsonStage); - - stages.Add(stage); } } + + if (jsonPipeLine.ContainsKey("versioning")) + Versioning = new Versioning(jsonPipeLine["versioning"] as JSONObject); } public void Run() @@ -54,6 +87,19 @@ namespace ln.build.pipeline } } + public Stage GetStage(string stageName) + { + foreach (Stage stage in stages) + { + if (stageName.Equals(stage.Name)) + return stage; + } + + Stage _stage = new Stage(this); + _stage.Name = stageName; + stages.Add(_stage); + return _stage; + } } @@ -61,11 +107,11 @@ namespace ln.build.pipeline { public DefaultPipeLine PipeLine { get; } public string Name { get; set; } + public int Priority { get; set; } public CommandEnvironment CommandEnvironment { get; } - List commands = new List(); - public IEnumerable Commands => commands; + public List commands = new List(); public Stage(DefaultPipeLine pipeLine) { @@ -76,6 +122,8 @@ namespace ln.build.pipeline public void LoadJson(JSONObject jsonStage) { Name = jsonStage["name"].ToNative().ToString(); + if (jsonStage.ContainsKey("priority")) + Priority = (int)(long)jsonStage["priority"].ToNative(); if (jsonStage.ContainsKey("env")) { @@ -86,7 +134,8 @@ namespace ln.build.pipeline JSONArray jsonCommands = jsonStage["commands"] as JSONArray; foreach (JSONValue jsonValue in jsonCommands.Children) { - commands.Add(StageCommand.Create(jsonValue.ToNative().ToString())); + Logging.Log(LogLevel.DEBUG, "stage {0} command: {1}", Name, jsonValue.ToNative().ToString()); + commands.Add(jsonValue.ToNative().ToString()); } } if (jsonStage.ContainsKey("secrets")) @@ -96,15 +145,14 @@ namespace ln.build.pipeline { CommandEnvironment.Set(key, CommandEnvironment.SecretStorage?.GetSecret(jsonSecrets[key]?.ToNative()?.ToString())); } - } } public void Run() { - foreach (StageCommand command in commands) - command.Run(this); + foreach (string command in commands) + PipeLine.CIService.StageCommands.Run(this, command.Split()); } } diff --git a/ln.build/pipeline/ReleaseCommand.cs b/ln.build/pipeline/DeployCommand.cs similarity index 82% rename from ln.build/pipeline/ReleaseCommand.cs rename to ln.build/pipeline/DeployCommand.cs index 7181474..2cee705 100644 --- a/ln.build/pipeline/ReleaseCommand.cs +++ b/ln.build/pipeline/DeployCommand.cs @@ -1,22 +1,26 @@ - using System; using System.IO; -using Jint.Native.Function; using ln.build.repositories; using ln.logging; namespace ln.build.pipeline { - public class ReleaseCommand : StageCommand + public static class DeployCommand { - public string[] Arguments { get; } - public ReleaseCommand(string args) :base("RELEASE") + + public static void Deploy(Stage stage,params string[] arguments) { - Arguments = args.Split(); + stage.CommandEnvironment.Logger.Log(LogLevel.WARNING, "stage command: deploy not yet implemented"); } - public override void Run(Stage stage) + public static void Release(Stage stage,params string[] arguments) { + stage.CommandEnvironment.Logger.Log(LogLevel.WARNING, "stage command: release not yet implemented"); + } + + + +/* if (stage.CommandEnvironment.Get("REPO_EVENT","").Equals("release")) { Repository repository = stage.CommandEnvironment.CIJob?.Repository; @@ -55,6 +59,6 @@ namespace ln.build.pipeline } else { stage.CommandEnvironment.Logger.Log(LogLevel.INFO, "RELEASE: build is no release build. Ignoring."); } - } + */ } } \ No newline at end of file diff --git a/ln.build/pipeline/DotNetCommand.cs b/ln.build/pipeline/DotNetCommand.cs deleted file mode 100644 index 43e7499..0000000 --- a/ln.build/pipeline/DotNetCommand.cs +++ /dev/null @@ -1,26 +0,0 @@ - -using ln.build.pipeline; - -namespace ln.build.commands -{ - - public class DotNetCommand : StageCommand - { - - - public DotNetCommand(string arguments) :base("DOTNET"){ - - } - - public void Analyze(Stage stage) - { - } - - - public override void Run(Stage stage) - { - - } - } - -} \ No newline at end of file diff --git a/ln.build/pipeline/DotNetCommands.cs b/ln.build/pipeline/DotNetCommands.cs new file mode 100644 index 0000000..39ee81f --- /dev/null +++ b/ln.build/pipeline/DotNetCommands.cs @@ -0,0 +1,211 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using ln.application; +using ln.build.pipeline; +using ln.build.semver; +using ln.build.support.dotnet; +using ln.logging; +using ln.type; + +namespace ln.build.commands +{ + + public static class DotNetCommand + { + public static void Prepare(Stage stage,params string[] arguments) + { + if (Directory.Exists(".build")) + Directory.Delete(".build", true); + + List projectFiles = new List(); + List slnFiles = new List(); + + foreach (string argument in arguments) + { + foreach (string filename in PathHelper.ResolvePattern(argument, stage.CommandEnvironment.WorkingDirectory)) + { + stage.CommandEnvironment.Logger.Log(LogLevel.INFO, "dotnet prepare: found {0}", filename); + if (filename.EndsWith(".csproj",StringComparison.InvariantCultureIgnoreCase)) + projectFiles.Add(filename); + else + slnFiles.Add(filename); + } + } + + // ToDo: Parse .sln files for referenced projects + + + foreach (string projectFileName in projectFiles) + { + stage.PipeLine.CommandEnvironment.Extend("DOTNET_PROJECTS", projectFileName); + CSProjHelper csp = new CSProjHelper(projectFileName); + + string projectName = csp.GetName(); + SemVersion projectVersion= csp.GetVersion(); + string ot = csp.GetOutputType(); + + stage.CommandEnvironment.Logger.Log(LogLevel.INFO, "dotnet prepare: project {0} version={1} type={2}", projectName, projectVersion, ot); + } + + } + + public static void Build(Stage stage,params string[] arguments) + { + foreach (string projectFileName in stage.CommandEnvironment.Get("DOTNET_PROJECTS","").Split(':')) + { + new CommandRunner("dotnet","build", projectFileName, new CommandRunner.Option("-c", stage.CommandEnvironment.Get("DOTNET_CONFIGURATION"))) + .Run(stage.CommandEnvironment); + } + } + + public static void Pack(Stage stage,params string[] arguments) + { + foreach (string projectFileName in stage.CommandEnvironment.Get("DOTNET_PROJECTS","").Split(':')) + { + CSProjHelper csProject = new CSProjHelper(projectFileName); + + if (!csProject.GetOutputType().Equals("Library")) + { + stage.CommandEnvironment.Logger.Log(LogLevel.WARNING, "dotnet pack: not packing {0} [{1}]",projectFileName, csProject.GetOutputType()); + continue; + } + stage.CommandEnvironment.Logger.Log(LogLevel.WARNING, "dotnet pack: packing now {0} [{1}]",projectFileName, csProject.GetOutputType()); + + new CommandRunner("dotnet","pack", projectFileName) + .AddArgument(new CommandRunner.Option("-c", stage.CommandEnvironment.Get("DOTNET_CONFIGURATION"))) + .AddArguments("-o", ".build/") + .Run(stage.CommandEnvironment); + + string artefact = String.Format(".build/{0}.{1}.nupkg",csProject.GetName(), csProject.GetVersion()); + if (!File.Exists(artefact)) + throw new FileNotFoundException(artefact); + + stage.PipeLine.CommandEnvironment.Extend("DOTNET_ARTEFACTS", artefact); + + } + } + public static void Publish(Stage stage,params string[] arguments) + { + List projectFileNames = new List(); + List artefacts = new List(); + + if (arguments.Length == 0) + projectFileNames.AddRange(stage.CommandEnvironment.Get("DOTNET_PROJECTS","").Split(':')); + else + projectFileNames.AddRange(arguments); + + foreach (string projectFileName in projectFileNames) + { + CSProjHelper csProject = new CSProjHelper(projectFileName); + + if (!csProject.GetOutputType().Equals("Exe")) + { + stage.CommandEnvironment.Logger.Log(LogLevel.WARNING, "dotnet publish: not publishing {0} [{1}]",projectFileName, csProject.GetOutputType()); + continue; + } + stage.CommandEnvironment.Logger.Log(LogLevel.WARNING, "dotnet publish: publishing now {0} [{1}]",projectFileName, csProject.GetOutputType()); + + CommandRunner cr = new CommandRunner("dotnet","publish", projectFileName) + .AddArgument(new CommandRunner.Option("-c", stage.CommandEnvironment.Get("DOTNET_CONFIGURATION"))) + .AddArguments("-p:PublishTrimmed=true","-p:PublishSingleFile=true","-p:PublishReadyToRun=false"); + + CommandRunner.Option outputOption = new CommandRunner.Option("-o", ".build/"); + cr.AddArgument(outputOption); + + string[] rids = stage.CommandEnvironment.Get("DOTNET_RIDS","").Split(':'); + stage.CommandEnvironment.Logger.Log(LogLevel.INFO,"dotnet publish: using rids: {0}", string.Join(' ', rids)); + + if (rids.Length == 0) + { + cr.Run(stage.CommandEnvironment); + } else + { + CommandRunner.Option ridOption = new CommandRunner.Option("-r", ""); + cr.AddArgument(ridOption); + cr.AddArgument("--self-contained"); + foreach (string rid in rids) + { + ridOption.SetValue(rid); + outputOption.SetValue(String.Format(".build/{0}/", rid)); + + cr.Run(stage.CommandEnvironment); + + string artefact = Directory.GetFiles(String.Format(".build/{0}/", rid)).Where((fn)=>!fn.EndsWith(".pdb")).FirstOrDefault(); + if (artefact != null) + { + string ext = Path.GetExtension(artefact); + if (!ext.Equals(".exe")) + ext = ""; + + string finalName = string.Format(".build/{0}-{1}{2}",csProject.GetName(), rid, ext); + File.Move(artefact, finalName); + artefacts.Add(finalName); + + stage.PipeLine.CommandEnvironment.Extend("DOTNET_ARTEFACTS", finalName); + + Directory.Delete(String.Format(".build/{0}/", rid), true); + } + } + } + } + stage.CommandEnvironment.Logger.Log(LogLevel.INFO,"dotnet publish: created the following artefacts: {0}", string.Join(' ', artefacts)); + } + + public static void Test(Stage stage,params string[] arguments) + { + foreach (string projectFileName in stage.CommandEnvironment.Get("DOTNET_PROJECTS","").Split(':')) + { + new CommandRunner("dotnet","test", projectFileName, new CommandRunner.Option("-c", stage.CommandEnvironment.Get("DOTNET_CONFIGURATION"))) + .Run(stage.CommandEnvironment); + } + } + + public static void Push(Stage stage,params string[] arguments) + { + if (stage.CommandEnvironment.SecretStorage == null) + { + stage.CommandEnvironment.Logger.Log(LogLevel.WARNING,"dotnet push: no SecretStorage available, push operations are going to fail without authorization!"); + } else { + stage.CommandEnvironment.Logger.Log(LogLevel.INFO,"dotnet push: using secrets from {0}", stage.CommandEnvironment.SecretStorage.FileName); + } + + string[] nupkgList = stage.CommandEnvironment.Get("DOTNET_ARTEFACTS","").Split(':').Where((fn)=>fn.EndsWith(".nupkg")).ToArray(); + string[] binaryList = stage.CommandEnvironment.Get("DOTNET_ARTEFACTS","").Split(':').Where((fn)=>!fn.EndsWith(".nupkg")).ToArray(); + + if (nupkgList.Length > 0) + { + string nugetSource = stage.CommandEnvironment.Get("NUGET_SOURCE"); + string nugetApiKey = null; + + if (!(stage.CommandEnvironment.SecretStorage?.TryGetSecret(nugetSource, out nugetApiKey) ?? false)) + { + URI uriNuget = new URI(nugetSource); + stage.CommandEnvironment.SecretStorage?.TryGetSecret(new URI(uriNuget.Scheme, uriNuget.Authority, "").ToString(), out nugetApiKey); + } + + CommandRunner cr = new CommandRunner("dotnet", "nuget", "push"); + + CommandRunner.Argument argNupkg = new CommandRunner.Argument(""); + cr.AddArgument(argNupkg); + + CommandRunner.Option optSource = new CommandRunner.Option("-s",nugetSource); + cr.AddArgument(optSource); + + CommandRunner.Option optApiKey = new CommandRunner.Option("-k", nugetApiKey); + cr.AddArgument(optApiKey); + + foreach (string nupkg in nupkgList) + { + argNupkg.SetValue(nupkg); + cr.Run(stage.CommandEnvironment); + } + } + } + + } + +} \ No newline at end of file diff --git a/ln.build/pipeline/ShellCommand.cs b/ln.build/pipeline/ShellCommand.cs deleted file mode 100644 index 895c320..0000000 --- a/ln.build/pipeline/ShellCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ - - -using System; -using System.IO; -using System.Text; -using ln.build.commands; -using ln.logging; - -namespace ln.build.pipeline -{ - public class ShellCommand : StageCommand - { - public event CommandExitedDelegate OnCommandExited; - - public CommandRunner CommandRunner { get; } - - public ShellCommand(string filename,params CommandRunner.Argument[] arguments) - :base("SH") - { - CommandRunner = new CommandRunner("/bin/bash", "-c"){ Throw = CRThrow.NEVER, }; - CommandRunner.AddArguments(arguments); - } - public ShellCommand(string cmdline) - :base("SH") - { - CommandRunner = new CommandRunner("/bin/bash", "-c", string.Format("\"{0}\"",cmdline)){ Throw = CRThrow.NEVER, }; - } - - public override void Run(Stage stage) - { - MemoryStream logStream = new MemoryStream(); - int result = CommandRunner.Run(stage.CommandEnvironment, logStream); - stage.CommandEnvironment.Logger.Log(LogLevel.INFO, "command output:\n{0}", Encoding.UTF8.GetString(logStream.ToArray())); - stage.CommandEnvironment.Logger.Log(LogLevel.INFO, "command exit code: {0}", result); - if (result != 0) - throw new Exception(String.Format("command exited with code {0} [0x{0:x}]", result)); - } - } - -} \ No newline at end of file diff --git a/ln.build/pipeline/StageCommand.cs b/ln.build/pipeline/StageCommand.cs deleted file mode 100644 index d0462d7..0000000 --- a/ln.build/pipeline/StageCommand.cs +++ /dev/null @@ -1,48 +0,0 @@ - - -using System; -using System.Collections.Generic; -using ln.build.commands; - -namespace ln.build.pipeline -{ - public abstract class StageCommand - { - public string Name { get; } - - public StageCommand(string name) - { - Name = name; - } - - public abstract void Run(Stage stage); - - - - - - static Dictionary> commandFactories = new Dictionary>(); - - public static StageCommand Create(string cmdline) - { - string[] tokens = cmdline.Split(new char[]{' ','\t'}, 2); - - if (commandFactories.TryGetValue(tokens[0],out Func factory)) - { - return factory(tokens[1]); - } - throw new Exception(string.Format("can't find factory for command keyword '{0}'", tokens[0])); - } - - static StageCommand() - { - commandFactories.Add("SH", (args) => new ShellCommand(args)); - commandFactories.Add("DOTNET", (args) => new DotNetCommand(args)); - commandFactories.Add("GITEA", (args) => new DotNetCommand(args)); - commandFactories.Add("RELEASE", (args) => new ReleaseCommand(args)); - - } - - } - -} \ No newline at end of file diff --git a/ln.build/pipeline/StageCommandContainer.cs b/ln.build/pipeline/StageCommandContainer.cs new file mode 100644 index 0000000..12ff5af --- /dev/null +++ b/ln.build/pipeline/StageCommandContainer.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using ln.build.commands; +using ln.type; + +namespace ln.build.pipeline +{ + + public class StageCommandContainer + { + public StageCommandContainer Parent { get; } + public string Name { get; } + + public string FullName => Parent?.FullName != null ? string.Format("{0} {1}", Parent.FullName, Name) : Name; + + Dictionary> commands = new Dictionary>(); + Dictionary children = new Dictionary(); + + public StageCommandContainer(StageCommandContainer parent, string name) + { + Parent = parent; + Name = name; + } + + public void AddCommand(Action commandAction, string commandPath) => AddCommand(commandAction, commandPath.Split()); + public void AddCommand(Action commandAction, params string[] commandPath) + { + if (commandPath.Length == 0) + throw new ArgumentException(nameof(commandPath)); + if (commandPath.Length > 1) + { + if (!children.TryGetValue(commandPath[0], out StageCommandContainer childContainer)) + { + childContainer = new StageCommandContainer(this, commandPath[0]); + children.Add(commandPath[0], childContainer); + } + childContainer.AddCommand(commandAction, commandPath.Slice(1)); + } else { + commands.Add(commandPath[0], commandAction); + } + } + + public void Run(Stage stage, params string[] arguments) + { + if (commands.TryGetValue(arguments[0], out Action commandAction)) + commandAction(stage, arguments.Slice(1)); + else if (children.TryGetValue(arguments[0], out StageCommandContainer childContainer)) + childContainer.Run(stage, arguments.Slice(1)); + else + throw new ArgumentException(String.Format("command not found: {0}", arguments[0])); + } + } + +} \ No newline at end of file diff --git a/ln.build/pipeline/Versioning.cs b/ln.build/pipeline/Versioning.cs new file mode 100644 index 0000000..8a9c83a --- /dev/null +++ b/ln.build/pipeline/Versioning.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Linq; +using ln.build.semver; +using ln.build.semver.provider; +using ln.json; + +namespace ln.build.pipeline +{ + public class Versioning + { + public string ProviderName { get; set; } + public string[] Sources { get; set; } + + public Provider Provider { get; } + + public Versioning() + {} + public Versioning(JSONObject jsonVersioning) + { + ProviderName = jsonVersioning["provider"].ToNative().ToString(); + if (jsonVersioning.ContainsKey("sources")) + Sources = jsonVersioning["sources"].Children.Select((s) => s.ToNative().ToString()).ToArray(); + + Provider = Provider.CreateProvider(ProviderName); + } + + public Versioning(string providerName) + { + ProviderName = providerName; + Provider = Provider.CreateProvider(ProviderName); + } + + public SemVersion GetCurrentVersion(CIJob job) => GetVersion(job, Sources[0]); + public SemVersion GetVersion(CIJob job, string source) + { + string sourceFileName = Path.Combine(job?.WorkingDirectory ?? "", source); + return Provider.GetVersion(sourceFileName); + } + + public void SetVersion(CIJob job, SemVersion version) + { + foreach (string source in Sources) + SetVersion(job, source, version); + } + public void SetVersion(CIJob job, string source, SemVersion version) + { + string sourceFileName = Path.Combine(job?.WorkingDirectory ?? "", source); + Provider.SetVersion(sourceFileName, version); + } + + + } +} \ No newline at end of file diff --git a/ln.build/repositories/Repository.cs b/ln.build/repositories/Repository.cs index 91c5259..8e81df7 100644 --- a/ln.build/repositories/Repository.cs +++ b/ln.build/repositories/Repository.cs @@ -13,6 +13,10 @@ namespace ln.build.repositories public abstract Release[] GetReleases(); public abstract Release GetRelease(string tagName); public abstract Release GetRelease(int id); + + + public abstract void CommitAndPush(string message, string[] addedPaths, string[] modifiedPaths, string[] removedPaths); + } diff --git a/ln.build/repositories/gitea/GiteaRepository.cs b/ln.build/repositories/gitea/GiteaRepository.cs index e1d9c63..b451417 100644 --- a/ln.build/repositories/gitea/GiteaRepository.cs +++ b/ln.build/repositories/gitea/GiteaRepository.cs @@ -1,11 +1,17 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.IO; using System.Net; +using ln.build.commands; +using ln.build.pipeline; using ln.build.secrets; +using ln.build.semver; using ln.http; using ln.json; using ln.logging; +using ln.threading; +using ln.type; namespace ln.build.repositories.gitea { @@ -20,6 +26,8 @@ namespace ln.build.repositories.gitea public JsonApiClient Client { get; } + string AccessToken { get; set; } + public GiteaRepository(string baseURL, string owner, string name) :base(string.Format("{0}/{1}/{2}.git",baseURL, owner, name)) { @@ -34,6 +42,7 @@ namespace ln.build.repositories.gitea public GiteaRepository(string baseURL, string owner, string name, string accessToken) :this(baseURL, owner, name) { + AccessToken = accessToken; if (accessToken != null) Client.HttpClient.DefaultRequestHeaders.Add("Authorization",String.Format("token {0}", accessToken)); } @@ -93,6 +102,27 @@ namespace ln.build.repositories.gitea public bool ContainsFile(string filename,string _ref) => (Client.GetJson(out JSONValue response, "repos", Owner, Name, "contents", string.Format("{0}?ref={1}",filename, _ref)) == HttpStatusCode.OK); + public override void CommitAndPush(string message, string[] addedPaths, string[] modifiedPaths, string[] removedPaths) + { + CommandRunner cr = new CommandRunner("git","add"); + cr.AddArguments(addedPaths); + cr.AddArguments(modifiedPaths); + cr.Run(); + + cr = new CommandRunner("git","rm"); + cr.AddArguments(removedPaths); + cr.Run(); + + cr = new CommandRunner("git","commit", "-m", message); + cr.Run(); + + URI baseUri = new URI(BaseURL); + if (!AccessToken?.Equals(String.Empty) ?? false) + baseUri = baseUri.WithUserInfo(AccessToken); + + cr = new CommandRunner("git","push", baseUri.ToString(true)); + cr.Run(); + } public static HttpResponse WebHookHandler(CIService ciService, HttpRequest request) @@ -135,6 +165,7 @@ namespace ln.build.repositories.gitea string eventType = request.GetRequestHeader("X-GITEA-EVENT"); List buildRefs = new List(); + JSONObject jsonCommit = null; switch (eventType) { @@ -145,8 +176,16 @@ namespace ln.build.repositories.gitea buildRefs.Add(message["release"]["tag_name"].ToNative().ToString()); break; case "push": - foreach (JSONObject jsonCommit in (message["commits"] as JSONArray).Children) - buildRefs.Add(jsonCommit["id"].ToNative().ToString()); + string currentCommitId = message["after"].ToNative().ToString(); + foreach (JSONObject _jsonCommit in (message["commits"] as JSONArray).Children) + { + if (currentCommitId.Equals(_jsonCommit["id"].ToNative().ToString())) + { + buildRefs.Add(_jsonCommit["id"].ToNative().ToString()); + jsonCommit = _jsonCommit; + break; + } + } break; default: Logging.Log(LogLevel.WARNING, "received webhook with unsupported event type [{0}]", eventType); @@ -162,6 +201,7 @@ namespace ln.build.repositories.gitea .SetVariable("REPO_OWNER", owner) .SetVariable("REPO_NAME", repoName) .SetVariable("REPO_REF", _ref) + .SetVariable("REPO_BASE", giteaRepository.BaseURL) // .SetVariable("NOTIFY", message["pusher"]["email"].ToNative().ToString()) ; ciJob.Environment.SecretStorage = secretStorage; @@ -173,6 +213,16 @@ namespace ln.build.repositories.gitea ciJob.SetVariable("RELEASE_ID", message["release"]["id"].ToNative().ToString()); break; case "push": + if (jsonCommit != null) + { + string commitMessage = jsonCommit["message"].ToNative().ToString(); + if (commitMessage.Contains("#ReleaseMajor")) + ciJob.OnJobCompleted += (job) => job.PublishRelease(SemVerLevels.MAJOR); + else if (commitMessage.Contains("#ReleaseMinor")) + ciJob.OnJobCompleted += (job) => job.PublishRelease(SemVerLevels.MINOR); + else if (commitMessage.Contains("#ReleasePatch")) + ciJob.OnJobCompleted += (job) => job.PublishRelease(SemVerLevels.PATCH); + } break; } diff --git a/ln.build/scripts/pipeline/dotnet.ln b/ln.build/scripts/pipeline/dotnet.ln new file mode 100644 index 0000000..65c24fe --- /dev/null +++ b/ln.build/scripts/pipeline/dotnet.ln @@ -0,0 +1,45 @@ +{ + "env": { + "CONFIGURATION": "Release", + "DOTNET_RIDS": "linux-x64:win-x64:osx-x64" + }, + "stages": [ + { + "name": "prepare", + "priority": 100, + "commands": [ + "dotnet prepare *.sln *.csproj" + ] + }, + { + "name": "build", + "priority": 300, + "commands": [ + "dotnet build" + ] + }, + { + "name": "test", + "priority": 500, + "commands": [ + "dotnet test" + ] + }, + { + "name": "pack", + "priority": 700, + "commands": [ + "dotnet pack", + "dotnet publish" + ] + }, + { + "name": "push", + "priority": 1000, + "commands": [ + "dotnet push", + "deploy" + ] + } + ] +} \ No newline at end of file diff --git a/ln.build/secrets/SecretStorage.cs b/ln.build/secrets/SecretStorage.cs index 0e726a1..8e467f7 100644 --- a/ln.build/secrets/SecretStorage.cs +++ b/ln.build/secrets/SecretStorage.cs @@ -28,6 +28,7 @@ namespace ln.build.secrets JSONObject secretsObject = JSONParser.ParseFile(FileName) as JSONObject; foreach (string key in secretsObject.Keys) { + //Logging.Log(LogLevel.INFO, "loading secret {0}", key); secrets.Add(key, secretsObject[key].ToNative().ToString()); } } @@ -36,9 +37,15 @@ namespace ln.build.secrets public string GetSecret(string key) { + //Logging.Log(LogLevel.INFO, "trying to fetch secret for: [{0}]", key); TryGetSecret(key, out string secret); return secret; } - public bool TryGetSecret(string key, out string secret) => secrets.TryGetValue(key, out secret); + public bool TryGetSecret(string key, out string secret){ + //Logging.Log(LogLevel.INFO, "trying to fetch secret for: [{0}]", key); + bool success = secrets.TryGetValue(key, out secret); + //Logging.Log(LogLevel.INFO, "{1} to fetch secret for: [{0}]", key, success ? "succeded" : "failed"); + return success; + } } } \ No newline at end of file diff --git a/ln.build/semver/SemVer.cs b/ln.build/semver/SemVer.cs new file mode 100644 index 0000000..952df9e --- /dev/null +++ b/ln.build/semver/SemVer.cs @@ -0,0 +1,11 @@ + + +namespace ln.build.semver +{ + public enum SemVerLevels { + MAJOR, + MINOR, + PATCH, + TAG + } +} \ No newline at end of file diff --git a/ln.build/semver/Version.cs b/ln.build/semver/Version.cs new file mode 100644 index 0000000..529e151 --- /dev/null +++ b/ln.build/semver/Version.cs @@ -0,0 +1,84 @@ + + +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using ln.build.semver; +using Microsoft.VisualBasic; + +namespace ln.build.semver +{ + + public class SemVersion + { + public int Major { get; set; } + public int Minor { get; set; } + public int Patch { get; set; } + public string PreRelease { get; set; } = String.Empty; + + public bool IsPreRelease => !PreRelease?.Equals(string.Empty) ?? false; + public bool IsRelease => PreRelease?.Equals(string.Empty) ?? true; + + public SemVersion(int major,int minor,int patch,string prerelease) + { + Major = major; + Minor = minor; + Patch = patch; + PreRelease = prerelease; + } + private SemVersion(){} + public SemVersion(SemVersion source) + :this(source.Major, source.Minor, source.Patch, source.PreRelease) + { } + + + public void Increment(SemVerLevels level){ + switch (level) + { + case SemVerLevels.MAJOR: + Major++; + Minor = 0; + Patch = 0; + break; + case SemVerLevels.MINOR: + Minor++; + Patch = 0; + break; + case SemVerLevels.PATCH: + Patch++; + break; + } + } + + public override string ToString() + { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.AppendFormat("{0}.{1}.{2}", Major, Minor, Patch); + if (!PreRelease?.Equals(string.Empty) ?? false) + stringBuilder.AppendFormat("-{0}", PreRelease); + return stringBuilder.ToString(); + } + + + public static Regex rexVersion = new Regex("^(?\\d+).(?\\d+).(?\\d+)(-(?.+))?$"); + public static SemVersion Parse(string versionString) + { + Match match = rexVersion.Match(versionString); + if (!match.Success) + throw new FormatException(String.Format("{0} is no valid SemVer", versionString)); + + SemVersion version = new SemVersion(); + version.Major = int.Parse(match.Groups["major"].Value); + version.Minor = int.Parse(match.Groups["minor"].Value); + version.Patch = int.Parse(match.Groups["patch"].Value); + if (match.Groups["prerelease"].Success) + version.PreRelease = match.Groups["prerelease"].Value; + + return version; + } + + + } + +} \ No newline at end of file diff --git a/ln.build/semver/provider/DotNetProvider.cs b/ln.build/semver/provider/DotNetProvider.cs new file mode 100644 index 0000000..38ebd0a --- /dev/null +++ b/ln.build/semver/provider/DotNetProvider.cs @@ -0,0 +1,34 @@ +using System; +using System.Xml; +using ln.build.semver; + +namespace ln.build.semver.provider +{ + + public class DotNetProvider : Provider + { + public DotNetProvider(): base("dotnet") + {} + + public override SemVersion GetVersion(string source) + { + XmlDocument projectFile = new XmlDocument(); + projectFile.Load(source); + + XmlNode nodeVersion = projectFile.SelectSingleNode("Project/PropertyGroup/Version"); + return SemVersion.Parse(nodeVersion.InnerText); + } + + public override void SetVersion(string source, SemVersion version) + { + XmlDocument projectFile = new XmlDocument(); + projectFile.Load(source); + + XmlNode nodeVersion = projectFile.SelectSingleNode("Project/PropertyGroup/Version"); + nodeVersion.InnerText = version.ToString(); + + projectFile.Save(source); + } + } + +} \ No newline at end of file diff --git a/ln.build/semver/provider/Provider.cs b/ln.build/semver/provider/Provider.cs new file mode 100644 index 0000000..3383ce3 --- /dev/null +++ b/ln.build/semver/provider/Provider.cs @@ -0,0 +1,39 @@ + + +using System; +using System.Diagnostics.Contracts; +using System.IO; + +namespace ln.build.semver.provider +{ + + public abstract class Provider + { + + public String Name { get; } + + public Provider(string providerName) + { + Name = providerName; + } + + public abstract SemVersion GetVersion(string source); + public abstract void SetVersion(string source, SemVersion version); + + + + public static Provider CreateProvider(string providerName) + { + switch (providerName) + { + case "dotnet": + return new DotNetProvider(); + default: + throw new FileNotFoundException(); + } + } + + + } + +} \ No newline at end of file diff --git a/ln.build/support/dotnet/CSProjHelper.cs b/ln.build/support/dotnet/CSProjHelper.cs new file mode 100644 index 0000000..60a5731 --- /dev/null +++ b/ln.build/support/dotnet/CSProjHelper.cs @@ -0,0 +1,66 @@ + + +using System.IO; +using System.Xml; +using ln.build.semver; + +namespace ln.build.support.dotnet +{ + + public class CSProjHelper + { + public string FileName { get; set; } + + public CSProjHelper(string filename) + { + FileName = filename; + } + + public string GetName() + { + XmlDocument projectFile = new XmlDocument(); + projectFile.Load(FileName); + + XmlNode nodeVersion = projectFile.SelectSingleNode("Project/PropertyGroup/AssemblyName"); + return nodeVersion?.InnerText ?? Path.GetFileNameWithoutExtension(FileName); + } + + public SemVersion GetVersion() + { + XmlDocument projectFile = new XmlDocument(); + projectFile.Load(FileName); + XmlNode nodeVersion = projectFile.SelectSingleNode("Project/PropertyGroup/Version"); + return SemVersion.Parse(nodeVersion.InnerText); + } + + public void SetVersion(SemVersion version) + { + XmlDocument projectFile = new XmlDocument(); + projectFile.Load(FileName); + + XmlNode nodeVersion = projectFile.SelectSingleNode("Project/PropertyGroup/Version"); + nodeVersion.InnerText = version.ToString(); + + projectFile.Save(FileName); + } + + public bool IsPackable() + { + XmlDocument projectFile = new XmlDocument(); + projectFile.Load(FileName); + XmlNode nodePackable = projectFile.SelectSingleNode("Project/PropertyGroup/IsPackable"); + return bool.Parse(nodePackable?.InnerText ?? "true"); + } + + public string GetOutputType() + { + XmlDocument projectFile = new XmlDocument(); + projectFile.Load(FileName); + + XmlNode nodeVersion = projectFile.SelectSingleNode("Project/PropertyGroup/OutputType"); + return nodeVersion?.InnerText ?? "Library"; + } + + } + +} \ No newline at end of file