From 0aa5bcc858efba23fbc101bd72a7ce5be45cc5ed Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 10 Feb 2026 15:58:00 +1100 Subject: [PATCH 01/12] [minor] Add ktsu ecosystem package references Add UndoRedo.Core, Keybinding.Core, IntervalAction, FuzzySearch, DeepClone packages to support upcoming editor features. --- Directory.Packages.props | 5 +++++ SchemaEditor/SchemaEditor.csproj | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5a91a18..1462b52 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,12 @@ + + + + + diff --git a/SchemaEditor/SchemaEditor.csproj b/SchemaEditor/SchemaEditor.csproj index 457c2a0..f8bf18b 100644 --- a/SchemaEditor/SchemaEditor.csproj +++ b/SchemaEditor/SchemaEditor.csproj @@ -9,9 +9,14 @@ + + + + + From 87f0834ed7aa375282f5dabab25457ca28680a68 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 10 Feb 2026 16:12:38 +1100 Subject: [PATCH 02/12] [minor] Replace manual debounce with ktsu.IntervalAction Replace DateTime-based debounce pattern (3 properties + SaveOptionsIfRequired) with IntervalAction polling on a 3-second interval and a simple dirty flag. --- SchemaEditor/SchemaEditor.cs | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/SchemaEditor/SchemaEditor.cs b/SchemaEditor/SchemaEditor.cs index cddba69..4a998ad 100644 --- a/SchemaEditor/SchemaEditor.cs +++ b/SchemaEditor/SchemaEditor.cs @@ -16,6 +16,7 @@ namespace ktsu.SchemaEditor; using ktsu.ImGui.App; using ktsu.ImGui.Styler; using ktsu.ImGui.Widgets; +using ktsu.IntervalAction; using ktsu.Schema.Models; using ktsu.Schema.Models.Names; using ktsu.Semantics.Paths; @@ -32,9 +33,10 @@ public class SchemaEditor internal DataSource? CurrentDataSource { get; set; } internal AppData Options { get; } internal static float FieldWidth => ImGui.GetIO().DisplaySize.X * 0.15f; - private DateTime LastSaveOptionsTime { get; set; } = DateTime.MinValue; - private DateTime SaveOptionsQueuedTime { get; set; } = DateTime.MinValue; - private TimeSpan SaveOptionsDebounceTime { get; } = TimeSpan.FromSeconds(3); + private bool OptionsDirty { get; set; } +#pragma warning disable IDE0052 // Remove unread private member - reference needed to prevent GC + private readonly IntervalAction? autoSaveOptionsAction; +#pragma warning restore IDE0052 private ImGuiWidgets.DividerContainer DividerContainerCols { get; init; } internal Popups Popups { get; } @@ -75,6 +77,20 @@ public SchemaEditor() Options = AppData.LoadOrCreate(); Popups = Options.Popups; + autoSaveOptionsAction = IntervalAction.Start(new() + { + Action = () => + { + if (OptionsDirty) + { + OptionsDirty = false; + SaveOptionsInternal(); + } + }, + ActionInterval = TimeSpan.FromSeconds(3), + IntervalType = IntervalType.FromLastCompletion, + }); + // restore open schema if (SchemaFile.TryLoad(Options.CurrentSchemaPath, out Schema? previouslyOpenSchema) && previouslyOpenSchema is not null) { @@ -116,19 +132,9 @@ private void SaveOptionsInternal() Options.Save(); } - private void QueueSaveOptions() => SaveOptionsQueuedTime = DateTime.Now; - - private void SaveOptionsIfRequired() - { - //debounce the save requests and avoid saving multiple times per frame or multiple frames in a row - if ((SaveOptionsQueuedTime > LastSaveOptionsTime) && ((DateTime.Now - SaveOptionsQueuedTime) > SaveOptionsDebounceTime)) - { - SaveOptionsInternal(); - LastSaveOptionsTime = DateTime.Now; - } - } + private void QueueSaveOptions() => OptionsDirty = true; - private void OnTick(float dt) => SaveOptionsIfRequired(); + private void OnTick(float dt) { } private void OnRender(float dt) { From eaff25807810083578ae02964de765c840fbcf32 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 10 Feb 2026 16:36:07 +1100 Subject: [PATCH 03/12] [minor] Integrate ktsu.UndoRedo for undoable schema mutations Add UndoRedoService to SchemaEditor with Edit menu (Undo/Redo). Wrap all add/delete operations in tree views with DelegateCommand. Add Restore* methods to Schema and SchemaClass for undo support. Wire save boundaries on file save and clear on new/open. --- Schema/Models/Schema.cs | 54 +++++++++++++++++++++++ Schema/Models/SchemaClass.cs | 20 +++++++++ SchemaEditor/SchemaEditor.cs | 35 ++++++++++++++- SchemaEditor/TreeClass.cs | 73 +++++++++++++++++++++++++------ SchemaEditor/TreeCodeGenerator.cs | 29 ++++++++++-- SchemaEditor/TreeDataSource.cs | 35 ++++++++++++--- SchemaEditor/TreeEnum.cs | 51 +++++++++++++++++---- 7 files changed, 262 insertions(+), 35 deletions(-) diff --git a/Schema/Models/Schema.cs b/Schema/Models/Schema.cs index afc9365..26d9d41 100644 --- a/Schema/Models/Schema.cs +++ b/Schema/Models/Schema.cs @@ -211,6 +211,60 @@ public static bool TryGetChild(TName name, Collection col return null; } + /// + /// Restores a previously removed child back into a collection. + /// Used for undo operations where the original object reference is preserved. + /// + /// The type of the child. + /// The type of the name. + /// The child to restore. + /// The collection to restore the child into. + /// True if the child was restored; false if a child with the same name already exists. + public bool RestoreChild(TChild child, Collection collection) + where TChild : SchemaChild, new() + where TName : SemanticString, ISchemaChildName, new() + { + Ensure.NotNull(child); + Ensure.NotNull(collection); + + if (GetChild(child.Name, collection) is not null) + { + return false; + } + + child.AssociateWith(this); + collection.Add(child); + return true; + } + + /// + /// Restores a previously removed class back into the schema. + /// + /// The class to restore. + /// True if restored; false if a class with the same name already exists. + public bool RestoreClass(SchemaClass schemaClass) => RestoreChild(schemaClass, ClassesInternal); + + /// + /// Restores a previously removed enum back into the schema. + /// + /// The enum to restore. + /// True if restored; false if an enum with the same name already exists. + public bool RestoreEnum(SchemaEnum schemaEnum) => RestoreChild(schemaEnum, EnumsInternal); + + /// + /// Restores a previously removed data source back into the schema. + /// + /// The data source to restore. + /// True if restored; false if a data source with the same name already exists. + public bool RestoreDataSource(DataSource dataSource) => RestoreChild(dataSource, DataSourcesInternal); + + /// + /// Restores a previously removed code generator back into the schema. + /// + /// The code generator to restore. + /// True if restored; false if a code generator with the same name already exists. + public bool RestoreCodeGenerator(SchemaCodeGenerator codeGenerator) => RestoreChild(codeGenerator, CodeGeneratorsInternal); + internal bool TryRemoveEnum(SchemaEnum schemaEnum) => TryRemoveChild(schemaEnum, EnumsInternal); internal bool TryRemoveClass(SchemaClass schemaClass) => TryRemoveChild(schemaClass, ClassesInternal); diff --git a/Schema/Models/SchemaClass.cs b/Schema/Models/SchemaClass.cs index b319d2c..f32f568 100644 --- a/Schema/Models/SchemaClass.cs +++ b/Schema/Models/SchemaClass.cs @@ -66,6 +66,26 @@ public class SchemaClass : SchemaChild /// True if the member was removed; otherwise, false. internal bool TryRemoveMember(SchemaMember member) => MembersInternal.Remove(member); + /// + /// Restores a previously removed member back into the class. + /// Used for undo operations where the original object reference is preserved. + /// + /// The member to restore. + /// True if the member was restored; false if a member with the same name already exists. + public bool RestoreMember(SchemaMember member) + { + Ensure.NotNull(member); + + if (MembersInternal.Any(m => m.Name == member.Name)) + { + return false; + } + + member.AssociateWith(this); + MembersInternal.Add(member); + return true; + } + /// /// Tries to get a member by name. /// diff --git a/SchemaEditor/SchemaEditor.cs b/SchemaEditor/SchemaEditor.cs index 4a998ad..fb9567c 100644 --- a/SchemaEditor/SchemaEditor.cs +++ b/SchemaEditor/SchemaEditor.cs @@ -21,6 +21,9 @@ namespace ktsu.SchemaEditor; using ktsu.Schema.Models.Names; using ktsu.Semantics.Paths; using ktsu.Semantics.Strings; +using ktsu.UndoRedo; +using ktsu.UndoRedo.Contracts; +using ktsu.UndoRedo.Core.Services; using SchemaTypes = ktsu.Schema.Models.Types; @@ -39,6 +42,7 @@ public class SchemaEditor #pragma warning restore IDE0052 private ImGuiWidgets.DividerContainer DividerContainerCols { get; init; } + internal IUndoRedoService UndoRedo { get; } internal Popups Popups { get; } private TreeSchema TreeSchema { get; init; } @@ -62,6 +66,7 @@ private static void Main(string[] _) public SchemaEditor() { + UndoRedo = new UndoRedoService(new StackManager(), new SaveBoundaryManager(), new CommandMerger()); TreeSchema = new(this); DividerContainerCols = new( @@ -200,11 +205,27 @@ private void OnMenu() ImGui.EndMenu(); } + + if (ImGui.BeginMenu("Edit")) + { + if (ImGui.MenuItem("Undo", UndoRedo.CanUndo)) + { + UndoRedo.Undo(); + } + + if (ImGui.MenuItem("Redo", UndoRedo.CanRedo)) + { + UndoRedo.Redo(); + } + + ImGui.EndMenu(); + } } private void New() { Reset(); + UndoRedo.Clear(); CurrentSchema = new Schema(); QueueSaveOptions(); } @@ -216,6 +237,7 @@ private void Open() Reset(); if (SchemaFile.TryLoad(filePath, out Schema? schema) && schema is not null) { + UndoRedo.Clear(); CurrentSchema = schema; CurrentSchemaPath = filePath; CurrentClass = CurrentSchema?.FirstClass; @@ -238,7 +260,10 @@ private void Save() if (CurrentSchema is not null) { - SchemaFile.TrySave(CurrentSchema, CurrentSchemaPath); + if (SchemaFile.TrySave(CurrentSchema, CurrentSchemaPath)) + { + UndoRedo.MarkAsSaved(); + } } } @@ -338,7 +363,13 @@ private void ShowMembers() string name = schemaMember.Name; if (ImGui.Button($"X##deleteMember{name}", new Vector2(frameHeight, 0))) { - schemaMember.TryRemove(); + SchemaMember captured = schemaMember; + SchemaClass parentClass = CurrentClass; + UndoRedo.Execute(new DelegateCommand( + $"Delete Member '{captured.Name}'", + () => captured.TryRemove(), + () => parentClass.RestoreMember(captured), + ChangeType.Delete)); } ImGui.SameLine(); diff --git a/SchemaEditor/TreeClass.cs b/SchemaEditor/TreeClass.cs index 916201c..ea185fd 100644 --- a/SchemaEditor/TreeClass.cs +++ b/SchemaEditor/TreeClass.cs @@ -12,8 +12,8 @@ namespace ktsu.SchemaEditor; using ktsu.ImGui.Widgets; using ktsu.Schema.Models; using ktsu.Schema.Models.Names; - using ktsu.Semantics.Strings; +using ktsu.UndoRedo; internal sealed class TreeClass(SchemaEditor schemaEditor) { @@ -45,7 +45,12 @@ internal void Show() { if (ImGui.Selectable($"Delete {x.Name}")) { - x.TryRemove(); + SchemaClass captured = x; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Delete Class '{captured.Name}'", + () => captured.TryRemove(), + () => schema.RestoreClass(captured), + ChangeType.Delete)); } }, }, parent: null); @@ -73,7 +78,12 @@ private void ShowMemberTree(ImGuiWidgets.Tree parent, SchemaClass schemaClass) { if (ImGui.Selectable($"Delete {x.Name}")) { - x.TryRemove(); + SchemaMember captured = x; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Delete Member '{captured.Name}'", + () => captured.TryRemove(), + () => schemaClass.RestoreMember(captured), + ChangeType.Delete)); } }, }, parent); @@ -89,14 +99,30 @@ private void ShowNewClass(Schema schema) Popups.OpenInputString("Input", "New Class Name", string.Empty, (newName) => { ClassName className = newName.As(); - if (schema.TryAddClass(className)) - { - schemaEditor.EditClass(className); - } - else + if (schema.GetClass(className) is not null) { Popups.OpenMessageOK("Error", $"A Class with that name ({newName}) already exists."); + return; } + + SchemaClass? addedClass = null; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Add Class '{className}'", + () => + { + if (addedClass is null) + { + addedClass = schema.AddClass(className); + } + else + { + schema.RestoreClass(addedClass); + } + + schemaEditor.EditClass(className); + }, + () => addedClass?.TryRemove(), + ChangeType.Insert)); }); } } @@ -110,15 +136,34 @@ private void ShowNewMember(SchemaClass schemaClass) { Popups.OpenInputString("Input", "New Member Name", string.Empty, (newName) => { - SchemaMember? schemaMember = schemaClass.AddMember(newName.As()); - if (schemaMember is not null) + MemberName memberName = newName.As(); + if (schemaClass.GetMember(memberName) is not null) { - Debug.Assert(schemaMember.ParentSchema is not null); - Popups.OpenTypeList("Select Type", "Type", schemaMember.ParentSchema.GetTypes(), schemaMember.Type, schemaMember.SetType); + Popups.OpenMessageOK("Error", $"A Member with that name ({newName}) already exists."); + return; } - else + + SchemaMember? addedMember = null; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Add Member '{memberName}'", + () => + { + if (addedMember is null) + { + addedMember = schemaClass.AddMember(memberName); + } + else + { + schemaClass.RestoreMember(addedMember); + } + }, + () => addedMember?.TryRemove(), + ChangeType.Insert)); + + if (addedMember is not null) { - Popups.OpenMessageOK("Error", $"A Member with that name ({newName}) already exists."); + Debug.Assert(addedMember.ParentSchema is not null); + Popups.OpenTypeList("Select Type", "Type", addedMember.ParentSchema.GetTypes(), addedMember.Type, addedMember.SetType); } }); } diff --git a/SchemaEditor/TreeCodeGenerator.cs b/SchemaEditor/TreeCodeGenerator.cs index e6a6749..eb7d233 100644 --- a/SchemaEditor/TreeCodeGenerator.cs +++ b/SchemaEditor/TreeCodeGenerator.cs @@ -9,8 +9,8 @@ namespace ktsu.SchemaEditor; using ktsu.ImGui.Styler; using ktsu.Schema.Models; using ktsu.Schema.Models.Names; - using ktsu.Semantics.Strings; +using ktsu.UndoRedo; internal sealed class TreeCodeGenerator(SchemaEditor schemaEditor) { @@ -40,7 +40,12 @@ internal void Show() { if (ImGui.Selectable($"Delete {x.Name}")) { - x.TryRemove(); + SchemaCodeGenerator captured = x; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Delete Code Generator '{captured.Name}'", + () => captured.TryRemove(), + () => schema.RestoreCodeGenerator(captured), + ChangeType.Delete)); } }, }, parent: null); @@ -56,10 +61,28 @@ private void ShowNewCodeGenerator(Schema schema) Popups.OpenInputString("Input", "New Code Generator Name", string.Empty, (newName) => { CodeGeneratorName codeGeneratorName = newName.As(); - if (!schema.TryAddCodeGenerator(codeGeneratorName)) + if (schema.GetCodeGenerator(codeGeneratorName) is not null) { Popups.OpenMessageOK("Error", $"A Code Generator with that name ({newName}) already exists."); + return; } + + SchemaCodeGenerator? addedCodeGenerator = null; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Add Code Generator '{codeGeneratorName}'", + () => + { + if (addedCodeGenerator is null) + { + addedCodeGenerator = schema.AddCodeGenerator(codeGeneratorName); + } + else + { + schema.RestoreCodeGenerator(addedCodeGenerator); + } + }, + () => addedCodeGenerator?.TryRemove(), + ChangeType.Insert)); }); } } diff --git a/SchemaEditor/TreeDataSource.cs b/SchemaEditor/TreeDataSource.cs index 02e3833..c84b921 100644 --- a/SchemaEditor/TreeDataSource.cs +++ b/SchemaEditor/TreeDataSource.cs @@ -9,8 +9,8 @@ namespace ktsu.SchemaEditor; using ktsu.ImGui.Styler; using ktsu.Schema.Models; using ktsu.Schema.Models.Names; - using ktsu.Semantics.Strings; +using ktsu.UndoRedo; internal sealed class TreeDataSource(SchemaEditor schemaEditor) { @@ -41,7 +41,12 @@ internal void Show() { if (ImGui.Selectable($"Delete {x.Name}")) { - x.TryRemove(); + DataSource captured = x; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Delete Data Source '{captured.Name}'", + () => captured.TryRemove(), + () => schema.RestoreDataSource(captured), + ChangeType.Delete)); } }, }, parent: null); @@ -57,14 +62,30 @@ private void ShowNewDataSource(Schema schema) Popups.OpenInputString("Input", "New Data Source Name", string.Empty, (newName) => { DataSourceName dataSourceName = newName.As(); - if (schema.TryAddDataSource(dataSourceName)) - { - schemaEditor.EditDataSource(dataSourceName); - } - else + if (schema.GetDataSource(dataSourceName) is not null) { Popups.OpenMessageOK("Error", $"A Data Source with that name ({newName}) already exists."); + return; } + + DataSource? addedDataSource = null; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Add Data Source '{dataSourceName}'", + () => + { + if (addedDataSource is null) + { + addedDataSource = schema.AddDataSource(dataSourceName); + } + else + { + schema.RestoreDataSource(addedDataSource); + } + + schemaEditor.EditDataSource(dataSourceName); + }, + () => addedDataSource?.TryRemove(), + ChangeType.Insert)); }); } } diff --git a/SchemaEditor/TreeEnum.cs b/SchemaEditor/TreeEnum.cs index 402707f..4fae49c 100644 --- a/SchemaEditor/TreeEnum.cs +++ b/SchemaEditor/TreeEnum.cs @@ -10,8 +10,8 @@ namespace ktsu.SchemaEditor; using ktsu.ImGui.Widgets; using ktsu.Schema.Models; using ktsu.Schema.Models.Names; - using ktsu.Semantics.Strings; +using ktsu.UndoRedo; internal sealed class TreeEnum(SchemaEditor schemaEditor) { @@ -42,7 +42,12 @@ internal void Show() { if (ImGui.Selectable($"Delete {x.Name}")) { - x.TryRemove(); + SchemaEnum captured = x; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Delete Enum '{captured.Name}'", + () => captured.TryRemove(), + () => schema.RestoreEnum(captured), + ChangeType.Delete)); } }, }, parent: null); @@ -60,7 +65,12 @@ private void ShowEnumValueTree(ImGuiWidgets.Tree parent, SchemaEnum schemaEnum) { if (ImGui.Selectable($"Delete {x}")) { - schemaEnum.TryRemoveValue(x); + EnumValueName captured = x; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Delete Enum Value '{captured}'", + () => schemaEnum.TryRemoveValue(captured), + () => schemaEnum.TryAddValue(captured), + ChangeType.Delete)); } }, OnTreeEnd = (t) => @@ -81,14 +91,29 @@ private void ShowNewEnum(Schema schema) { Popups.OpenInputString("Input", "New Enum Name", string.Empty, (newName) => { - if (schema.TryAddEnum(newName.As())) - { - - } - else + EnumName enumName = newName.As(); + if (schema.GetEnum(enumName) is not null) { Popups.OpenMessageOK("Error", $"An Enum with that name ({newName}) already exists."); + return; } + + SchemaEnum? addedEnum = null; + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Add Enum '{enumName}'", + () => + { + if (addedEnum is null) + { + addedEnum = schema.AddEnum(enumName); + } + else + { + schema.RestoreEnum(addedEnum); + } + }, + () => addedEnum?.TryRemove(), + ChangeType.Insert)); }); } } @@ -102,10 +127,18 @@ private void ShowNewEnumValue(SchemaEnum schemaEnum) { Popups.OpenInputString("Input", "New Enum Value", string.Empty, (newValue) => { - if (!schemaEnum.TryAddValue(newValue.As())) + EnumValueName valueName = newValue.As(); + if (schemaEnum.Values.Any(v => v == valueName)) { Popups.OpenMessageOK("Error", $"A Enum Value with that name ({newValue}) already exists."); + return; } + + schemaEditor.UndoRedo.Execute(new DelegateCommand( + $"Add Enum Value '{valueName}'", + () => schemaEnum.TryAddValue(valueName), + () => schemaEnum.TryRemoveValue(valueName), + ChangeType.Insert)); }); } } From bb219bd7830bdcaa1460473888d997818840f2ca Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 10 Feb 2026 16:39:33 +1100 Subject: [PATCH 04/12] [minor] Add keyboard shortcuts for common editor operations Add Ctrl+Z/Ctrl+Shift+Z for undo, Ctrl+Y for redo, Ctrl+S for save, Ctrl+N for new, Ctrl+O for open. Show shortcut hints in menu items. Skip text input fields when processing shortcuts. --- SchemaEditor/SchemaEditor.cs | 52 +++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/SchemaEditor/SchemaEditor.cs b/SchemaEditor/SchemaEditor.cs index fb9567c..dd9f22c 100644 --- a/SchemaEditor/SchemaEditor.cs +++ b/SchemaEditor/SchemaEditor.cs @@ -139,7 +139,47 @@ private void SaveOptionsInternal() private void QueueSaveOptions() => OptionsDirty = true; - private void OnTick(float dt) { } + private void OnTick(float dt) => ProcessKeyboardShortcuts(); + + private void ProcessKeyboardShortcuts() + { + ImGuiIOPtr io = ImGui.GetIO(); + if (io.WantTextInput) + { + return; + } + + bool ctrl = io.KeyCtrl; + bool shift = io.KeyShift; + + if (ctrl && ImGui.IsKeyPressed(ImGuiKey.Z, false)) + { + if (shift) + { + UndoRedo.Redo(); + } + else + { + UndoRedo.Undo(); + } + } + else if (ctrl && ImGui.IsKeyPressed(ImGuiKey.Y, false)) + { + UndoRedo.Redo(); + } + else if (ctrl && ImGui.IsKeyPressed(ImGuiKey.S, false)) + { + Save(); + } + else if (ctrl && ImGui.IsKeyPressed(ImGuiKey.N, false)) + { + New(); + } + else if (ctrl && ImGui.IsKeyPressed(ImGuiKey.O, false)) + { + Open(); + } + } private void OnRender(float dt) { @@ -177,17 +217,17 @@ private void OnMenu() { if (ImGui.BeginMenu("File")) { - if (ImGui.MenuItem("New")) + if (ImGui.MenuItem("New", "Ctrl+N")) { New(); } - if (ImGui.MenuItem("Open")) + if (ImGui.MenuItem("Open", "Ctrl+O")) { Open(); } - if (ImGui.MenuItem("Save")) + if (ImGui.MenuItem("Save", "Ctrl+S")) { Save(); } @@ -208,12 +248,12 @@ private void OnMenu() if (ImGui.BeginMenu("Edit")) { - if (ImGui.MenuItem("Undo", UndoRedo.CanUndo)) + if (ImGui.MenuItem("Undo", "Ctrl+Z", false, UndoRedo.CanUndo)) { UndoRedo.Undo(); } - if (ImGui.MenuItem("Redo", UndoRedo.CanRedo)) + if (ImGui.MenuItem("Redo", "Ctrl+Y", false, UndoRedo.CanRedo)) { UndoRedo.Redo(); } From 9390d6b5076bfe5ecad7b874d3446856196b4090 Mon Sep 17 00:00:00 2001 From: SyncFileContents Date: Sat, 14 Feb 2026 12:14:22 +1100 Subject: [PATCH 05/12] Sync global.json --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index a32aac3..0dcafe4 100644 --- a/global.json +++ b/global.json @@ -12,4 +12,4 @@ "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} From 4f80809cbe77dcf9da39aec4529a9e2f2b2a49aa Mon Sep 17 00:00:00 2001 From: SyncFileContents Date: Sat, 14 Feb 2026 12:14:24 +1100 Subject: [PATCH 06/12] Sync .github\workflows\dotnet.yml --- .github/workflows/dotnet.yml | 109 ++++++++++++++++------------------- 1 file changed, 51 insertions(+), 58 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4dfef5a..85f8bc2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -35,7 +35,6 @@ jobs: version: ${{ steps.pipeline.outputs.version }} release_hash: ${{ steps.pipeline.outputs.release_hash }} should_release: ${{ steps.pipeline.outputs.should_release }} - skipped_release: ${{ steps.pipeline.outputs.skipped_release }} steps: - name: Set up JDK 17 @@ -103,64 +102,48 @@ jobs: run: | .\.sonar\scanner\dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" /d:sonar.coverage.exclusions="**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll" /d:sonar.cs.vstest.reportsPaths="coverage/TestResults/**/*.trx" - - name: Run PSBuild Pipeline + - name: Clone KtsuBuild (Latest Tag) + run: | + LATEST_TAG=$(git ls-remote --tags https://github.com/ktsu-dev/KtsuBuild.git | grep -o 'refs/tags/v[0-9]*\.[0-9]*\.[0-9]*$' | sed 's/refs\/tags\///' | sort -V | tail -1 || true) + if [ -z "$LATEST_TAG" ]; then + echo "No version tags found, falling back to HEAD" + git clone --depth 1 https://github.com/ktsu-dev/KtsuBuild.git "${{ runner.temp }}/KtsuBuild" + else + echo "Cloning KtsuBuild at tag: $LATEST_TAG" + git clone --depth 1 --branch "$LATEST_TAG" https://github.com/ktsu-dev/KtsuBuild.git "${{ runner.temp }}/KtsuBuild" + fi + shell: bash + + - name: Run KtsuBuild CI Pipeline id: pipeline shell: pwsh env: GH_TOKEN: ${{ github.token }} + NUGET_API_KEY: ${{ secrets.NUGET_KEY }} + KTSU_PACKAGE_KEY: ${{ secrets.KTSU_PACKAGE_KEY }} + EXPECTED_OWNER: ktsu-dev run: | - # Import the PSBuild module - Import-Module ${{ github.workspace }}/scripts/PSBuild.psm1 - - # Get build configuration - $buildConfig = Get-BuildConfiguration ` - -ServerUrl "${{ github.server_url }}" ` - -GitRef "${{ github.ref }}" ` - -GitSha "${{ github.sha }}" ` - -GitHubOwner "${{ github.repository_owner }}" ` - -GitHubRepo "${{ github.repository }}" ` - -GithubToken "${{ github.token }}" ` - -NuGetApiKey "${{ secrets.NUGET_KEY }}" ` - -KtsuPackageKey "${{ secrets.KTSU_PACKAGE_KEY }}" ` - -WorkspacePath "${{ github.workspace }}" ` - -ExpectedOwner "ktsu-dev" ` - -ChangelogFile "CHANGELOG.md" ` - -AssetPatterns @("staging/*.nupkg", "staging/*.zip") - - if (-not $buildConfig.Success) { - throw $buildConfig.Error - } - - # Run the complete CI/CD pipeline - $result = Invoke-CIPipeline ` - -BuildConfiguration $buildConfig.Data - - if (-not $result.Success) { - Write-Information "CI/CD pipeline failed: $($result.Error)" -Tags "Invoke-CIPipeline" - Write-Information "Stack Trace: $($result.StackTrace)" -Tags "Invoke-CIPipeline" - Write-Information "Build Configuration: $($buildConfig.Data | ConvertTo-Json -Depth 10)" -Tags "Invoke-CIPipeline" - throw $result.Error - } - - # Set outputs for GitHub Actions from build configuration and pipeline result - # Use pipeline result values when available (for skipped releases), otherwise use buildConfig - if ($result.Data.SkippedRelease) { - "version=$($result.Data.Version)" >> $env:GITHUB_OUTPUT - "release_hash=$($result.Data.ReleaseHash)" >> $env:GITHUB_OUTPUT - "should_release=$($buildConfig.Data.ShouldRelease)" >> $env:GITHUB_OUTPUT - "skipped_release=true" >> $env:GITHUB_OUTPUT - } else { - "version=$($buildConfig.Data.Version)" >> $env:GITHUB_OUTPUT - "release_hash=$($buildConfig.Data.ReleaseHash)" >> $env:GITHUB_OUTPUT - "should_release=$($buildConfig.Data.ShouldRelease)" >> $env:GITHUB_OUTPUT - # Check for skipped release from buildConfig as fallback - if ($buildConfig.Data.SkippedRelease) { - "skipped_release=true" >> $env:GITHUB_OUTPUT - } - } + # Run the CI pipeline + dotnet run --project "${{ runner.temp }}/KtsuBuild/KtsuBuild.CLI" -- ci --workspace "${{ github.workspace }}" --verbose + + # Set outputs for downstream jobs + $version = (Get-Content "${{ github.workspace }}/VERSION.md" -Raw).Trim() + "version=$version" >> $env:GITHUB_OUTPUT + + $releaseHash = git rev-parse HEAD + "release_hash=$releaseHash" >> $env:GITHUB_OUTPUT + + # Compute should_release (same logic as BuildConfigurationProvider) + $isMain = "${{ github.ref }}" -eq "refs/heads/main" + $isTagged = [bool](git tag --points-at "${{ github.sha }}" 2>$null) + $isFork = "${{ github.event.repository.fork }}" -eq "true" + $isExpectedOwner = "${{ github.repository_owner }}" -eq "ktsu-dev" + $isOfficial = (-not $isFork) -and $isExpectedOwner + $shouldRelease = $isMain -and (-not $isTagged) -and $isOfficial + "should_release=$($shouldRelease.ToString().ToLower())" >> $env:GITHUB_OUTPUT - name: End SonarQube - if: env.SONAR_TOKEN != '' && steps.pipeline.outputs.skipped_release != 'true' + if: env.SONAR_TOKEN != '' env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell @@ -169,7 +152,7 @@ jobs: - name: Upload Coverage Report uses: actions/upload-artifact@v4 - if: always() && steps.pipeline.outputs.skipped_release != 'true' + if: always() with: name: coverage-report path: | @@ -179,7 +162,7 @@ jobs: winget: name: Update Winget Manifests needs: build - if: needs.build.outputs.should_release == 'true' && needs.build.outputs.skipped_release != 'true' + if: needs.build.outputs.should_release == 'true' runs-on: windows-latest timeout-minutes: 10 permissions: @@ -197,14 +180,24 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }}.x + - name: Clone KtsuBuild (Latest Tag) + run: | + LATEST_TAG=$(git ls-remote --tags https://github.com/ktsu-dev/KtsuBuild.git | grep -o 'refs/tags/v[0-9]*\.[0-9]*\.[0-9]*$' | sed 's/refs\/tags\///' | sort -V | tail -1 || true) + if [ -z "$LATEST_TAG" ]; then + echo "No version tags found, falling back to HEAD" + git clone --depth 1 https://github.com/ktsu-dev/KtsuBuild.git "${{ runner.temp }}/KtsuBuild" + else + echo "Cloning KtsuBuild at tag: $LATEST_TAG" + git clone --depth 1 --branch "$LATEST_TAG" https://github.com/ktsu-dev/KtsuBuild.git "${{ runner.temp }}/KtsuBuild" + fi + shell: bash + - name: Update Winget Manifests shell: pwsh env: GH_TOKEN: ${{ github.token }} run: | - # Use enhanced script with auto-detection capabilities - Write-Host "Updating winget manifests for version ${{ needs.build.outputs.version }}" - .\scripts\update-winget-manifests.ps1 -Version "${{ needs.build.outputs.version }}" + dotnet run --project "${{ runner.temp }}/KtsuBuild/KtsuBuild.CLI" -- winget generate --version "${{ needs.build.outputs.version }}" --workspace "${{ github.workspace }}" --verbose - name: Upload Updated Manifests uses: actions/upload-artifact@v4 @@ -216,7 +209,7 @@ jobs: security: name: Security Scanning needs: build - if: needs.build.outputs.should_release == 'true' && needs.build.outputs.skipped_release != 'true' + if: needs.build.outputs.should_release == 'true' runs-on: windows-latest timeout-minutes: 10 permissions: From a117a6283f716a769fd825d78b8dd90b7819abb5 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Sat, 14 Feb 2026 12:44:10 +1100 Subject: [PATCH 07/12] Remove legacy build scripts --- scripts/LICENSE.template | 23 - scripts/PSBuild.psd1 | 103 -- scripts/PSBuild.psm1 | 2500 --------------------------- scripts/README.md | 202 --- scripts/update-winget-manifests.ps1 | 1037 ----------- 5 files changed, 3865 deletions(-) delete mode 100644 scripts/LICENSE.template delete mode 100644 scripts/PSBuild.psd1 delete mode 100644 scripts/PSBuild.psm1 delete mode 100644 scripts/README.md delete mode 100644 scripts/update-winget-manifests.ps1 diff --git a/scripts/LICENSE.template b/scripts/LICENSE.template deleted file mode 100644 index ccf9e79..0000000 --- a/scripts/LICENSE.template +++ /dev/null @@ -1,23 +0,0 @@ -MIT License - -{PROJECT_URL} - -{COPYRIGHT} - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/scripts/PSBuild.psd1 b/scripts/PSBuild.psd1 deleted file mode 100644 index ff51087..0000000 --- a/scripts/PSBuild.psd1 +++ /dev/null @@ -1,103 +0,0 @@ -@{ - # Module information - RootModule = 'PSBuild.psm1' - ModuleVersion = '1.1.0' - GUID = '15dd2bfc-0f11-4c8a-b98a-f2529558f423' - Author = 'ktsu.dev' - CompanyName = 'ktsu.dev' - Copyright = '(c) 2023-2025 ktsu.dev. All rights reserved.' - Description = 'A comprehensive PowerShell module for automating the build, test, package, and release process for .NET applications using Git-based versioning.' - - # PowerShell version required - PowerShellVersion = '5.1' - - # Functions to export - FunctionsToExport = @( - # Core build and environment functions - 'Initialize-BuildEnvironment', - 'Get-BuildConfiguration', - - # Version management functions - 'Get-GitTags', - 'Get-VersionType', - 'Get-VersionInfoFromGit', - 'New-Version', - - # Version comparison and conversion functions - 'ConvertTo-FourComponentVersion', - 'Get-VersionNotes', - - # Metadata and documentation functions - 'New-Changelog', - 'Update-ProjectMetadata', - 'New-License', - - # .NET SDK operations - 'Invoke-DotNetRestore', - 'Invoke-DotNetBuild', - 'Invoke-DotNetTest', - 'Invoke-DotNetPack', - 'Invoke-DotNetPublish', - - # Release and publishing functions - 'Invoke-NuGetPublish', - 'New-GitHubRelease', - - # Utility functions - 'Assert-LastExitCode', - 'Write-StepHeader', - 'Test-AnyFiles', - 'Get-GitLineEnding', - 'Set-GitIdentity', - 'Write-InformationStream', - 'Invoke-ExpressionWithLogging', - - # High-level workflow functions - 'Invoke-BuildWorkflow', - 'Invoke-ReleaseWorkflow', - 'Invoke-CIPipeline' - ) - - # Variables to export - VariablesToExport = @() - - # Aliases to export - AliasesToExport = @() - - # Tags for PowerShell Gallery - PrivateData = @{ - PSData = @{ - Tags = @( - 'build', - 'dotnet', - 'ci', - 'cd', - 'nuget', - 'github', - 'versioning', - 'release', - 'automation' - ) - LicenseUri = 'https://github.com/ktsu-dev/PSBuild/blob/main/LICENSE.md' - ProjectUri = 'https://github.com/ktsu-dev/PSBuild' - ReleaseNotes = @' -v1.1.0: -- Improved object model using PSCustomObjects instead of hashtables -- Enhanced git status detection and commit handling -- Fixed logging and variable capture issues -- Added comprehensive help comments to all functions -- Added utility functions to the exported functions list - -v1.0.0: -- Initial release of PSBuild module featuring: -- Semantic versioning based on git history -- Automatic version calculation from commit analysis -- Metadata file generation and management -- Comprehensive build, test, and package pipeline -- NuGet package creation and publishing -- GitHub release creation with assets -- Proper line ending handling based on git config -'@ - } - } -} diff --git a/scripts/PSBuild.psm1 b/scripts/PSBuild.psm1 deleted file mode 100644 index 5f05f9e..0000000 --- a/scripts/PSBuild.psm1 +++ /dev/null @@ -1,2500 +0,0 @@ -# PSBuild Module for .NET CI/CD -# Author: ktsu.dev -# License: MIT -# -# A comprehensive PowerShell module for automating the build, test, package, -# and release process for .NET applications using Git-based versioning. -# See README.md for detailed documentation and usage examples. - -# Set Strict Mode -Set-StrictMode -Version Latest - -#region Environment and Configuration - -function Initialize-BuildEnvironment { - <# - .SYNOPSIS - Initializes the build environment with standard settings. - .DESCRIPTION - Sets up environment variables for .NET SDK and initializes other required build settings. - #> - [CmdletBinding()] - param() - - $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1' - $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' - $env:DOTNET_NOLOGO = 'true' - - Write-Information "Build environment initialized" -Tags "Initialize-BuildEnvironment" -} - -function Get-BuildConfiguration { - <# - .SYNOPSIS - Gets the build configuration based on Git status and environment. - .DESCRIPTION - Determines if this is a release build, checks Git status, and sets up build paths. - Returns a configuration object containing all necessary build settings and paths. - .PARAMETER ServerUrl - The server URL to use for the build. - .PARAMETER GitRef - The Git reference (branch/tag) being built. - .PARAMETER GitSha - The Git commit SHA being built. - .PARAMETER GitHubOwner - The GitHub owner of the repository. - .PARAMETER GitHubRepo - The GitHub repository name. - .PARAMETER GithubToken - The GitHub token for API operations. - .PARAMETER NuGetApiKey - The NuGet API key for package publishing. Optional - if not provided or empty, NuGet publishing will be skipped. - .PARAMETER KtsuPackageKey - The Ktsu package key for package publishing. Optional - if not provided or empty, Ktsu publishing will be skipped. - .PARAMETER WorkspacePath - The path to the workspace/repository root. - .PARAMETER ExpectedOwner - The expected owner/organization of the official repository. - .PARAMETER ChangelogFile - The path to the changelog file. - .PARAMETER LatestChangelogFile - The path to the file containing only the latest version's changelog. Defaults to "LATEST_CHANGELOG.md". - .PARAMETER AssetPatterns - Array of glob patterns for release assets. - .OUTPUTS - PSCustomObject containing build configuration data with Success, Error, and Data properties. - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param ( - [Parameter(Mandatory=$true)] - [string]$ServerUrl, - [Parameter(Mandatory=$true)] - [string]$GitRef, - [Parameter(Mandatory=$true)] - [string]$GitSha, - [Parameter(Mandatory=$true)] - [string]$GitHubOwner, - [Parameter(Mandatory=$true)] - [string]$GitHubRepo, - [Parameter(Mandatory=$true)] - [string]$GithubToken, - [Parameter(Mandatory=$false)] - [AllowEmptyString()] - [string]$NuGetApiKey = "", - [Parameter(Mandatory=$false)] - [AllowEmptyString()] - [string]$KtsuPackageKey = "", - [Parameter(Mandatory=$true)] - [string]$WorkspacePath, - [Parameter(Mandatory=$true)] - [string]$ExpectedOwner, - [Parameter(Mandatory=$true)] - [string]$ChangelogFile, - [Parameter(Mandatory=$false)] - [string]$LatestChangelogFile = "LATEST_CHANGELOG.md", - [Parameter(Mandatory=$true)] - [string[]]$AssetPatterns - ) - - # Determine if this is an official repo (verify owner and ensure it's not a fork) - $IS_OFFICIAL = $false - if ($GithubToken) { - try { - $env:GH_TOKEN = $GithubToken - $repoInfo = "gh repo view --json owner,nameWithOwner,isFork 2>`$null" | Invoke-ExpressionWithLogging -Tags "Get-BuildConfiguration" | ConvertFrom-Json - if ($repoInfo) { - # Consider it official only if it's not a fork AND belongs to the expected owner - $IS_OFFICIAL = (-not $repoInfo.isFork) -and ($repoInfo.owner.login -eq $ExpectedOwner) - Write-Information "Repository: $($repoInfo.nameWithOwner), Is Fork: $($repoInfo.isFork), Owner: $($repoInfo.owner.login)" -Tags "Get-BuildConfiguration" - } else { - Write-Information "Could not retrieve repository information. Assuming unofficial build." -Tags "Get-BuildConfiguration" - } - } - catch { - Write-Information "Failed to check repository status: $_. Assuming unofficial build." -Tags "Get-BuildConfiguration" - } - } - - Write-Information "Is Official: $IS_OFFICIAL" -Tags "Get-BuildConfiguration" - - # Determine if this is main branch and not tagged - $IS_MAIN = $GitRef -eq "refs/heads/main" - $IS_TAGGED = "(git show-ref --tags -d | Out-String).Contains(`"$GitSha`")" | Invoke-ExpressionWithLogging -Tags "Get-BuildConfiguration" - $SHOULD_RELEASE = ($IS_MAIN -AND -NOT $IS_TAGGED -AND $IS_OFFICIAL) - - # Check for .csx files (dotnet-script) - $csx = @(Get-ChildItem -Path $WorkspacePath -Recurse -Filter *.csx -ErrorAction SilentlyContinue) - $USE_DOTNET_SCRIPT = $csx.Count -gt 0 - - # Setup paths - $OUTPUT_PATH = Join-Path $WorkspacePath 'output' - $STAGING_PATH = Join-Path $WorkspacePath 'staging' - - # Setup artifact patterns - $PACKAGE_PATTERN = Join-Path $STAGING_PATH "*.nupkg" - $SYMBOLS_PATTERN = Join-Path $STAGING_PATH "*.snupkg" - $APPLICATION_PATTERN = Join-Path $STAGING_PATH "*.zip" - - # Set build arguments - $BUILD_ARGS = "" - if ($USE_DOTNET_SCRIPT) { - $BUILD_ARGS = "-maxCpuCount:1" - } - - # Create configuration object with standard format - $config = [PSCustomObject]@{ - Success = $true - Error = "" - Data = @{ - IsOfficial = $IS_OFFICIAL - IsMain = $IS_MAIN - IsTagged = $IS_TAGGED - ShouldRelease = $SHOULD_RELEASE - UseDotnetScript = $USE_DOTNET_SCRIPT - OutputPath = $OUTPUT_PATH - StagingPath = $STAGING_PATH - PackagePattern = $PACKAGE_PATTERN - SymbolsPattern = $SYMBOLS_PATTERN - ApplicationPattern = $APPLICATION_PATTERN - BuildArgs = $BUILD_ARGS - WorkspacePath = $WorkspacePath - DotnetVersion = $script:DOTNET_VERSION - ServerUrl = $ServerUrl - GitRef = $GitRef - GitSha = $GitSha - GitHubOwner = $GitHubOwner - GitHubRepo = $GitHubRepo - GithubToken = $GithubToken - NuGetApiKey = $NuGetApiKey - KtsuPackageKey = $KtsuPackageKey - ExpectedOwner = $ExpectedOwner - Version = "1.0.0-pre.0" - ReleaseHash = $GitSha - ChangelogFile = $ChangelogFile - LatestChangelogFile = $LatestChangelogFile - AssetPatterns = $AssetPatterns - } - } - - return $config -} - -#endregion - -#region Version Management - -function Get-GitTags { - <# - .SYNOPSIS - Gets sorted git tags from the repository. - .DESCRIPTION - Retrieves a list of git tags sorted by version in descending order. - Returns a default tag if no tags exist. - #> - [CmdletBinding()] - [OutputType([string[]])] - param () - - # Configure git versionsort to correctly handle prereleases - $suffixes = @('-alpha', '-beta', '-rc', '-pre') - foreach ($suffix in $suffixes) { - "git config versionsort.suffix `"$suffix`"" | Invoke-ExpressionWithLogging -Tags "Get-GitTags" | Write-InformationStream -Tags "Get-GitTags" - } - - Write-Information "Getting sorted tags..." -Tags "Get-GitTags" - # Get tags - $output = "git tag --list --sort=-v:refname" | Invoke-ExpressionWithLogging -Tags "Get-GitTags" - - # Ensure we always return an array - if ($null -eq $output) { - Write-Information "No tags found, returning empty array" -Tags "Get-GitTags" - return @() - } - - # Convert to array if it's not already - if ($output -isnot [array]) { - if ([string]::IsNullOrWhiteSpace($output)) { - Write-Information "No tags found, returning empty array" -Tags "Get-GitTags" - return @() - } - $output = @($output) - } - - if ($output.Count -eq 0) { - Write-Information "No tags found, returning empty array" -Tags "Get-GitTags" - return @() - } - - Write-Information "Found $($output.Count) tags" -Tags "Get-GitTags" - return $output -} - -function Get-VersionType { - <# - .SYNOPSIS - Determines the type of version bump needed based on commit history and public API changes - .DESCRIPTION - Analyzes commit messages and code changes to determine whether the next version should be: - - Major (1.0.0 → 2.0.0): Breaking changes, indicated by [major] tags in commits - - Minor (1.0.0 → 1.1.0): Non-breaking public API changes (additions, modifications, removals) - - Patch (1.0.0 → 1.0.1): Bug fixes and changes that don't modify the public API - - Prerelease (1.0.0 → 1.0.1-pre.1): Small changes or no significant changes - - Skip: Only [skip ci] commits or no significant changes requiring a version bump - - Version bump determination follows these rules in order: - 1. Explicit tags in commit messages: [major], [minor], [patch], [pre] - 2. Public API changes detection via regex patterns (triggers minor bump) - 3. Code changes that don't modify public API (triggers patch bump) - 4. Default to prerelease bump for minimal changes - 5. If only [skip ci] commits are found, suggest skipping the release - .PARAMETER Range - The git commit range to analyze (e.g., "v1.0.0...HEAD" or a specific commit range) - .OUTPUTS - Returns a PSCustomObject with 'Type' and 'Reason' properties explaining the version increment decision. - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param ( - [Parameter(Mandatory=$true)] - [string]$Range - ) - - # Initialize to the most conservative version bump - $versionType = "prerelease" - $reason = "No significant changes detected" - - # Bot and PR patterns to exclude - $EXCLUDE_BOTS = '^(?!.*(\[bot\]|github|ProjectDirector|SyncFileContents)).*$' - $EXCLUDE_PRS = '^.*(Merge pull request|Merge branch ''main''|Updated packages in|Update.*package version).*$' - - # First check for explicit version markers in commit messages - $messages = "git log --format=format:%s `"$Range`"" | Invoke-ExpressionWithLogging -Tags "Get-VersionType" - - # Ensure messages is always an array - if ($null -eq $messages) { - $messages = @() - } elseif ($messages -isnot [array]) { - $messages = @($messages) - } - - # Check if we have any commits at all - if (@($messages).Count -eq 0) { - return [PSCustomObject]@{ - Type = "skip" - Reason = "No commits found in the specified range" - } - } - - # Check if all commits are skip ci commits - $skipCiPattern = '\[skip ci\]|\[ci skip\]' - $skipCiCommits = $messages | Where-Object { $_ -match $skipCiPattern } - - if (@($skipCiCommits).Count -eq @($messages).Count -and @($messages).Count -gt 0) { - return [PSCustomObject]@{ - Type = "skip" - Reason = "All commits contain [skip ci] tag, skipping release" - } - } - - foreach ($message in $messages) { - if ($message.Contains('[major]')) { - $versionType = 'major' - $reason = "Explicit [major] tag found in commit message: $message" - # Return immediately for major version bumps - return [PSCustomObject]@{ - Type = $versionType - Reason = $reason - } - } elseif ($message.Contains('[minor]') -and $versionType -ne 'major') { - $versionType = 'minor' - $reason = "Explicit [minor] tag found in commit message: $message" - } elseif ($message.Contains('[patch]') -and $versionType -notin @('major', 'minor')) { - $versionType = 'patch' - $reason = "Explicit [patch] tag found in commit message: $message" - } elseif ($message.Contains('[pre]') -and $versionType -eq 'prerelease') { - # Keep as prerelease, but update reason - $reason = "Explicit [pre] tag found in commit message: $message" - } - } - - # If no explicit version markers, check for code changes - if ($versionType -eq "prerelease") { - # Check for any commits that would warrant at least a patch version - $patchCommits = "git log -n 1 --topo-order --perl-regexp --regexp-ignore-case --format=format:%H --committer=`"$EXCLUDE_BOTS`" --author=`"$EXCLUDE_BOTS`" --grep=`"$EXCLUDE_PRS`" --invert-grep `"$Range`"" | Invoke-ExpressionWithLogging -Tags "Get-VersionType" - - if ($patchCommits) { - $versionType = "patch" - $reason = "Found changes warranting at least a patch version" - - # Check for public API changes that would warrant a minor version - - # First, check if we can detect public API changes via git diff - $apiChangePatterns = @( - # C# public API patterns - '^\+\s*(public|protected)\s+(class|interface|enum|struct|record)\s+\w+', # Added public types - '^\+\s*(public|protected)\s+\w+\s+\w+\s*\(', # Added public methods - '^\+\s*(public|protected)\s+\w+(\s+\w+)*\s*{', # Added public properties - '^\-\s*(public|protected)\s+(class|interface|enum|struct|record)\s+\w+', # Removed public types - '^\-\s*(public|protected)\s+\w+\s+\w+\s*\(', # Removed public methods - '^\-\s*(public|protected)\s+\w+(\s+\w+)*\s*{', # Removed public properties - '^\+\s*public\s+const\s', # Added public constants - '^\-\s*public\s+const\s' # Removed public constants - ) - - # Combine patterns for git diff - $apiChangePattern = "(" + ($apiChangePatterns -join ")|(") + ")" - - # Search for API changes - $apiDiffCmd = "git diff `"$Range`" -- `"*.cs`" | Select-String -Pattern `"$apiChangePattern`" -SimpleMatch" - $apiChanges = Invoke-Expression $apiDiffCmd - - if ($apiChanges) { - $versionType = "minor" - $reason = "Public API changes detected (additions, removals, or modifications)" - return [PSCustomObject]@{ - Type = $versionType - Reason = $reason - } - } - } - } - - return [PSCustomObject]@{ - Type = $versionType - Reason = $reason - } -} - -function Get-VersionInfoFromGit { - <# - .SYNOPSIS - Gets comprehensive version information based on Git tags and commit analysis. - .DESCRIPTION - Finds the most recent version tag, analyzes commit history, and determines the next version - following semantic versioning principles. Returns a rich object with all version components. - .PARAMETER CommitHash - The Git commit hash being built. - .PARAMETER InitialVersion - The version to use if no tags exist. Defaults to "1.0.0". - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param ( - [Parameter(Mandatory=$true)] - [string]$CommitHash, - [string]$InitialVersion = "1.0.0" - ) - - Write-StepHeader "Analyzing Version Information" -Tags "Get-VersionInfoFromGit" - Write-Information "Analyzing repository for version information..." -Tags "Get-VersionInfoFromGit" - Write-Information "Commit hash: $CommitHash" -Tags "Get-VersionInfoFromGit" - - # Get all tags - $tags = Get-GitTags - - # Ensure tags is always an array - if ($null -eq $tags) { - $tags = @() - } elseif ($tags -isnot [array]) { - $tags = @($tags) - } - - Write-Information "Found $(@($tags).Count) tag(s)" -Tags "Get-VersionInfoFromGit" - - # Get the last tag and its commit - $usingFallbackTag = $false - $lastTag = "" - - if (@($tags).Count -eq 0) { - $lastTag = "v$InitialVersion-pre.0" - $usingFallbackTag = $true - Write-Information "No tags found. Using fallback: $lastTag" -Tags "Get-VersionInfoFromGit" - } else { - $lastTag = $tags[0] - Write-Information "Using last tag: $lastTag" -Tags "Get-VersionInfoFromGit" - } - - # Extract the version without 'v' prefix - $lastVersion = $lastTag -replace 'v', '' - Write-Information "Last version: $lastVersion" -Tags "Get-VersionInfoFromGit" - - # Parse previous version - $wasPrerelease = $lastVersion.Contains('-') - $cleanVersion = $lastVersion -replace '-alpha.*$', '' -replace '-beta.*$', '' -replace '-rc.*$', '' -replace '-pre.*$', '' - - $parts = $cleanVersion -split '\.' - $lastMajor = [int]$parts[0] - $lastMinor = [int]$parts[1] - $lastPatch = [int]$parts[2] - $lastPrereleaseNum = 0 - - # Extract prerelease number if applicable - if ($wasPrerelease -and $lastVersion -match '-(?:pre|alpha|beta|rc)\.(\d+)') { - $lastPrereleaseNum = [int]$Matches[1] - } - - # Determine version increment type based on commit range - Write-Information "$($script:lineEnding)Getting commits to analyze..." -Tags "Get-VersionInfoFromGit" - - # Get the first commit in repo for fallback - $firstCommit = "git rev-list HEAD" | Invoke-ExpressionWithLogging -Tags "Get-VersionInfoFromGit" - if ($firstCommit -is [array] -and @($firstCommit).Count -gt 0) { - $firstCommit = $firstCommit[-1] - } - Write-Information "First commit: $firstCommit" -Tags "Get-VersionInfoFromGit" - - # Find the last tag's commit - $lastTagCommit = "" - if ($usingFallbackTag) { - $lastTagCommit = $firstCommit - Write-Information "Using first commit as starting point: $firstCommit" -Tags "Get-VersionInfoFromGit" - } else { - $lastTagCommit = "git rev-list -n 1 $lastTag" | Invoke-ExpressionWithLogging -Tags "Get-VersionInfoFromGit" - Write-Information "Last tag commit: $lastTagCommit" -Tags "Get-VersionInfoFromGit" - } - - # Define the commit range to analyze - $commitRange = "$lastTagCommit..$CommitHash" - Write-Information "Analyzing commit range: $commitRange" -Tags "Get-VersionInfoFromGit" - - # Get the increment type - $incrementInfo = Get-VersionType -Range $commitRange - $incrementType = $incrementInfo.Type - $incrementReason = $incrementInfo.Reason - - # If type is "skip", return the current version without bumping - if ($incrementType -eq "skip") { - Write-Information "Version increment type: $incrementType" -Tags "Get-VersionInfoFromGit" - Write-Information "Reason: $incrementReason" -Tags "Get-VersionInfoFromGit" - - # Use the same version, don't increment - $newVersion = $lastVersion - - return [PSCustomObject]@{ - Success = $true - Error = "" - Data = [PSCustomObject]@{ - Version = $newVersion - Major = $lastMajor - Minor = $lastMinor - Patch = $lastPatch - IsPrerelease = $wasPrerelease - PrereleaseNumber = $lastPrereleaseNum - PrereleaseLabel = if ($wasPrerelease) { ($lastVersion -split '-')[1].Split('.')[0] } else { "pre" } - LastTag = $lastTag - LastVersion = $lastVersion - LastVersionMajor = $lastMajor - LastVersionMinor = $lastMinor - LastVersionPatch = $lastPatch - WasPrerelease = $wasPrerelease - LastVersionPrereleaseNumber = $lastPrereleaseNum - VersionIncrement = $incrementType - IncrementReason = $incrementReason - FirstCommit = $firstCommit - LastCommit = $CommitHash - LastTagCommit = $lastTagCommit - UsingFallbackTag = $usingFallbackTag - CommitRange = $commitRange - } - } - } - - # Initialize new version with current values - $newMajor = $lastMajor - $newMinor = $lastMinor - $newPatch = $lastPatch - $newPrereleaseNum = 0 - $isPrerelease = $false - $prereleaseLabel = "pre" - - Write-Information "$($script:lineEnding)Calculating new version..." -Tags "Get-VersionInfoFromGit" - - # Calculate new version based on increment type - switch ($incrementType) { - 'major' { - $newMajor = $lastMajor + 1 - $newMinor = 0 - $newPatch = 0 - Write-Information "Incrementing major version: $lastMajor.$lastMinor.$lastPatch -> $newMajor.0.0" -Tags "Get-VersionInfoFromGit" - } - 'minor' { - $newMinor = $lastMinor + 1 - $newPatch = 0 - Write-Information "Incrementing minor version: $lastMajor.$lastMinor.$lastPatch -> $lastMajor.$newMinor.0" -Tags "Get-VersionInfoFromGit" - } - 'patch' { - if (-not $wasPrerelease) { - $newPatch = $lastPatch + 1 - Write-Information "Incrementing patch version: $lastMajor.$lastMinor.$lastPatch -> $lastMajor.$lastMinor.$newPatch" -Tags "Get-VersionInfoFromGit" - } else { - Write-Information "Converting prerelease to stable version: $lastVersion -> $lastMajor.$lastMinor.$lastPatch" -Tags "Get-VersionInfoFromGit" - } - } - 'prerelease' { - if ($wasPrerelease) { - # Bump prerelease number - $newPrereleaseNum = $lastPrereleaseNum + 1 - $isPrerelease = $true - Write-Information "Incrementing prerelease: $lastVersion -> $lastMajor.$lastMinor.$lastPatch-$prereleaseLabel.$newPrereleaseNum" -Tags "Get-VersionInfoFromGit" - } else { - # Start new prerelease series - $newPatch = $lastPatch + 1 - $newPrereleaseNum = 1 - $isPrerelease = $true - Write-Information "Starting new prerelease: $lastVersion -> $lastMajor.$lastMinor.$newPatch-$prereleaseLabel.1" -Tags "Get-VersionInfoFromGit" - } - } - } - - # Build version string - $newVersion = "$newMajor.$newMinor.$newPatch" - if ($isPrerelease) { - $newVersion += "-$prereleaseLabel.$newPrereleaseNum" - } - - Write-Information "$($script:lineEnding)Version decision:" -Tags "Get-VersionInfoFromGit" - Write-Information "Previous version: $lastVersion" -Tags "Get-VersionInfoFromGit" - Write-Information "New version: $newVersion" -Tags "Get-VersionInfoFromGit" - Write-Information "Reason: $incrementReason" -Tags "Get-VersionInfoFromGit" - - try { - # Return comprehensive object with standard format - return [PSCustomObject]@{ - Success = $true - Error = "" - Data = [PSCustomObject]@{ - Version = $newVersion - Major = $newMajor - Minor = $newMinor - Patch = $newPatch - IsPrerelease = $isPrerelease - PrereleaseNumber = $newPrereleaseNum - PrereleaseLabel = $prereleaseLabel - LastTag = $lastTag - LastVersion = $lastVersion - LastVersionMajor = $lastMajor - LastVersionMinor = $lastMinor - LastVersionPatch = $lastPatch - WasPrerelease = $wasPrerelease - LastVersionPrereleaseNumber = $lastPrereleaseNum - VersionIncrement = $incrementType - IncrementReason = $incrementReason - FirstCommit = $firstCommit - LastCommit = $CommitHash - LastTagCommit = $lastTagCommit - UsingFallbackTag = $usingFallbackTag - CommitRange = $commitRange - } - } - } - catch { - return [PSCustomObject]@{ - Success = $false - Error = $_.ToString() - Data = [PSCustomObject]@{ - ErrorDetails = $_.Exception.Message - StackTrace = $_.ScriptStackTrace - } - StackTrace = $_.ScriptStackTrace - } - } -} - -function New-Version { - <# - .SYNOPSIS - Creates a new version file and sets environment variables. - .DESCRIPTION - Generates a new version number based on git history, writes it to version files, - and optionally sets GitHub environment variables for use in Actions. - .PARAMETER CommitHash - The Git commit hash being built. - .PARAMETER OutputPath - Optional path to write the version file to. Defaults to workspace root. - #> - [CmdletBinding()] - [OutputType([string])] - param ( - [Parameter(Mandatory=$true)] - [string]$CommitHash, - [string]$OutputPath = "" - ) - - # Get complete version information object - $versionInfo = Get-VersionInfoFromGit -CommitHash $CommitHash - - # Write version file with correct line ending - $filePath = if ($OutputPath) { Join-Path $OutputPath "VERSION.md" } else { "VERSION.md" } - $version = $versionInfo.Data.Version.Trim() - [System.IO.File]::WriteAllText($filePath, $version + $script:lineEnding, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-Version" - - Write-Information "Previous version: $($versionInfo.Data.LastVersion), New version: $($versionInfo.Data.Version)" -Tags "New-Version" - - return $versionInfo.Data.Version -} - -#endregion - -#region License Management - -function New-License { - <# - .SYNOPSIS - Creates a license file from template. - .DESCRIPTION - Generates a LICENSE.md file using the template and repository information. - .PARAMETER ServerUrl - The GitHub server URL. - .PARAMETER Owner - The repository owner/organization. - .PARAMETER Repository - The repository name. - .PARAMETER OutputPath - Optional path to write the license file to. Defaults to workspace root. - #> - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)] - [string]$ServerUrl, - [Parameter(Mandatory=$true)] - [string]$Owner, - [Parameter(Mandatory=$true)] - [string]$Repository, - [string]$OutputPath = "" - ) - - if (-not (Test-Path $script:LICENSE_TEMPLATE)) { - throw "License template not found at: $script:LICENSE_TEMPLATE" - } - - $year = (Get-Date).Year - $content = Get-Content $script:LICENSE_TEMPLATE -Raw - - # Project URL - $projectUrl = "$ServerUrl/$Repository" - $content = $content.Replace('{PROJECT_URL}', $projectUrl) - - # Copyright line - $copyright = "Copyright (c) 2023-$year $Owner contributors" - $content = $content.Replace('{COPYRIGHT}', $copyright) - - # Normalize line endings - $content = $content.ReplaceLineEndings($script:lineEnding) - - $copyrightFilePath = if ($OutputPath) { Join-Path $OutputPath "COPYRIGHT.md" } else { "COPYRIGHT.md" } - [System.IO.File]::WriteAllText($copyrightFilePath, $copyright + $script:lineEnding, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-License" - - $filePath = if ($OutputPath) { Join-Path $OutputPath "LICENSE.md" } else { "LICENSE.md" } - [System.IO.File]::WriteAllText($filePath, $content, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-License" - - Write-Information "License file created at: $filePath" -Tags "New-License" -} - -#endregion - -#region Changelog Management - -function ConvertTo-FourComponentVersion { - <# - .SYNOPSIS - Converts a version tag to a four-component version for comparison. - .DESCRIPTION - Standardizes version tags to a four-component version (major.minor.patch.prerelease) for easier comparison. - .PARAMETER VersionTag - The version tag to convert. - #> - [CmdletBinding()] - [OutputType([string])] - param ( - [Parameter(Mandatory=$true)] - [string]$VersionTag - ) - - $version = $VersionTag -replace 'v', '' - $version = $version -replace '-alpha', '' -replace '-beta', '' -replace '-rc', '' -replace '-pre', '' - $versionComponents = $version -split '\.' - $versionMajor = [int]$versionComponents[0] - $versionMinor = [int]$versionComponents[1] - $versionPatch = [int]$versionComponents[2] - $versionPrerelease = 0 - - if (@($versionComponents).Count -gt 3) { - $versionPrerelease = [int]$versionComponents[3] - } - - return "$versionMajor.$versionMinor.$versionPatch.$versionPrerelease" -} - -function Get-VersionNotes { - <# - .SYNOPSIS - Generates changelog notes for a specific version range. - .DESCRIPTION - Creates formatted changelog entries for commits between two version tags. - .PARAMETER Tags - All available tags in the repository. - .PARAMETER FromTag - The starting tag of the range. - .PARAMETER ToTag - The ending tag of the range. - .PARAMETER ToSha - Optional specific commit SHA to use as the range end. - #> - [CmdletBinding()] - [OutputType([string])] - param ( - [Parameter(Mandatory=$true)] - [AllowEmptyCollection()] - [string[]]$Tags, - [Parameter(Mandatory=$true)] - [string]$FromTag, - [Parameter(Mandatory=$true)] - [string]$ToTag, - [Parameter()] - [string]$ToSha = "" - ) - - # Define common patterns used for filtering commits - $EXCLUDE_BOTS = '^(?!.*(\[bot\]|github|ProjectDirector|SyncFileContents)).*$' - $EXCLUDE_PRS = '^.*(Merge pull request|Merge branch ''main''|Updated packages in|Update.*package version).*$' - - # Convert tags to comparable versions - $toVersion = ConvertTo-FourComponentVersion -VersionTag $ToTag - $fromVersion = ConvertTo-FourComponentVersion -VersionTag $FromTag - - # Parse components for comparison - $toVersionComponents = $toVersion -split '\.' - $toVersionMajor = [int]$toVersionComponents[0] - $toVersionMinor = [int]$toVersionComponents[1] - $toVersionPatch = [int]$toVersionComponents[2] - $toVersionPrerelease = [int]$toVersionComponents[3] - - $fromVersionComponents = $fromVersion -split '\.' - $fromVersionMajor = [int]$fromVersionComponents[0] - $fromVersionMinor = [int]$fromVersionComponents[1] - $fromVersionPatch = [int]$fromVersionComponents[2] - $fromVersionPrerelease = [int]$fromVersionComponents[3] - - # Calculate previous version numbers for finding the correct tag - $fromMajorVersionNumber = $toVersionMajor - 1 - $fromMinorVersionNumber = $toVersionMinor - 1 - $fromPatchVersionNumber = $toVersionPatch - 1 - $fromPrereleaseVersionNumber = $toVersionPrerelease - 1 - - # Determine version type and search tag - $searchTag = $FromTag - $versionType = "unknown" - - if ($toVersionPrerelease -ne 0) { - $versionType = "prerelease" - $searchTag = "$toVersionMajor.$toVersionMinor.$toVersionPatch.$fromPrereleaseVersionNumber" - } - else { - if ($toVersionPatch -gt $fromVersionPatch) { - $versionType = "patch" - $searchTag = "$toVersionMajor.$toVersionMinor.$fromPatchVersionNumber.0" - } - if ($toVersionMinor -gt $fromVersionMinor) { - $versionType = "minor" - $searchTag = "$toVersionMajor.$fromMinorVersionNumber.0.0" - } - if ($toVersionMajor -gt $fromVersionMajor) { - $versionType = "major" - $searchTag = "$fromMajorVersionNumber.0.0.0" - } - } - - # Handle case where version is same but prerelease was dropped - if ($toVersionMajor -eq $fromVersionMajor -and - $toVersionMinor -eq $fromVersionMinor -and - $toVersionPatch -eq $fromVersionPatch -and - $toVersionPrerelease -eq 0 -and - $fromVersionPrerelease -ne 0) { - $versionType = "patch" - $searchTag = "$toVersionMajor.$toVersionMinor.$fromPatchVersionNumber.0" - } - - if ($searchTag.Contains("-")) { - $searchTag = $FromTag - } - - $searchVersion = ConvertTo-FourComponentVersion -VersionTag $searchTag - - if ($FromTag -ne "v0.0.0") { - $foundSearchTag = $false - $Tags | ForEach-Object { - if (-not $foundSearchTag) { - $otherTag = $_ - $otherVersion = ConvertTo-FourComponentVersion -VersionTag $otherTag - if ($searchVersion -eq $otherVersion) { - $foundSearchTag = $true - $searchTag = $otherTag - } - } - } - - if (-not $foundSearchTag) { - $searchTag = $FromTag - } - } - - $rangeFrom = $searchTag - if ($rangeFrom -eq "v0.0.0" -or $rangeFrom -eq "0.0.0.0" -or $rangeFrom -eq "1.0.0.0") { - $rangeFrom = "" - } - - $rangeTo = $ToSha - if ($rangeTo -eq "") { - $rangeTo = $ToTag - } - - # Determine proper commit range - $isNewestVersion = $false - if ($ToSha -ne "") { - # If ToSha is provided, this is likely the newest version being generated - $isNewestVersion = $true - } - - # Get the actual commit SHA for the from tag if it exists - $range = "" - $fromSha = "" - $gitSuccess = $true - - if ($rangeFrom -ne "") { - try { - # Try to get the SHA for the from tag, but don't error if it doesn't exist - $fromSha = "git rev-list -n 1 $rangeFrom 2>`$null" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue - if ($LASTEXITCODE -ne 0) { - Write-Information "Warning: Could not find SHA for tag $rangeFrom. Using fallback range." -Tags "Get-VersionNotes" - $gitSuccess = $false - $fromSha = "" - } - - # For the newest version with SHA provided (not yet tagged): - if ($isNewestVersion -and $ToSha -ne "" -and $gitSuccess) { - $range = "$fromSha..$ToSha" - } elseif ($gitSuccess) { - # For already tagged versions, get the SHA for the to tag - $toShaResolved = "git rev-list -n 1 $rangeTo 2>`$null" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue - if ($LASTEXITCODE -ne 0) { - Write-Information "Warning: Could not find SHA for tag $rangeTo. Using fallback range." -Tags "Get-VersionNotes" - $gitSuccess = $false - } - else { - $range = "$fromSha..$toShaResolved" - } - } - } - catch { - Write-Information "Error getting commit SHAs: $_" -Tags "Get-VersionNotes" - $gitSuccess = $false - } - } - - # Handle case with no FROM tag (first version) or failed git commands - if ($rangeFrom -eq "" -or -not $gitSuccess) { - if ($ToSha -ne "") { - $range = $ToSha - } else { - try { - $toShaResolved = "git rev-list -n 1 $rangeTo 2>`$null" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue - if ($LASTEXITCODE -eq 0) { - $range = $toShaResolved - } else { - # If we can't resolve either tag, use HEAD as fallback - $range = "HEAD" - } - } - catch { - Write-Information "Error resolving tag SHA: $_. Using HEAD instead." -Tags "Get-VersionNotes" - $range = "HEAD" - } - } - } - - # Debug output - Write-Information "Processing range: $range (From: $rangeFrom, To: $rangeTo)" -Tags "Get-VersionNotes" - - # For repositories with no valid tags or no commits between tags, handle gracefully - if ([string]::IsNullOrWhiteSpace($range) -or $range -eq ".." -or $range -match '^\s*$') { - Write-Information "No valid commit range found. Creating a placeholder entry." -Tags "Get-VersionNotes" - $versionType = "initial" # Mark as initial release - $versionChangelog = "## $ToTag (initial release)$script:lineEnding$script:lineEnding" - $versionChangelog += "Initial version.$script:lineEnding$script:lineEnding" - return ($versionChangelog.Trim() + $script:lineEnding) - } - - # Try with progressively more relaxed filtering to ensure we show commits - $rawCommits = @() - - try { - # Get full commit info with hash to ensure uniqueness - $format = '%h|%s|%aN' - - # First try with standard filters - $rawCommitsResult = "git log --pretty=format:`"$format`" --perl-regexp --regexp-ignore-case --grep=`"$EXCLUDE_PRS`" --invert-grep --committer=`"$EXCLUDE_BOTS`" --author=`"$EXCLUDE_BOTS`" `"$range`"" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue - - # Safely convert to array and handle any errors - $rawCommits = ConvertTo-ArraySafe -InputObject $rawCommitsResult - - # Additional safety check - ensure we have a valid array with Count property - if ($null -eq $rawCommits) { - Write-Information "rawCommits is null, creating empty array" -Tags "Get-VersionNotes" - $rawCommits = @() - } - - # Use @() subexpression to safely get count - $rawCommitsCount = @($rawCommits).Count - - # If no commits found, try with just PR exclusion but no author filtering - if ($rawCommitsCount -eq 0) { - Write-Information "No commits found with standard filters, trying with relaxed author/committer filters..." -Tags "Get-VersionNotes" - $rawCommitsResult = "git log --pretty=format:`"$format`" --perl-regexp --regexp-ignore-case --grep=`"$EXCLUDE_PRS`" --invert-grep `"$range`"" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue - - # Safely convert to array and handle any errors - $rawCommits = ConvertTo-ArraySafe -InputObject $rawCommitsResult - - # Additional safety check - if ($null -eq $rawCommits) { - Write-Information "rawCommits is null, creating empty array" -Tags "Get-VersionNotes" - $rawCommits = @() - } - } - - # Use @() subexpression to safely get count - $rawCommitsCount = @($rawCommits).Count - - # If still no commits, try with no filtering at all - show everything in the range - if ($rawCommitsCount -eq 0) { - Write-Information "Still no commits found, trying with no filters..." -Tags "Get-VersionNotes" - $rawCommitsResult = "git log --pretty=format:`"$format`" `"$range`"" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue - - # Safely convert to array and handle any errors - $rawCommits = ConvertTo-ArraySafe -InputObject $rawCommitsResult - - # Additional safety check - if ($null -eq $rawCommits) { - Write-Information "rawCommits is null, creating empty array" -Tags "Get-VersionNotes" - $rawCommits = @() - } - - # Use @() subexpression to safely get count - $rawCommitsCount = @($rawCommits).Count - - # If it's a prerelease version, include also version update commits - if ($versionType -eq "prerelease" -and $rawCommitsCount -eq 0) { - Write-Information "Looking for version update commits for prerelease..." -Tags "Get-VersionNotes" - $rawCommitsResult = "git log --pretty=format:`"$format`" --grep=`"Update VERSION to`" `"$range`"" | Invoke-ExpressionWithLogging -ErrorAction SilentlyContinue - - # Safely convert to array and handle any errors - $rawCommits = ConvertTo-ArraySafe -InputObject $rawCommitsResult - - # Additional safety check - if ($null -eq $rawCommits) { - Write-Information "rawCommits is null, creating empty array" -Tags "Get-VersionNotes" - $rawCommits = @() - } - } - } - } - catch { - Write-Information "Error during git log operations: $_" -Tags "Get-VersionNotes" - $rawCommits = @() - } - - # Process raw commits into structured format - $structuredCommits = @() - foreach ($commit in $rawCommits) { - $parts = $commit -split '\|' - # Use @() subexpression to safely get count - if (@($parts).Count -ge 3) { - $structuredCommits += [PSCustomObject]@{ - Hash = $parts[0] - Subject = $parts[1] - Author = $parts[2] - FormattedEntry = "$($parts[1]) ([@$($parts[2])](https://github.com/$($parts[2])))" - } - } - } - - # Get unique commits based on hash (ensures unique commits) - $uniqueCommits = ConvertTo-ArraySafe -InputObject ($structuredCommits | Sort-Object -Property Hash -Unique | ForEach-Object { $_.FormattedEntry }) - - # Use @() subexpression to safely get count - $uniqueCommitsCount = @($uniqueCommits).Count - Write-Information "Found $uniqueCommitsCount commits for $ToTag" -Tags "Get-VersionNotes" - - # Format changelog entry - $versionChangelog = "" - if ($uniqueCommitsCount -gt 0) { - $versionChangelog = "## $ToTag" - if ($versionType -ne "unknown") { - $versionChangelog += " ($versionType)" - } - $versionChangelog += "$script:lineEnding$script:lineEnding" - - if ($rangeFrom -ne "") { - $versionChangelog += "Changes since ${rangeFrom}:$script:lineEnding$script:lineEnding" - } - - # Only filter out version updates for non-prerelease versions - if ($versionType -ne "prerelease") { - $filteredCommits = $uniqueCommits | Where-Object { -not $_.Contains("Update VERSION to") -and -not $_.Contains("[skip ci]") } - } else { - $filteredCommits = $uniqueCommits | Where-Object { -not $_.Contains("[skip ci]") } - } - - foreach ($commit in $filteredCommits) { - $versionChangelog += "- $commit$script:lineEnding" - } - $versionChangelog += "$script:lineEnding" - } elseif ($versionType -eq "prerelease") { - # For prerelease versions with no detected commits, include a placeholder entry - $versionChangelog = "## $ToTag (prerelease)$script:lineEnding$script:lineEnding" - $versionChangelog += "Incremental prerelease update.$script:lineEnding$script:lineEnding" - } else { - # For all other versions with no commits, create a placeholder message - $versionChangelog = "## $ToTag" - if ($versionType -ne "unknown") { - $versionChangelog += " ($versionType)" - } - $versionChangelog += "$script:lineEnding$script:lineEnding" - - if ($FromTag -eq "v0.0.0") { - $versionChangelog += "Initial release.$script:lineEnding$script:lineEnding" - } else { - $versionChangelog += "No significant changes detected since $FromTag.$script:lineEnding$script:lineEnding" - } - } - - return ($versionChangelog.Trim() + $script:lineEnding) -} - -function New-Changelog { - <# - .SYNOPSIS - Creates a complete changelog file. - .DESCRIPTION - Generates a comprehensive CHANGELOG.md with entries for all versions. - .PARAMETER Version - The current version number being released. - .PARAMETER CommitHash - The Git commit hash being released. - .PARAMETER OutputPath - Optional path to write the changelog file to. Defaults to workspace root. - .PARAMETER IncludeAllVersions - Whether to include all previous versions in the changelog. Defaults to $true. - .PARAMETER LatestChangelogFile - Optional path to write the latest version's changelog to. Defaults to "LATEST_CHANGELOG.md". - #> - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)] - [string]$Version, - [Parameter(Mandatory=$true)] - [string]$CommitHash, - [string]$OutputPath = "", - [bool]$IncludeAllVersions = $true, - [string]$LatestChangelogFile = "LATEST_CHANGELOG.md" - ) - - # Configure git versionsort to correctly handle prereleases - $suffixes = @('-alpha', '-beta', '-rc', '-pre') - foreach ($suffix in $suffixes) { - "git config versionsort.suffix `"$suffix`"" | Invoke-ExpressionWithLogging -Tags "Get-GitTags" | Write-InformationStream -Tags "Get-GitTags" - } - - # Get all tags sorted by version - $tags = Get-GitTags - $changelog = "" - - # Make sure tags is always an array - $tags = ConvertTo-ArraySafe -InputObject $tags - - # Check if we have any tags at all - $hasTags = $tags.Count -gt 0 - - # For first release, there's no previous tag to compare against - $previousTag = 'v0.0.0' - - # If we have tags, find the most recent one to compare against - if ($hasTags) { - $previousTag = $tags[0] # Most recent tag - } - - # Always add entry for current/new version (comparing current commit to previous tag or initial state) - $currentTag = "v$Version" - Write-Information "Generating changelog from $previousTag to $currentTag (commit: $CommitHash)" -Tags "New-Changelog" - $versionNotes = Get-VersionNotes -Tags $tags -FromTag $previousTag -ToTag $currentTag -ToSha $CommitHash - - # Store the latest version's notes for later use in GitHub releases - $latestVersionNotes = "" - - # If we have changes, add them to the changelog - if (-not [string]::IsNullOrWhiteSpace($versionNotes)) { - $changelog += $versionNotes - $latestVersionNotes = $versionNotes - } else { - # Handle no changes detected case - add a minimal entry - $minimalEntry = "## $currentTag$script:lineEnding$script:lineEnding" - $minimalEntry += "Initial release or no significant changes since $previousTag.$script:lineEnding$script:lineEnding" - - $changelog += $minimalEntry - $latestVersionNotes = $minimalEntry - } - - # Add entries for all previous versions if requested - if ($IncludeAllVersions -and $hasTags) { - $tagIndex = 0 - - foreach ($tag in $tags) { - if ($tag -like "v*") { - $previousTag = "v0.0.0" - if ($tagIndex -lt $tags.Count - 1) { - $previousTag = $tags[$tagIndex + 1] - } - - if (-not ($previousTag -like "v*")) { - $previousTag = "v0.0.0" - } - - $versionNotes = Get-VersionNotes -Tags $tags -FromTag $previousTag -ToTag $tag - $changelog += $versionNotes - } - $tagIndex++ - } - } - - # Write changelog to file - $filePath = if ($OutputPath) { Join-Path $OutputPath "CHANGELOG.md" } else { "CHANGELOG.md" } - - # Normalize line endings in changelog content - $changelog = $changelog.ReplaceLineEndings($script:lineEnding) - - [System.IO.File]::WriteAllText($filePath, $changelog, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-Changelog" - - # Write latest version's changelog to separate file for GitHub releases - $latestPath = if ($OutputPath) { Join-Path $OutputPath $LatestChangelogFile } else { $LatestChangelogFile } - $latestVersionNotes = $latestVersionNotes.ReplaceLineEndings($script:lineEnding) - - # Truncate release notes if they exceed NuGet's 35,000 character limit - $maxLength = 35000 - if ($latestVersionNotes.Length -gt $maxLength) { - Write-Information "Release notes exceed $maxLength characters ($($latestVersionNotes.Length)). Truncating to fit NuGet limit." -Tags "New-Changelog" - $truncationMessage = "$script:lineEnding$script:lineEnding... (truncated due to NuGet length limits)" - $targetLength = $maxLength - $truncationMessage.Length - 10 # Extra buffer for safety - $truncatedNotes = $latestVersionNotes.Substring(0, $targetLength) - $truncatedNotes += $truncationMessage - $latestVersionNotes = $truncatedNotes - Write-Information "Truncated release notes to $($latestVersionNotes.Length) characters" -Tags "New-Changelog" - - # Final safety check - ensure we never exceed the limit - if ($latestVersionNotes.Length -gt $maxLength) { - Write-Warning "Truncated release notes still exceed limit ($($latestVersionNotes.Length) > $maxLength). Further truncating..." -Tags "New-Changelog" - $latestVersionNotes = $latestVersionNotes.Substring(0, $maxLength - 50) + "... (truncated)" - } - } - - [System.IO.File]::WriteAllText($latestPath, $latestVersionNotes, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "New-Changelog" - Write-Information "Latest version changelog saved to: $latestPath" -Tags "New-Changelog" - - $versionCount = if ($hasTags) { $tags.Count + 1 } else { 1 } - Write-Information "Changelog generated with entries for $versionCount versions" -Tags "New-Changelog" -} - -#endregion - -#region Metadata Management - -function Update-ProjectMetadata { - <# - .SYNOPSIS - Updates project metadata files based on build configuration. - .DESCRIPTION - Generates and updates version information, license, changelog, and other metadata files for a project. - This function centralizes all metadata generation to ensure consistency across project documentation. - - Metadata files are always generated, but commits and pushes are only performed in official repositories - (not forks). This is controlled by the BuildConfiguration.IsOfficial flag. - .PARAMETER BuildConfiguration - The build configuration object containing paths, version info, and GitHub details. - Should be obtained from Get-BuildConfiguration. The IsOfficial property determines whether - metadata changes will be committed and pushed. - .PARAMETER Authors - Optional array of author names to include in the AUTHORS.md file. - .PARAMETER CommitMessage - Optional commit message to use when committing metadata changes. - Defaults to "[bot][skip ci] Update Metadata". - .EXAMPLE - $config = Get-BuildConfiguration -GitRef "refs/heads/main" -GitSha "abc123" -GitHubOwner "myorg" -GitHubRepo "myproject" - Update-ProjectMetadata -BuildConfiguration $config - .EXAMPLE - Update-ProjectMetadata -BuildConfiguration $config -Authors @("Developer 1", "Developer 2") -CommitMessage "Update project documentation" - .OUTPUTS - PSCustomObject with Success, Error, and Data properties. - Data contains Version, ReleaseHash, and HasChanges information. - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param( - [Parameter(Mandatory = $true)] - [PSCustomObject]$BuildConfiguration, - [Parameter(Mandatory = $false)] - [string[]]$Authors = @(), - [Parameter(Mandatory = $false)] - [string]$CommitMessage = "[bot][skip ci] Update Metadata" - ) - - try { - Write-Information "Generating version information..." -Tags "Update-ProjectMetadata" - $version = New-Version -CommitHash $BuildConfiguration.ReleaseHash - Write-Information "Version: $version" -Tags "Update-ProjectMetadata" - - Write-Information "Generating license..." -Tags "Update-ProjectMetadata" - New-License -ServerUrl $BuildConfiguration.ServerUrl -Owner $BuildConfiguration.GitHubOwner -Repository $BuildConfiguration.GitHubRepo | Write-InformationStream -Tags "Update-ProjectMetadata" - - Write-Information "Generating changelog..." -Tags "Update-ProjectMetadata" - # Generate both full changelog and latest version changelog - try { - New-Changelog -Version $version -CommitHash $BuildConfiguration.ReleaseHash -LatestChangelogFile $BuildConfiguration.LatestChangelogFile | Write-InformationStream -Tags "Update-ProjectMetadata" - } - catch { - $errorMessage = $_.ToString() - Write-Information "Failed to generate complete changelog: $errorMessage" -Tags "Update-ProjectMetadata" - Write-Information "Creating minimal changelog instead..." -Tags "Update-ProjectMetadata" - - # Create a minimal changelog - $minimalChangelog = "## v$version$($script:lineEnding)$($script:lineEnding)" - $minimalChangelog += "Initial release or repository with no prior history.$($script:lineEnding)$($script:lineEnding)" - - [System.IO.File]::WriteAllText("CHANGELOG.md", $minimalChangelog, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" - [System.IO.File]::WriteAllText($BuildConfiguration.LatestChangelogFile, $minimalChangelog, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" - } - - # Create AUTHORS.md if authors are provided - if (@($Authors).Count -gt 0) { - Write-Information "Generating authors file..." -Tags "Update-ProjectMetadata" - $authorsContent = "# Project Authors$script:lineEnding$script:lineEnding" - foreach ($author in $Authors) { - $authorsContent += "* $author$script:lineEnding" - } - [System.IO.File]::WriteAllText("AUTHORS.md", $authorsContent, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" - } - - # Create AUTHORS.url - $authorsUrl = "[InternetShortcut]$($script:lineEnding)URL=$($BuildConfiguration.ServerUrl)/$($BuildConfiguration.GitHubOwner)" - [System.IO.File]::WriteAllText("AUTHORS.url", $authorsUrl, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" - - # Create PROJECT_URL.url - $projectUrl = "[InternetShortcut]$($script:lineEnding)URL=$($BuildConfiguration.ServerUrl)/$($BuildConfiguration.GitHubRepo)" - [System.IO.File]::WriteAllText("PROJECT_URL.url", $projectUrl, [System.Text.UTF8Encoding]::new($false)) | Write-InformationStream -Tags "Update-ProjectMetadata" - - Write-Information "Adding files to git..." -Tags "Update-ProjectMetadata" - $filesToAdd = @( - "VERSION.md", - "LICENSE.md", - "AUTHORS.md", - "CHANGELOG.md", - "COPYRIGHT.md", - "PROJECT_URL.url", - "AUTHORS.url" - ) - - # Add latest changelog if it exists - if (Test-Path $BuildConfiguration.LatestChangelogFile) { - $filesToAdd += $BuildConfiguration.LatestChangelogFile - } - Write-Information "Files to add: $($filesToAdd -join ", ")" -Tags "Update-ProjectMetadata" - "git add $filesToAdd" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Update-ProjectMetadata" - - Write-Information "Checking for changes to commit..." -Tags "Update-ProjectMetadata" - $postStatus = "git status --porcelain" | Invoke-ExpressionWithLogging -Tags "Update-ProjectMetadata" | Out-String - $hasChanges = -not [string]::IsNullOrWhiteSpace($postStatus) - $statusMessage = if ($hasChanges) { 'Changes detected' } else { 'No changes' } - Write-Information "Git status: $statusMessage" -Tags "Update-ProjectMetadata" - - # Get the current commit hash regardless of whether we make changes - $currentHash = "git rev-parse HEAD" | Invoke-ExpressionWithLogging - Write-Information "Current commit hash: $currentHash" -Tags "Update-ProjectMetadata" - - if (-not [string]::IsNullOrWhiteSpace($postStatus)) { - # Only commit and push metadata changes in official repositories - if ($BuildConfiguration.IsOfficial) { - # Configure git user before committing - Set-GitIdentity | Write-InformationStream -Tags "Update-ProjectMetadata" - - Write-Information "Committing changes..." -Tags "Update-ProjectMetadata" - "git commit -m `"$CommitMessage`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Update-ProjectMetadata" - - Write-Information "Pushing changes..." -Tags "Update-ProjectMetadata" - "git push" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Update-ProjectMetadata" - - Write-Information "Getting release hash..." -Tags "Update-ProjectMetadata" - $releaseHash = "git rev-parse HEAD" | Invoke-ExpressionWithLogging - Write-Information "Metadata committed as $releaseHash" -Tags "Update-ProjectMetadata" - } else { - Write-Information "Skipping metadata commit/push (not an official repository)" -Tags "Update-ProjectMetadata" - $releaseHash = $currentHash - } - - Write-Information "Metadata update completed successfully with changes" -Tags "Update-ProjectMetadata" - Write-Information "Version: $version" -Tags "Update-ProjectMetadata" - Write-Information "Release Hash: $releaseHash" -Tags "Update-ProjectMetadata" - - return [PSCustomObject]@{ - Success = $true - Error = "" - Data = [PSCustomObject]@{ - Version = $version - ReleaseHash = $releaseHash - HasChanges = $true - } - } - } - else { - Write-Information "No changes to commit" -Tags "Update-ProjectMetadata" - Write-Information "Version: $version" -Tags "Update-ProjectMetadata" - Write-Information "Using current commit hash: $currentHash" -Tags "Update-ProjectMetadata" - - return [PSCustomObject]@{ - Success = $true - Error = "" - Data = [PSCustomObject]@{ - Version = $version - ReleaseHash = $currentHash - HasChanges = $false - } - } - } - } - catch { - $errorMessage = $_.ToString() - Write-Information "Failed to update metadata: $errorMessage" -Tags "Update-ProjectMetadata" - return [PSCustomObject]@{ - Success = $false - Error = $errorMessage - Data = [PSCustomObject]@{ - Version = $null - ReleaseHash = $null - HasChanges = $false - StackTrace = $_.ScriptStackTrace - } - } - } -} - -#endregion - -#region Build Operations - -function Invoke-DotNetRestore { - <# - .SYNOPSIS - Restores NuGet packages. - .DESCRIPTION - Runs dotnet restore to get all dependencies. - #> - [CmdletBinding()] - param() - - Write-StepHeader "Restoring Dependencies" -Tags "Invoke-DotNetRestore" - - # Execute command and stream output directly to console - "dotnet restore --locked-mode -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetRestore" - Assert-LastExitCode "Restore failed" -} - -function Invoke-DotNetBuild { - <# - .SYNOPSIS - Builds the .NET solution. - .DESCRIPTION - Runs dotnet build with specified configuration. - .PARAMETER Configuration - The build configuration (Debug/Release). - .PARAMETER BuildArgs - Additional build arguments. - #> - [CmdletBinding()] - param ( - [string]$Configuration = "Release", - [string]$BuildArgs = "" - ) - - Write-StepHeader "Building Solution" -Tags "Invoke-DotNetBuild" - - try { - # First attempt with quiet verbosity - stream output directly - "dotnet build --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`" --no-incremental $BuildArgs --no-restore" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetBuild" - - if ($LASTEXITCODE -ne 0) { - Write-Information "Build failed with exit code $LASTEXITCODE. Retrying with detailed verbosity..." -Tags "Invoke-DotNetBuild" - - # Retry with more detailed verbosity - stream output directly - "dotnet build --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`" --no-incremental $BuildArgs --no-restore" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetBuild" - - # Still failed, show diagnostic info and throw error - if ($LASTEXITCODE -ne 0) { - Write-Information "Checking for common build issues:" -Tags "Invoke-DotNetBuild" - - # Check for project files - $projectFiles = @(Get-ChildItem -Recurse -Filter *.csproj) - Write-Information "Found $($projectFiles.Count) project files" -Tags "Invoke-DotNetBuild" - - foreach ($proj in $projectFiles) { - Write-Information " - $($proj.FullName)" -Tags "Invoke-DotNetBuild" - } - - Assert-LastExitCode "Build failed" - } - } - } - catch { - Write-Information "Exception during build process: $_" -Tags "Invoke-DotNetBuild" - throw - } -} - -function Invoke-DotNetTest { - <# - .SYNOPSIS - Runs dotnet test with code coverage collection. - .DESCRIPTION - Runs dotnet test with code coverage collection. - .PARAMETER Configuration - The build configuration to use. - .PARAMETER CoverageOutputPath - The path to output code coverage results. - #> - [CmdletBinding()] - param ( - [string]$Configuration = "Release", - [string]$CoverageOutputPath = "coverage" - ) - - Write-StepHeader "Running Tests with Coverage" -Tags "Invoke-DotNetTest" - - # Check if there are any test projects in the solution - $testProjects = @(Get-ChildItem -Recurse -Filter "*.csproj" | Where-Object { - $_.Name -match "\.Test\.csproj$" -or - $_.Directory.Name -match "\.Test$" -or - $_.Directory.Name -eq "Test" -or - (Select-String -Path $_.FullName -Pattern "true" -Quiet) - }) - - if ($testProjects.Count -eq 0) { - Write-Information "No test projects found in solution. Skipping test execution." -Tags "Invoke-DotNetTest" - return - } - - Write-Information "Found $($testProjects.Count) test project(s)" -Tags "Invoke-DotNetTest" - - # Ensure the TestResults directory exists - $testResultsPath = Join-Path $CoverageOutputPath "TestResults" - New-Item -Path $testResultsPath -ItemType Directory -Force | Out-Null - - # Run tests with both coverage collection and TRX logging for SonarQube - "dotnet test --configuration $Configuration --coverage --coverage-output-format xml --coverage-output `"coverage.xml`" --results-directory `"$testResultsPath`" --report-trx --report-trx-filename TestResults.trx" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetTest" - Assert-LastExitCode "Tests failed" - - # Find and copy coverage file to expected location for SonarQube - $coverageFiles = @(Get-ChildItem -Path . -Recurse -Filter "coverage.xml" -ErrorAction SilentlyContinue) - if ($coverageFiles.Count -gt 0) { - $latestCoverageFile = $coverageFiles | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - $targetCoverageFile = Join-Path $CoverageOutputPath "coverage.xml" - Copy-Item -Path $latestCoverageFile.FullName -Destination $targetCoverageFile -Force - Write-Information "Coverage file copied to: $targetCoverageFile" -Tags "Invoke-DotNetTest" - } else { - Write-Information "Warning: No coverage file found" -Tags "Invoke-DotNetTest" - } -} - -function Invoke-DotNetPack { - <# - .SYNOPSIS - Creates NuGet packages. - .DESCRIPTION - Runs dotnet pack to create NuGet packages. - .PARAMETER Configuration - The build configuration (Debug/Release). - .PARAMETER OutputPath - The path to output packages to. - .PARAMETER Project - Optional specific project to package. If not provided, all projects are packaged. - .PARAMETER LatestChangelogFile - Optional path to the latest changelog file to use for PackageReleaseNotesFile. Defaults to "LATEST_CHANGELOG.md". - #> - [CmdletBinding()] - param ( - [string]$Configuration = "Release", - [Parameter(Mandatory=$true)] - [string]$OutputPath, - [string]$Project = "", - [string]$LatestChangelogFile = "LATEST_CHANGELOG.md" - ) - - Write-StepHeader "Packaging Libraries" -Tags "Invoke-DotNetPack" - - # Ensure output directory exists - New-Item -Path $OutputPath -ItemType Directory -Force | Write-InformationStream -Tags "Invoke-DotNetPack" - - # Check if any projects exist (excluding test projects) - $projectFiles = @(Get-ChildItem -Recurse -Filter *.csproj -ErrorAction SilentlyContinue | Where-Object { - -not ($_.Name -match "\.Tests?\.csproj$" -or - $_.Directory.Name -match "\.Tests?$" -or - $_.Directory.Name -eq "Tests" -or - $_.Directory.Name -eq "Test" -or - (Select-String -Path $_.FullName -Pattern "true" -Quiet)) - }) - if ($projectFiles.Count -eq 0) { - Write-Information "No .NET library projects found to package" -Tags "Invoke-DotNetPack" - return - } - - try { - # Override PackageReleaseNotes to use LATEST_CHANGELOG.md instead of full CHANGELOG.md - # Use PackageReleaseNotesFile property to avoid command line length limits and escaping issues - $releaseNotesProperty = "" - - if (Test-Path $LatestChangelogFile) { - # Get absolute path to the changelog file for MSBuild - $absoluteChangelogPath = (Resolve-Path $LatestChangelogFile).Path - Write-Information "Using release notes from file: $absoluteChangelogPath" -Tags "Invoke-DotNetPack" - - # Use PackageReleaseNotesFile property instead of PackageReleaseNotes to avoid command line issues - $releaseNotesProperty = "-p:PackageReleaseNotesFile=`"$absoluteChangelogPath`"" - Write-Information "Overriding PackageReleaseNotesFile with latest changelog file path" -Tags "Invoke-DotNetPack" - } else { - Write-Information "No latest changelog found, SDK will use full CHANGELOG.md (automatically truncated if needed)" -Tags "Invoke-DotNetPack" - } - - # Build either a specific project or all non-test projects - if ([string]::IsNullOrWhiteSpace($Project)) { - Write-Information "Packaging $($projectFiles.Count) non-test projects..." -Tags "Invoke-DotNetPack" - foreach ($proj in $projectFiles) { - $projName = [System.IO.Path]::GetFileNameWithoutExtension($proj) - Write-Information "Packaging project: $projName" -Tags "Invoke-DotNetPack" - "dotnet pack `"$proj`" --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`" --no-build --output $OutputPath $releaseNotesProperty" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetPack" - if ($LASTEXITCODE -ne 0) { - throw "Library packaging failed for $projName with exit code $LASTEXITCODE" - } - } - } else { - Write-Information "Packaging project: $Project" -Tags "Invoke-DotNetPack" - "dotnet pack $Project --configuration $Configuration -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`" --no-build --output $OutputPath $releaseNotesProperty" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetPack" - if ($LASTEXITCODE -ne 0) { - throw "Library packaging failed with exit code $LASTEXITCODE" - } - } - - # Report on created packages - $packages = @(Get-ChildItem -Path $OutputPath -Filter *.nupkg -ErrorAction SilentlyContinue) - if ($packages.Count -gt 0) { - Write-Information "Created $($packages.Count) packages in $OutputPath" -Tags "Invoke-DotNetPack" - foreach ($package in $packages) { - Write-Information " - $($package.Name)" -Tags "Invoke-DotNetPack" - } - } else { - Write-Information "No packages were created (projects may not be configured for packaging)" -Tags "Invoke-DotNetPack" - } - } - catch { - $originalException = $_.Exception - Write-Information "Package creation failed: $originalException" -Tags "Invoke-DotNetPack" - throw "Library packaging failed: $originalException" - } -} - -function Invoke-DotNetPublish { - <# - .SYNOPSIS - Publishes .NET applications and creates winget-compatible packages. - .DESCRIPTION - Runs dotnet publish and creates zip archives for applications. - Also creates winget-compatible packages for multiple architectures if console applications are found. - Uses the build configuration to determine output paths and version information. - .PARAMETER Configuration - The build configuration (Debug/Release). Defaults to "Release". - .PARAMETER BuildConfiguration - The build configuration object containing output paths, version, and other settings. - This object should be obtained from Get-BuildConfiguration. - .OUTPUTS - None. Creates published applications, zip archives, and winget packages in the specified output paths. - #> - [CmdletBinding()] - param ( - [string]$Configuration = "Release", - [Parameter(Mandatory=$true)] - [PSCustomObject]$BuildConfiguration - ) - - Write-StepHeader "Publishing Applications" -Tags "Invoke-DotNetPublish" - - # Find all projects (excluding test projects) - $projectFiles = @(Get-ChildItem -Recurse -Filter *.csproj -ErrorAction SilentlyContinue | Where-Object { - -not ($_.Name -match "\.Tests?\.csproj$" -or - $_.Directory.Name -match "\.Tests?$" -or - $_.Directory.Name -eq "Tests" -or - $_.Directory.Name -eq "Test" -or - (Select-String -Path $_.FullName -Pattern "true" -Quiet)) - }) - if ($projectFiles.Count -eq 0) { - Write-Information "No .NET application projects found to publish" -Tags "Invoke-DotNetPublish" - return - } - - # Clean output directory if it exists - if (Test-Path $BuildConfiguration.OutputPath) { - Remove-Item -Recurse -Force $BuildConfiguration.OutputPath | Write-InformationStream -Tags "Invoke-DotNetPublish" - } - - # Ensure staging directory exists - New-Item -Path $BuildConfiguration.StagingPath -ItemType Directory -Force | Write-InformationStream -Tags "Invoke-DotNetPublish" - - $publishedCount = 0 - $version = $BuildConfiguration.Version - - # Define target architectures for comprehensive publishing across all platforms - $architectures = @( - # Windows - "win-x64", "win-x86", "win-arm64", - # Linux - "linux-x64", "linux-arm64", - # macOS - "osx-x64", "osx-arm64" - ) - - foreach ($csproj in $projectFiles) { - $projName = [System.IO.Path]::GetFileNameWithoutExtension($csproj) - Write-Information "Publishing $projName..." -Tags "Invoke-DotNetPublish" - - foreach ($arch in $architectures) { - $outDir = Join-Path $BuildConfiguration.OutputPath "$projName-$arch" - - # Create output directory - New-Item -Path $outDir -ItemType Directory -Force | Write-InformationStream -Tags "Invoke-DotNetPublish" - - # Publish application with optimized settings for both general use and winget compatibility - # Note: PublishSingleFile is disabled because Silk.NET native libraries (GLFW, SDL) don't bundle correctly - "dotnet publish `"$csproj`" --configuration $Configuration --runtime $arch --self-contained true --output `"$outDir`" -p:PublishSingleFile=false -p:PublishTrimmed=false -p:DebugType=none -p:DebugSymbols=false -logger:`"Microsoft.Build.Logging.ConsoleLogger,Microsoft.Build;Summary;ForceNoAlign;ShowTimestamp;ShowCommandLine;Verbosity=quiet`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotNetPublish" - - if ($LASTEXITCODE -eq 0) { - # Create general application zip archive for all platforms - $stageFile = Join-Path $BuildConfiguration.StagingPath "$projName-$version-$arch.zip" - Compress-Archive -Path "$outDir/*" -DestinationPath $stageFile -Force | Write-InformationStream -Tags "Invoke-DotNetPublish" - - $publishedCount++ - Write-Information "Successfully published $projName for $arch" -Tags "Invoke-DotNetPublish" - } else { - Write-Information "Failed to publish $projName for $arch" -Tags "Invoke-DotNetPublish" - continue - } - } - } - - # Generate SHA256 hashes for all published packages - $allPackages = @(Get-ChildItem -Path $BuildConfiguration.StagingPath -Filter "*.zip" -ErrorAction SilentlyContinue) - - if ($allPackages.Count -gt 0) { - Write-Information "Generating SHA256 hashes for all published packages..." -Tags "Invoke-DotNetPublish" - - foreach ($package in $allPackages) { - # Calculate and store SHA256 hash - $hash = Get-FileHash -Path $package.FullName -Algorithm SHA256 - Write-Information "SHA256 for $($package.Name): $($hash.Hash)" -Tags "Invoke-DotNetPublish" - - # Store hash for integrity verification and distribution use - "$($package.Name)=$($hash.Hash)" | Out-File -FilePath (Join-Path $BuildConfiguration.StagingPath "hashes.txt") -Append -Encoding UTF8 - } - } - - if ($publishedCount -gt 0) { - Write-Information "Published $publishedCount application packages across all platforms and architectures" -Tags "Invoke-DotNetPublish" - - # Report hash generation results - if ($allPackages.Count -gt 0) { - Write-Information "Generated SHA256 hashes for $($allPackages.Count) published packages" -Tags "Invoke-DotNetPublish" - } - } else { - Write-Information "No applications were published (projects may not be configured as executables)" -Tags "Invoke-DotNetPublish" - } -} - -#endregion - -#region Publishing and Release - -function Invoke-NuGetPublish { - <# - .SYNOPSIS - Publishes NuGet packages. - .DESCRIPTION - Publishes packages to GitHub Packages and NuGet.org. - Uses the build configuration to determine package paths and authentication details. - .PARAMETER BuildConfiguration - The build configuration object containing package patterns, GitHub token, and NuGet API key. - This object should be obtained from Get-BuildConfiguration. - .OUTPUTS - None. Publishes packages to the configured package repositories. - #> - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)] - [PSCustomObject]$BuildConfiguration - ) - - # Check if there are any packages to publish - $packages = @(Get-Item -Path $BuildConfiguration.PackagePattern -ErrorAction SilentlyContinue) - if ($packages.Count -eq 0) { - Write-Information "No packages found to publish" -Tags "Invoke-NuGetPublish" - return - } - - Write-Information "Found $($packages.Count) package(s) to publish" -Tags "Invoke-NuGetPublish" - - Write-StepHeader "Publishing to GitHub Packages" -Tags "Invoke-NuGetPublish" - - # Execute the command and stream output - "dotnet nuget push `"$($BuildConfiguration.PackagePattern)`" --api-key `"$($BuildConfiguration.GithubToken)`" --source `"https://nuget.pkg.github.com/$($BuildConfiguration.GithubOwner)/index.json`" --skip-duplicate" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-NuGetPublish" - Assert-LastExitCode "GitHub package publish failed" - - # Only publish to NuGet.org if API key is provided - if (-not [string]::IsNullOrWhiteSpace($BuildConfiguration.NuGetApiKey)) { - Write-StepHeader "Publishing to NuGet.org" -Tags "Invoke-NuGetPublish" - - # Execute the command and stream output - "dotnet nuget push `"$($BuildConfiguration.PackagePattern)`" --api-key `"$($BuildConfiguration.NuGetApiKey)`" --source `"https://api.nuget.org/v3/index.json`" --skip-duplicate" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-NuGetPublish" - Assert-LastExitCode "NuGet.org package publish failed" - } else { - Write-Information "Skipping NuGet.org publishing - no API key provided" -Tags "Invoke-NuGetPublish" - } - - # Only publish to Ktsu.dev if API key is provided - if (-not [string]::IsNullOrWhiteSpace($BuildConfiguration.KtsuPackageKey)) { - Write-StepHeader "Publishing to packages.ktsu.dev" -Tags "Invoke-NuGetPublish" - - # Execute the command and stream output - "dotnet nuget push `"$($BuildConfiguration.PackagePattern)`" --api-key `"$($BuildConfiguration.KtsuPackageKey)`" --source `"https://packages.ktsu.dev/v3/index.json`" --skip-duplicate" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-NuGetPublish" - Assert-LastExitCode "packages.ktsu.dev package publish failed" - } else { - Write-Information "Skipping packages.ktsu.dev publishing - no API key provided" -Tags "Invoke-NuGetPublish" - } -} - -function New-GitHubRelease { - <# - .SYNOPSIS - Creates a new GitHub release. - .DESCRIPTION - Creates a new GitHub release with the specified version, creates and pushes a git tag, - and uploads release assets. Uses the GitHub CLI (gh) for release creation. - .PARAMETER BuildConfiguration - The build configuration object containing version, commit hash, GitHub token, and asset patterns. - This object should be obtained from Get-BuildConfiguration. - .OUTPUTS - None. Creates a GitHub release and uploads specified assets. - #> - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)] - [PSCustomObject]$BuildConfiguration - ) - - # Set GitHub token for CLI - $env:GH_TOKEN = $BuildConfiguration.GithubToken - - # Configure git user - Set-GitIdentity | Write-InformationStream -Tags "New-GitHubRelease" - - # Create and push the tag first - Write-Information "Creating and pushing tag v$($BuildConfiguration.Version)..." -Tags "New-GitHubRelease" - "git tag -a `"v$($BuildConfiguration.Version)`" `"$($BuildConfiguration.ReleaseHash)`" -m `"Release v$($BuildConfiguration.Version)`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "New-GitHubRelease" - Assert-LastExitCode "Failed to create git tag" - - "git push origin `"v$($BuildConfiguration.Version)`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "New-GitHubRelease" - Assert-LastExitCode "Failed to push git tag" - - # Collect all assets - $assets = @() - foreach ($pattern in $BuildConfiguration.AssetPatterns) { - $matched = Get-Item -Path $pattern -ErrorAction SilentlyContinue - if ($matched) { - $assets += $matched.FullName - } - } - - # Create release - Write-StepHeader "Creating GitHub Release v$($BuildConfiguration.Version)" -Tags "New-GitHubRelease" - - $releaseArgs = @( - "release", - "create", - "v$($BuildConfiguration.Version)" - ) - - # Add target commit - $releaseArgs += "--target" - $releaseArgs += $BuildConfiguration.ReleaseHash.ToString() - - # Add notes generation - $releaseArgs += "--generate-notes" - - # First check for latest changelog file (preferred for releases) - $latestChangelogPath = "LATEST_CHANGELOG.md" - if (Test-Path $latestChangelogPath) { - Write-Information "Using latest version changelog from $latestChangelogPath" -Tags "New-GitHubRelease" - $releaseArgs += "--notes-file" - $releaseArgs += $latestChangelogPath - } - # Fall back to full changelog if specified in config and latest not found - elseif (Test-Path $BuildConfiguration.ChangelogFile) { - Write-Information "Using full changelog from $($BuildConfiguration.ChangelogFile)" -Tags "New-GitHubRelease" - $releaseArgs += "--notes-file" - $releaseArgs += $BuildConfiguration.ChangelogFile - } - - # Add assets as positional arguments - $releaseArgs += $assets - - # Join the arguments into a single string - $releaseArgs = $releaseArgs -join ' ' - - "gh $releaseArgs" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "New-GitHubRelease" - Assert-LastExitCode "Failed to create GitHub release" -} - -#endregion - -#region Utility Functions - -function Assert-LastExitCode { - <# - .SYNOPSIS - Verifies that the last command executed successfully. - .DESCRIPTION - Throws an exception if the last command execution resulted in a non-zero exit code. - This function is used internally to ensure each step completes successfully. - .PARAMETER Message - The error message to display if the exit code check fails. - .PARAMETER Command - Optional. The command that was executed, for better error reporting. - .EXAMPLE - dotnet build - Assert-LastExitCode "The build process failed" -Command "dotnet build" - .NOTES - Author: ktsu.dev - #> - [CmdletBinding()] - param ( - [string]$Message = "Command failed", - [string]$Command = "" - ) - - if ($LASTEXITCODE -ne 0) { - $errorDetails = "Exit code: $LASTEXITCODE" - if (-not [string]::IsNullOrWhiteSpace($Command)) { - $errorDetails += " | Command: $Command" - } - - $fullMessage = "$Message$script:lineEnding$errorDetails" - Write-Information $fullMessage -Tags "Assert-LastExitCode" - throw $fullMessage - } -} - -function Write-StepHeader { - <# - .SYNOPSIS - Writes a formatted step header to the console. - .DESCRIPTION - Creates a visually distinct header for build steps in the console output. - Used to improve readability of the build process logs. - .PARAMETER Message - The header message to display. - .EXAMPLE - Write-StepHeader "Restoring Packages" - .NOTES - Author: ktsu.dev - #> - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true)] - [string]$Message, - [Parameter()] - [AllowEmptyCollection()] - [string[]]$Tags = @("Write-StepHeader") - ) - Write-Information "$($script:lineEnding)=== $Message ===$($script:lineEnding)" -Tags $Tags -} - -function Test-AnyFiles { - <# - .SYNOPSIS - Tests if any files match the specified pattern. - .DESCRIPTION - Tests if any files exist that match the given glob pattern. This is useful for - determining if certain file types (like packages) exist before attempting operations - on them. - .PARAMETER Pattern - The glob pattern to check for matching files. - .EXAMPLE - if (Test-AnyFiles -Pattern "*.nupkg") { - Write-Host "NuGet packages found!" - } - .NOTES - Author: ktsu.dev - #> - [CmdletBinding()] - [OutputType([bool])] - param ( - [Parameter(Mandatory=$true)] - [string]$Pattern - ) - - # Use array subexpression to ensure consistent collection handling - $matchingFiles = @(Get-Item -Path $Pattern -ErrorAction SilentlyContinue) - return $matchingFiles.Count -gt 0 -} - -function Write-InformationStream { - <# - .SYNOPSIS - Streams output to the console. - .DESCRIPTION - Streams output to the console. - .PARAMETER Object - The object to write to the console. - .EXAMPLE - & git status | Write-InformationStream - .NOTES - Author: ktsu.dev - #> - [CmdletBinding()] - param ( - [Parameter(ValueFromPipeline=$true, ParameterSetName="Object")] - [object]$Object, - [Parameter()] - [AllowEmptyCollection()] - [string[]]$Tags = @("Write-InformationStream") - ) - - process { - # Use array subexpression to ensure consistent collection handling - $Object | ForEach-Object { - Write-Information $_ -Tags $Tags - } - } -} - -function Invoke-ExpressionWithLogging { - <# - .SYNOPSIS - Invokes an expression and logs the result to the console. - .DESCRIPTION - Invokes an expression and logs the result to the console. - .PARAMETER ScriptBlock - The script block to execute. - .PARAMETER Command - A string command to execute, which will be converted to a script block. - .PARAMETER Tags - Optional tags to include in the logging output for filtering and organization. - .OUTPUTS - The result of the expression. - .NOTES - Author: ktsu.dev - This function is useful for debugging expressions that are not returning the expected results. - #> - [CmdletBinding()] - param ( - [Parameter(ValueFromPipeline=$true, ParameterSetName="ScriptBlock")] - [scriptblock]$ScriptBlock, - - [Parameter(ValueFromPipeline=$true, ParameterSetName="Command")] - [string]$Command, - - [Parameter()] - [AllowEmptyCollection()] - [string[]]$Tags = @("Invoke-ExpressionWithLogging") - ) - - process { - # Convert command string to scriptblock if needed - if ($PSCmdlet.ParameterSetName -eq "Command" -and -not [string]::IsNullOrWhiteSpace($Command)) { - Write-Information "Executing command: $Command" -Tags $Tags - $ScriptBlock = [scriptblock]::Create($Command) - } - else { - Write-Information "Executing script block: $ScriptBlock" -Tags $Tags - } - - if ($ScriptBlock) { - # Execute the expression and return its result - & $ScriptBlock | ForEach-Object { - Write-Output $_ - } - } - } -} - -function Get-GitLineEnding { - <# - .SYNOPSIS - Gets the correct line ending based on git config. - .DESCRIPTION - Determines whether to use LF or CRLF based on the git core.autocrlf and core.eol settings. - Falls back to system default line ending if no git settings are found. - .OUTPUTS - String. Returns either "`n" for LF or "`r`n" for CRLF line endings. - .NOTES - The function checks git settings in the following order: - 1. core.eol setting (if set to 'lf' or 'crlf') - 2. core.autocrlf setting ('true', 'input', or 'false') - 3. System default line ending - #> - [CmdletBinding()] - [OutputType([string])] - param() - - $autocrlf = "git config --get core.autocrlf" | Invoke-ExpressionWithLogging - $eol = "git config --get core.eol" | Invoke-ExpressionWithLogging - - # If core.eol is set, use that - if ($LASTEXITCODE -eq 0 -and $eol -in @('lf', 'crlf')) { - return if ($eol -eq 'lf') { "`n" } else { "`r`n" } - } - - # Otherwise use autocrlf setting - if ($LASTEXITCODE -eq 0) { - switch ($autocrlf.ToLower()) { - 'true' { return "`n" } # Git will convert to CRLF on checkout - 'input' { return "`n" } # Always use LF - 'false' { - # Use OS default - return [System.Environment]::NewLine - } - default { - # Default to OS line ending if setting is not recognized - return [System.Environment]::NewLine - } - } - } - - # If git config fails or no setting found, use OS default - return [System.Environment]::NewLine -} - -function Set-GitIdentity { - <# - .SYNOPSIS - Configures git user identity for automated operations. - .DESCRIPTION - Sets up git user name and email globally for GitHub Actions or other automated processes. - #> - [CmdletBinding()] - param() - - Write-Information "Configuring git user for GitHub Actions..." -Tags "Set-GitIdentity" - "git config --global user.name `"Github Actions`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Set-GitIdentity" - Assert-LastExitCode "Failed to configure git user name" - "git config --global user.email `"actions@users.noreply.github.com`"" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Set-GitIdentity" - Assert-LastExitCode "Failed to configure git user email" -} - -function ConvertTo-ArraySafe { - <# - .SYNOPSIS - Safely converts an object to an array, even if it's already an array, a single item, or null. - .DESCRIPTION - Ensures that the returned object is always an array, handling PowerShell's behavior - where single item arrays are automatically unwrapped. Also handles error objects and other edge cases. - .PARAMETER InputObject - The object to convert to an array. - .OUTPUTS - Returns an array, even if the input is null or a single item. - #> - [CmdletBinding()] - [OutputType([object[]])] - param ( - [Parameter(ValueFromPipeline=$true)] - [AllowNull()] - [object]$InputObject - ) - - # Handle null or empty input - if ($null -eq $InputObject -or [string]::IsNullOrEmpty($InputObject)) { - return ,[object[]]@() - } - - # Handle error objects - return empty array for safety - if ($InputObject -is [System.Management.Automation.ErrorRecord]) { - Write-Information "ConvertTo-ArraySafe: Received error object, returning empty array" -Tags "ConvertTo-ArraySafe" - return ,[object[]]@() - } - - # Handle empty strings - if ($InputObject -is [string] -and [string]::IsNullOrWhiteSpace($InputObject)) { - return ,[object[]]@() - } - - try { - # Always force array context using the comma operator and explicit array subexpression - if ($InputObject -is [array]) { - # Ensure we return a proper array even if it's a single-item array - return ,[object[]]@($InputObject) - } - elseif ($InputObject -is [string] -and $InputObject.Contains("`n")) { - # Handle multi-line strings by splitting them - $lines = $InputObject -split "`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - return ,[object[]]@($lines) - } - else { - # Single item, make it an array using explicit array operators - return ,[object[]]@($InputObject) - } - } - catch { - Write-Information "ConvertTo-ArraySafe: Error converting object to array: $_" -Tags "ConvertTo-ArraySafe" - return ,[object[]]@() - } -} - -#endregion - -#region High-Level Workflows - -function Invoke-BuildWorkflow { - <# - .SYNOPSIS - Executes the main build workflow. - .DESCRIPTION - Runs the complete build, test, and package process. - .PARAMETER Configuration - The build configuration (Debug/Release). - .PARAMETER BuildArgs - Additional build arguments. - .PARAMETER BuildConfiguration - The build configuration object from Get-BuildConfiguration. - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param ( - [string]$Configuration = "Release", - [string]$BuildArgs = "", - [Parameter(Mandatory=$true)] - [PSCustomObject]$BuildConfiguration - ) - - try { - # Setup - Initialize-BuildEnvironment | Write-InformationStream -Tags "Invoke-BuildWorkflow" - - # Install dotnet-script if needed - if ($BuildConfiguration.UseDotnetScript) { - Write-StepHeader "Installing dotnet-script" -Tags "Invoke-DotnetScript" - "dotnet tool install -g dotnet-script" | Invoke-ExpressionWithLogging | Write-InformationStream -Tags "Invoke-DotnetScript" - Assert-LastExitCode "Failed to install dotnet-script" - } - - # Build and Test - Invoke-DotNetRestore | Write-InformationStream -Tags "Invoke-BuildWorkflow" - Invoke-DotNetBuild -Configuration $Configuration -BuildArgs $BuildArgs | Write-InformationStream -Tags "Invoke-BuildWorkflow" - Invoke-DotNetTest -Configuration $Configuration -CoverageOutputPath "coverage" | Write-InformationStream -Tags "Invoke-BuildWorkflow" - - return [PSCustomObject]@{ - Success = $true - Error = "" - Data = [PSCustomObject]@{ - Configuration = $Configuration - BuildArgs = $BuildArgs - } - } - } - catch { - Write-Information "Build workflow failed: $_" -Tags "Invoke-BuildWorkflow" - return [PSCustomObject]@{ - Success = $false - Error = $_.ToString() - Data = [PSCustomObject]@{} - StackTrace = $_.ScriptStackTrace - } - } -} - -function Invoke-ReleaseWorkflow { - <# - .SYNOPSIS - Executes the release workflow. - .DESCRIPTION - Generates metadata, packages, and creates a release. - .PARAMETER Configuration - The build configuration (Debug/Release). Defaults to "Release". - .PARAMETER BuildConfiguration - The build configuration object from Get-BuildConfiguration. - .OUTPUTS - PSCustomObject with Success, Error, and Data properties. - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param ( - [string]$Configuration = "Release", - [Parameter(Mandatory=$true)] - [PSCustomObject]$BuildConfiguration - ) - - try { - Write-StepHeader "Starting Release Process" -Tags "Invoke-ReleaseWorkflow" - - # Package and publish if not skipped - $packagePaths = @() - - # Create NuGet packages - try { - Write-StepHeader "Packaging Libraries" -Tags "Invoke-DotNetPack" - Invoke-DotNetPack -Configuration $Configuration -OutputPath $BuildConfiguration.StagingPath -LatestChangelogFile $BuildConfiguration.LatestChangelogFile | Write-InformationStream -Tags "Invoke-DotNetPack" - - # Add package paths if they exist - if (Test-Path $BuildConfiguration.PackagePattern) { - $packagePaths += $BuildConfiguration.PackagePattern - } - if (Test-Path $BuildConfiguration.SymbolsPattern) { - $packagePaths += $BuildConfiguration.SymbolsPattern - } - } - catch { - Write-Information "Library packaging failed: $_" -Tags "Invoke-DotNetPack" - Write-Information "Continuing with release process without NuGet packages." -Tags "Invoke-DotNetPack" - } - - # Create application packages - try { - Invoke-DotNetPublish -Configuration $Configuration -BuildConfiguration $BuildConfiguration | Write-InformationStream -Tags "Invoke-DotNetPublish" - - # Add application paths if they exist - if (Test-Path $BuildConfiguration.ApplicationPattern) { - $packagePaths += $BuildConfiguration.ApplicationPattern - } - - # Note: hashes.txt is now stored in staging directory alongside packages - } - catch { - Write-Information "Application publishing failed: $_" -Tags "Invoke-DotNetPublish" - Write-Information "Continuing with release process without application packages." -Tags "Invoke-DotNetPublish" - } - - # Publish packages if we have any and NuGet key is provided AND this is a release build - $packages = @(Get-Item -Path $BuildConfiguration.PackagePattern -ErrorAction SilentlyContinue) - if ($packages.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($BuildConfiguration.NuGetApiKey) -and $BuildConfiguration.ShouldRelease) { - Write-StepHeader "Publishing NuGet Packages" -Tags "Invoke-NuGetPublish" - try { - Invoke-NuGetPublish -BuildConfiguration $BuildConfiguration | Write-InformationStream -Tags "Invoke-NuGetPublish" - } - catch { - Write-Information "NuGet package publishing failed: $_" -Tags "Invoke-NuGetPublish" - Write-Information "Continuing with release process." -Tags "Invoke-NuGetPublish" - } - } elseif ($packages.Count -gt 0 -and -not $BuildConfiguration.ShouldRelease) { - Write-Information "Packages found but skipping publication (not a release build: ShouldRelease=$($BuildConfiguration.ShouldRelease))" -Tags "Invoke-ReleaseWorkflow" - } - - # Create GitHub release only if this is a release build - if ($BuildConfiguration.ShouldRelease) { - Write-StepHeader "Creating GitHub Release" -Tags "New-GitHubRelease" - Write-Information "Creating release for version $($BuildConfiguration.Version)..." -Tags "New-GitHubRelease" - New-GitHubRelease -BuildConfiguration $BuildConfiguration | Write-InformationStream -Tags "New-GitHubRelease" - } else { - Write-Information "Skipping GitHub release creation (not a release build: ShouldRelease=$($BuildConfiguration.ShouldRelease))" -Tags "Invoke-ReleaseWorkflow" - } - - Write-StepHeader "Release Process Completed" -Tags "Invoke-ReleaseWorkflow" - Write-Information "Release process completed successfully!" -Tags "Invoke-ReleaseWorkflow" - return [PSCustomObject]@{ - Success = $true - Error = "" - Data = [PSCustomObject]@{ - Version = $BuildConfiguration.Version - ReleaseHash = $BuildConfiguration.ReleaseHash - PackagePaths = $packagePaths - } - } - } - catch { - Write-Information "Release workflow failed: $_" -Tags "Invoke-ReleaseWorkflow" - return [PSCustomObject]@{ - Success = $false - Error = $_.ToString() - Data = [PSCustomObject]@{ - ErrorDetails = $_.Exception.Message - PackagePaths = @() - } - StackTrace = $_.ScriptStackTrace - } - } -} - -function Invoke-CIPipeline { - <# - .SYNOPSIS - Executes the CI/CD pipeline. - .DESCRIPTION - Executes the CI/CD pipeline, including metadata updates and build workflow. - .PARAMETER BuildConfiguration - The build configuration to use. - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param ( - [Parameter(Mandatory=$true)] - [PSCustomObject]$BuildConfiguration - ) - - Write-Information "BuildConfiguration: $($BuildConfiguration | ConvertTo-Json -Depth 10)" -Tags "Invoke-CIPipeline" - - try { - Write-Information "Updating metadata..." -Tags "Invoke-CIPipeline" - $metadata = Update-ProjectMetadata ` - -BuildConfiguration $BuildConfiguration - - if ($null -eq $metadata) { - Write-Information "Metadata update returned null" -Tags "Invoke-CIPipeline" - return [PSCustomObject]@{ - Success = $false - Error = "Metadata update returned null" - StackTrace = $_.ScriptStackTrace - } - } - - Write-Information "Metadata: $($metadata | ConvertTo-Json -Depth 10)" -Tags "Invoke-CIPipeline" - - $BuildConfiguration.Version = $metadata.Data.Version - $BuildConfiguration.ReleaseHash = $metadata.Data.ReleaseHash - - if (-not $metadata.Success) { - Write-Information "Failed to update metadata: $($metadata.Error)" -Tags "Invoke-CIPipeline" - return [PSCustomObject]@{ - Success = $false - Error = "Failed to update metadata: $($metadata.Error)" - StackTrace = $_.ScriptStackTrace - } - } - - # Get the version increment info to check if we should skip the release - Write-Information "Checking for significant changes..." -Tags "Invoke-CIPipeline" - $versionInfo = Get-VersionInfoFromGit -CommitHash $BuildConfiguration.ReleaseHash - - if ($versionInfo.Data.VersionIncrement -eq "skip") { - Write-Information "Skipping release: $($versionInfo.Data.IncrementReason)" -Tags "Invoke-CIPipeline" - return [PSCustomObject]@{ - Success = $true - Error = "" - Data = [PSCustomObject]@{ - Version = $metadata.Data.Version - ReleaseHash = $metadata.Data.ReleaseHash - SkippedRelease = $true - SkipReason = $versionInfo.Data.IncrementReason - } - } - } - - Write-Information "Running build workflow..." -Tags "Invoke-CIPipeline" - $result = Invoke-BuildWorkflow -BuildConfiguration $BuildConfiguration - if (-not $result.Success) { - Write-Information "Build workflow failed: $($result.Error)" -Tags "Invoke-CIPipeline" - return [PSCustomObject]@{ - Success = $false - Error = "Build workflow failed: $($result.Error)" - StackTrace = $_.ScriptStackTrace - } - } - - Write-Information "Running release workflow..." -Tags "Invoke-CIPipeline" - $result = Invoke-ReleaseWorkflow -BuildConfiguration $BuildConfiguration - if (-not $result.Success) { - Write-Information "Release workflow failed: $($result.Error)" -Tags "Invoke-CIPipeline" - return [PSCustomObject]@{ - Success = $false - Error = "Release workflow failed: $($result.Error)" - StackTrace = $_.ScriptStackTrace - } - } - - Write-Information "CI/CD pipeline completed successfully" -Tags "Invoke-CIPipeline" - return [PSCustomObject]@{ - Success = $true - Version = $metadata.Data.Version - ReleaseHash = $metadata.Data.ReleaseHash - } - } - catch { - Write-Information "CI/CD pipeline failed: $_" -Tags "Invoke-CIPipeline" - return [PSCustomObject]@{ - Success = $false - Error = "CI/CD pipeline failed: $_" - StackTrace = $_.ScriptStackTrace - } - } -} - -#endregion - -# Export public functions -# Core build and environment functions -Export-ModuleMember -Function Initialize-BuildEnvironment, - Get-BuildConfiguration - -# Version management functions -Export-ModuleMember -Function Get-GitTags, - Get-VersionType, - Get-VersionInfoFromGit, - New-Version - -# Version comparison and conversion functions -Export-ModuleMember -Function ConvertTo-FourComponentVersion, - Get-VersionNotes - -# Metadata and documentation functions -Export-ModuleMember -Function New-Changelog, - Update-ProjectMetadata, - New-License - -# .NET SDK operations -Export-ModuleMember -Function Invoke-DotNetRestore, - Invoke-DotNetBuild, - Invoke-DotNetTest, - Invoke-DotNetPack, - Invoke-DotNetPublish - -# Release and publishing functions -Export-ModuleMember -Function Invoke-NuGetPublish, - New-GitHubRelease - -# Utility functions -Export-ModuleMember -Function Assert-LastExitCode, - Write-StepHeader, - Test-AnyFiles, - Get-GitLineEnding, - Set-GitIdentity, - Write-InformationStream, - Invoke-ExpressionWithLogging, - ConvertTo-ArraySafe - -# High-level workflow functions -Export-ModuleMember -Function Invoke-BuildWorkflow, - Invoke-ReleaseWorkflow, - Invoke-CIPipeline - -#region Module Variables -$script:DOTNET_VERSION = '9.0' -$script:LICENSE_TEMPLATE = Join-Path $PSScriptRoot "LICENSE.template" - -# Set PowerShell preferences -$ErrorActionPreference = 'Stop' -$WarningPreference = 'Stop' -$InformationPreference = 'Continue' -$DebugPreference = 'Ignore' -$VerbosePreference = 'Ignore' -$ProgressPreference = 'Ignore' - -# Get the line ending for the current system -$script:lineEnding = Get-GitLineEnding -#endregion diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index f1cc62a..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,202 +0,0 @@ -# PSBuild Module - -A comprehensive PowerShell module for automating the build, test, package, and release process for .NET applications using Git-based versioning. - -## Features - -- Semantic versioning based on git history and commit messages -- Automatic version calculation from commit analysis -- Metadata file generation and management -- Comprehensive build, test, and package pipeline -- NuGet package creation and publishing -- GitHub release creation with assets -- Proper line ending handling based on git config - -## Installation - -1. Copy the `PSBuild.psm1` file to your project's `scripts` directory -2. Import the module in your PowerShell session: - ```powershell - Import-Module ./scripts/PSBuild.psm1 - ``` - -## Usage - -The main entry point is `Invoke-CIPipeline`, which handles the complete build, test, package, and release process: - -```powershell -# First, get the build configuration -$buildConfig = Get-BuildConfiguration ` - -ServerUrl "https://github.com" ` - -GitRef "refs/heads/main" ` - -GitSha "abc123" ` - -GitHubOwner "myorg" ` - -GitHubRepo "myrepo" ` - -GithubToken $env:GITHUB_TOKEN ` - -NuGetApiKey $env:NUGET_API_KEY ` - -WorkspacePath "." ` - -ExpectedOwner "myorg" ` - -ChangelogFile "CHANGELOG.md" ` - -AssetPatterns @("staging/*.nupkg", "staging/*.zip") - -# Then run the pipeline -$result = Invoke-CIPipeline -BuildConfiguration $buildConfig - -if ($result.Success) { - Write-Host "Pipeline completed successfully!" - Write-Host "Version: $($result.Version)" - Write-Host "Release Hash: $($result.ReleaseHash)" -} -``` - -## Object Model - -The module consistently uses PSCustomObjects for return values and data storage, providing several benefits: - -- Easy property access with dot notation -- Better IntelliSense support in modern editors -- Consistent patterns throughout the codebase -- More efficient memory usage -- Clearer code structure - -Each function returns a standardized object with at least: -- `Success`: Boolean indicating operation success -- `Error`: Error message if operation failed -- `Data`: Object containing function-specific results - -## Managed Files - -The module manages several metadata files in your repository: - -| File | Description | -|------|-------------| -| VERSION.md | Contains the current semantic version | -| LICENSE.md | MIT license with project URL and copyright | -| COPYRIGHT.md | Copyright notice with year range and owner | -| AUTHORS.md | List of contributors from git history | -| CHANGELOG.md | Auto-generated changelog from git history | -| PROJECT_URL.url | Link to project repository | -| AUTHORS.url | Link to organization/owner | - -## Version Control - -### Version Tags - -Commits can include the following tags to control version increments: - -| Tag | Description | Example | -|-----|-------------|---------| -| [major] | Triggers a major version increment | 2.0.0 | -| [minor] | Triggers a minor version increment | 1.2.0 | -| [patch] | Triggers a patch version increment | 1.1.2 | -| [pre] | Creates/increments pre-release version | 1.1.2-pre.1 | - -### Automatic Version Calculation - -The module analyzes commit history to determine appropriate version increments, following semantic versioning principles: - -1. Checks for explicit version tags in commit messages ([major], [minor], [patch], [pre]) -2. Detects public API changes by analyzing code diffs - - Adding, modifying, or removing public classes, interfaces, enums, structs, or records - - Changes to public methods, properties, or constants - - Any public API surface change triggers a minor version bump -3. Non-API changing code commits trigger patch version increments -4. Minimal changes default to prerelease increments - -This approach ensures that: -- Breaking changes are always major version increments (manually tagged) -- Public API additions or modifications are minor version increments (automatically detected) -- Bug fixes and internal changes are patch version increments -- Trivial changes result in prerelease increments - -### Public API Detection - -The module automatically analyzes code changes to detect modifications to the public API surface: - -- Added, modified, or removed public/protected classes, interfaces, enums, structs, or records -- Added, modified, or removed public/protected methods -- Added, modified, or removed public/protected properties -- Added or removed public constants - -When any of these changes are detected, the module automatically triggers a minor version increment, following semantic versioning best practices where non-breaking API changes warrant a minor version bump. - -## Build Configuration - -The `Get-BuildConfiguration` function returns a configuration object with the following key properties: - -| Property | Description | -|----------|-------------| -| IsOfficial | Whether this is an official repository build | -| IsMain | Whether building from main branch | -| IsTagged | Whether the current commit is tagged | -| ShouldRelease | Whether a release should be created | -| UseDotnetScript | Whether .NET script files are present | -| OutputPath | Path for build outputs | -| StagingPath | Path for staging artifacts | -| PackagePattern | Pattern for NuGet packages | -| SymbolsPattern | Pattern for symbol packages | -| ApplicationPattern | Pattern for application archives | -| Version | Current version number | -| ReleaseHash | Hash of the release commit | - -## Advanced Usage - -The module provides several functions for advanced scenarios: - -### Build and Release Functions -- `Initialize-BuildEnvironment`: Sets up the build environment -- `Get-BuildConfiguration`: Creates the build configuration object -- `Invoke-BuildWorkflow`: Runs the build and test process -- `Invoke-ReleaseWorkflow`: Handles package creation and publishing - -### Version Management Functions -- `Get-GitTags`: Gets sorted list of version tags -- `Get-VersionType`: Determines version increment type -- `Get-VersionInfoFromGit`: Gets comprehensive version information -- `New-Version`: Creates a new version file - -### Package and Release Functions -- `Invoke-DotNetRestore`: Restores NuGet packages -- `Invoke-DotNetBuild`: Builds the solution -- `Invoke-DotNetTest`: Runs unit tests with coverage -- `Invoke-DotNetPack`: Creates NuGet packages -- `Invoke-DotNetPublish`: Publishes applications -- `Invoke-NuGetPublish`: Publishes packages to repositories -- `New-GitHubRelease`: Creates GitHub release with assets - -### Utility Functions -- `Assert-LastExitCode`: Verifies command execution success -- `Write-StepHeader`: Creates formatted step headers in logs -- `Test-AnyFiles`: Tests for existence of files matching a pattern -- `Get-GitLineEnding`: Determines correct line endings based on git config -- `Set-GitIdentity`: Configures git user identity for automated operations -- `Write-InformationStream`: Streams output to the information stream -- `Invoke-ExpressionWithLogging`: Executes commands with proper logging - -## Line Ending Handling - -The module respects git's line ending settings when generating files: - -1. Uses git's `core.eol` setting if defined -2. Falls back to `core.autocrlf` setting -3. Defaults to OS-specific line endings if no git settings are found - -## Git Status Handling - -The module carefully handles git status to prevent empty commits: - -1. Only attempts commits when there are actual changes -2. Properly captures and interprets the git status output -3. Reports clear status messages during metadata updates -4. Preserves the correct commit hash for both success and no-change scenarios - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Commit your changes with appropriate version tags -4. Create a pull request - -## License - -MIT License - See LICENSE.md for details diff --git a/scripts/update-winget-manifests.ps1 b/scripts/update-winget-manifests.ps1 deleted file mode 100644 index 8755661..0000000 --- a/scripts/update-winget-manifests.ps1 +++ /dev/null @@ -1,1037 +0,0 @@ -#Requires -Version 7.0 -<# -.SYNOPSIS - Updates winget manifest files with new version and SHA256 hashes from GitHub releases. - -.DESCRIPTION - This script automates the process of updating winget manifest files when a new version - is released. It fetches the SHA256 hashes from the GitHub releases and updates the - manifest files accordingly. Settings are automatically inferred from the repository. - -.PARAMETER Version - The version to update the manifests for (e.g., "1.0.3") - -.PARAMETER GitHubRepo - The GitHub repository in the format "owner/repo" (optional - will be detected from git remote) - -.PARAMETER PackageId - The package identifier (e.g., "company.Product") - optional - -.PARAMETER ArtifactNamePattern - Pattern for artifact filenames, with {version} and {arch} placeholders - optional - -.PARAMETER ExecutableName - Name of the executable in the zip file - optional - -.PARAMETER CommandAlias - Command alias for the executable - optional - -.PARAMETER ConfigFile - Path to a JSON configuration file with project-specific settings (optional) - -.EXAMPLE - .\update-winget-manifests.ps1 -Version "1.0.3" - -.EXAMPLE - .\update-winget-manifests.ps1 -Version "1.0.3" -GitHubRepo "myorg/myrepo" -PackageId "myorg.MyApp" -#> - -param( - [Parameter(Mandatory = $true)] - [string]$Version, - - [Parameter(Mandatory = $false)] - [string]$GitHubRepo, - - [Parameter(Mandatory = $false)] - [string]$PackageId, - - [Parameter(Mandatory = $false)] - [string]$ArtifactNamePattern, - - [Parameter(Mandatory = $false)] - [string]$ExecutableName, - - [Parameter(Mandatory = $false)] - [string]$CommandAlias, - - [Parameter(Mandatory = $false)] - [string]$ConfigFile -) - -$ErrorActionPreference = "Stop" - -# ----- Helper Functions ----- - -function Test-IsLibraryOnlyProject { - param ( - [string]$RootDir, - [hashtable]$ProjectInfo - ) - - $hasApplications = $false - $hasLibraries = $false - $isMainProjectLibrary = $false - - # Get the repository name to identify the main project - $repoName = (Get-Item -Path $RootDir).Name - - # Check for generated NuGet packages in bin directories (indicator, not definitive) - $nupkgFiles = Get-ChildItem -Path $RootDir -Filter "*.nupkg" -Recurse -File -ErrorAction SilentlyContinue | - Where-Object { $_.Directory.Name -eq "Release" -or $_.Directory.Name -eq "Debug" } - if ($nupkgFiles.Count -gt 0) { - Write-Host "Detected NuGet package files" -ForegroundColor Yellow - $hasLibraries = $true - } - - # Check for C# projects - if ($ProjectInfo.type -eq "csharp") { - $csprojFiles = Get-ChildItem -Path $RootDir -Filter "*.csproj" -Recurse -File -Depth 3 - - foreach ($csprojFile in $csprojFiles) { - $csprojContent = Get-Content -Path $csprojFile.FullName -Raw - $projectName = $csprojFile.BaseName - - # Check if this is the main project (matches repository name or starts with it) - # For multi-project solutions like "Semantics.Strings" in repo "Semantics" - # Also handle naming variations like "ImGui.App" in repo "ImGuiApp" - $normalizedRepoName = $repoName -replace '[\.\-_]', '' - $normalizedProjectName = $projectName -replace '[\.\-_]', '' - $isMainProject = ($projectName -eq $repoName -or - $projectName.StartsWith("$repoName.") -or - $normalizedProjectName -eq $normalizedRepoName -or - $normalizedProjectName.StartsWith($normalizedRepoName)) - - # Skip test projects - $isTestProject = ($csprojContent -match 'Sdk="[^"]*\.Test["/]' -or - $csprojContent -match 'Sdk="[^"]*Sdk\.Test["/]' -or - $csprojContent -match 'Sdk="[^"]*Test[^"]*"' -or - $projectName -match "Test" -or - $projectName -match "\.Tests$") - - # Skip demo/example projects - $isDemoProject = ($projectName -match "Demo|Example|Sample" -or - $projectName.Contains("Demo") -or - $projectName.Contains("Example") -or - $projectName.Contains("Sample")) - - if ($isTestProject -or $isDemoProject) { - continue - } - - # Explicitly check if it's an executable - $isExecutable = ($csprojContent -match "\s*Exe\s*" -or - $csprojContent -match "\s*WinExe\s*" -or - $csprojContent -match 'Sdk="[^"]*\.App["/]' -or - $csprojContent -match 'Sdk="[^"]*Sdk\.App["/]' -or - $csprojContent -match '' -or - $csprojContent -match '') - - # Check if it's a library (explicit markers or implicit) - $isLibrary = ($csprojContent -match "\s*Library\s*" -or - $csprojContent -match "" -or - $csprojContent -match "\s*true\s*" -or - $csprojContent -match "\s*true\s*" -or - $csprojContent -match 'Sdk="[^"]*\.Lib["/]' -or - $csprojContent -match 'Sdk="[^"]*Sdk\.Lib["/]' -or - $csprojContent -match 'Sdk="[^"]*Library[^"]*"' -or - $csprojContent -match '' -or - $csprojContent -match '' -or - $csprojContent -match '' -or - $csprojContent -match "" -or # Multiple target frameworks often = library - (-not $isExecutable)) # No explicit exe = library by default - - if ($isLibrary) { - $hasLibraries = $true - if ($isMainProject) { - $isMainProjectLibrary = $true - } - } - - if ($isExecutable) { - $hasApplications = $true - } - } - } - - # Check for Node.js library patterns - if ($ProjectInfo.type -eq "node") { - $packageJsonPath = Join-Path -Path $RootDir -ChildPath "package.json" - if (Test-Path $packageJsonPath) { - $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json - # Check if it's a library (no bin field, or private: true) - if (-not $packageJson.bin -or $packageJson.private -eq $true) { - $hasLibraries = $true - } else { - $hasApplications = $true - } - } - } - - # Check for standalone NuGet package indicators (separate from project files) - $nuspecFiles = Get-ChildItem -Path $RootDir -Filter "*.nuspec" -Recurse -File -Depth 2 - if ($nuspecFiles.Count -gt 0) { - $hasLibraries = $true - } - - # Return true if the main project is a library and we have no main applications (demos don't count) - return $isMainProjectLibrary -and -not $hasApplications -} - -function Exit-GracefullyForLibrary { - param ( - [string]$Message = "Detected library project - no executable artifacts expected." - ) - - Write-Host $Message -ForegroundColor Yellow - Write-Host "Skipping winget manifest generation as this appears to be a library/NuGet package." -ForegroundColor Yellow - Write-Host "Winget manifests are intended for executable applications, not libraries." -ForegroundColor Cyan - exit 0 -} - -function Get-MSBuildProperty { - param ( - [string]$ProjectPath, - [string]$PropertyName - ) - - try { - $dotnetPath = Get-Command dotnet -ErrorAction SilentlyContinue - if (-not $dotnetPath) { - return $null - } - - # Use dotnet msbuild with /getProperty to get the evaluated property value - $result = & dotnet msbuild "$ProjectPath" /nologo /t:Build /p:DesignTimeBuild=true /getProperty:$PropertyName 2>$null - if ($LASTEXITCODE -eq 0 -and $result) { - $value = $result.Trim() - if ($value -and $value -ne "") { - return $value - } - } - } - catch { - # Silently fail, caller will handle null - } - - return $null -} - -function Get-MSBuildProperties { - param ( - [string]$ProjectPath - ) - - try { - $dotnetPath = Get-Command dotnet -ErrorAction SilentlyContinue - if (-not $dotnetPath) { - Write-Host "dotnet CLI not found. Falling back to XML parsing." -ForegroundColor Yellow - return $null - } - - # Restore packages first so SDK-provided properties are available - Write-Host "Restoring packages to resolve SDK properties..." -ForegroundColor Yellow - & dotnet restore "$ProjectPath" --verbosity quiet 2>$null - - $properties = @{} - $propertyNames = @("AssemblyName", "RootNamespace", "PackageId", "Product", "Authors", "Version", "Description", "RepositoryUrl", "Copyright", "PackageTags") - - foreach ($propName in $propertyNames) { - $value = Get-MSBuildProperty -ProjectPath $ProjectPath -PropertyName $propName - if ($value) { - $properties[$propName] = $value - } - } - - if ($properties.Count -gt 0) { - return $properties - } - - Write-Host "MSBuild property evaluation returned no results. Falling back to XML parsing." -ForegroundColor Yellow - return $null - } - catch { - Write-Host "Error evaluating MSBuild properties: $_" -ForegroundColor Yellow - Write-Host "Falling back to XML parsing." -ForegroundColor Yellow - return $null - } -} - -function Get-GitRemoteInfo { - param ( - [string]$RootDir - ) - - try { - # Get the GitHub URL from git remote - $remoteUrl = git remote get-url origin 2>$null - if ($remoteUrl) { - # Extract owner and repo from different Git URL formats (HTTPS or SSH) - if ($remoteUrl -match "github\.com[:/]([^/]+)/([^/.]+)(\.git)?$") { - $owner = $Matches[1] - $repo = $Matches[2] - return "$owner/$repo" - } - } - } - catch { - # Ignore errors if git is not available - Write-Host "Git command failed, cannot auto-detect repository info: $_" -ForegroundColor Yellow - } - - # Try to extract from PROJECT_URL.url file if available - if ($RootDir) { - $projectUrlFile = Join-Path -Path $RootDir -ChildPath "PROJECT_URL.url" - if (Test-Path $projectUrlFile) { - $content = Get-Content -Path $projectUrlFile -Raw - if ($content -match "URL=https://github.com/([^/]+)/([^/\r\n]+)") { - return "$($Matches[1])/$($Matches[2])" - } - } - } - - return $null -} - -function Get-FileContent { - param ( - [string]$FilePath - ) - - if (Test-Path $FilePath) { - return Get-Content -Path $FilePath -Raw - } - - return $null -} - -function Get-FirstLine { - param ( - [string]$Text - ) - - if ($Text) { - $lines = $Text -split "`n" - return $lines[0].Trim() - } - - return $null -} - -function Get-ShortDescription { - param ( - [string]$Text - ) - - if (-not $Text) { return $null } - - # Try to find quoted text that might be a short description - if ($Text -match ">\s*(.+?)(?=\r?\n|$)") { - return $Matches[1].Trim() - } - - # Or try the first non-empty line after title - $lines = $Text -split "`n" - foreach ($line in $lines | Select-Object -Skip 1) { - $trimmed = $line.Trim() - if ($trimmed -and -not $trimmed.StartsWith('#')) { - return $trimmed - } - } - - return $null -} - -function ConvertFrom-TagsList { - param ( - [string]$TagsText - ) - - if (-not $TagsText) { return @() } - - $tags = @() - $tagsList = $TagsText -split ";" | ForEach-Object { $_.Trim() } - - foreach ($tag in $tagsList) { - if ($tag) { - # Replace spaces and hyphens with underscores, and take only the first word - $cleanTag = $tag -replace "[\s\-]+", "-" - # Limit to first 3 words - $cleanTag = ($cleanTag -split "-" | Select-Object -First 3) -join "-" - $tags += $cleanTag - } - } - - # Return top tags (most relevant for winget) - return $tags | Select-Object -First 10 -} - -function Find-ProjectInfo { - param ( - [string]$RootDir - ) - - $projectInfo = @{ - name = "" - type = "unknown" - executableName = "" - fileExtensions = @() - tags = @() - version = "" - shortDescription = "" - description = "" - publisher = "" - rootNamespace = "" - } - - # Try to get version from VERSION.md - $versionFile = Join-Path -Path $RootDir -ChildPath "VERSION.md" - if (Test-Path $versionFile) { - $versionContent = Get-Content -Path $versionFile -Raw - if ($versionContent) { - $projectInfo.version = $versionContent.Trim() - } - } - - # Try to get publisher info from AUTHORS.md - $authorsFile = Join-Path -Path $RootDir -ChildPath "AUTHORS.md" - if (Test-Path $authorsFile) { - $authorsContent = Get-Content -Path $authorsFile -Raw - if ($authorsContent -and $authorsContent.Trim()) { - $projectInfo.publisher = $authorsContent.Trim().Split("`n")[0].Trim() - } - } - - # Try to get short description from README.md - $readmeFile = Join-Path -Path $RootDir -ChildPath "README.md" - if (Test-Path $readmeFile) { - $readmeContent = Get-Content -Path $readmeFile -Raw - - # Extract name from README title - if ($readmeContent -match "^#\s+(.+?)(?=\r?\n|$)") { - $projectInfo.name = $Matches[1].Trim() - } - - # Extract short description - $shortDesc = Get-ShortDescription -Text $readmeContent - if ($shortDesc) { - $projectInfo.shortDescription = $shortDesc - } - } - - # Try to get detailed description from DESCRIPTION.md - $descriptionFile = Join-Path -Path $RootDir -ChildPath "DESCRIPTION.md" - if (Test-Path $descriptionFile) { - $descContent = Get-Content -Path $descriptionFile -Raw - if ($descContent -and $descContent.Trim()) { - $projectInfo.description = $descContent.Trim() - } - elseif ($projectInfo.shortDescription) { - # Use short description as fallback - $projectInfo.description = $projectInfo.shortDescription - } - } - elseif ($projectInfo.shortDescription) { - # Use short description as fallback - $projectInfo.description = $projectInfo.shortDescription - } - - # Try to get tags from TAGS.md - $tagsFile = Join-Path -Path $RootDir -ChildPath "TAGS.md" - if (Test-Path $tagsFile) { - $tagsContent = Get-Content -Path $tagsFile -Raw - if ($tagsContent -and $tagsContent.Trim()) { - $projectInfo.tags = ConvertFrom-TagsList -TagsText $tagsContent.Trim() - } - } - - # Check for .csproj files (C# projects) - $csprojFiles = Get-ChildItem -Path $RootDir -Filter "*.csproj" -Recurse -File -Depth 3 - if ($csprojFiles.Count -gt 0) { - $projectInfo.type = "csharp" - $csproj = $csprojFiles[0] - - # Try to use MSBuild to extract project properties - $msBuildProps = Get-MSBuildProperties -ProjectPath $csproj.FullName - - if ($msBuildProps -and $msBuildProps.Count -gt 0) { - Write-Host "Successfully extracted MSBuild properties from project" -ForegroundColor Green - - # Extract project properties - if (-not $projectInfo.name -and $msBuildProps.Product) { - $projectInfo.name = $msBuildProps.Product - } elseif (-not $projectInfo.name -and $msBuildProps.AssemblyName) { - $projectInfo.name = $msBuildProps.AssemblyName - } - - if (-not $projectInfo.version -and $msBuildProps.Version) { - $projectInfo.version = $msBuildProps.Version - } - - if (-not $projectInfo.description -and $msBuildProps.Description) { - $projectInfo.description = $msBuildProps.Description - if (-not $projectInfo.shortDescription) { - $projectInfo.shortDescription = $msBuildProps.Description.Split('.')[0] + '.' - } - } - - if (-not $projectInfo.publisher -and $msBuildProps.Authors) { - $projectInfo.publisher = $msBuildProps.Authors.Split(',')[0].Trim() - } - - if ($msBuildProps.PackageTags) { - $packageTags = $msBuildProps.PackageTags.Split(';').Trim() | Where-Object { $_ } - if ($packageTags -and $packageTags.Count -gt 0) { - foreach ($tag in $packageTags) { - if ($tag -and -not $projectInfo.tags.Contains($tag)) { - $projectInfo.tags += $tag - } - } - } - } - - if ($msBuildProps.RootNamespace) { - $projectInfo.rootNamespace = $msBuildProps.RootNamespace - } - } else { - # Fallback to parsing the csproj XML - Write-Host "Falling back to parsing csproj XML" -ForegroundColor Yellow - - # Extract project name from csproj if not already set - if (-not $projectInfo.name) { - $csprojContent = Get-Content -Path $csproj.FullName -Raw - if ($csprojContent -match "(.*?)") { - $projectInfo.name = $Matches[1] - } - elseif ($csproj.BaseName) { - $projectInfo.name = $csproj.BaseName - } - - # Try to extract other properties - if ($csprojContent -match "(.*?)") { - $projectInfo.version = $Matches[1] - } - - if ($csprojContent -match "(.*?)") { - $projectInfo.description = $Matches[1] - if (-not $projectInfo.shortDescription) { - $projectInfo.shortDescription = $Matches[1].Split('.')[0] + '.' - } - } - - if ($csprojContent -match "(.*?)") { - $projectInfo.publisher = $Matches[1].Split(',')[0].Trim() - } - - if ($csprojContent -match "(.*?)") { - $projectInfo.rootNamespace = $Matches[1] - } - } - } - - # Attempt to find supported file extensions from project - $projectFileExtensions = @() - - # Try to find file extensions from .csproj - # Look for ItemGroups that might contain extensions the app handles - $csprojContent = Get-Content -Path $csproj.FullName -Raw - if ($csprojContent -match "(.*?)") { - $extensions = $Matches[1] -split "[,;]" | ForEach-Object { $_.Trim() } - foreach ($ext in $extensions) { - # Remove any dots and ensure lowercase - $ext = $ext.TrimStart(".").ToLower() - if ($ext -and -not [string]::IsNullOrWhiteSpace($ext)) { - $projectFileExtensions += $ext - } - } - } - - # Look for patterns - $extensionMatches = [regex]::Matches($csprojContent, '(.*?)") { - $projectInfo.executableName = $Matches[1] - } else { - $projectInfo.executableName = "$($projectInfo.name).exe" - } - - # Look for command alias - if ($csprojContent -match "(.*?)") { - $projectInfo.commandAlias = $Matches[1] - } - } - - # Check for package.json (Node.js projects) - $packageJsonPath = Join-Path -Path $RootDir -ChildPath "package.json" - if (Test-Path $packageJsonPath) { - $projectInfo.type = "node" - $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json - - # Extract name from package.json if not already set - if (-not $projectInfo.name -and $packageJson.name) { - $projectInfo.name = $packageJson.name - } - - # Extract description if not already set - if (-not $projectInfo.shortDescription -and $packageJson.description) { - $projectInfo.shortDescription = $packageJson.description - } - - # Add common file extensions for Node.js projects if not set - if ($projectInfo.fileExtensions.Count -eq 0) { - $projectInfo.fileExtensions = @("js", "json", "ts", "html", "css") - } - - # Add Node.js tags if not set - if ($projectInfo.tags.Count -eq 0) { - $projectInfo.tags = @("nodejs", "javascript") - } - else { - # Add Node.js tags to existing tags - $projectInfo.tags += @("nodejs", "javascript") - $projectInfo.tags = $projectInfo.tags | Select-Object -Unique - } - - $projectInfo.executableName = "$($projectInfo.name).js" - } - - # Check for Cargo.toml (Rust projects) - $cargoTomlPath = Join-Path -Path $RootDir -ChildPath "Cargo.toml" - if (Test-Path $cargoTomlPath) { - $projectInfo.type = "rust" - $cargoContent = Get-Content -Path $cargoTomlPath -Raw - - # Extract name from Cargo.toml if not already set - if (-not $projectInfo.name -and $cargoContent -match "\[package\][\s\S]*?name\s*=\s*""([^""]+)""") { - $projectInfo.name = $Matches[1] - } - - # Add common file extensions for Rust projects if not set - if ($projectInfo.fileExtensions.Count -eq 0) { - $projectInfo.fileExtensions = @("rs", "toml") - } - - # Add Rust tags if not set - if ($projectInfo.tags.Count -eq 0) { - $projectInfo.tags = @("rust") - } - else { - # Add Rust tags to existing tags - $projectInfo.tags += @("rust") - $projectInfo.tags = $projectInfo.tags | Select-Object -Unique - } - - $projectInfo.executableName = $projectInfo.name - } - - # Find executables in common build directories - if (-not $projectInfo.executableName -or -not (Test-Path -Path "$RootDir/bin/$($projectInfo.executableName)")) { - # Look for executables in common build directories - $buildDirs = @("bin", "publish", "target/release", "target/debug", "dist", "build", "out") - - foreach ($dir in $buildDirs) { - $exeFiles = Get-ChildItem -Path "$RootDir/$dir" -Filter "*.exe" -File -Recurse -ErrorAction SilentlyContinue - if ($exeFiles.Count -gt 0) { - $projectInfo.executableName = $exeFiles[0].Name - break - } - } - } - - # If we still don't have a project name, use the directory name - if (-not $projectInfo.name) { - $projectInfo.name = (Get-Item -Path $RootDir).Name - } - - return $projectInfo -} - -function Get-FileDescription { - param ( - [string]$ProjectType, - [string]$FallbackDescription - ) - - # Return fallback if provided - if ($FallbackDescription) { - return $FallbackDescription - } - - # Otherwise return a generic description based on project type - switch ($ProjectType) { - "csharp" { return ".NET application" } - "node" { return "Node.js application" } - "rust" { return "Rust application" } - default { return "Software application" } - } -} - -# ----- Main Script ----- - -# Set root directory to the parent of the scripts folder -$rootDir = Join-Path $PSScriptRoot ".." -$manifestDir = Join-Path $rootDir "winget" - -# Create the directory if it doesn't exist -if (-not (Test-Path $manifestDir)) { - New-Item -Path $manifestDir -ItemType Directory | Out-Null -} - -# Load configuration from file if it exists and specified -$config = @{} -if ($ConfigFile -and (Test-Path $ConfigFile)) { - Write-Host "Loading configuration from $ConfigFile..." -ForegroundColor Yellow - $config = Get-Content -Path $ConfigFile -Raw | ConvertFrom-Json -AsHashtable -} - -# Detect repository info if not provided -if (-not $GitHubRepo) { - $detectedRepo = Get-GitRemoteInfo -RootDir $rootDir - if ($detectedRepo) { - $GitHubRepo = $detectedRepo - Write-Host "Detected GitHub repository: $GitHubRepo" -ForegroundColor Green - } - elseif ($config.githubRepo) { - $GitHubRepo = $config.githubRepo - } - else { - Write-Error "Could not detect GitHub repository. Please specify it using -GitHubRepo parameter." - exit 1 - } -} - -# Extract owner and repo name from GitHub repo string -$ownerRepo = $GitHubRepo.Split('/') -$owner = $ownerRepo[0] -$repo = $ownerRepo[1] - -# Detect project info from repository -$projectInfo = Find-ProjectInfo -RootDir $rootDir -Write-Host "Detected project: $($projectInfo.name) (Type: $($projectInfo.type))" -ForegroundColor Green - -# Early check for library-only projects to avoid unnecessary processing -if (Test-IsLibraryOnlyProject -RootDir $rootDir -ProjectInfo $projectInfo) { - Exit-GracefullyForLibrary -Message "Detected library-only solution with no applications." -} - -# Check for explicit version provided -if ($projectInfo.version -and -not $Version) { - $Version = $projectInfo.version - Write-Host "Using detected version: $Version" -ForegroundColor Green -} - -# Build configuration object with detected and provided values -$config = @{ - packageId = if ($PackageId) { $PackageId } elseif ($config.packageId) { $config.packageId } elseif ($projectInfo.rootNamespace) { $projectInfo.rootNamespace } elseif ($projectInfo.name) { $projectInfo.name } else { "$owner.$repo" } - githubRepo = $GitHubRepo - artifactNamePattern = if ($ArtifactNamePattern) { $ArtifactNamePattern } elseif ($config.artifactNamePattern) { $config.artifactNamePattern } else { "$repo-{version}-{arch}.zip" } - executableName = if ($ExecutableName) { $ExecutableName } elseif ($config.executableName) { $config.executableName } elseif ($projectInfo.executableName) { $projectInfo.executableName } else { "$repo.exe" } - commandAlias = if ($CommandAlias) { $CommandAlias } elseif ($config.commandAlias) { $config.commandAlias } else { $repo.ToLower() } - packageName = if ($config.packageName) { $config.packageName } else { $projectInfo.name -replace "^$owner\.", "" } - publisher = if ($config.publisher) { $config.publisher } elseif ($projectInfo.publisher) { $projectInfo.publisher } else { $owner } - shortDescription = if ($config.shortDescription) { $config.shortDescription } elseif ($projectInfo.shortDescription) { $projectInfo.shortDescription } else { "A $($projectInfo.type) application" } - description = if ($config.description) { $config.description } elseif ($projectInfo.description) { $projectInfo.description } else { Get-FileDescription -ProjectType $projectInfo.type } - fileExtensions = if ($config.fileExtensions -and $config.fileExtensions.Count -gt 0) { $config.fileExtensions } elseif ($projectInfo.fileExtensions.Count -gt 0) { $projectInfo.fileExtensions } else { @("txt", "md", "json") } - tags = if ($config.tags -and $config.tags.Count -gt 0) { $config.tags } elseif ($projectInfo.tags.Count -gt 0) { $projectInfo.tags } else { @("utility", "application") } -} - -Write-Host "Configuration:" -ForegroundColor Yellow -Write-Host " Package ID: $($config.packageId)" -ForegroundColor Cyan -Write-Host " GitHub Repo: $($config.githubRepo)" -ForegroundColor Cyan -Write-Host " Package Name: $($config.packageName)" -ForegroundColor Cyan -Write-Host " Publisher: $($config.publisher)" -ForegroundColor Cyan -Write-Host " Artifact Pattern: $($config.artifactNamePattern)" -ForegroundColor Cyan -Write-Host " Executable: $($config.executableName)" -ForegroundColor Cyan -Write-Host " Command Alias: $($config.commandAlias)" -ForegroundColor Cyan -Write-Host " Description: $($config.shortDescription)" -ForegroundColor Cyan -Write-Host " Tags: $($config.tags -join ', ')" -ForegroundColor Cyan - -# GitHub API configuration -$releaseUrl = "https://api.github.com/repos/$($config.githubRepo)/releases/tags/v$Version" -$downloadBaseUrl = "https://github.com/$($config.githubRepo)/releases/download/v$Version" - -# Build headers for GitHub API requests (with optional authentication) -$githubHeaders = @{ - "User-Agent" = "Winget-Manifest-Updater" - "Accept" = "application/vnd.github.v3+json" -} - -# Check for GITHUB_TOKEN environment variable for authenticated requests (higher rate limit) -$githubToken = $env:GITHUB_TOKEN -if (-not $githubToken) { - $githubToken = $env:GH_TOKEN -} -if ($githubToken) { - $githubHeaders["Authorization"] = "Bearer $githubToken" - Write-Host "Using authenticated GitHub API requests" -ForegroundColor Green -} else { - Write-Host "Warning: No GITHUB_TOKEN found. API requests may be rate-limited." -ForegroundColor Yellow - Write-Host "Set GITHUB_TOKEN environment variable for authenticated requests." -ForegroundColor Yellow -} - -Write-Host "Updating winget manifests for $($config.packageName) version $Version..." -ForegroundColor Green - -# Fetch release information from GitHub -try { - Write-Host "Fetching release information from GitHub..." -ForegroundColor Yellow - $release = Invoke-RestMethod -Uri $releaseUrl -Headers $githubHeaders - - Write-Host "Found release: $($release.name)" -ForegroundColor Green - $releaseDate = [DateTime]::Parse($release.published_at).ToString("yyyy-MM-dd") -} catch { - # Check if this might be a library-only project before failing - if (Test-IsLibraryOnlyProject -RootDir $rootDir -ProjectInfo $projectInfo) { - Exit-GracefullyForLibrary -Message "Failed to fetch release information, but detected library-only solution." - } - - Write-Error "Failed to fetch release information: $_" - exit 1 -} - -# Download and calculate SHA256 hashes for each architecture -$architectures = @("win-x64", "win-x86", "win-arm64") -$sha256Hashes = @{} - -# First, try to read hashes from local file if available (from recent build) -$localHashesFile = Join-Path $rootDir "staging" "hashes.txt" -$localHashes = @{} - -if (Test-Path $localHashesFile) { - Write-Host "Reading hashes from local build output..." -ForegroundColor Yellow - Get-Content $localHashesFile | ForEach-Object { - if ($_ -match '^(.+)=(.+)$') { - $localHashes[$Matches[1]] = $Matches[2] - } - } -} - -foreach ($arch in $architectures) { - # Replace placeholders in artifact name pattern - $fileName = $config.artifactNamePattern -replace '{name}', $repo -replace '{version}', $Version -replace '{arch}', $arch - - # Try to use local hash first - if ($localHashes.ContainsKey($fileName)) { - $sha256Hashes[$arch] = $localHashes[$fileName].ToUpper() - Write-Host " $arch`: $($sha256Hashes[$arch]) (from local build)" -ForegroundColor Cyan - continue - } - - # Fall back to downloading and calculating hash - $downloadUrl = "$downloadBaseUrl/$fileName" - $tempFile = Join-Path $env:TEMP $fileName - - try { - Write-Host "Downloading $fileName to calculate SHA256..." -ForegroundColor Yellow - Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile -UseBasicParsing - - $hash = Get-FileHash -Path $tempFile -Algorithm SHA256 - $sha256Hashes[$arch] = $hash.Hash.ToUpper() - - Write-Host " $arch`: $($hash.Hash)" -ForegroundColor Cyan - - # Clean up temp file - Remove-Item $tempFile -Force - } catch { - Write-Host "Warning: Failed to download or hash $fileName`: $_" -ForegroundColor Yellow - Write-Host "Skipping this architecture. If required, please provide the correct artifact name pattern." -ForegroundColor Yellow - } -} - -# Check if we have at least one hash -if ($sha256Hashes.Count -eq 0) { - # Check if this appears to be a library-only project (no executable artifacts) - if (Test-IsLibraryOnlyProject -RootDir $rootDir -ProjectInfo $projectInfo) { - Exit-GracefullyForLibrary - } else { - Write-Error "Could not obtain any SHA256 hashes. Please check that the artifact name pattern matches your release files." - exit 1 - } -} - -# Update version manifest -$versionManifestPath = Join-Path $manifestDir "$($config.packageId).yaml" -Write-Host "Updating version manifest: $versionManifestPath" -ForegroundColor Yellow - -$versionContent = @" -# yaml-language-server: `$schema=https://aka.ms/winget-manifest.version.1.10.0.schema.json -PackageIdentifier: $($config.packageId) -PackageVersion: $Version -DefaultLocale: en-US -ManifestType: version -ManifestVersion: 1.10.0 -"@ - -Set-Content -Path $versionManifestPath -Value $versionContent -Encoding UTF8 - -# Update locale manifest -$localeManifestPath = Join-Path $manifestDir "$($config.packageId).locale.en-US.yaml" -Write-Host "Updating locale manifest: $localeManifestPath" -ForegroundColor Yellow - -# Generate tags string for YAML -$tagsYaml = "" -if ($config.tags -and $config.tags.Count -gt 0) { - $tagsYaml = "Tags:`n" - foreach ($tag in $config.tags) { - $tagsYaml += "- $tag`n" - } -} - -$localeContent = @" -# yaml-language-server: `$schema=https://aka.ms/winget-manifest.defaultLocale.1.10.0.schema.json -PackageIdentifier: $($config.packageId) -PackageVersion: $Version -PackageLocale: en-US -Publisher: $($config.publisher) -PublisherUrl: https://github.com/$owner -PublisherSupportUrl: https://github.com/$($config.githubRepo)/issues -# PrivacyUrl: -Author: $($config.publisher) -PackageName: $($config.packageName) -PackageUrl: https://github.com/$($config.githubRepo) -License: MIT -LicenseUrl: https://github.com/$($config.githubRepo)/blob/main/LICENSE.md -Copyright: Copyright (c) $($config.publisher) -# CopyrightUrl: -ShortDescription: $($config.shortDescription) -Description: $($config.description) -Moniker: $($config.commandAlias) -$tagsYaml -ReleaseNotes: |- - See full changelog at: https://github.com/$($config.githubRepo)/blob/main/CHANGELOG.md -ReleaseNotesUrl: https://github.com/$($config.githubRepo)/releases/tag/v$Version -# PurchaseUrl: -# InstallationNotes: -Documentations: -- DocumentLabel: README - DocumentUrl: https://github.com/$($config.githubRepo)/blob/main/README.md -ManifestType: defaultLocale -ManifestVersion: 1.10.0 -"@ - -Set-Content -Path $localeManifestPath -Value $localeContent -Encoding UTF8 - -# Update installer manifest -$installerManifestPath = Join-Path $manifestDir "$($config.packageId).installer.yaml" -Write-Host "Updating installer manifest: $installerManifestPath" -ForegroundColor Yellow - -# Generate file extensions string for YAML -$fileExtensionsYaml = "" -if ($config.fileExtensions -and $config.fileExtensions.Count -gt 0) { - $fileExtensionsYaml = "FileExtensions:`n" - foreach ($ext in $config.fileExtensions) { - $fileExtensionsYaml += "- $ext`n" - } -} - -# Generate commands string for YAML -$commandsYaml = "Commands:`n- $($config.commandAlias)" -if ($config.executableName -ne $config.commandAlias) { - $commandsYaml += "`n- $($config.executableName.Replace('.exe', ''))" -} - -$installerContent = @" -# yaml-language-server: `$schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json -PackageIdentifier: $($config.packageId) -PackageVersion: $Version -Platform: -- Windows.Desktop -MinimumOSVersion: 10.0.17763.0 -InstallerType: zip -InstallModes: -- interactive -- silent -UpgradeBehavior: install -$($commandsYaml.TrimEnd()) -$($fileExtensionsYaml.TrimEnd()) -ReleaseDate: $releaseDate -Dependencies: - PackageDependencies: - -"@ - -# Add .NET dependency based on project type -if ($projectInfo.type -eq "csharp") { - $installerContent += " - PackageIdentifier: Microsoft.DotNet.DesktopRuntime.10`n" -} - -$installerContent += "Installers:`n" - -foreach ($arch in $sha256Hashes.Keys) { - # Replace placeholders in artifact name pattern - $fileName = $config.artifactNamePattern -replace '{name}', $repo -replace '{version}', $Version -replace '{arch}', $arch - - # Add installer entry for this architecture - $installerContent += @" -- Architecture: $($arch.Replace('win-', '')) - InstallerUrl: $downloadBaseUrl/$fileName - InstallerSha256: $($sha256Hashes[$arch]) - NestedInstallerType: portable - NestedInstallerFiles: - - RelativeFilePath: $($config.executableName) - PortableCommandAlias: $($config.commandAlias) - -"@ -} - -$installerContent += @" -ManifestType: installer -ManifestVersion: 1.10.0 -"@ - -Set-Content -Path $installerManifestPath -Value $installerContent -Encoding UTF8 - -# Try to upload manifest files to GitHub release if gh CLI is available -try { - $ghCommand = Get-Command gh -ErrorAction SilentlyContinue - if ($ghCommand) { - Write-Host "Uploading manifest files to GitHub release..." -ForegroundColor Yellow - gh release upload v$Version $versionManifestPath $localeManifestPath $installerManifestPath --repo $($config.githubRepo) - Write-Host "Manifest files uploaded to release." -ForegroundColor Green - } -} catch { - # Check if upload failure might be due to missing release artifacts for library-only projects - if ($_.Exception.Message -match "not found|404" -and (Test-IsLibraryOnlyProject -RootDir $rootDir -ProjectInfo $projectInfo)) { - Exit-GracefullyForLibrary -Message "Release upload failed, likely due to library-only solution having no executable artifacts." - } - - Write-Host "GitHub CLI not available or error uploading files: $_" -ForegroundColor Yellow - Write-Host "Manifest files were created but not uploaded to the release." -ForegroundColor Yellow -} - -Write-Host "`n✅ Winget manifests updated successfully!" -ForegroundColor Green -Write-Host "Files updated:" -ForegroundColor Yellow -Write-Host " - $versionManifestPath" -ForegroundColor Cyan -Write-Host " - $localeManifestPath" -ForegroundColor Cyan -Write-Host " - $installerManifestPath" -ForegroundColor Cyan - -Write-Host "`nNext steps:" -ForegroundColor Yellow -Write-Host "1. Review the updated manifest files" -ForegroundColor White -Write-Host "2. Test the manifests locally with: winget install --manifest $manifestDir" -ForegroundColor White -Write-Host "3. Submit to winget-pkgs repository: https://github.com/microsoft/winget-pkgs" -ForegroundColor White -Write-Host "4. Create a PR following the winget contribution guidelines" -ForegroundColor White From 3a8667d0d21f66939c75df70c58bd89ef36947f0 Mon Sep 17 00:00:00 2001 From: SyncFileContents Date: Mon, 16 Feb 2026 14:02:19 +1100 Subject: [PATCH 08/12] Sync .github\workflows\dotnet.yml --- .github/workflows/dotnet.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 85f8bc2..438d365 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -11,6 +11,17 @@ on: schedule: - cron: "0 23 * * *" # Daily at 11 PM UTC workflow_dispatch: # Allow manual triggers + inputs: + version-bump: + description: 'Version bump type' + required: false + default: 'auto' + type: choice + options: + - auto + - patch + - minor + - major concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -124,7 +135,9 @@ jobs: EXPECTED_OWNER: ktsu-dev run: | # Run the CI pipeline - dotnet run --project "${{ runner.temp }}/KtsuBuild/KtsuBuild.CLI" -- ci --workspace "${{ github.workspace }}" --verbose + $versionBump = "${{ github.event.inputs.version-bump }}" + if ([string]::IsNullOrEmpty($versionBump)) { $versionBump = "auto" } + dotnet run --project "${{ runner.temp }}/KtsuBuild/KtsuBuild.CLI" -- ci --workspace "${{ github.workspace }}" --verbose --version-bump $versionBump # Set outputs for downstream jobs $version = (Get-Content "${{ github.workspace }}/VERSION.md" -Raw).Trim() From dd34e8807d8105a620c4c83ffd51fe6d9b4872f2 Mon Sep 17 00:00:00 2001 From: SyncFileContents Date: Mon, 16 Feb 2026 14:07:13 +1100 Subject: [PATCH 09/12] Sync .github\workflows\dotnet.yml --- .github/workflows/dotnet.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 438d365..9e1b51b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -136,8 +136,14 @@ jobs: run: | # Run the CI pipeline $versionBump = "${{ github.event.inputs.version-bump }}" - if ([string]::IsNullOrEmpty($versionBump)) { $versionBump = "auto" } - dotnet run --project "${{ runner.temp }}/KtsuBuild/KtsuBuild.CLI" -- ci --workspace "${{ github.workspace }}" --verbose --version-bump $versionBump + + # Build the command - only add --version-bump if explicitly set (for backward compatibility during bootstrap) + $command = "ci --workspace `"${{ github.workspace }}`" --verbose" + if (![string]::IsNullOrEmpty($versionBump) -and $versionBump -ne "auto") { + $command += " --version-bump $versionBump" + } + + dotnet run --project "${{ runner.temp }}/KtsuBuild/KtsuBuild.CLI" -- $command # Set outputs for downstream jobs $version = (Get-Content "${{ github.workspace }}/VERSION.md" -Raw).Trim() From 6d50f368291ea34ad56d4ebcc0b7f6af2de14934 Mon Sep 17 00:00:00 2001 From: SyncFileContents Date: Mon, 16 Feb 2026 14:11:57 +1100 Subject: [PATCH 10/12] Sync .github\workflows\dotnet.yml --- .github/workflows/dotnet.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 9e1b51b..4ace06a 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -137,13 +137,13 @@ jobs: # Run the CI pipeline $versionBump = "${{ github.event.inputs.version-bump }}" - # Build the command - only add --version-bump if explicitly set (for backward compatibility during bootstrap) - $command = "ci --workspace `"${{ github.workspace }}`" --verbose" + # Build arguments array - only add --version-bump if explicitly set (for backward compatibility during bootstrap) + $args = @("ci", "--workspace", "${{ github.workspace }}", "--verbose") if (![string]::IsNullOrEmpty($versionBump) -and $versionBump -ne "auto") { - $command += " --version-bump $versionBump" + $args += @("--version-bump", $versionBump) } - dotnet run --project "${{ runner.temp }}/KtsuBuild/KtsuBuild.CLI" -- $command + & dotnet run --project "${{ runner.temp }}/KtsuBuild/KtsuBuild.CLI" -- @args # Set outputs for downstream jobs $version = (Get-Content "${{ github.workspace }}/VERSION.md" -Raw).Trim() From 49c9080d43258d1ad4ee99498ee23147484b289f Mon Sep 17 00:00:00 2001 From: SyncFileContents Date: Mon, 16 Feb 2026 16:16:05 +1100 Subject: [PATCH 11/12] Sync .github\workflows\dotnet.yml --- .github/workflows/dotnet.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4ace06a..97d310f 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -28,7 +28,8 @@ concurrency: cancel-in-progress: true # Default permissions -permissions: read-all +permissions: + contents: read env: DOTNET_VERSION: "10.0" # Only needed for actions/setup-dotnet @@ -111,7 +112,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" /d:sonar.coverage.exclusions="**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll" /d:sonar.cs.vstest.reportsPaths="coverage/TestResults/**/*.trx" + .\.sonar\scanner\dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" /d:sonar.coverage.exclusions="**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll" /d:sonar.cs.vstest.reportsPaths="coverage/TestResults/**/*.trx" /d:sonar.exclusions="_temp/**,_actions/**" - name: Clone KtsuBuild (Latest Tag) run: | From ec8f57efef777ec4c6304b963e3d4ebaf4dd7b1b Mon Sep 17 00:00:00 2001 From: KtsuTools Date: Tue, 17 Feb 2026 20:06:33 +1100 Subject: [PATCH 12/12] Sync .github\workflows\dotnet.yml --- .github/workflows/dotnet.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 97d310f..c952af9 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -106,13 +106,22 @@ jobs: New-Item -Path .\.sonar\scanner -ItemType Directory dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner + - name: Configure SonarQube exclusions + shell: bash + run: | + EXCLUSIONS="_temp/**,_actions/**" + if [ "${{ github.event.repository.name }}" != "KtsuBuild" ]; then + EXCLUSIONS="$EXCLUSIONS,**/KtsuBuild/**" + fi + echo "SONAR_EXCLUSIONS=$EXCLUSIONS" >> $GITHUB_ENV + - name: Begin SonarQube if: ${{ env.SONAR_TOKEN != '' }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" /d:sonar.coverage.exclusions="**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll" /d:sonar.cs.vstest.reportsPaths="coverage/TestResults/**/*.trx" /d:sonar.exclusions="_temp/**,_actions/**" + .\.sonar\scanner\dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" /d:sonar.coverage.exclusions="**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll" /d:sonar.cs.vstest.reportsPaths="coverage/TestResults/**/*.trx" /d:sonar.exclusions="${{ env.SONAR_EXCLUSIONS }}" - name: Clone KtsuBuild (Latest Tag) run: |