diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..ebf1316 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "cake.tool": { + "version": "6.0.0", + "commands": [ + "dotnet-cake" + ] + } + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0fca79c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,346 @@ +# EditorConfig is awesome:http://EditorConfig.org + +# top-most EditorConfig file +root = true + +#======================================================================================= +# Generic +#======================================================================================= + +# Don't use tabs for indentation +[*] +indent_style = space +# (Please don't specify an indent_size here; that has too many unintended consequences.) + +#======================================================================================= +# Xml files +#======================================================================================= + +[*.{xml,xaml})] +indent_size = 2 + +#--------------------------------------------------------------------------------------- +# ReSharper specific +#--------------------------------------------------------------------------------------- + +indent_style = space +indent_size = 2 +blank_line_after_pi = false + +#======================================================================================= +# Code files +#======================================================================================= + +[*.{cs,csx,vb,vbx}] +indent_size = 4 +insert_final_newline = true +charset = utf-8-bom + +#======================================================================================= +# Psl files +#======================================================================================= + +[*.ps1] +indent_size = 2 + +#======================================================================================= +# Xml project files +#======================================================================================= + +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +#======================================================================================= +# Xml config files +#======================================================================================= + +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +#======================================================================================= +# JSON files +#======================================================================================= + +[*.json] +indent_size = 2 + +#======================================================================================= +# Groovy files +#======================================================================================= + +[*.groovy] +indent_size = 2 + +#======================================================================================= +# .NET (C# / VB) code style settings +#======================================================================================= + +# Dotnet code style settings: +[*.{cs,vb}] + +#--------------------------------------------------------------------------------------- +# Sort using and Import directives with System.* appearing first +#--------------------------------------------------------------------------------------- + +dotnet_sort_system_directives_first = true +dotnet_style_require_accessibility_modifiers = always:warning + +#--------------------------------------------------------------------------------------- +# Put a blank line between System.* and Microsoft.* +#--------------------------------------------------------------------------------------- + +dotnet_separate_import_directive_groups = false + +#--------------------------------------------------------------------------------------- +# Usings inside the namespace +#--------------------------------------------------------------------------------------- + +csharp_using_directive_placement = inside_namespace:error + +#--------------------------------------------------------------------------------------- +# Avoid "this." and "Me." if not necessary +#--------------------------------------------------------------------------------------- + +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +#--------------------------------------------------------------------------------------- +# Use language keywords instead of framework type names for type references +#--------------------------------------------------------------------------------------- + +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +#--------------------------------------------------------------------------------------- +# Prefer read-only on fields +#--------------------------------------------------------------------------------------- + +dotnet_style_readonly_field = true:error + +#--------------------------------------------------------------------------------------- +# Naming Rules +#--------------------------------------------------------------------------------------- + +dotnet_naming_rule.interfaces_must_be_pascal_cased_and_prefixed_with_I.symbols = interface_symbols +dotnet_naming_rule.interfaces_must_be_pascal_cased_and_prefixed_with_I.style = pascal_case_and_prefix_with_I_style +dotnet_naming_rule.interfaces_must_be_pascal_cased_and_prefixed_with_I.severity = error + +dotnet_naming_rule.externally_visible_members_must_be_pascal_cased.symbols = externally_visible_symbols +dotnet_naming_rule.externally_visible_members_must_be_pascal_cased.style = pascal_case_style +dotnet_naming_rule.externally_visible_members_must_be_pascal_cased.severity = error + +dotnet_naming_rule.parameters_must_be_camel_cased.symbols = parameter_symbols +dotnet_naming_rule.parameters_must_be_camel_cased.style = camel_case_style +dotnet_naming_rule.parameters_must_be_camel_cased.severity = error + +dotnet_naming_rule.constants_must_be_pascal_cased.symbols = constant_symbols +dotnet_naming_rule.constants_must_be_pascal_cased.style = pascal_case_style +dotnet_naming_rule.constants_must_be_pascal_cased.severity = error + +dotnet_naming_rule.private_static_fields_must_be_camel_cased_and_prefixed_with_s_underscore.symbols = private_static_field_symbols +dotnet_naming_rule.private_static_fields_must_be_camel_cased_and_prefixed_with_s_underscore.style = pascal_case_style +dotnet_naming_rule.private_static_fields_must_be_camel_cased_and_prefixed_with_s_underscore.severity = error + +dotnet_naming_rule.protected_instance_fields_must_be_camel_cased_and_prefixed_with_underscore.symbols = protected_field_symbols +dotnet_naming_rule.protected_instance_fields_must_be_camel_cased_and_prefixed_with_underscore.style = camel_case_and_prefix_with_underscore_style +dotnet_naming_rule.protected_instance_fields_must_be_camel_cased_and_prefixed_with_underscore.severity = error + +dotnet_naming_rule.private_instance_fields_must_be_camel_cased_and_prefixed_with_underscore.symbols = private_field_symbols +dotnet_naming_rule.private_instance_fields_must_be_camel_cased_and_prefixed_with_underscore.style = camel_case_and_prefix_with_underscore_style +dotnet_naming_rule.private_instance_fields_must_be_camel_cased_and_prefixed_with_underscore.severity = error + +#--------------------------------------------------------------------------------------- +# Symbols +#--------------------------------------------------------------------------------------- + +dotnet_naming_symbols.externally_visible_symbols.applicable_kinds = class,struct,interface,enum,property,method,field,event,delegate +dotnet_naming_symbols.externally_visible_symbols.applicable_accessibilities = public,internal,friend,protected,protected_internal,protected_friend,private_protected + +dotnet_naming_symbols.interface_symbols.applicable_kinds = interface +dotnet_naming_symbols.interface_symbols.applicable_accessibilities = * + +dotnet_naming_symbols.parameter_symbols.applicable_kinds = parameter +dotnet_naming_symbols.parameter_symbols.applicable_accessibilities = * + +dotnet_naming_symbols.constant_symbols.applicable_kinds = field +dotnet_naming_symbols.constant_symbols.required_modifiers = const +dotnet_naming_symbols.constant_symbols.applicable_accessibilities = * + +dotnet_naming_symbols.private_static_field_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_field_symbols.required_modifiers = static,shared +dotnet_naming_symbols.private_static_field_symbols.applicable_accessibilities = private + +dotnet_naming_symbols.private_field_symbols.applicable_kinds = field +dotnet_naming_symbols.private_field_symbols.applicable_accessibilities = private + +dotnet_naming_symbols.protected_field_symbols.applicable_kinds = field +dotnet_naming_symbols.protected_field_symbols.applicable_accessibilities = protected + +#--------------------------------------------------------------------------------------- +# Styles +#--------------------------------------------------------------------------------------- + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +dotnet_naming_style.camel_case_and_prefix_with_s_underscore_style.required_prefix = s_ +dotnet_naming_style.camel_case_and_prefix_with_s_underscore_style.capitalization = camel_case + +dotnet_naming_style.camel_case_and_prefix_with_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_and_prefix_with_underscore_style.capitalization = camel_case + +dotnet_naming_style.pascal_case_and_prefix_with_I_style.required_prefix = I +dotnet_naming_style.pascal_case_and_prefix_with_I_style.capitalization = pascal_case + + +#======================================================================================= +# CSharp code style settings +#======================================================================================= + +[*.cs] +# Prefer "var" only when the type is apparent +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +#--------------------------------------------------------------------------------------- +# Prefer method-like constructs to have a block body +#--------------------------------------------------------------------------------------- + +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +#--------------------------------------------------------------------------------------- +# Prefer property-like constructs to have an expression-body +#--------------------------------------------------------------------------------------- + +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +#--------------------------------------------------------------------------------------- +# Suggest more modern language features when available +#--------------------------------------------------------------------------------------- + +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false +dotnet_style_prefer_conditional_expression_over_assignment = false +dotnet_style_prefer_auto_properties = false +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +#--------------------------------------------------------------------------------------- +# Newline settings +#--------------------------------------------------------------------------------------- + +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +#--------------------------------------------------------------------------------------- +# Identation options +#--------------------------------------------------------------------------------------- + +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = no_change +csharp_indent_block_contents = true +csharp_indent_braces = false + +#--------------------------------------------------------------------------------------- +# Spacing options +#--------------------------------------------------------------------------------------- + +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false +csharp_space_between_empty_square_brackets = false +csharp_space_before_open_square_brackets = false +csharp_space_around_declaration_statements = false +csharp_space_around_binary_operators = before_and_after +csharp_space_after_cast = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_before_dot = false +csharp_space_after_dot = false +csharp_space_before_comma = false +csharp_space_after_comma = true +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_semicolon_in_for_statement = true + +#--------------------------------------------------------------------------------------- +# Wrapping +#--------------------------------------------------------------------------------------- + +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true + +#--------------------------------------------------------------------------------------- +# ReSharper specific +#--------------------------------------------------------------------------------------- + +add_imports_to_deepest_scope = true +blank_lines_inside_region = 0 +csharp_blank_lines_inside_region = 0 +csharp_braces_for_ifelse = required +csharp_braces_for_for = required +csharp_braces_for_foreach = required +csharp_braces_for_while = required +csharp_braces_for_dowhile = required +csharp_keep_existing_declaration_parens_arrangement = true +csharp_keep_user_linebreaks = true +csharp_max_line_length = 1000 +xml_indent_style = space +xml_indent_size = 2 +xmldoc_indent_style = space +xmldoc_indent_size = 2 + +#======================================================================================= +# Test projects +#======================================================================================= + +[src/*Tests/**/*.cs] +# We allow usage of "var" inside tests as it reduces churn as we remove/rename types +csharp_style_var_for_built_in_types = true:none +csharp_style_var_elsewhere = true:none + +#======================================================================================= +# Ignore designer files +#======================================================================================= + +[src/**/*.designer.cs,src/**/GlobalSuppressions.cs,src/**/MethodTimeLogger.cs,src/**/SolutionAssemblyInfo.cs] +charset = none +end_of_line = none +insert_final_newline = none +trim_trailing_whitespace = none +indent_style = none +indent_size = none \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..6a9b5d4 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing + +Thanks you for your interest in contributing - Please see our our [Code of Conduct](CODE_OF_CONDUCT.md). + + +### Bug Fixes + +If you're looking for something to fix, please browse the open issues. + +Follow the style used by the [.NET Foundation](https://github.com/dotnet/corefx/blob/master/Documentation/coding-guidelines/coding-style.md), with a few exceptions: + +- Apply readonly on class level private variables that are assigned in the constructor +- 4 SPACES - tabs do not exist :) + +Read and follow our [Pull Request template](PULL_REQUEST_TEMPLATE.md) if you are submitting fixes. + +### Feature Requests + +To propose a change or new feature, please make use the feature request area in issues. + +#### Non-Starter Topics +The following topics should generally not be proposed for discussion as they are non-starters: + +* Large renames of APIs +* Large non-backward-compatible breaking changes +* Avoid clutter posts like "+1" which do not serve to further the conversation + +#### Proposal States +##### Open +Open proposals are still under discussion. Please leave your concrete, constructive feedback on this proposal. +1s and other clutter posts which do not add to the discussion will be removed. + +##### Accepted +Accepted proposals are proposals that both the community and core team agree should be a part of projects. These proposals are ready for implementation. These proposals are available for anyone to work on unless it is already assigned to someone. + +If you wish to start working on an accepted proposal, please reply to the thread so we can mark you as the implementor and change the title to In Progress. This helps to avoid multiple people working on the same thing. If you decide to work on this proposal publicly, feel free to post a link to the branch as well for folks to follow along. + +###### What "Accepted" does mean +* Any community member is welcome to work on the idea. +* The maintainers _may_ consider working on this idea on their own, but has not done so until it is marked "In Progress" with a team member assigned as the implementor. +* Any pull request implementing the proposal will be welcomed with an API and code review. + +###### What "Accepted" does not mean +* The proposal will ever be implemented, either by a community member or maintainers. +* The maintainers are committing to implementing a proposal, even if nobody else does. + +##### In Progress +Once a developer has begun work on a proposal, either from the team or a community member, the proposal is marked as in progress with the implementors name and (possibly) a link to a development branch to follow along with progress. + +#### Rejected +Rejected proposals will not be implemented or merged into the code base. Once a proposal is rejected, the thread will be closed and the conversation is considered completed, pending considerable new information or changes.. \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..bfd68e1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,8 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: WildGums-oss # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +custom: # Replace with a single custom sponsorship URL \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/---support-request.md b/.github/ISSUE_TEMPLATE/---support-request.md new file mode 100644 index 0000000..e485a18 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---support-request.md @@ -0,0 +1,16 @@ +--- +name: "\U0001F62C Support Request" +about: Having Trouble - ONLY contributors to other OSS projects OR people who are + funding this project can submit these! If you aren't one of these, expect the ban + hammer to fall +title: '' +labels: '' +assignees: '' + +--- + +ONLY active OSS contributors OR people who buy us a coffee can ask questions here. If you don't do either of these things - DO NOT FILE HERE :) + +Give as much details as humanly possible if you want any sort of answer! + +Enter Question Below (don't delete this line) \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--bug.md b/.github/ISSUE_TEMPLATE/--bug.md new file mode 100644 index 0000000..2990ca7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--bug.md @@ -0,0 +1,36 @@ +--- +name: "\U0001F99FBug" +about: Create a report to help us improve +title: '' +labels: "s/unverified, t/bug \U0001F47E" +assignees: '' + +--- + +# IF YOU DON'T ANSWER THIS TEMPLATE - THE BOT WILL AUTOMATICALLY CLOSE YOUR ISSUE! + +## Please check all of the platforms you are having the issue on (if platform is not listed, it is not supported) + + - [ ] WPF + - [ ] Blazor WASM + - [ ] .NET Core + +## Component + +What component is this issue occurring in? + +## Version of Library + + +## Version of OS(s) listed above with issue + + +## Steps to Reproduce +1. +2. +3. + +## Expected Behavior + + +## Actual Behavior \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--feature-request.md b/.github/ISSUE_TEMPLATE/--feature-request.md new file mode 100644 index 0000000..25fa772 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--feature-request.md @@ -0,0 +1,19 @@ +--- +name: "\U0001F354Feature Request" +about: Suggest an idea for this project +title: '' +labels: "s/unverified, t/enhancement \U0001F423" +assignees: '' + +--- + +# IF YOU DON'T ANSWER THIS TEMPLATE - THE BOT WILL AUTOMATICALLY CLOSE YOUR ISSUE! + +## Summary +Please provide a brief summary of your proposal. Two to three sentences is best here. + +## API Changes +Include a list of all API changes, additions, subtractions as would be required by your proposal. These APIs should be considered placeholders, so the naming is not as important as getting the concepts correct. If possible you should include some "example" code of usage of your new API. + +## Intended Use Case +Provide a detailed example of where your proposal would be used and for what purpose. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--thank-you.md b/.github/ISSUE_TEMPLATE/--thank-you.md new file mode 100644 index 0000000..932c781 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--thank-you.md @@ -0,0 +1,10 @@ +--- +name: "❤️Thank You" +about: Just want to say thank you, this is the one to do it in +title: Thank You +labels: '' +assignees: '' + +--- + +Leave Your Message Below (don't delete this line though) \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..079180a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ +### Description of Change ### + + + +### Issues Resolved ### + + + +- fixes # + +### API Changes ### + + + +None + +### Behavioral Changes ### + + + +None + +### Testing Procedure ### + + + +### PR Checklist ### + +- [ ] I have included examples or tests +- [ ] I have updated the change log or created a GitHub ticket with the change +- [ ] I am listed in the CONTRIBUTORS file (if it exists) +- [ ] Changes adhere to coding standard +- [ ] I checked the licenses of Third Party software and discussed new dependencies with at least 1 other team member \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a515d1f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +registries: + nuget-feed-default: + type: nuget-feed + url: https://api.nuget.org/v3/index.json + +updates: +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + +- package-ecosystem: nuget + directories: + - "/src" + schedule: + interval: daily + open-pull-requests-limit: 10 + ignore: + - dependency-name: "*Analyzers" + versions: + - ">= 0" + registries: + - nuget-feed-default \ No newline at end of file diff --git a/.github/lock.yml b/.github/lock.yml new file mode 100644 index 0000000..0ecc49e --- /dev/null +++ b/.github/lock.yml @@ -0,0 +1,38 @@ +# Configuration for Lock Threads - https://github.com/dessant/lock-threads + +# Number of days of inactivity before a closed issue or pull request is locked +daysUntilLock: 4 + +# Skip issues and pull requests created before a given timestamp. Timestamp must +# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable +skipCreatedBefore: false + +# Issues and pull requests with these labels will be ignored. Set to `[]` to disable +exemptLabels: ["pinned", "planned"] + +# Label to add before locking, such as `outdated`. Set to `false` to disable +lockLabel: false + +# Comment to post before locking. Set to `false` to disable +lockComment: > + This thread has been automatically locked since there has not been + any recent activity after it was closed. Please open a new issue for + related bugs. + +# Assign `resolved` as the reason for locking. Set to `false` to disable +setLockReason: true + +# Limit to only `issues` or `pulls` +# only: issues + +# Optionally, specify configuration settings just for `issues` or `pulls` +# issues: +# exemptLabels: +# - help-wanted +# lockLabel: outdated + +# pulls: +# daysUntilLock: 30 + +# Repository to extend settings from +# _extends: repo \ No newline at end of file diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..1733272 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,19 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - planned + - nostale +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false \ No newline at end of file diff --git a/.github/support.yml b/.github/support.yml new file mode 100644 index 0000000..941fe55 --- /dev/null +++ b/.github/support.yml @@ -0,0 +1,23 @@ +# Configuration for Support Requests - https://github.com/dessant/support-requests + +# Label used to mark issues as support requests +supportLabel: support + +# Comment to post on issues marked as support requests, `{issue-author}` is an +# optional placeholder. Set to `false` to disable +supportComment: > + :wave: @{issue-author}, we use the issue tracker exclusively for bug reports + and feature requests. However, this issue appears to be a support request. + Please use our support channels to get help with the project. + +# Close issues marked as support requests +close: true + +# Lock issues marked as support requests +lock: false + +# Assign `off-topic` as the reason for locking. Set to `false` to disable +setLockReason: true + +# Repository to extend settings from +# _extends: repo \ No newline at end of file diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..93f657f --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,36 @@ +name: Build and test + +on: + push: + branches: + - develop + - master + pull_request: + +#permissions: + #pull-requests: write + #contents: write + +jobs: + build-and-test: + runs-on: windows-latest # Required for some (WPF) projects + + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + id: checkout + with: + fetch-depth: 0 + + - name: Setup .NET Core + id: setup-dotnet + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + with: + dotnet-version: '10.0.x' + + - name: Cake Action + id: cake-action + uses: cake-build/cake-action@d218f1133bb74a1df0b08c89cfd8fc100c09e1a0 #v3.0.1 + with: + target: BuildAndTest + arguments: | + IsCiBuild: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e33e26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +.nuget/ +tools/FAKE/ +build-log.xml +Nuget.key +TestResult.xml + +# Build results +[Bb]in/ +[Cc]lientbin/ +[Dd]ebug/ +[Rr]elease/ +[Oo]utput*/ +[Pp]ackages*/ +[Tt]emp/ +bin +obj +[Ll]ib/* +![Ll]ib/repositories.config +*.ide/ +*_i.c +*_p.c +*.ilk +*.meta +*.obj +*.orig +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.vspscc +*.xap +.builds +*.log + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf + +# Visual Studio profiler +*.psess +*.vsp + +# ReSharper is a .NET coding add-in +_ReSharper* +*.resharper.user + +# Catel +CatelLogging.txt + +# Dotcover +*.dotCover +*.dotsettings.user + +# Finalbuilder +*.fbl7 +*.fb7lck +*.fbpInf + +# Ghostdoc +*.GhostDoc.xml + +# Deployments +deployment/FinalBuilder/backup +deployment/InnoSetup/template/templates +deployment/InnoSetup/template/snippets +deployment/InnoSetup/template/libraries +deployment/InnoSetup/template/doc + +# Installshield output folder +[Ee]xpress + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish + +# Others +[Bb]in +[Oo]bj +sql +TestResults +*.Cache +ClientBin +stylecop.* +~$* +*.dbmdl +Generated_Code #added for RIA/Silverlight projects + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML + +# Windows image file caches +Thumbs.db + +# Folder config file +Desktop.ini + +# Cake - Uncomment if you are using it +tools/** +!tools/packages.config +build.cakeoverrides + +# mstest test results +TestResults +.vs/ +.sonarqube/ +BundleArtifacts/ + +# docker / tye +.tye + +# editors +.idea +.vscode + +# Binaries +*.dll +*.exe + +# fody +FodyWeavers.xsd + +# Approval tests +*.received.* + +# ANTLR +data/dsl/*.class +data/dsl/*.java + +# Nodejs / NPM +node_modules +package-lock.json + +# Azure / .NET Aspire +.azure +infra/ \ No newline at end of file diff --git a/GitReleaseManager.yaml b/GitReleaseManager.yaml new file mode 100644 index 0000000..47e4240 --- /dev/null +++ b/GitReleaseManager.yaml @@ -0,0 +1,14 @@ +issue-labels-include: + - Breaking change + - Feature + - Bug + - Improvement + - Documentation + - Dependencies +issue-labels-exclude: + - Build + - Won't fix +issue-labels-alias: + - name: Documentation + header: Documentation + plural: Documentation \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..21821d2 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'Stop' + +Set-Location -LiteralPath $PSScriptRoot + +$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1' +$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' +$env:DOTNET_NOLOGO = '1' + +dotnet tool restore +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +dotnet cake @args +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/cake.config b/cake.config new file mode 100644 index 0000000..67faf32 --- /dev/null +++ b/cake.config @@ -0,0 +1,10 @@ +; The configuration file for Cake. + +[Settings] +SkipVerification=true + +[Settings] +EnableScriptCache=true + +[Paths] +Cache=%temp%/cake-build/cache/ \ No newline at end of file diff --git a/deployment/cake/apps-uwp-tasks.cake b/deployment/cake/apps-uwp-tasks.cake new file mode 100644 index 0000000..58bc958 --- /dev/null +++ b/deployment/cake/apps-uwp-tasks.cake @@ -0,0 +1,209 @@ +#l "apps-uwp-variables.cake" + +#addin "nuget:?package=Cake.WindowsAppStore&version=2.0.0" + +//------------------------------------------------------------- + +public class UwpProcessor : ProcessorBase +{ + public UwpProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + public override bool HasItems() + { + return BuildContext.Uwp.Items.Count > 0; + } + + private void UpdateAppxManifestVersion(string path, string version) + { + CakeContext.Information("Updating AppxManifest version @ '{0}' to '{1}'", path, version); + + CakeContext.TransformConfig(path, + new TransformationCollection { + { "Package/Identity/@Version", version } + }); + } + + private string GetArtifactsDirectory(string outputRootDirectory) + { + // 1 directory up since we want to turn "/output/release" into "/output/" + var artifactsDirectoryString = System.IO.Path.Combine(outputRootDirectory, ".."); + var artifactsDirectory = CakeContext.MakeAbsolute(CakeContext.Directory(artifactsDirectoryString)).FullPath; + + return artifactsDirectory; + } + + private string GetAppxUploadFileName(string artifactsDirectory, string solutionName, string versionMajorMinorPatch) + { + var appxUploadSearchPattern = System.IO.Path.Combine(artifactsDirectory, string.Format("{0}_{1}.0_*.appxupload", solutionName, versionMajorMinorPatch)); + + CakeContext.Information("Searching for appxupload using '{0}'", appxUploadSearchPattern); + + var filesToZip = CakeContext.GetFiles(appxUploadSearchPattern); + + CakeContext.Information("Found '{0}' files to upload", filesToZip.Count); + + var appxUploadFile = filesToZip.FirstOrDefault(); + if (appxUploadFile is null) + { + return null; + } + + var appxUploadFileName = appxUploadFile.FullPath; + return appxUploadFileName; + } + + public override async Task PrepareAsync() + { + if (!HasItems()) + { + return; + } + + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + foreach (var uwpApp in BuildContext.Uwp.Items.ToList()) + { + if (!ShouldProcessProject(BuildContext, uwpApp)) + { + BuildContext.Uwp.Items.Remove(uwpApp); + } + } + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var uwpApp in BuildContext.Uwp.Items) + { + var appxManifestFile = System.IO.Path.Combine(".", "src", uwpApp, "Package.appxmanifest"); + UpdateAppxManifestVersion(appxManifestFile, string.Format("{0}.0", BuildContext.General.Version.MajorMinorPatch)); + } + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + var platforms = new Dictionary(); + //platforms["AnyCPU"] = PlatformTarget.MSIL; + platforms["x86"] = PlatformTarget.x86; + platforms["x64"] = PlatformTarget.x64; + platforms["arm"] = PlatformTarget.ARM; + + // Important note: we only have to build for ARM, it will auto-build x86 / x64 as well + var platform = platforms.First(x => x.Key == "arm"); + + foreach (var uwpApp in BuildContext.Uwp.Items) + { + CakeContext.Information("Building UWP app '{0}'", uwpApp); + + var artifactsDirectory = GetArtifactsDirectory(BuildContext.General.OutputRootDirectory); + var appxUploadFileName = GetAppxUploadFileName(artifactsDirectory, uwpApp, BuildContext.General.Version.MajorMinorPatch); + + // If already exists, skip for store upload debugging + if (appxUploadFileName != null && CakeContext.FileExists(appxUploadFileName)) + { + CakeContext.Information(string.Format("File '{0}' already exists, skipping build", appxUploadFileName)); + continue; + } + + var msBuildSettings = new MSBuildSettings { + Verbosity = Verbosity.Quiet, // Verbosity.Diagnostic + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = platform.Value + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, uwpApp, "build"); + + // Always disable SourceLink + msBuildSettings.WithProperty("EnableSourceLink", "false"); + + // See https://docs.microsoft.com/en-us/windows/uwp/packaging/auto-build-package-uwp-apps for all the details + //msBuildSettings.Properties["UseDotNetNativeToolchain"] = new List(new [] { "false" }); + //msBuildSettings.Properties["UapAppxPackageBuildMode"] = new List(new [] { "StoreUpload" }); + msBuildSettings.Properties["UapAppxPackageBuildMode"] = new List(new [] { "CI" }); + msBuildSettings.Properties["AppxBundlePlatforms"] = new List(new [] { string.Join("|", platforms.Keys) }); + msBuildSettings.Properties["AppxBundle"] = new List(new [] { "Always" }); + msBuildSettings.Properties["AppxPackageDir"] = new List(new [] { artifactsDirectory }); + + CakeContext.Information("Building project for platform {0}, artifacts directory is '{1}'", platform.Key, artifactsDirectory); + + var projectFileName = GetProjectFileName(BuildContext, uwpApp); + + // Note: if csproj doesn't work, use SolutionFileName instead + //var projectFileName = SolutionFileName; + RunMsBuild(BuildContext, uwpApp, projectFileName, msBuildSettings, "build"); + + // Recalculate! + appxUploadFileName = GetAppxUploadFileName(artifactsDirectory, uwpApp, BuildContext.General.Version.MajorMinorPatch); + if (appxUploadFileName is null) + { + throw new Exception(string.Format("Couldn't determine the appxupload file using base directory '{0}'", artifactsDirectory)); + } + + CakeContext.Information("Created appxupload file '{0}'", appxUploadFileName, artifactsDirectory); + } + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + // No specific implementation required for now, build already wraps it up + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var uwpApp in BuildContext.Uwp.Items) + { + if (!ShouldDeployProject(BuildContext, uwpApp)) + { + CakeContext.Information("UWP app '{0}' should not be deployed", uwpApp); + continue; + } + + BuildContext.CakeContext.LogSeparator("Deploying UWP app '{0}'", uwpApp); + + var artifactsDirectory = GetArtifactsDirectory(BuildContext.General.OutputRootDirectory); + var appxUploadFileName = GetAppxUploadFileName(artifactsDirectory, uwpApp, BuildContext.General.Version.MajorMinorPatch); + + CakeContext.Information("Creating Windows Store app submission"); + + CakeContext.CreateWindowsStoreAppSubmission(appxUploadFileName, new WindowsStoreAppSubmissionSettings + { + ApplicationId = BuildContext.Uwp.WindowsStoreAppId, + ClientId = BuildContext.Uwp.WindowsStoreClientId, + ClientSecret = BuildContext.Uwp.WindowsStoreClientSecret, + TenantId = BuildContext.Uwp.WindowsStoreTenantId + }); + + await BuildContext.Notifications.NotifyAsync(uwpApp, string.Format("Deployed to store"), TargetType.UwpApp); + } + } + + public override async Task FinalizeAsync() + { + + } +} diff --git a/deployment/cake/apps-uwp-variables.cake b/deployment/cake/apps-uwp-variables.cake new file mode 100644 index 0000000..2a445a6 --- /dev/null +++ b/deployment/cake/apps-uwp-variables.cake @@ -0,0 +1,58 @@ +#l "./buildserver.cake" + +//------------------------------------------------------------- + +public class UwpContext : BuildContextWithItemsBase +{ + public UwpContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string WindowsStoreAppId { get; set; } + public string WindowsStoreClientId { get; set; } + public string WindowsStoreClientSecret { get; set; } + public string WindowsStoreTenantId { get; set; } + + protected override void ValidateContext() + { + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' uwp projects"); + } +} + +//------------------------------------------------------------- + +private UwpContext InitializeUwpContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new UwpContext(parentBuildContext) + { + Items = UwpApps ?? new List(), + WindowsStoreAppId = buildContext.BuildServer.GetVariable("WindowsStoreAppId", showValue: true), + WindowsStoreClientId = buildContext.BuildServer.GetVariable("WindowsStoreClientId", showValue: false), + WindowsStoreClientSecret = buildContext.BuildServer.GetVariable("WindowsStoreClientSecret", showValue: false), + WindowsStoreTenantId = buildContext.BuildServer.GetVariable("WindowsStoreTenantId", showValue: false) + }; + + return data; +} + +//------------------------------------------------------------- + +List _uwpApps; + +public List UwpApps +{ + get + { + if (_uwpApps is null) + { + _uwpApps = new List(); + } + + return _uwpApps; + } +} \ No newline at end of file diff --git a/deployment/cake/apps-wpf-tasks.cake b/deployment/cake/apps-wpf-tasks.cake new file mode 100644 index 0000000..3538401 --- /dev/null +++ b/deployment/cake/apps-wpf-tasks.cake @@ -0,0 +1,242 @@ +#l "apps-wpf-variables.cake" + +#tool "nuget:?package=AzureStorageSync&version=2.0.0-alpha0039&prerelease" + +//------------------------------------------------------------- + +public class WpfProcessor : ProcessorBase +{ + public WpfProcessor(BuildContext buildContext) + : base(buildContext) + { + } + + public override bool HasItems() + { + return BuildContext.Wpf.Items.Count > 0; + } + + public override async Task PrepareAsync() + { + if (!HasItems()) + { + return; + } + + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + foreach (var wpfApp in BuildContext.Wpf.Items.ToList()) + { + if (!ShouldProcessProject(BuildContext, wpfApp)) + { + BuildContext.Wpf.Items.Remove(wpfApp); + } + } + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + // No specific implementation required for now + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var wpfApp in BuildContext.Wpf.Items) + { + BuildContext.CakeContext.LogSeparator("Building WPF app '{0}'", wpfApp); + + var projectFileName = GetProjectFileName(BuildContext, wpfApp); + + var channelSuffix = BuildContext.Installer.GetDeploymentChannelSuffix(); + + var sourceFileName = System.IO.Path.Combine(".", "design", "logo", $"logo{channelSuffix}.ico"); + if (BuildContext.CakeContext.FileExists(sourceFileName)) + { + CakeContext.Information("Enforcing channel specific icon '{0}'", sourceFileName); + + var projectDirectory = GetProjectDirectory(wpfApp); + var targetFileName = System.IO.Path.Combine(projectDirectory, "Resources", "Icons", "logo.ico"); + + BuildContext.CakeContext.CopyFile(sourceFileName, targetFileName); + } + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, // Verbosity.Diagnostic + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, wpfApp, "build"); + + // Always disable SourceLink + msBuildSettings.WithProperty("EnableSourceLink", "false"); + + RunMsBuild(BuildContext, wpfApp, projectFileName, msBuildSettings, "build"); + } + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + if (string.IsNullOrWhiteSpace(BuildContext.Wpf.DeploymentsShare)) + { + CakeContext.Warning("DeploymentsShare variable is not set, cannot package WPF apps"); + return; + } + + var channels = new List(); + + if (BuildContext.General.IsOfficialBuild) + { + // Note: we used to deploy stable to stable, beta and alpha, but want to keep things separated now + channels.Add("stable"); + } + else if (BuildContext.General.IsBetaBuild) + { + // Note: we used to deploy beta to beta and alpha, but want to keep things separated now + channels.Add("beta"); + } + else if (BuildContext.General.IsAlphaBuild) + { + // Single channel + channels.Add("alpha"); + } + else + { + // Unknown build type, just just a single channel + channels.Add(BuildContext.Wpf.Channel); + } + + CakeContext.Information($"Found '{channels.Count}' target channels"); + + foreach (var wpfApp in BuildContext.Wpf.Items) + { + if (!ShouldPackageProject(BuildContext, wpfApp)) + { + CakeContext.Information($"WPF app '{wpfApp}' should not be packaged"); + continue; + } + + var deploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(wpfApp); + + CakeContext.Information($"Using deployment share '{deploymentShare}' for WPF app '{wpfApp}'"); + + System.IO.Directory.CreateDirectory(deploymentShare); + + CakeContext.Information($"Deleting unnecessary files for WPF app '{wpfApp}'"); + + var outputDirectory = GetProjectOutputDirectory(BuildContext, wpfApp); + var extensionsToDelete = new [] { ".pdb", ".RoslynCA.json" }; + + foreach (var extensionToDelete in extensionsToDelete) + { + var searchPattern = $"{outputDirectory}/**/*{extensionToDelete}"; + var filesToDelete = CakeContext.GetFiles(searchPattern); + + CakeContext.Information("Deleting '{0}' files using search pattern '{1}'", filesToDelete.Count, searchPattern); + + CakeContext.DeleteFiles(filesToDelete); + } + + if (BuildContext.General.CodeSign.IsAvailable || + BuildContext.General.AzureCodeSign.IsAvailable) + { + SignFilesInDirectory(BuildContext, outputDirectory, string.Empty); + } + else + { + BuildContext.CakeContext.Warning("No signing certificate subject name provided, not signing any files"); + } + + foreach (var channel in channels) + { + CakeContext.Information("Packaging app '{0}' for channel '{1}'", wpfApp, channel); + + var deploymentShareForChannel = System.IO.Path.Combine(deploymentShare, channel); + System.IO.Directory.CreateDirectory(deploymentShareForChannel); + + await BuildContext.Installer.PackageAsync(wpfApp, channel); + } + } + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + var azureConnectionString = BuildContext.Wpf.AzureDeploymentsStorageConnectionString; + if (string.IsNullOrWhiteSpace(azureConnectionString)) + { + CakeContext.Warning("Skipping deployments of WPF apps because not Azure deployments storage connection string was specified"); + return; + } + + var azureStorageSyncExes = CakeContext.GetFiles("./tools/AzureStorageSync*/**/AzureStorageSync.exe"); + var azureStorageSyncExe = azureStorageSyncExes.LastOrDefault(); + if (azureStorageSyncExe is null) + { + throw new Exception("Can't find the AzureStorageSync tool that should have been installed via this script"); + } + + foreach (var wpfApp in BuildContext.Wpf.Items) + { + if (!ShouldDeployProject(BuildContext, wpfApp)) + { + CakeContext.Information($"WPF app '{wpfApp}' should not be deployed"); + continue; + } + + BuildContext.CakeContext.LogSeparator($"Deploying WPF app '{wpfApp}'"); + + // TODO: Respect the deploy settings per category, requires changes to AzureStorageSync + if (!BuildContext.Wpf.DeployUpdatesToAlphaChannel || + !BuildContext.Wpf.DeployUpdatesToBetaChannel || + !BuildContext.Wpf.DeployUpdatesToStableChannel || + !BuildContext.Wpf.DeployInstallers) + { + throw new Exception("Not deploying a specific channel is not yet supported, please implement"); + } + + //%DeploymentsShare%\%ProjectName% /%ProjectName% -c %AzureDeploymentsStorageConnectionString% + var deploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(wpfApp); + var projectSlug = GetProjectSlug(wpfApp, "-"); + + var exitCode = CakeContext.StartProcess(azureStorageSyncExe, new ProcessSettings + { + Arguments = $"{deploymentShare} /{projectSlug} -c {azureConnectionString}" + }); + + if (exitCode != 0) + { + throw new Exception($"Received unexpected exit code '{exitCode}' for WPF app '{wpfApp}'"); + } + + await BuildContext.Notifications.NotifyAsync(wpfApp, string.Format("Deployed to target"), TargetType.WpfApp); + } + } + + public override async Task FinalizeAsync() + { + + } +} diff --git a/deployment/cake/apps-wpf-variables.cake b/deployment/cake/apps-wpf-variables.cake new file mode 100644 index 0000000..2a26230 --- /dev/null +++ b/deployment/cake/apps-wpf-variables.cake @@ -0,0 +1,97 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class WpfContext : BuildContextWithItemsBase +{ + public WpfContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + + public string DeploymentsShare { get; set; } + public string Channel { get; set; } + public bool AppendDeploymentChannelSuffix { get; set; } + public bool UpdateDeploymentsShare { get; set; } + public string AzureDeploymentsStorageConnectionString { get; set; } + + public bool GenerateDeploymentCatalog { get; set; } + public bool GroupUpdatesByMajorVersion { get; set; } + public bool DeployUpdatesToAlphaChannel { get; set; } + public bool DeployUpdatesToBetaChannel { get; set; } + public bool DeployUpdatesToStableChannel { get; set; } + public bool DeployInstallers { get; set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' wpf projects"); + + CakeContext.Information($"Generate Deployment Catalog: '{GenerateDeploymentCatalog}'"); + CakeContext.Information($"Group updates by major version: '{GroupUpdatesByMajorVersion}'"); + CakeContext.Information($"Deploy updates to alpha channel: '{DeployUpdatesToAlphaChannel}'"); + CakeContext.Information($"Deploy updates to beta channel: '{DeployUpdatesToBetaChannel}'"); + CakeContext.Information($"Deploy updates to stable channel: '{DeployUpdatesToStableChannel}'"); + CakeContext.Information($"Deploy installers: '{DeployInstallers}'"); + } + + public string GetDeploymentShareForProject(string projectName) + { + var projectSlug = GetProjectSlug(projectName, "-"); + var deploymentShare = System.IO.Path.Combine(DeploymentsShare, projectSlug); + + return deploymentShare; + } +} + +//------------------------------------------------------------- + +private WpfContext InitializeWpfContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new WpfContext(parentBuildContext) + { + Items = WpfApps ?? new List(), + DeploymentsShare = buildContext.BuildServer.GetVariable("DeploymentsShare", showValue: true), + Channel = buildContext.BuildServer.GetVariable("Channel", showValue: true), + AppendDeploymentChannelSuffix = buildContext.BuildServer.GetVariableAsBool("AppendDeploymentChannelSuffix", false, showValue: true), + UpdateDeploymentsShare = buildContext.BuildServer.GetVariableAsBool("UpdateDeploymentsShare", true, showValue: true), + AzureDeploymentsStorageConnectionString = buildContext.BuildServer.GetVariable("AzureDeploymentsStorageConnectionString"), + GenerateDeploymentCatalog = buildContext.BuildServer.GetVariableAsBool("WpfGenerateDeploymentCatalog", true, showValue: true), + GroupUpdatesByMajorVersion = buildContext.BuildServer.GetVariableAsBool("WpfGroupUpdatesByMajorVersion", false, showValue: true), + DeployUpdatesToAlphaChannel = buildContext.BuildServer.GetVariableAsBool("WpfDeployUpdatesToAlphaChannel", true, showValue: true), + DeployUpdatesToBetaChannel = buildContext.BuildServer.GetVariableAsBool("WpfDeployUpdatesToBetaChannel", true, showValue: true), + DeployUpdatesToStableChannel = buildContext.BuildServer.GetVariableAsBool("WpfDeployUpdatesToStableChannel", true, showValue: true), + DeployInstallers = buildContext.BuildServer.GetVariableAsBool("WpfDeployInstallers", true, showValue: true), + }; + + if (string.IsNullOrWhiteSpace(data.Channel)) + { + data.Channel = DetermineChannel(buildContext.General); + + data.CakeContext.Information($"Determined channel '{data.Channel}' for wpf projects"); + } + + return data; +} + +//------------------------------------------------------------- + +List _wpfApps; + +public List WpfApps +{ + get + { + if (_wpfApps is null) + { + _wpfApps = new List(); + } + + return _wpfApps; + } +} \ No newline at end of file diff --git a/deployment/cake/aspire-tasks.cake b/deployment/cake/aspire-tasks.cake new file mode 100644 index 0000000..448ad1b --- /dev/null +++ b/deployment/cake/aspire-tasks.cake @@ -0,0 +1,189 @@ +#l "aspire-variables.cake" + +using System.Xml.Linq; + +//------------------------------------------------------------- + +public class AspireProcessor : ProcessorBase +{ + public AspireProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + public override bool HasItems() + { + return BuildContext.Aspire.Items.Count > 0; + } + + public override async Task PrepareAsync() + { + if (!HasItems()) + { + return; + } + + // Nothing needed + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + // Nothing needed + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + // Nothing needed + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + var aspireContext = BuildContext.Aspire; + + if (aspireContext.Items.Count > 1) + { + throw new InvalidOperationException("Multiple Aspire projects found. Please ensure only one Aspire project is defined in the solution."); + } + + var environmentName = GetEnvironmentName(aspireContext); + + foreach (var aspireProject in aspireContext.Items) + { + if (BuildContext.General.SkipComponentsThatAreNotDeployable && + !ShouldPackageProject(BuildContext, aspireProject)) + { + CakeContext.Information("Aspire project '{0}' should not be packaged", aspireProject); + continue; + } + + BuildContext.CakeContext.LogSeparator("Packaging Aspire project '{0}'", aspireProject); + + BuildContext.CakeContext.Information("Setting environment variables"); + + var environmentVariables = new Dictionary + { + { "AZURE_PRINCIPAL_ID", aspireContext.AzurePrincipalId }, + { "AZURE_PRINCIPAL_TYPE", aspireContext.AzurePrincipalType }, + { "AZURE_LOCATION", aspireContext.AzureLocation }, + { "AZURE_RESOURCE_GROUP", $"rg-{aspireContext.AzureResourceGroup}-{aspireContext.EnvironmentName}" }, + { "AZURE_SUBSCRIPTION_ID", aspireContext.AzureSubscriptionId }, + { "AZURE_ENV_NAME", aspireContext.EnvironmentName }, + }; + + foreach (var environmentVariable in environmentVariables) + { + RunAzd($"env set {environmentVariable.Key}=\"{environmentVariable.Value}\" -e {environmentName} --no-prompt"); + } + + BuildContext.CakeContext.Information("Generating infrastructure context"); + + RunAzd($"infra gen -e {environmentName} --force"); + + BuildContext.CakeContext.LogSeparator(); + } + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + var aspireContext = BuildContext.Aspire; + + if (aspireContext.Items.Count > 1) + { + throw new InvalidOperationException("Multiple Aspire projects found. Please ensure only one Aspire project is defined in the solution."); + } + + var environmentName = GetEnvironmentName(aspireContext); + + foreach (var aspireProject in aspireContext.Items) + { + if (!ShouldDeployProject(BuildContext, aspireProject)) + { + CakeContext.Information("Aspire project '{0}' should not be deployed", aspireProject); + continue; + } + + BuildContext.CakeContext.LogSeparator("Deploying Aspire project '{0}'", aspireProject); + + try + { + BuildContext.CakeContext.Information("Logging in to Azure"); + + RunAzd($"auth login --tenant-id {aspireContext.AzureTenantId} --client-id {aspireContext.AzureClientId} --client-secret {aspireContext.AzureClientSecret} --no-prompt"); + + // Note: got weird errors when running provision and deploy manually, so using up instead + + BuildContext.CakeContext.Information("Deploying to Azure"); + + RunAzd($"up -e {environmentName} --no-prompt"); + + //BuildContext.CakeContext.Information("Provisioning infrastructure for Aspire project '{0}'", aspireProject); + + //RunAzd($"provision -e {environmentName}"); + + //BuildContext.CakeContext.Information("Deploying Aspire project '{0}'", aspireProject); + + // Note: this could technically be improved in the future by using + // azd deploy 'componentname' + + //RunAzd($"deploy --all -e {environmentName}"); + + await BuildContext.Notifications.NotifyAsync(aspireProject, string.Format("Deployed to Azure"), TargetType.AspireProject); + } + finally + { + BuildContext.CakeContext.Information("Logging out of Azure"); + + RunAzd($"auth logout"); + } + + BuildContext.CakeContext.LogSeparator(); + } + } + + public override async Task FinalizeAsync() + { + // Nothing needed + } + + private string GetEnvironmentName(AspireContext aspireContext) + { + // Because resource group names are set: "rg-{environmentName}" by Aspire, we automatically add + // an extra name to the environment + + var environmentName = $"{aspireContext.AzureResourceGroup}-{aspireContext.EnvironmentName}"; + + return environmentName; + } + + private void RunAzd(string arguments) + { + if (BuildContext.CakeContext.StartProcess("azd", new ProcessSettings + { + Arguments = arguments + }) != 0) + { + throw new CakeException("Azd failed failed. Please check the logs for more details."); + } + } +} \ No newline at end of file diff --git a/deployment/cake/aspire-variables.cake b/deployment/cake/aspire-variables.cake new file mode 100644 index 0000000..fbff15b --- /dev/null +++ b/deployment/cake/aspire-variables.cake @@ -0,0 +1,125 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class AspireContext : BuildContextWithItemsBase +{ + public AspireContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string EnvironmentName { get; set; } + + public string AzurePrincipalId { get; set; } + + public string AzurePrincipalType { get; set; } + + public string AzureLocation { get; set; } + + public string AzureResourceGroup { get; set; } + + public string AzureSubscriptionId { get; set; } + + public string AzureTenantId { get; set; } + + public string AzureClientId { get; set; } + + public string AzureClientSecret { get; set; } + + protected override void ValidateContext() + { + if (Items.Count == 0) + { + return; + } + + if (Items.Count > 1) + { + throw new InvalidOperationException("Multiple Aspire projects found. Please ensure only one Aspire project is defined in the solution."); + } + + if (string.IsNullOrWhiteSpace(EnvironmentName)) + { + throw new InvalidOperationException("Environment name is not set. Please set the 'AspireEnvironment' variable."); + } + + if (string.IsNullOrWhiteSpace(AzurePrincipalId)) + { + throw new InvalidOperationException("Azure principal ID is not set. Please set the 'AzurePrincipalId' variable."); + } + + if (string.IsNullOrWhiteSpace(AzureLocation)) + { + throw new InvalidOperationException("Azure location is not set. Please set the 'AzureLocation' variable."); + } + + if (string.IsNullOrWhiteSpace(AzureResourceGroup)) + { + throw new InvalidOperationException("Azure resource group is not set. Please set the 'AzureResourceGroup' variable."); + } + + if (string.IsNullOrWhiteSpace(AzureSubscriptionId)) + { + throw new InvalidOperationException("Azure subscription ID is not set. Please set the 'AzureSubscriptionId' variable."); + } + + if (string.IsNullOrWhiteSpace(AzureTenantId)) + { + throw new InvalidOperationException("Azure tenant ID is not set. Please set the 'AzureTenantId' variable."); + } + + if (string.IsNullOrWhiteSpace(AzureClientId)) + { + throw new InvalidOperationException("Azure client ID is not set. Please set the 'AzureClientId' variable."); + } + + if (string.IsNullOrWhiteSpace(AzureClientSecret)) + { + throw new InvalidOperationException("Azure client secret is not set. Please set the 'AzureClientSecret' variable."); + } + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' Aspire projects"); + } +} + +//------------------------------------------------------------- + +private AspireContext InitializeAspireContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new AspireContext(parentBuildContext) + { + Items = AspireProjects ?? new List(), + EnvironmentName = buildContext.BuildServer.GetVariable("AspireEnvironment", "prod", showValue: true), + AzurePrincipalId = buildContext.BuildServer.GetVariable("AspireAzurePrincipalId", showValue: true), + AzurePrincipalType = buildContext.BuildServer.GetVariable("AspireAzurePrincipalType", "ManagedIdentity", showValue: true), + AzureLocation = buildContext.BuildServer.GetVariable("AspireAzureLocation", showValue: true), + AzureResourceGroup = buildContext.BuildServer.GetVariable("AspireAzureResourceGroup", showValue: true), + AzureSubscriptionId = buildContext.BuildServer.GetVariable("AspireAzureSubscriptionId", showValue: true), + AzureTenantId = buildContext.BuildServer.GetVariable("AspireAzureTenantId", showValue: true), + AzureClientId = buildContext.BuildServer.GetVariable("AspireAzureClientId", showValue: true), + AzureClientSecret = buildContext.BuildServer.GetVariable("AspireAzureClientSecret", showValue: false) + }; + + return data; +} + +//------------------------------------------------------------- + +List _aspireProjects; + +public List AspireProjects +{ + get + { + if (_aspireProjects is null) + { + _aspireProjects = new List(); + } + + return _aspireProjects; + } +} \ No newline at end of file diff --git a/deployment/cake/buildserver-continuaci.cake b/deployment/cake/buildserver-continuaci.cake new file mode 100644 index 0000000..863ccb1 --- /dev/null +++ b/deployment/cake/buildserver-continuaci.cake @@ -0,0 +1,182 @@ +public class ContinuaCIBuildServer : BuildServerBase +{ + public ContinuaCIBuildServer(ICakeContext cakeContext) + : base(cakeContext) + { + } + + //------------------------------------------------------------- + + public override async Task OnTestFailedAsync() + { + await ImportUnitTestsAsync(); + } + + //------------------------------------------------------------- + + public override async Task AfterTestAsync() + { + await ImportUnitTestsAsync(); + } + + //------------------------------------------------------------- + + private async Task ImportUnitTestsAsync() + { + foreach (var project in BuildContext.Tests.Items) + { + await ImportTestFilesAsync(project); + } + } + + //------------------------------------------------------------- + + private async Task ImportTestFilesAsync(string projectName) + { + var continuaCIContext = GetContinuaCIContext(); + if (!continuaCIContext.IsRunningOnContinuaCI) + { + return; + } + + CakeContext.Warning($"Importing test results for '{projectName}'"); + + var testResultsDirectory = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, "testresults"); + + if (!CakeContext.DirectoryExists(testResultsDirectory)) + { + CakeContext.Warning("No test results directory"); + return; + } + + var type = string.Empty; + var importType = string.Empty; + + if (IsNUnitTestProject(BuildContext, projectName)) + { + type = "nunit"; + importType = "nunit"; + } + + if (IsXUnitTestProject(BuildContext, projectName)) + { + type = "xunit"; + importType = "mstest"; // Xml type is different + } + + if (string.IsNullOrWhiteSpace(type)) + { + CakeContext.Warning("Could not find test project type"); + return; + } + + CakeContext.Warning($"Determined project type '{type}'"); + + var cakeFilePattern = System.IO.Path.Combine(testResultsDirectory, projectName, "*.xml"); + + CakeContext.Warning($"Using pattern '{cakeFilePattern}'"); + + var testResultsFiles = CakeContext.GetFiles(cakeFilePattern); + if (!testResultsFiles.Any()) + { + CakeContext.Warning($"No test result file found using '{cakeFilePattern}'"); + return; + } + + var continuaCiFilePattern = System.IO.Path.Combine(testResultsDirectory, "**.xml"); + + CakeContext.Information($"Importing test results from using '{continuaCiFilePattern}' using import type '{importType}'"); + + var message = $"@@continua[importUnitTestResults type='{importType}' filePatterns='{cakeFilePattern}']"; + WriteIntegration(message); + } + + //------------------------------------------------------------- + + public override async Task PinBuildAsync(string comment) + { + var continuaCIContext = GetContinuaCIContext(); + if (!continuaCIContext.IsRunningOnContinuaCI) + { + return; + } + + CakeContext.Information("Pinning build in Continua CI"); + + var message = string.Format("@@continua[pinBuild comment='{0}' appendComment='{1}']", + comment, !string.IsNullOrWhiteSpace(comment)); + WriteIntegration(message); + } + + //------------------------------------------------------------- + + public override async Task SetVersionAsync(string version) + { + var continuaCIContext = GetContinuaCIContext(); + if (!continuaCIContext.IsRunningOnContinuaCI) + { + return; + } + + CakeContext.Information("Setting version '{0}' in Continua CI", version); + + var message = string.Format("@@continua[setBuildVersion value='{0}']", version); + WriteIntegration(message); + } + + //------------------------------------------------------------- + + public override async Task SetVariableAsync(string variableName, string value) + { + var continuaCIContext = GetContinuaCIContext(); + if (!continuaCIContext.IsRunningOnContinuaCI) + { + return; + } + + CakeContext.Information("Setting variable '{0}' to '{1}' in Continua CI", variableName, value); + + var message = string.Format("@@continua[setVariable name='{0}' value='{1}' skipIfNotDefined='true']", variableName, value); + WriteIntegration(message); + } + + //------------------------------------------------------------- + + public override Tuple GetVariable(string variableName, string defaultValue) + { + var continuaCIContext = GetContinuaCIContext(); + if (!continuaCIContext.IsRunningOnContinuaCI) + { + return new Tuple(false, string.Empty); + } + + var exists = false; + var value = string.Empty; + + var buildServerVariables = continuaCIContext.Environment.Variable; + if (buildServerVariables.ContainsKey(variableName)) + { + CakeContext.Information("Variable '{0}' is specified via Continua CI", variableName); + + exists = true; + value = buildServerVariables[variableName]; + } + + return new Tuple(exists, value); + } + + //------------------------------------------------------------- + + private IContinuaCIProvider GetContinuaCIContext() + { + return CakeContext.ContinuaCI(); + } + + //------------------------------------------------------------- + + private void WriteIntegration(string message) + { + // Must be Console.WriteLine + CakeContext.Information(message); + } +} \ No newline at end of file diff --git a/deployment/cake/buildserver.cake b/deployment/cake/buildserver.cake new file mode 100644 index 0000000..b2cfc0a --- /dev/null +++ b/deployment/cake/buildserver.cake @@ -0,0 +1,511 @@ +// Customize this file when using a different build server +#l "buildserver-continuaci.cake" + +using System.Runtime.InteropServices; + +public interface IBuildServer +{ + Task PinBuildAsync(string comment); + Task SetVersionAsync(string version); + Task SetVariableAsync(string name, string value); + Tuple GetVariable(string variableName, string defaultValue); + + void SetBuildContext(BuildContext buildContext); + + Task BeforeInitializeAsync(); + Task AfterInitializeAsync(); + + Task BeforePrepareAsync(); + Task AfterPrepareAsync(); + + Task BeforeUpdateInfoAsync(); + Task AfterUpdateInfoAsync(); + + Task BeforeBuildAsync(); + Task OnBuildFailedAsync(); + Task AfterBuildAsync(); + + Task BeforeTestAsync(); + Task OnTestFailedAsync(); + Task AfterTestAsync(); + + Task BeforePackageAsync(); + Task AfterPackageAsync(); + + Task BeforeDeployAsync(); + Task AfterDeployAsync(); + + Task BeforeFinalizeAsync(); + Task AfterFinalizeAsync(); +} + +public abstract class BuildServerBase : IBuildServer +{ + protected BuildServerBase(ICakeContext cakeContext) + { + CakeContext = cakeContext; + } + + public ICakeContext CakeContext { get; private set; } + + public BuildContext BuildContext { get; private set; } + + public abstract Task PinBuildAsync(string comment); + public abstract Task SetVersionAsync(string version); + public abstract Task SetVariableAsync(string name, string value); + public abstract Tuple GetVariable(string variableName, string defaultValue); + + //------------------------------------------------------------- + + public void SetBuildContext(BuildContext buildContext) + { + BuildContext = buildContext; + } + + //------------------------------------------------------------- + + public virtual async Task BeforeInitializeAsync() + { + } + + public virtual async Task AfterInitializeAsync() + { + } + + //------------------------------------------------------------- + + public virtual async Task BeforePrepareAsync() + { + } + + public virtual async Task AfterPrepareAsync() + { + } + + //------------------------------------------------------------- + + public virtual async Task BeforeUpdateInfoAsync() + { + } + + public virtual async Task AfterUpdateInfoAsync() + { + } + + //------------------------------------------------------------- + + public virtual async Task BeforeBuildAsync() + { + } + + public virtual async Task OnBuildFailedAsync() + { + } + + public virtual async Task AfterBuildAsync() + { + } + + //------------------------------------------------------------- + + public virtual async Task BeforeTestAsync() + { + } + + public virtual async Task OnTestFailedAsync() + { + } + + public virtual async Task AfterTestAsync() + { + } + + //------------------------------------------------------------- + + public virtual async Task BeforePackageAsync() + { + } + + public virtual async Task AfterPackageAsync() + { + } + + //------------------------------------------------------------- + + public virtual async Task BeforeDeployAsync() + { + } + + public virtual async Task AfterDeployAsync() + { + } + + //------------------------------------------------------------- + + public virtual async Task BeforeFinalizeAsync() + { + } + + public virtual async Task AfterFinalizeAsync() + { + } +} + +//------------------------------------------------------------- + +public class BuildServerIntegration : IIntegration +{ + [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] + static extern uint GetPrivateProfileString( + string lpAppName, + string lpKeyName, + string lpDefault, + StringBuilder lpReturnedString, + uint nSize, + string lpFileName); + + private readonly Dictionary _parameters; + private readonly List _buildServers = new List(); + private readonly Dictionary _buildServerVariableCache = new Dictionary(); + + public BuildServerIntegration(ICakeContext cakeContext, Dictionary parameters) + { + CakeContext = cakeContext; + _parameters = parameters; + + _buildServers.Add(new ContinuaCIBuildServer(cakeContext)); + } + + public void SetBuildContext(BuildContext buildContext) + { + BuildContext = buildContext; + + foreach (var buildServer in _buildServers) + { + buildServer.SetBuildContext(buildContext); + } + } + + public BuildContext BuildContext { get; private set; } + + public ICakeContext CakeContext { get; private set; } + + //------------------------------------------------------------- + + public async Task BeforeInitializeAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.BeforeInitializeAsync(); + } + } + + public async Task AfterInitializeAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.AfterInitializeAsync(); + } + } + + //------------------------------------------------------------- + + public async Task BeforePrepareAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.BeforePrepareAsync(); + } + } + + public async Task AfterPrepareAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.AfterPrepareAsync(); + } + } + + //------------------------------------------------------------- + + public async Task BeforeUpdateInfoAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.BeforeUpdateInfoAsync(); + } + } + + public async Task AfterUpdateInfoAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.AfterUpdateInfoAsync(); + } + } + + //------------------------------------------------------------- + + public async Task BeforeBuildAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.BeforeBuildAsync(); + } + } + + public async Task OnBuildFailedAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.OnBuildFailedAsync(); + } + } + + public async Task AfterBuildAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.AfterBuildAsync(); + } + } + + //------------------------------------------------------------- + + public async Task BeforeTestAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.BeforeTestAsync(); + } + } + + public async Task OnTestFailedAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.OnTestFailedAsync(); + } + } + + public async Task AfterTestAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.AfterTestAsync(); + } + } + + //------------------------------------------------------------- + + public async Task BeforePackageAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.BeforePackageAsync(); + } + } + + public async Task AfterPackageAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.AfterPackageAsync(); + } + } + + //------------------------------------------------------------- + + public async Task BeforeDeployAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.BeforeDeployAsync(); + } + } + + public async Task AfterDeployAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.AfterDeployAsync(); + } + } + + //------------------------------------------------------------- + + public async Task BeforeFinalizeAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.BeforeFinalizeAsync(); + } + } + + public async Task AfterFinalizeAsync() + { + foreach (var buildServer in _buildServers) + { + await buildServer.AfterFinalizeAsync(); + } + } + + //------------------------------------------------------------- + + public async Task PinBuildAsync(string comment) + { + foreach (var buildServer in _buildServers) + { + await buildServer.PinBuildAsync(comment); + } + } + + //------------------------------------------------------------- + + public async Task SetVersionAsync(string version) + { + foreach (var buildServer in _buildServers) + { + await buildServer.SetVersionAsync(version); + } + } + + //------------------------------------------------------------- + + public async Task SetVariableAsync(string variableName, string value) + { + foreach (var buildServer in _buildServers) + { + await buildServer.SetVariableAsync(variableName, value); + } + } + + //------------------------------------------------------------- + + public bool GetVariableAsBool(string variableName, bool defaultValue, bool showValue = false) + { + var value = defaultValue; + + if (bool.TryParse(GetVariable(variableName, "unknown", showValue: false), out var retrievedValue)) + { + value = retrievedValue; + } + + if (showValue) + { + PrintVariableValue(variableName, value.ToString()); + } + + return value; + } + + //------------------------------------------------------------- + + public string GetVariable(string variableName, string defaultValue = null, bool showValue = false) + { + var cacheKey = string.Format("{0}__{1}", variableName ?? string.Empty, defaultValue ?? string.Empty); + + if (!_buildServerVariableCache.TryGetValue(cacheKey, out string value)) + { + value = GetVariableForCache(variableName, defaultValue); + + if (showValue) + { + PrintVariableValue(variableName, value); + } + + _buildServerVariableCache[cacheKey] = value; + } + //else + //{ + // Information("Retrieved value for '{0}' from cache", variableName); + //} + + return value; + } + + //------------------------------------------------------------- + + private string GetVariableForCache(string variableName, string defaultValue = null) + { + var argumentValue = CakeContext.Argument(variableName, "non-existing"); + if (argumentValue != "non-existing") + { + CakeContext.Information("Variable '{0}' is specified via an argument", variableName); + + return argumentValue; + } + + // Check each build server + foreach (var buildServer in _buildServers) + { + var buildServerVariable = buildServer.GetVariable(variableName, defaultValue); + if (buildServerVariable.Item1) + { + return buildServerVariable.Item2; + } + } + + var overrideFile = System.IO.Path.Combine(".", "build.cakeoverrides"); + if (System.IO.File.Exists(overrideFile)) + { + var sb = new StringBuilder(string.Empty, 256); + var lengthRead = GetPrivateProfileString("General", variableName, null, sb, (uint)sb.Capacity, overrideFile); + if (lengthRead > 0) + { + CakeContext.Information("Variable '{0}' is specified via build.cakeoverrides", variableName); + + var sbValue = sb.ToString(); + if (sbValue == "[ignore]" || + sbValue == "[empty]") + { + return string.Empty; + } + + return sbValue; + } + } + + if (CakeContext.HasEnvironmentVariable(variableName)) + { + CakeContext.Information("Variable '{0}' is specified via an environment variable", variableName); + + return CakeContext.EnvironmentVariable(variableName); + } + + if (_parameters.TryGetValue(variableName, out var parameter)) + { + CakeContext.Information("Variable '{0}' is specified via the Parameters dictionary", variableName); + + if (parameter is null) + { + return null; + } + + if (parameter is string) + { + return (string)parameter; + } + + if (parameter is Func) + { + return ((Func)parameter).Invoke(); + } + + throw new Exception(string.Format("Parameter is defined as '{0}', but that type is not supported yet...", parameter.GetType().Name)); + } + + CakeContext.Information("Variable '{0}' is not specified, returning default value", variableName); + + return defaultValue ?? string.Empty; + } + + //------------------------------------------------------------- + + private void PrintVariableValue(string variableName, string value, bool isSensitive = false) + { + var valueForLog = isSensitive ? "********" : value; + CakeContext.Information("{0}: '{1}'", variableName, valueForLog); + } +} + diff --git a/deployment/cake/codesigning-tasks.cake b/deployment/cake/codesigning-tasks.cake new file mode 100644 index 0000000..64364f5 --- /dev/null +++ b/deployment/cake/codesigning-tasks.cake @@ -0,0 +1,7 @@ +#l "codesigning-variables.cake" + +using System.Xml.Linq; + +//------------------------------------------------------------- + +// Empty by design for now diff --git a/deployment/cake/codesigning-variables.cake b/deployment/cake/codesigning-variables.cake new file mode 100644 index 0000000..4bd6e82 --- /dev/null +++ b/deployment/cake/codesigning-variables.cake @@ -0,0 +1,52 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class CodeSigningContext : BuildContextBase +{ + public CodeSigningContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public List ProjectsToSignImmediately { get; set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + //CakeContext.Information($"Found '{Items.Count}' component projects"); + } +} + +//------------------------------------------------------------- + +private CodeSigningContext InitializeCodeSigningContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new CodeSigningContext(parentBuildContext) + { + ProjectsToSignImmediately = CodeSignImmediately, + }; + + return data; +} + +//------------------------------------------------------------- + +List _codeSignImmediately; + +public List CodeSignImmediately +{ + get + { + if (_codeSignImmediately is null) + { + _codeSignImmediately = new List(); + } + + return _codeSignImmediately; + } +} \ No newline at end of file diff --git a/deployment/cake/components-tasks.cake b/deployment/cake/components-tasks.cake new file mode 100644 index 0000000..078d2be --- /dev/null +++ b/deployment/cake/components-tasks.cake @@ -0,0 +1,380 @@ +#l "components-variables.cake" + +using System.Xml.Linq; + +//------------------------------------------------------------- + +public class ComponentsProcessor : ProcessorBase +{ + public ComponentsProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + public override bool HasItems() + { + return BuildContext.Components.Items.Count > 0; + } + + private string GetComponentNuGetRepositoryUrl(string projectName) + { + // Allow per project overrides via "NuGetRepositoryUrlFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "NuGetRepositoryUrlFor", BuildContext.Components.NuGetRepositoryUrl); + } + + private string GetComponentNuGetRepositoryApiKey(string projectName) + { + // Allow per project overrides via "NuGetRepositoryApiKeyFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "NuGetRepositoryApiKeyFor", BuildContext.Components.NuGetRepositoryApiKey); + } + + public override async Task PrepareAsync() + { + if (!HasItems()) + { + return; + } + + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + foreach (var component in BuildContext.Components.Items.ToList()) + { + if (!ShouldProcessProject(BuildContext, component)) + { + BuildContext.Components.Items.Remove(component); + } + } + + if (BuildContext.General.IsLocalBuild && BuildContext.General.Target.ToLower().Contains("packagelocal")) + { + foreach (var component in BuildContext.Components.Items) + { + var expandableCacheDirectory = System.IO.Path.Combine("%userprofile%", ".nuget", "packages", component, BuildContext.General.Version.NuGet); + var cacheDirectory = Environment.ExpandEnvironmentVariables(expandableCacheDirectory); + + CakeContext.Information("Checking for existing local NuGet cached version at '{0}'", cacheDirectory); + + var retryCount = 3; + + while (retryCount > 0) + { + if (!CakeContext.DirectoryExists(cacheDirectory)) + { + break; + } + + CakeContext.Information("Deleting already existing NuGet cached version from '{0}'", cacheDirectory); + + CakeContext.DeleteDirectory(cacheDirectory, new DeleteDirectorySettings + { + Force = true, + Recursive = true + }); + + await System.Threading.Tasks.Task.Delay(1000); + + retryCount--; + } + } + } + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var component in BuildContext.Components.Items) + { + CakeContext.Information("Updating version for component '{0}'", component); + + var projectFileName = GetProjectFileName(BuildContext, component); + + CakeContext.TransformConfig(projectFileName, new TransformationCollection + { + { "Project/PropertyGroup/PackageVersion", BuildContext.General.Version.NuGet } + }); + } + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var component in BuildContext.Components.Items) + { + BuildContext.CakeContext.LogSeparator("Building component '{0}'", component); + + var projectFileName = GetProjectFileName(BuildContext, component); + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, + //Verbosity = Verbosity.Diagnostic, + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, component, "build"); + + // Note: we need to set OverridableOutputPath because we need to be able to respect + // AppendTargetFrameworkToOutputPath which isn't possible for global properties (which + // are properties passed in using the command line) + var outputDirectory = GetProjectOutputDirectory(BuildContext, component); + CakeContext.Information("Output directory: '{0}'", outputDirectory); + msBuildSettings.WithProperty("OverridableOutputPath", outputDirectory); + + // SourceLink specific stuff + if (IsSourceLinkSupported(BuildContext, component, projectFileName)) + { + var repositoryUrl = BuildContext.General.Repository.Url; + var repositoryCommitId = BuildContext.General.Repository.CommitId; + + CakeContext.Information("Repository url is specified, enabling SourceLink to commit '{0}/commit/{1}'", + repositoryUrl, repositoryCommitId); + + // TODO: For now we are assuming everything is git, we might need to change that in the future + // See why we set the values at https://github.com/dotnet/sourcelink/issues/159#issuecomment-427639278 + msBuildSettings.WithProperty("EnableSourceLink", "true"); + msBuildSettings.WithProperty("EnableSourceControlManagerQueries", "false"); + msBuildSettings.WithProperty("PublishRepositoryUrl", "true"); + msBuildSettings.WithProperty("RepositoryType", "git"); + msBuildSettings.WithProperty("RepositoryUrl", repositoryUrl); + msBuildSettings.WithProperty("RevisionId", repositoryCommitId); + + InjectSourceLinkInProjectFile(BuildContext, component, projectFileName); + } + + RunMsBuild(BuildContext, component, projectFileName, msBuildSettings, "build"); + + // Specific code signing, requires the following MSBuild properties: + // * CodeSignEnabled + // * CodeSignCommand + // + // This feature is built to allow projects that have post-build copy + // steps (e.g. for assets) to be signed correctly before being embedded + if (ShouldSignImmediately(BuildContext, component)) + { + SignProjectFiles(BuildContext, component); + } + } + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + var configurationName = BuildContext.General.Solution.ConfigurationName; + + foreach (var component in BuildContext.Components.Items) + { + // Note: some projects, such as Catel.Fody, require packaging + // of non-deployable projects + if (BuildContext.General.SkipComponentsThatAreNotDeployable && + !ShouldPackageProject(BuildContext, component)) + { + CakeContext.Information("Component '{0}' should not be packaged", component); + continue; + } + + // Special exception for Blazor projects + var isBlazorProject = IsBlazorProject(BuildContext, component); + var isPackageContainerProject = IsPackageContainerProject(BuildContext, component); + + BuildContext.CakeContext.LogSeparator("Packaging component '{0}'", component); + CakeContext.Information("IsPackageContainerProject = '{0}'", isPackageContainerProject); + + var projectDirectory = GetProjectDirectory(component); + var projectFileName = GetProjectFileName(BuildContext, component); + var outputDirectory = GetProjectOutputDirectory(BuildContext, component); + CakeContext.Information("Output directory: '{0}'", outputDirectory); + + // Step 1: remove intermediate files to ensure we have the same results on the build server, somehow NuGet + // targets tries to find the resource assemblies in [ProjectName]\obj\Release\net46\de\[ProjectName].resources.dll', + // we won't run a clean on the project since it will clean out the actual output (which we still need for packaging) + + CakeContext.Information("Cleaning intermediate files for component '{0}'", component); + + var binFolderPattern = string.Format("{0}/bin/{1}/**.dll", projectDirectory, configurationName); + + CakeContext.Information("Deleting 'bin' directory contents using '{0}'", binFolderPattern); + + var binFiles = CakeContext.GetFiles(binFolderPattern); + CakeContext.DeleteFiles(binFiles); + + if (!isBlazorProject) + { + var objFolderPattern = string.Format("{0}/obj/{1}/**.dll", projectDirectory, configurationName); + + CakeContext.Information("Deleting 'bin' directory contents using '{0}'", objFolderPattern); + + var objFiles = CakeContext.GetFiles(objFolderPattern); + CakeContext.DeleteFiles(objFiles); + } + + CakeContext.Information(string.Empty); + + // Step 2: Go packaging! + CakeContext.Information("Using 'msbuild' to package '{0}'", component); + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, + //Verbosity = Verbosity.Diagnostic, + ToolVersion = MSBuildToolVersion.Default, + Configuration = configurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, component, "pack"); + + // Note: we need to set OverridableOutputPath because we need to be able to respect + // AppendTargetFrameworkToOutputPath which isn't possible for global properties (which + // are properties passed in using the command line) + msBuildSettings.WithProperty("OverridableOutputPath", outputDirectory); + msBuildSettings.WithProperty("ConfigurationName", configurationName); + msBuildSettings.WithProperty("PackageVersion", BuildContext.General.Version.NuGet); + + // SourceLink specific stuff + var repositoryUrl = BuildContext.General.Repository.Url; + var repositoryCommitId = BuildContext.General.Repository.CommitId; + if (!BuildContext.General.SourceLink.IsDisabled && + !BuildContext.General.IsLocalBuild && + !string.IsNullOrWhiteSpace(repositoryUrl)) + { + CakeContext.Information("Repository url is specified, adding commit specific data to package"); + + // TODO: For now we are assuming everything is git, we might need to change that in the future + // See why we set the values at https://github.com/dotnet/sourcelink/issues/159#issuecomment-427639278 + msBuildSettings.WithProperty("PublishRepositoryUrl", "true"); + msBuildSettings.WithProperty("RepositoryType", "git"); + msBuildSettings.WithProperty("RepositoryUrl", repositoryUrl); + msBuildSettings.WithProperty("RevisionId", repositoryCommitId); + } + + // Disable Multilingual App Toolkit (MAT) during packaging + msBuildSettings.WithProperty("DisableMAT", "true"); + + // Fix for .NET Core 3.0, see https://github.com/dotnet/core-sdk/issues/192, it + // uses obj/release instead of [outputdirectory] + msBuildSettings.WithProperty("DotNetPackIntermediateOutputPath", outputDirectory); + + var noBuild = true; + + if (isBlazorProject) + { + CakeContext.Information("Allowing build and package restore during package phase since this is a Blazor project which requires the 'obj' directory"); + + // Don't use WithProperty since that will concatenate, and we need to overwrite the + // value here + //msBuildSettings.WithProperty("ResolveNuGetPackages", "true"); + msBuildSettings.Properties["ResolveNuGetPackages"] = new List + { + "true" + }; + + msBuildSettings.Restore = true; + noBuild = false; + } + + // Disabled on 2025-09-23 since it was causing issues on local builds during packaging + // if (isPackageContainerProject) + // { + // // In debug / local builds, automatic building of reference projects + // // is enabled for convenience. If that is the case, noBuild must be + // // set to false, but *only* in debug mode + // if (BuildContext.General.IsLocalBuild) + // { + // noBuild = false; + // } + // } + + // As described in the this issue: https://github.com/NuGet/Home/issues/4360 + // we should not use IsTool, but set BuildOutputTargetFolder instead + msBuildSettings.WithProperty("CopyLocalLockFileAssemblies", "true"); + msBuildSettings.WithProperty("IncludeBuildOutput", "true"); + msBuildSettings.WithProperty("NoDefaultExcludes", "true"); + + msBuildSettings.WithProperty("NoBuild", noBuild.ToString()); + msBuildSettings.Targets.Add("Pack"); + + RunMsBuild(BuildContext, component, projectFileName, msBuildSettings, "pack"); + + BuildContext.CakeContext.LogSeparator(); + } + + await SignNuGetPackageAsync(); + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var component in BuildContext.Components.Items) + { + if (!ShouldDeployProject(BuildContext, component)) + { + CakeContext.Information("Component '{0}' should not be deployed", component); + continue; + } + + BuildContext.CakeContext.LogSeparator("Deploying component '{0}'", component); + + var packageToPush = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, $"{component}.{BuildContext.General.Version.NuGet}.nupkg"); + var nuGetRepositoryUrl = GetComponentNuGetRepositoryUrl(component); + var nuGetRepositoryApiKey = GetComponentNuGetRepositoryApiKey(component); + + if (string.IsNullOrWhiteSpace(nuGetRepositoryUrl)) + { + throw new Exception("NuGet repository is empty, as a protection mechanism this must *always* be specified to make sure packages aren't accidentally deployed to the default public NuGet feed"); + } + + CakeContext.NuGetPush(packageToPush, new NuGetPushSettings + { + Source = nuGetRepositoryUrl, + ApiKey = nuGetRepositoryApiKey, + ArgumentCustomization = args => args.Append("-SkipDuplicate") + }); + + await BuildContext.Notifications.NotifyAsync(component, string.Format("Deployed to NuGet store"), TargetType.Component); + } + } + + public override async Task FinalizeAsync() + { + + } + + private async Task SignNuGetPackageAsync() + { + if (BuildContext.General.IsCiBuild || + BuildContext.General.IsLocalBuild) + { + return; + } + + // For details, see https://docs.microsoft.com/en-us/nuget/create-packages/sign-a-package + // nuget sign MyPackage.nupkg -CertificateSubjectName -Timestamper + var filesToSign = CakeContext.GetFiles($"{BuildContext.General.OutputRootDirectory}/*.nupkg"); + + foreach (var fileToSign in filesToSign) + { + SignNuGetPackage(BuildContext, fileToSign.FullPath); + } + } +} \ No newline at end of file diff --git a/deployment/cake/components-variables.cake b/deployment/cake/components-variables.cake new file mode 100644 index 0000000..244363e --- /dev/null +++ b/deployment/cake/components-variables.cake @@ -0,0 +1,55 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class ComponentsContext : BuildContextWithItemsBase +{ + public ComponentsContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string NuGetRepositoryUrl { get; set; } + public string NuGetRepositoryApiKey { get; set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' component projects"); + } +} + +//------------------------------------------------------------- + +private ComponentsContext InitializeComponentsContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new ComponentsContext(parentBuildContext) + { + Items = Components ?? new List(), + NuGetRepositoryUrl = buildContext.BuildServer.GetVariable("NuGetRepositoryUrl", showValue: true), + NuGetRepositoryApiKey = buildContext.BuildServer.GetVariable("NuGetRepositoryApiKey", showValue: false) + }; + + return data; +} + +//------------------------------------------------------------- + +List _components; + +public List Components +{ + get + { + if (_components is null) + { + _components = new List(); + } + + return _components; + } +} \ No newline at end of file diff --git a/deployment/cake/dependencies-tasks.cake b/deployment/cake/dependencies-tasks.cake new file mode 100644 index 0000000..f817004 --- /dev/null +++ b/deployment/cake/dependencies-tasks.cake @@ -0,0 +1,185 @@ +#l "dependencies-variables.cake" + +using System.Xml.Linq; + +//------------------------------------------------------------- + +public class DependenciesProcessor : ProcessorBase +{ + public DependenciesProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + public override bool HasItems() + { + return BuildContext.Dependencies.Items.Count > 0; + } + + public override async Task PrepareAsync() + { + BuildContext.CakeContext.Information($"Checking '{BuildContext.Dependencies.Items.Count}' dependencies"); + + if (!HasItems()) + { + return; + } + + // We need to go through this twice because a dependency can be a dependency of a dependency + var dependenciesToBuild = new List(); + + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + for (int i = 0; i < 3; i++) + { + foreach (var dependency in BuildContext.Dependencies.Items.ToList()) + { + if (dependenciesToBuild.Contains(dependency)) + { + // Already done + continue; + } + + BuildContext.CakeContext.Information($"Checking dependency '{dependency}' using run {i + 1}"); + + if (BuildContext.Dependencies.ShouldBuildDependency(dependency, dependenciesToBuild)) + { + BuildContext.CakeContext.Information($"Dependency '{dependency}' should be included"); + + dependenciesToBuild.Add(dependency); + } + } + } + + // TODO: How to determine the sort order? E.g. dependencies of dependencies? + + foreach (var dependency in BuildContext.Dependencies.Items.ToList()) + { + if (!dependenciesToBuild.Contains(dependency)) + { + BuildContext.CakeContext.Information($"Skipping dependency '{dependency}' because no dependent projects are included"); + + BuildContext.Dependencies.Dependencies.Remove(dependency); + BuildContext.Dependencies.Items.Remove(dependency); + } + } + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var dependency in BuildContext.Dependencies.Items) + { + CakeContext.Information("Updating version for dependency '{0}'", dependency); + + var projectFileName = GetProjectFileName(BuildContext, dependency); + + CakeContext.TransformConfig(projectFileName, new TransformationCollection + { + { "Project/PropertyGroup/PackageVersion", BuildContext.General.Version.NuGet } + }); + } + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var dependency in BuildContext.Dependencies.Items) + { + BuildContext.CakeContext.LogSeparator("Building dependency '{0}'", dependency); + + var projectFileName = GetProjectFileName(BuildContext, dependency); + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, + //Verbosity = Verbosity.Diagnostic, + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform, + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, dependency, "build"); + + // Note: we need to set OverridableOutputPath because we need to be able to respect + // AppendTargetFrameworkToOutputPath which isn't possible for global properties (which + // are properties passed in using the command line) + var isCppProject = IsCppProject(projectFileName); + if (isCppProject) + { + // Special C++ exceptions + msBuildSettings.MSBuildPlatform = MSBuildPlatform.Automatic; + msBuildSettings.PlatformTarget = PlatformTarget.Win32; + } + + // SourceLink specific stuff + if (IsSourceLinkSupported(BuildContext, dependency, projectFileName)) + { + var repositoryUrl = BuildContext.General.Repository.Url; + var repositoryCommitId = BuildContext.General.Repository.CommitId; + + CakeContext.Information("Repository url is specified, enabling SourceLink to commit '{0}/commit/{1}'", + repositoryUrl, repositoryCommitId); + + // TODO: For now we are assuming everything is git, we might need to change that in the future + // See why we set the values at https://github.com/dotnet/sourcelink/issues/159#issuecomment-427639278 + msBuildSettings.WithProperty("EnableSourceLink", "true"); + msBuildSettings.WithProperty("EnableSourceControlManagerQueries", "false"); + msBuildSettings.WithProperty("PublishRepositoryUrl", "true"); + msBuildSettings.WithProperty("RepositoryType", "git"); + msBuildSettings.WithProperty("RepositoryUrl", repositoryUrl); + msBuildSettings.WithProperty("RevisionId", repositoryCommitId); + + InjectSourceLinkInProjectFile(BuildContext, dependency, projectFileName); + } + + RunMsBuild(BuildContext, dependency, projectFileName, msBuildSettings, "build"); + + // Specific code signing, requires the following MSBuild properties: + // * CodeSignEnabled + // * CodeSignCommand + // + // This feature is built to allow projects that have post-build copy + // steps (e.g. for assets) to be signed correctly before being embedded + if (ShouldSignImmediately(BuildContext, dependency)) + { + SignProjectFiles(BuildContext, dependency); + } + } + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + // No packaging required for dependencies + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + // No deployment required for dependencies + } + + public override async Task FinalizeAsync() + { + + } +} \ No newline at end of file diff --git a/deployment/cake/dependencies-variables.cake b/deployment/cake/dependencies-variables.cake new file mode 100644 index 0000000..e7fa8b7 --- /dev/null +++ b/deployment/cake/dependencies-variables.cake @@ -0,0 +1,103 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class DependenciesContext : BuildContextWithItemsBase +{ + public DependenciesContext(IBuildContext parentBuildContext, Dictionary> dependencies) + : base(parentBuildContext) + { + Dependencies = dependencies ?? new Dictionary>(); + Items = Dependencies.Keys.ToList(); + } + + public Dictionary> Dependencies { get; private set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' dependency projects"); + } + + public bool ShouldBuildDependency(string dependencyProject) + { + return ShouldBuildDependency(dependencyProject, Array.Empty()); + } + + public bool ShouldBuildDependency(string dependencyProject, IEnumerable knownDependenciesToBeBuilt) + { + if (!Dependencies.TryGetValue(dependencyProject, out var dependencyInfo)) + { + return false; + } + + if (dependencyInfo.Count == 0) + { + // No explicit projects defined, always build dependency + return true; + } + + foreach (var projectRequiringDependency in dependencyInfo) + { + CakeContext.Information($"Checking whether '{projectRequiringDependency}' is in the list to be processed"); + + // Check dependencies of dependencies + if (knownDependenciesToBeBuilt.Any(x => string.Equals(x, projectRequiringDependency, StringComparison.OrdinalIgnoreCase))) + { + CakeContext.Information($"Dependency '{dependencyProject}' is a dependency of dependency project '{projectRequiringDependency}', including this in the build"); + return true; + } + + // Special case: *if* this is the 2nd round we check, and the project requiring this dependency is a test project, + // we should check whether the test project is not already excluded. If so, the Deploy[SomeProject]Tests will return true + // and this logic will still include it, so we need to exclude it explicitly + if (IsTestProject((BuildContext)ParentContext, projectRequiringDependency) && + !knownDependenciesToBeBuilt.Contains(projectRequiringDependency)) + { + CakeContext.Information($"Dependency '{dependencyProject}' is a dependency of '{projectRequiringDependency}', but that is an already excluded test project, not yet including in the build"); + + // Important: don't return, there might be other projects + continue; + } + + // Check if we should build this project + if (ShouldProcessProject((BuildContext)ParentContext, projectRequiringDependency)) + { + CakeContext.Information($"Dependency '{dependencyProject}' is a dependency of '{projectRequiringDependency}', including this in the build"); + return true; + } + } + + return false; + } +} + +//------------------------------------------------------------- + +private DependenciesContext InitializeDependenciesContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new DependenciesContext(parentBuildContext, Dependencies); + + return data; +} + +//------------------------------------------------------------- + +Dictionary> _dependencies; + +public Dictionary> Dependencies +{ + get + { + if (_dependencies is null) + { + _dependencies = new Dictionary>(); + } + + return _dependencies; + } +} \ No newline at end of file diff --git a/deployment/cake/docker-tasks.cake b/deployment/cake/docker-tasks.cake new file mode 100644 index 0000000..4a20573 --- /dev/null +++ b/deployment/cake/docker-tasks.cake @@ -0,0 +1,368 @@ +#l "docker-variables.cake" + +#addin "nuget:?package=Cake.Docker&version=1.3.0" + +//------------------------------------------------------------- + +public class DockerImagesProcessor : ProcessorBase +{ + public DockerImagesProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + public override bool HasItems() + { + return BuildContext.DockerImages.Items.Count > 0; + } + + public string GetDockerRegistryUrl(string projectName) + { + // Allow per project overrides via "DockerRegistryUrlFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "DockerRegistryUrlFor", BuildContext.DockerImages.DockerRegistryUrl); + } + + public string GetDockerRegistryUserName(string projectName) + { + // Allow per project overrides via "DockerRegistryUserNameFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "DockerRegistryUserNameFor", BuildContext.DockerImages.DockerRegistryUserName); + } + + public string GetDockerRegistryPassword(string projectName) + { + // Allow per project overrides via "DockerRegistryPasswordFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "DockerRegistryPasswordFor", BuildContext.DockerImages.DockerRegistryPassword); + } + + private string GetDockerImageName(string projectName) + { + var name = projectName.Replace(".", "-"); + return name.ToLower(); + } + + private string GetDockerImageTag(string projectName, string version) + { + var dockerRegistryUrl = GetDockerRegistryUrl(projectName); + + var tag = string.Format("{0}/{1}:{2}", dockerRegistryUrl, GetDockerImageName(projectName), version); + return tag.TrimStart(' ', '/').ToLower(); + } + + private string[] GetDockerImageTags(string projectName) + { + var dockerTags = new List(); + + var versions = new List(); + + versions.Add(BuildContext.General.Version.NuGet); + + foreach (var version in new [] + { + BuildContext.General.Version.MajorMinor, + BuildContext.General.Version.Major + }) + { + var additionalTag = version; + + if (BuildContext.General.IsAlphaBuild) + { + additionalTag += "-alpha"; + } + + if (BuildContext.General.IsBetaBuild) + { + additionalTag += "-beta"; + } + + versions.Add(additionalTag); + } + + foreach (var version in versions) + { + dockerTags.Add(GetDockerImageTag(projectName, version)); + } + + if (BuildContext.General.IsAlphaBuild) + { + dockerTags.Add(GetDockerImageTag(projectName, "latest-alpha")); + } + + if (BuildContext.General.IsBetaBuild) + { + dockerTags.Add(GetDockerImageTag(projectName, "latest-beta")); + } + + if (BuildContext.General.IsOfficialBuild) + { + dockerTags.Add(GetDockerImageTag(projectName, "latest-stable")); + dockerTags.Add(GetDockerImageTag(projectName, "latest")); + } + + return dockerTags.ToArray(); + } + + private void ConfigureDockerSettings(AutoToolSettings dockerSettings) + { + var engineUrl = BuildContext.DockerImages.DockerEngineUrl; + if (!string.IsNullOrWhiteSpace(engineUrl)) + { + CakeContext.Information("Using remote docker engine: '{0}'", engineUrl); + + dockerSettings.ArgumentCustomization = args => args.Prepend($"-H {engineUrl}"); + //dockerSettings.BuildArg = new [] { $"DOCKER_HOST={engineUrl}" }; + } + } + + public override async Task PrepareAsync() + { + if (!HasItems()) + { + return; + } + + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + foreach (var dockerImage in BuildContext.DockerImages.Items.ToList()) + { + foreach (var imageTag in GetDockerImageTags(dockerImage)) + { + CakeContext.Information(imageTag); + } + + if (!ShouldProcessProject(BuildContext, dockerImage)) + { + BuildContext.DockerImages.Items.Remove(dockerImage); + } + } + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + // Doesn't seem neccessary yet + // foreach (var dockerImage in BuildContext.DockerImages.Items) + // { + // Information("Updating version for docker image '{0}'", dockerImage); + + // var projectFileName = GetProjectFileName(BuildContext, dockerImage); + + // TransformConfig(projectFileName, new TransformationCollection + // { + // { "Project/PropertyGroup/PackageVersion", VersionNuGet } + // }); + // } + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var dockerImage in BuildContext.DockerImages.Items) + { + BuildContext.CakeContext.LogSeparator("Building docker image '{0}'", dockerImage); + + var projectFileName = GetProjectFileName(BuildContext, dockerImage); + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, // Verbosity.Diagnostic + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, dockerImage, "build"); + + // Always disable SourceLink + msBuildSettings.WithProperty("EnableSourceLink", "false"); + + RunMsBuild(BuildContext, dockerImage, projectFileName, msBuildSettings, "build"); + } + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + // The following directories are being created, ready for docker images to be used: + // ./output => output of the publish step + // ./config => docker image and config files, in case they need to be packed as well + + foreach (var dockerImage in BuildContext.DockerImages.Items) + { + if (!ShouldPackageProject(BuildContext, dockerImage)) + { + CakeContext.Information("Docker image '{0}' should not be packaged", dockerImage); + continue; + } + + BuildContext.CakeContext.LogSeparator("Packaging docker image '{0}'", dockerImage); + + var projectFileName = GetProjectFileName(BuildContext, dockerImage); + var dockerImageSpecificationDirectory = System.IO.Path.Combine(".", "deployment", "docker", dockerImage); + var dockerImageSpecificationFileName = System.IO.Path.Combine(dockerImageSpecificationDirectory, dockerImage); + + var outputRootDirectory = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, dockerImage, "output"); + + CakeContext.Information("1) Preparing ./config for package '{0}'", dockerImage); + + // ./config + var confTargetDirectory = System.IO.Path.Combine(outputRootDirectory, "conf"); + CakeContext.Information("Conf directory: '{0}'", confTargetDirectory); + + CakeContext.CreateDirectory(confTargetDirectory); + + var confSourceDirectory = string.Format("{0}/*", dockerImageSpecificationDirectory); + CakeContext.Information("Copying files from '{0}' => '{1}'", confSourceDirectory, confTargetDirectory); + + CakeContext.CopyFiles(confSourceDirectory, confTargetDirectory, true); + + BuildContext.CakeContext.LogSeparator(); + + CakeContext.Information("2) Preparing ./output using 'dotnet publish' for package '{0}'", dockerImage); + + // ./output + var outputDirectory = System.IO.Path.Combine(outputRootDirectory, "output"); + CakeContext.Information("Output directory: '{0}'", outputDirectory); + + var msBuildSettings = new DotNetMSBuildSettings(); + + ConfigureMsBuildForDotNet(BuildContext, msBuildSettings, dockerImage, "pack"); + + msBuildSettings.WithProperty("ConfigurationName", BuildContext.General.Solution.ConfigurationName); + msBuildSettings.WithProperty("PackageVersion", BuildContext.General.Version.NuGet); + + // Disable code analyses, we experienced publish issues with mvc .net core projects + msBuildSettings.WithProperty("RunCodeAnalysis", "false"); + + var publishSettings = new DotNetPublishSettings + { + MSBuildSettings = msBuildSettings, + OutputDirectory = outputDirectory, + Configuration = BuildContext.General.Solution.ConfigurationName, + //NoBuild = true + }; + + CakeContext.DotNetPublish(projectFileName, publishSettings); + + BuildContext.CakeContext.LogSeparator(); + + CakeContext.Information("3) Using 'docker build' to package '{0}'", dockerImage); + + // docker build ..\..\output\Release\platform -f .\Dockerfile + + // From the docs (https://docs.microsoft.com/en-us/azure/app-service/containers/tutorial-custom-docker-image#use-a-docker-image-from-any-private-registry-optional), + // we need something like this: + // docker tag .azurecr.io/mydockerimage + var dockerRegistryUrl = GetDockerRegistryUrl(dockerImage); + + // Note: to prevent all output & source files to be copied to the docker context, we will set the + // output directory as context (to keep the footprint as small as possible) + + var dockerSettings = new DockerImageBuildSettings + { + NoCache = true, // Don't use cache, always make sure to fetch the right images + File = dockerImageSpecificationFileName, + //Platform = "linux", + Tag = GetDockerImageTags(dockerImage) + }; + + ConfigureDockerSettings(dockerSettings); + + CakeContext.Information("Docker files source directory: '{0}'", outputRootDirectory); + + CakeContext.DockerBuild(dockerSettings, outputRootDirectory); + + BuildContext.CakeContext.LogSeparator(); + } + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var dockerImage in BuildContext.DockerImages.Items) + { + if (!ShouldDeployProject(BuildContext, dockerImage)) + { + CakeContext.Information("Docker image '{0}' should not be deployed", dockerImage); + continue; + } + + BuildContext.CakeContext.LogSeparator("Deploying docker image '{0}'", dockerImage); + + var dockerRegistryUrl = GetDockerRegistryUrl(dockerImage); + var dockerRegistryUserName = GetDockerRegistryUserName(dockerImage); + var dockerRegistryPassword = GetDockerRegistryPassword(dockerImage); + var dockerImageName = GetDockerImageName(dockerImage); + + if (string.IsNullOrWhiteSpace(dockerRegistryUrl)) + { + throw new Exception("Docker registry url is empty, as a protection mechanism this must *always* be specified to make sure packages aren't accidentally deployed to some default public registry"); + } + + // Note: we are logging in each time because the registry might be different per container + CakeContext.Information("Logging in to docker @ '{0}'", dockerRegistryUrl); + + var dockerLoginSettings = new DockerRegistryLoginSettings + { + Username = dockerRegistryUserName, + Password = dockerRegistryPassword + }; + + ConfigureDockerSettings(dockerLoginSettings); + + CakeContext.DockerLogin(dockerLoginSettings, dockerRegistryUrl); + + try + { + foreach (var dockerImageTag in GetDockerImageTags(dockerImage)) + { + CakeContext.Information("Pushing docker images with tag '{0}' to '{1}'", dockerImageTag, dockerRegistryUrl); + + var dockerImagePushSettings = new DockerImagePushSettings + { + }; + + ConfigureDockerSettings(dockerImagePushSettings); + + CakeContext.DockerPush(dockerImagePushSettings, dockerImageTag); + + await BuildContext.Notifications.NotifyAsync(dockerImage, string.Format("Deployed to Docker"), TargetType.DockerImage); + } + } + finally + { + CakeContext.Information("Logging out of docker @ '{0}'", dockerRegistryUrl); + + var dockerLogoutSettings = new DockerRegistryLogoutSettings + { + }; + + ConfigureDockerSettings(dockerLogoutSettings); + + CakeContext.DockerLogout(dockerLogoutSettings, dockerRegistryUrl); + } + } + } + + public override async Task FinalizeAsync() + { + + } +} diff --git a/deployment/cake/docker-variables.cake b/deployment/cake/docker-variables.cake new file mode 100644 index 0000000..b129cf4 --- /dev/null +++ b/deployment/cake/docker-variables.cake @@ -0,0 +1,58 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class DockerImagesContext : BuildContextWithItemsBase +{ + public DockerImagesContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string DockerEngineUrl { get; set; } + public string DockerRegistryUrl { get; set; } + public string DockerRegistryUserName { get; set; } + public string DockerRegistryPassword { get; set; } + + protected override void ValidateContext() + { + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' docker image projects"); + } +} + +//------------------------------------------------------------- + +private DockerImagesContext InitializeDockerImagesContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new DockerImagesContext(parentBuildContext) + { + Items = DockerImages ?? new List(), + DockerEngineUrl = buildContext.BuildServer.GetVariable("DockerEngineUrl", showValue: true), + DockerRegistryUrl = buildContext.BuildServer.GetVariable("DockerRegistryUrl", showValue: true), + DockerRegistryUserName = buildContext.BuildServer.GetVariable("DockerRegistryUserName", showValue: false), + DockerRegistryPassword = buildContext.BuildServer.GetVariable("DockerRegistryPassword", showValue: false) + }; + + return data; +} + +//------------------------------------------------------------- + +List _dockerImages; + +public List DockerImages +{ + get + { + if (_dockerImages is null) + { + _dockerImages = new List(); + } + + return _dockerImages; + } +} \ No newline at end of file diff --git a/deployment/cake/generic-tasks.cake b/deployment/cake/generic-tasks.cake new file mode 100644 index 0000000..5cf07ea --- /dev/null +++ b/deployment/cake/generic-tasks.cake @@ -0,0 +1,311 @@ +#l "generic-variables.cake" + +//#addin "nuget:?package=Cake.DependencyCheck&version=1.2.0" + +//#tool "nuget:?package=DependencyCheck.Runner.Tool&version=3.2.1&include=./**/dependency-check.sh&include=./**/dependency-check.bat" + +//------------------------------------------------------------- + +private void ValidateRequiredInput(string parameterName) +{ + // TODO: Do we want to check the configuration as well? + + if (!Parameters.ContainsKey(parameterName)) + { + throw new Exception(string.Format("Parameter '{0}' is required but not defined", parameterName)); + } +} + +//------------------------------------------------------------- + +private void CleanUpCode(bool failOnChanges) +{ + Information("Cleaning up code using dotnet-format"); + + // --check: return non-0 exit code if changes are needed + // --dry-run: don't save files + + // Note: disabled for now, see: + // * https://github.com/onovotny/MSBuildSdkExtras/issues/164 + // * https://github.com/microsoft/msbuild/issues/4376 + // var arguments = new List(); + + // //arguments.Add("--dry-run"); + + // if (failOnChanges) + // { + // arguments.Add("--check"); + // } + + // DotNetTool(null, "format", string.Join(" ", arguments), + // new DotNetToolSettings + // { + // WorkingDirectory = "./src/" + // }); +} + +//------------------------------------------------------------- + +private void VerifyDependencies(string pathToScan = "./src/**/*.csproj") +{ + Information("Verifying dependencies for security vulnerabilities in '{0}'", pathToScan); + + // Disabled for now + //DependencyCheck(new DependencyCheckSettings + //{ + // Project = SolutionName, + // Scan = pathToScan, + // FailOnCVSS = "0", + // Format = "HTML", + // Data = "%temp%/dependency-check/data" + //}); +} + +//------------------------------------------------------------- + +private void UpdateSolutionAssemblyInfo(BuildContext buildContext) +{ + Information("Updating assembly info to '{0}'", buildContext.General.Version.FullSemVer); + + var assemblyInfoParseResult = ParseAssemblyInfo(buildContext.General.Solution.AssemblyInfoFileName); + + var assemblyInfo = new AssemblyInfoSettings + { + Company = buildContext.General.Copyright.Company, + Version = buildContext.General.Version.MajorMinorPatch, + FileVersion = buildContext.General.Version.MajorMinorPatch, + InformationalVersion = buildContext.General.Version.FullSemVer, + Copyright = string.Format("Copyright © {0} {1} - {2}", + buildContext.General.Copyright.Company, buildContext.General.Copyright.StartYear, DateTime.Now.Year) + }; + + CreateAssemblyInfo(buildContext.General.Solution.AssemblyInfoFileName, assemblyInfo); +} + +//------------------------------------------------------------- + +Task("UpdateNuGet") + .ContinueOnError() + .Does(buildContext => +{ + // DISABLED UNTIL NUGET GETS FIXED: https://github.com/NuGet/Home/issues/10853 + + // Information("Making sure NuGet is using the latest version"); + + // if (buildContext.General.IsLocalBuild && buildContext.General.MaximizePerformance) + // { + // Information("Local build with maximized performance detected, skipping NuGet update check"); + // return; + // } + + // var nuGetExecutable = buildContext.General.NuGet.Executable; + + // var exitCode = StartProcess(nuGetExecutable, new ProcessSettings + // { + // Arguments = "update -self" + // }); + + // var newNuGetVersionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(nuGetExecutable); + // var newNuGetVersion = newNuGetVersionInfo.FileVersion; + + // Information("Updating NuGet.exe exited with '{0}', version is '{1}'", exitCode, newNuGetVersion); +}); + +//------------------------------------------------------------- + +Task("RestorePackages") + .IsDependentOn("Prepare") + .IsDependentOn("UpdateNuGet") + .ContinueOnError() + .Does(buildContext => +{ + if (buildContext.General.IsLocalBuild && buildContext.General.MaximizePerformance) + { + Information("Local build with maximized performance detected, skipping package restore"); + return; + } + + //var csharpProjects = GetFiles("./**/*.csproj"); + // var cProjects = GetFiles("./**/*.vcxproj"); + var solutions = new List(); + solutions.AddRange(GetFiles("./**/*.sln")); + solutions.AddRange(GetFiles("./**/*.slnx")); + + var csharpProjects = new List(); + + foreach (var project in buildContext.AllProjects) + { + // Once a project is in AllProjects, it should always be restored + + var projectFileName = GetProjectFileName(buildContext, project); + if (projectFileName.EndsWith(".csproj")) + { + Information("Adding '{0}' as C# specific project to restore", project); + + csharpProjects.Add(projectFileName); + + // Inject source link *before* package restore + InjectSourceLinkInProjectFile(buildContext, project, projectFileName); + } + } + + var allFiles = new List(); + //allFiles.AddRange(solutions); + allFiles.AddRange(csharpProjects); + // //allFiles.AddRange(cProjects); + + Information($"Found '{allFiles.Count}' projects to restore"); + + foreach (var file in allFiles) + { + RestoreNuGetPackages(buildContext, file); + } + + // C++ files need to be done manually + foreach (var project in buildContext.AllProjects) + { + var projectFileName = GetProjectFileName(buildContext, project); + if (IsCppProject(projectFileName)) + { + buildContext.CakeContext.LogSeparator("'{0}' is a C++ project, restoring NuGet packages separately", project); + + RestoreNuGetPackages(buildContext, projectFileName); + + // For C++ projects, we must clean the project again after a package restore + CleanProject(buildContext, project); + } + } +}); + +//------------------------------------------------------------- + +// Note: it might look weird that this is dependent on restore packages, +// but to clean, the msbuild projects must be able to load. However, they need +// some targets files that come in via packages + +Task("Clean") + //.IsDependentOn("RestorePackages") + .IsDependentOn("Prepare") + .ContinueOnError() + .Does(buildContext => +{ + if (buildContext.General.IsLocalBuild && buildContext.General.MaximizePerformance) + { + Information("Local build with maximized performance detected, skipping solution clean"); + return; + } + + // Note: this is all coming from the solution file, but the cake build solution parser + // unfortunately does not support the 'platform' attribute, so we have to assume all for now + //var solutionParser = buildContext.CakeContext.ParseSolution(buildContext.General.Solution.FileName); + + var platformTargets = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // These are well-known platform targets + platformTargets["AnyCPU"] = "Any CPU"; + platformTargets["x86"] = "x86"; + platformTargets["x86"] = "x64"; + //platformTargets["Win32"] = "Win32"; + //platformTargets["ARM"] = "ARM"; + platformTargets["ARM32"] = "ARM32"; + platformTargets["ARM64"] = "ARM64"; + + foreach (var platformTarget in platformTargets) + { + try + { + Information("Cleaning output for platform '{0}'", platformTarget.Value); + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Minimal, + ToolVersion = MSBuildToolVersion.Default, + Configuration = buildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + //PlatformTarget = platform.Value // use string variant + }; + + msBuildSettings = msBuildSettings.SetPlatformTarget(platformTarget.Value); + + ConfigureMsBuild(buildContext, msBuildSettings, platformTarget.Value, "clean"); + + msBuildSettings.Targets.Add("Clean"); + + MSBuild(buildContext.General.Solution.FileName, msBuildSettings); + } + catch (System.Exception ex) + { + Warning("Failed to clean output for platform '{0}': {1}", platformTarget.Key, ex.Message); + } + } + + // Output directory + DeleteDirectoryWithLogging(buildContext, buildContext.General.OutputRootDirectory); + + // obj directories + foreach (var project in buildContext.AllProjects) + { + CleanProject(buildContext, project); + } +}); + +//------------------------------------------------------------- + +Task("VerifyDependencies") + .IsDependentOn("Prepare") + .Does(async () => +{ + // if (DependencyCheckDisabled) + // { + // Information("Dependency analysis is disabled"); + // return; + // } + + // VerifyDependencies(); +}); + +//------------------------------------------------------------- + +Task("CleanupCode") + .Does(buildContext => +{ + CleanUpCode(true); +}); + +//------------------------------------------------------------- + +Task("CodeSign") + //.ContinueOnError() + .Does(buildContext => +{ + if (buildContext.General.IsCiBuild) + { + Information("Skipping code signing because this is a CI build"); + return; + } + + if (buildContext.General.IsLocalBuild) + { + Information("Local build detected, skipping code signing"); + return; + } + + if (!buildContext.General.CodeSign.IsAvailable && + !buildContext.General.AzureCodeSign.IsAvailable) + { + Information("Skipping code signing since no option is available"); + return; + } + + var filesToSign = new List(); + + // Note: only code-sign components & wpf apps, skip test projects & uwp apps + var projectsToCodeSign = new List(); + projectsToCodeSign.AddRange(buildContext.Components.Items); + projectsToCodeSign.AddRange(buildContext.Wpf.Items); + + foreach (var projectToCodeSign in projectsToCodeSign) + { + SignProjectFiles(buildContext, projectToCodeSign); + } +}); \ No newline at end of file diff --git a/deployment/cake/generic-variables.cake b/deployment/cake/generic-variables.cake new file mode 100644 index 0000000..9366a15 --- /dev/null +++ b/deployment/cake/generic-variables.cake @@ -0,0 +1,826 @@ +#l "buildserver.cake" + +#tool "nuget:?package=GitVersion.CommandLine&version=5.12.0" +#tool "nuget:?package=NuGet.CommandLine&version=7.0.1" + +#addin "nuget:?package=LibGit2Sharp&version=0.31.0" + +//------------------------------------------------------------- + +public class GeneralContext : BuildContextWithItemsBase +{ + public GeneralContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + SkipComponentsThatAreNotDeployable = true; + EnableMsBuildBinaryLog = true; + EnableMsBuildFileLog = true; + EnableMsBuildXmlLog = true; + } + + public string Target { get; set; } + public string RootDirectory { get; set; } + public string OutputRootDirectory { get; set; } + + public bool IsCiBuild { get; set; } + public bool IsAlphaBuild { get; set; } + public bool IsBetaBuild { get; set; } + public bool IsOfficialBuild { get; set; } + public bool IsLocalBuild { get; set; } + public bool MaximizePerformance { get; set; } + public bool UseVisualStudioPrerelease { get; set; } + public bool VerifyDependencies { get; set; } + public bool SkipComponentsThatAreNotDeployable { get; set; } + + public bool EnableMsBuildBinaryLog { get; set; } + public bool EnableMsBuildFileLog { get; set; } + public bool EnableMsBuildXmlLog { get; set; } + + public VersionContext Version { get; set; } + public CopyrightContext Copyright { get; set; } + public NuGetContext NuGet { get; set; } + public SolutionContext Solution { get; set; } + public SourceLinkContext SourceLink { get; set; } + public CodeSignContext CodeSign { get; set; } + public AzureCodeSignContext AzureCodeSign { get; set; } + public RepositoryContext Repository { get; set; } + public SonarQubeContext SonarQube { get; set; } + + public List Includes { get; set; } + public List Excludes { get; set; } + + protected override void ValidateContext() + { + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Running target '{Target}'"); + CakeContext.Information($"Using output directory '{OutputRootDirectory}'"); + } +} + +//------------------------------------------------------------- + +public class VersionContext : BuildContextBase +{ + private GitVersion _gitVersionContext; + + public VersionContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public GitVersion GetGitVersionContext(GeneralContext generalContext) + { + if (_gitVersionContext is null) + { + var gitVersionSettings = new GitVersionSettings + { + UpdateAssemblyInfo = false, + Verbosity = GitVersionVerbosity.Verbose, + NoFetch = true + }; + + var mutexName = $"Global\\Cake_GitVersion_Clone_{generalContext.Solution.Name}"; + + CakeContext.Information("Trying to acquire mutex to determine version"); + + using (var mutex = new System.Threading.Mutex(false, mutexName, out var createdNew)) + { + if (!mutex.WaitOne(TimeSpan.FromMinutes(2))) + { + throw new Exception("Could not acquire mutex to determine version"); + } + + CakeContext.Information("Mutex acquired"); + + CakeContext.Information("[{0}] Preparing GitVersion", GetTime()); + + var gitDirectory = ".git"; + if (!CakeContext.DirectoryExists(gitDirectory)) + { + CakeContext.Information("No local .git directory found, treating as dynamic repository"); + + // Make a *BIG* assumption that the solution name == repository name + var repositoryName = generalContext.Solution.Name; + var dynamicRepositoryPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), repositoryName); + + // Note: for now we fully clear the cache each time until we found a solid way to pull the latest changes + var clearCache = ClearCache || true; + if (clearCache) + { + CakeContext.Warning("Cleaning the cloned temp directory, disable by setting 'GitVersion_ClearCache' to 'false'"); + + if (CakeContext.DirectoryExists(dynamicRepositoryPath)) + { + CakeContext.DeleteDirectory(dynamicRepositoryPath, new DeleteDirectorySettings + { + Force = true, + Recursive = true + }); + } + } + + // Validate first + if (string.IsNullOrWhiteSpace(generalContext.Repository.BranchName)) + { + throw new Exception("No local .git directory was found, but repository branch was not specified either. Make sure to specify the branch"); + } + + if (string.IsNullOrWhiteSpace(generalContext.Repository.Url)) + { + throw new Exception("No local .git directory was found, but repository url was not specified either. Make sure to specify the branch"); + } + + CakeContext.Information($"Fetching dynamic repository from url '{generalContext.Repository.Url}' => '{dynamicRepositoryPath}'"); + + // Note: starting with GitVersion 6.x, we need to handle dynamic repos ourselves, + // and we will be using Cake.Git and LibGit2Sharp directly to support cloning a specific commit id + + var existingRepository = false; + if (CakeContext.DirectoryExists(dynamicRepositoryPath)) + { + CakeContext.Information("Dynamic repository directory already exists"); + + if (CakeContext.GitIsValidRepository(dynamicRepositoryPath)) + { + CakeContext.Information("Dynamic repository already exists, reusing existing clone"); + existingRepository = true; + } + else + { + CakeContext.Information("Dynamic repository already exists but is not valid, recloning"); + + CakeContext.DeleteDirectory(dynamicRepositoryPath, new DeleteDirectorySettings + { + Force = true, + Recursive = true + }); + } + } + + if (existingRepository) + { + // TODO: How to pull? + } + else + { + var gitCloneSettings = new GitCloneSettings + { + BranchName = generalContext.Repository.BranchName, + Checkout = true, + IsBare = false, + RecurseSubmodules = false, + }; + + if (!string.IsNullOrWhiteSpace(generalContext.Repository.Username) && + !string.IsNullOrWhiteSpace(generalContext.Repository.Password)) + { + CakeContext.Information("Cloning with authentication"); + + CakeContext.GitClone(generalContext.Repository.Url, + dynamicRepositoryPath, + generalContext.Repository.Username, + generalContext.Repository.Password, + gitCloneSettings); + } + else + { + CakeContext.Information("Cloning without authentication"); + + CakeContext.GitClone(generalContext.Repository.Url, + dynamicRepositoryPath, + gitCloneSettings); + } + } + + //LibGit2Sharp.Repository.Clone(generalContext.Repository.Url, dynamicRepositoryPath, cloneOptions); + + if (!CakeContext.GitIsValidRepository(dynamicRepositoryPath)) + { + throw new Exception($"Cloned repository at '{dynamicRepositoryPath}' is not a valid repository"); + } + + CakeContext.Information("Ensuring correct commit ID"); + + // According to docs, to not get into a detached head state, we need to: + // + // git checkout -B 'branch' 'commit id' + // + // This seems impossible via Cake.Git (and LibGit2Sharp directly), so we will + // just invoke git.exe directly here + // + //CakeContext.GitCheckout(dynamicRepositoryPath, generalContext.Repository.CommitId); + + var gitCommit = CakeContext.GitLogTip(dynamicRepositoryPath); + if (!string.Equals(gitCommit.Sha, generalContext.Repository.CommitId, StringComparison.OrdinalIgnoreCase)) + { + var gitExe = CakeContext.Tools.Resolve("git.exe").FullPath; + + using (var process = CakeContext.StartAndReturnProcess(gitExe, + new ProcessSettings + { + WorkingDirectory = dynamicRepositoryPath, + Arguments = $"checkout -B {generalContext.Repository.BranchName} {generalContext.Repository.CommitId}", + })) + { + process.WaitForExit(); + + // This should output 0 as valid arguments supplied + CakeContext.Information("Exit code: {0}", process.GetExitCode()); + } + } + + CakeContext.Information("Preparing GitVersion settings"); + + gitVersionSettings.WorkingDirectory = dynamicRepositoryPath; + } + + CakeContext.Information("[{0}] Running GitVersion", GetTime()); + + _gitVersionContext = CakeContext.GitVersion(gitVersionSettings); + + CakeContext.Information("[{0}] Finished GitVersion", GetTime()); + } + } + + return _gitVersionContext; + } + + public bool ClearCache { get; set; } + + private string _major; + + public string Major + { + get + { + if (string.IsNullOrWhiteSpace(_major)) + { + _major = GetVersion(MajorMinorPatch, 1); + } + + return _major; + } + } + + private string _majorMinor; + + public string MajorMinor + { + get + { + if (string.IsNullOrWhiteSpace(_majorMinor)) + { + _majorMinor = GetVersion(MajorMinorPatch, 2); + } + + return _majorMinor; + } + } + + public string MajorMinorPatch { get; set; } + public string FullSemVer { get; set; } + public string NuGet { get; set; } + public string CommitsSinceVersionSource { get; set; } + + private string GetVersion(string version, int breakCount) + { + var finalVersion = string.Empty; + + for (int i = 0; i < version.Length; i++) + { + var character = version[i]; + if (!char.IsDigit(character)) + { + breakCount--; + if (breakCount <= 0) + { + break; + } + } + + finalVersion += character.ToString(); + } + + return finalVersion; + } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + + } +} + +//------------------------------------------------------------- + +public class CopyrightContext : BuildContextBase +{ + public CopyrightContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string Company { get; set; } + public string StartYear { get; set; } + + protected override void ValidateContext() + { + if (string.IsNullOrWhiteSpace(Company)) + { + throw new Exception($"Company must be defined"); + } + } + + protected override void LogStateInfoForContext() + { + + } +} + +//------------------------------------------------------------- + +public class NuGetContext : BuildContextBase +{ + public NuGetContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string PackageSources { get; set; } + public string Executable { get; set; } + public string LocalPackagesDirectory { get; set; } + + public bool RestoreUsingNuGet { get; set; } + public bool RestoreUsingDotNetRestore { get; set; } + public bool NoDependencies { get; set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"NuGet executable path '{Executable}'"); + CakeContext.Information($"NuGet executable version '{FileVersionInfo.GetVersionInfo(Executable).FileVersion}'"); + CakeContext.Information($"Restore using NuGet: '{RestoreUsingNuGet}'"); + CakeContext.Information($"Restore using dotnet restore: '{RestoreUsingDotNetRestore}'"); + } +} + +//------------------------------------------------------------- + +public class SolutionContext : BuildContextBase +{ + public SolutionContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string Name { get; set; } + public string AssemblyInfoFileName { get; set; } + public string FileName { get; set; } + public string Directory + { + get + { + var directory = System.IO.Directory.GetParent(FileName).FullName; + var separator = System.IO.Path.DirectorySeparatorChar.ToString(); + + if (!directory.EndsWith(separator)) + { + directory += separator; + } + + return directory; + } + } + + public bool BuildSolution { get; set; } + public string PublishType { get; set; } + public string ConfigurationName { get; set; } + + protected override void ValidateContext() + { + if (string.IsNullOrWhiteSpace(Name)) + { + throw new Exception($"SolutionName must be defined"); + } + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Solution filename: '{FileName}'"); + } +} + +//------------------------------------------------------------- + +public class SourceLinkContext : BuildContextBase +{ + public SourceLinkContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public bool IsDisabled { get; set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + + } +} + +//------------------------------------------------------------- + +public class CodeSignContext : BuildContextBase +{ + public CodeSignContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string WildCard { get; set; } + public string CertificateSubjectName { get; set; } + public string TimeStampUri { get; set; } + public string HashAlgorithm { get; set; } + + public bool IsAvailable + { + get + { + if (string.IsNullOrWhiteSpace(CertificateSubjectName)) + { + return false; + } + + return true; + } + } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + if (!IsAvailable) + { + CakeContext.Information($"Code signing is not configured"); + return; + } + + CakeContext.Information($"Code signing subject name: '{CertificateSubjectName}'"); + CakeContext.Information($"Code signing timestamp uri: '{TimeStampUri}'"); + CakeContext.Information($"Code signing hash algorithm: '{HashAlgorithm}'"); + } +} + +//------------------------------------------------------------- + +public class AzureCodeSignContext : BuildContextBase +{ + public AzureCodeSignContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string VaultName { get; set; } + public string VaultUrl { get { return $"https://{VaultName}.vault.azure.net"; } } + public string CertificateName { get; set; } + public string TimeStampUri { get; set; } + public string HashAlgorithm { get; set; } + public string TenantId { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + + public bool IsAvailable + { + get + { + if (string.IsNullOrWhiteSpace(VaultName) || + string.IsNullOrWhiteSpace(CertificateName) || + string.IsNullOrWhiteSpace(TenantId) || + string.IsNullOrWhiteSpace(ClientId) || + string.IsNullOrWhiteSpace(ClientSecret)) + { + return false; + } + + return true; + } + } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + if (!IsAvailable) + { + CakeContext.Information($"Azure Code signing is not configured"); + return; + } + + CakeContext.Information($"Azure Code vault name: '{VaultName}'"); + CakeContext.Information($"Azure Code vault URL: '{VaultUrl}'"); + CakeContext.Information($"Azure Code signing certificate name: '{CertificateName}'"); + CakeContext.Information($"Azure Code signing timestamp uri: '{TimeStampUri}'"); + CakeContext.Information($"Azure Code signing hash algorithm: '{HashAlgorithm}'"); + } +} + +//------------------------------------------------------------- + +public class RepositoryContext : BuildContextBase +{ + public RepositoryContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string Url { get; set; } + public string BranchName { get; set; } + public string CommitId { get; set; } + public string Username { get; set; } + public string Password { get; set; } + + protected override void ValidateContext() + { + if (string.IsNullOrWhiteSpace(Url)) + { + throw new Exception($"RepositoryUrl must be defined"); + } + } + + protected override void LogStateInfoForContext() + { + + } +} + +//------------------------------------------------------------- + +public class SonarQubeContext : BuildContextBase +{ + public SonarQubeContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public bool IsDisabled { get; set; } + public bool SupportBranches { get; set; } + public string Url { get; set; } + public string Organization { get; set; } + public string Username { get; set; } + public string Token { get; set; } + public string Project { get; set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + + } +} + +//------------------------------------------------------------- + +private GeneralContext InitializeGeneralContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new GeneralContext(parentBuildContext) + { + Target = buildContext.BuildServer.GetVariable("Target", "Default", showValue: true), + }; + + data.Version = new VersionContext(data) + { + ClearCache = buildContext.BuildServer.GetVariableAsBool("GitVersion_ClearCache", false, showValue: true), + MajorMinorPatch = buildContext.BuildServer.GetVariable("GitVersion_MajorMinorPatch", "unknown", showValue: true), + FullSemVer = buildContext.BuildServer.GetVariable("GitVersion_FullSemVer", "unknown", showValue: true), + NuGet = buildContext.BuildServer.GetVariable("GitVersion_NuGetVersion", "unknown", showValue: true), + CommitsSinceVersionSource = buildContext.BuildServer.GetVariable("GitVersion_CommitsSinceVersionSource", "unknown", showValue: true) + }; + + data.Copyright = new CopyrightContext(data) + { + Company = buildContext.BuildServer.GetVariable("Company", showValue: true), + StartYear = buildContext.BuildServer.GetVariable("StartYear", showValue: true) + }; + + data.NuGet = new NuGetContext(data) + { + PackageSources = buildContext.BuildServer.GetVariable("NuGetPackageSources", showValue: true), + // Executable = "./tools/nuget.exe", + Executable = buildContext.CakeContext.Tools.Resolve("nuget.exe").FullPath, + LocalPackagesDirectory = "c:\\source\\_packages", + RestoreUsingNuGet = buildContext.BuildServer.GetVariableAsBool("NuGet_RestoreUsingNuGet", false, showValue: true), + RestoreUsingDotNetRestore = buildContext.BuildServer.GetVariableAsBool("NuGet_RestoreUsingDotNetRestore", true, showValue: true), + NoDependencies = buildContext.BuildServer.GetVariableAsBool("NuGet_NoDependencies", true, showValue: true) + }; + + var solutionName = buildContext.BuildServer.GetVariable("SolutionName", showValue: true); + + var solutionExtension = "slnx"; + + var solutionFiles = GetFiles(string.Format("./src/{0}.{1}", solutionName, solutionExtension)); + if (solutionFiles.Count == 0) + { + solutionExtension = "sln"; + } + + data.Solution = new SolutionContext(data) + { + Name = solutionName, + AssemblyInfoFileName = "./src/SolutionAssemblyInfo.cs", + FileName = string.Format("./src/{0}", string.Format("{0}.{1}", solutionName, solutionExtension)), + PublishType = buildContext.BuildServer.GetVariable("PublishType", "Unknown", showValue: true), + ConfigurationName = buildContext.BuildServer.GetVariable("ConfigurationName", "Release", showValue: true), + BuildSolution = buildContext.BuildServer.GetVariableAsBool("BuildSolution", false, showValue: true) + }; + + data.IsCiBuild = buildContext.BuildServer.GetVariableAsBool("IsCiBuild", false, showValue: true); + data.IsAlphaBuild = buildContext.BuildServer.GetVariableAsBool("IsAlphaBuild", false, showValue: true); + data.IsBetaBuild = buildContext.BuildServer.GetVariableAsBool("IsBetaBuild", false, showValue: true); + data.IsOfficialBuild = buildContext.BuildServer.GetVariableAsBool("IsOfficialBuild", false, showValue: true); + data.IsLocalBuild = data.Target.ToLower().Contains("local"); + data.MaximizePerformance = buildContext.BuildServer.GetVariableAsBool("MaximizePerformance", true, showValue: true); + data.UseVisualStudioPrerelease = buildContext.BuildServer.GetVariableAsBool("UseVisualStudioPrerelease", false, showValue: true); + data.VerifyDependencies = !buildContext.BuildServer.GetVariableAsBool("DependencyCheckDisabled", false, showValue: true); + data.SkipComponentsThatAreNotDeployable = buildContext.BuildServer.GetVariableAsBool("SkipComponentsThatAreNotDeployable", true, showValue: true); + + data.EnableMsBuildBinaryLog = buildContext.BuildServer.GetVariableAsBool("EnableMsBuildBinaryLog", true, showValue: true); + data.EnableMsBuildFileLog = buildContext.BuildServer.GetVariableAsBool("EnableMsBuildFileLog", true, showValue: true); + data.EnableMsBuildXmlLog = buildContext.BuildServer.GetVariableAsBool("EnableMsBuildXmlLog", true, showValue: true); + + // If local, we want full pdb, so do a debug instead + if (data.IsLocalBuild) + { + parentBuildContext.CakeContext.Warning("Enforcing configuration 'Debug' because this is seems to be a local build, do not publish this package!"); + data.Solution.ConfigurationName = "Debug"; + } + + // Important: do *after* initializing the configuration name + data.RootDirectory = System.IO.Path.GetFullPath("."); + data.OutputRootDirectory = System.IO.Path.GetFullPath(buildContext.BuildServer.GetVariable("OutputRootDirectory", string.Format("./output/{0}", data.Solution.ConfigurationName), showValue: true)); + + data.SourceLink = new SourceLinkContext(data) + { + IsDisabled = buildContext.BuildServer.GetVariableAsBool("SourceLinkDisabled", false, showValue: true) + }; + + data.CodeSign = new CodeSignContext(data) + { + WildCard = buildContext.BuildServer.GetVariable("CodeSignWildcard", showValue: true), + CertificateSubjectName = buildContext.BuildServer.GetVariable("CodeSignCertificateSubjectName", showValue: true), + TimeStampUri = buildContext.BuildServer.GetVariable("CodeSignTimeStampUri", "http://timestamp.digicert.com", showValue: true), + HashAlgorithm = buildContext.BuildServer.GetVariable("CodeSignHashAlgorithm", "SHA256", showValue: true) + }; + + data.AzureCodeSign = new AzureCodeSignContext(data) + { + VaultName = buildContext.BuildServer.GetVariable("AzureCodeSignVaultName", showValue: true), + CertificateName = buildContext.BuildServer.GetVariable("AzureCodeSignCertificateName", showValue: true), + TimeStampUri = buildContext.BuildServer.GetVariable("AzureCodeSignTimeStampUri", "http://timestamp.digicert.com", showValue: true), + HashAlgorithm = buildContext.BuildServer.GetVariable("AzureCodeSignHashAlgorithm", "SHA256", showValue: true), + TenantId = buildContext.BuildServer.GetVariable("AzureCodeSignTenantId", showValue: false), + ClientId = buildContext.BuildServer.GetVariable("AzureCodeSignClientId", showValue: false), + ClientSecret = buildContext.BuildServer.GetVariable("AzureCodeSignClientSecret", showValue: false), + }; + + data.Repository = new RepositoryContext(data) + { + Url = buildContext.BuildServer.GetVariable("RepositoryUrl", showValue: true), + BranchName = buildContext.BuildServer.GetVariable("RepositoryBranchName", showValue: true), + CommitId = buildContext.BuildServer.GetVariable("RepositoryCommitId", showValue: true), + Username = buildContext.BuildServer.GetVariable("RepositoryUsername", showValue: false), + Password = buildContext.BuildServer.GetVariable("RepositoryPassword", showValue: false) + }; + + data.SonarQube = new SonarQubeContext(data) + { + IsDisabled = buildContext.BuildServer.GetVariableAsBool("SonarDisabled", false, showValue: true), + SupportBranches = buildContext.BuildServer.GetVariableAsBool("SonarSupportBranches", true, showValue: true), + Url = buildContext.BuildServer.GetVariable("SonarUrl", showValue: true), + Organization = buildContext.BuildServer.GetVariable("SonarOrganization", showValue: true), + Username = buildContext.BuildServer.GetVariable("SonarUsername", showValue: false), + Token = buildContext.BuildServer.GetVariable("SonarToken", showValue: false), + Project = buildContext.BuildServer.GetVariable("SonarProject", data.Solution.Name, showValue: true) + }; + + data.Includes = SplitCommaSeparatedList(buildContext.BuildServer.GetVariable("Include", string.Empty, showValue: true)); + data.Excludes = SplitCommaSeparatedList(buildContext.BuildServer.GetVariable("Exclude", string.Empty, showValue: true)); + + // Specific overrides, done when we have *all* info + parentBuildContext.CakeContext.Information("Ensuring correct runtime data based on version"); + + var versionContext = data.Version; + if (string.IsNullOrWhiteSpace(versionContext.NuGet) || versionContext.NuGet == "unknown") + { + parentBuildContext.CakeContext.Information("No version info specified, falling back to GitVersion"); + + var gitVersion = versionContext.GetGitVersionContext(data); + + versionContext.MajorMinorPatch = gitVersion.MajorMinorPatch; + versionContext.FullSemVer = gitVersion.FullSemVer; + versionContext.NuGet = gitVersion.NuGetVersionV2; + versionContext.CommitsSinceVersionSource = (gitVersion.CommitsSinceVersionSource ?? 0).ToString(); + } + + parentBuildContext.CakeContext.Information("Defined version: '{0}', commits since version source: '{1}'", versionContext.FullSemVer, versionContext.CommitsSinceVersionSource); + + if (string.IsNullOrWhiteSpace(data.Repository.CommitId)) + { + parentBuildContext.CakeContext.Information("No commit id specified, falling back to GitVersion"); + + var gitVersion = versionContext.GetGitVersionContext(data); + + data.Repository.BranchName = gitVersion.BranchName; + data.Repository.CommitId = gitVersion.Sha; + } + + if (string.IsNullOrWhiteSpace(data.Repository.BranchName)) + { + parentBuildContext.CakeContext.Information("No branch name specified, falling back to GitVersion"); + + var gitVersion = versionContext.GetGitVersionContext(data); + + data.Repository.BranchName = gitVersion.BranchName; + } + + var versionToCheck = versionContext.FullSemVer; + if (versionToCheck.Contains("alpha")) + { + data.IsAlphaBuild = true; + } + else if (versionToCheck.Contains("beta")) + { + data.IsBetaBuild = true; + } + else + { + data.IsOfficialBuild = true; + } + + return data; +} + +//------------------------------------------------------------- + +private static string DetermineChannel(GeneralContext context) +{ + var version = context.Version.FullSemVer; + + var channel = "stable"; + + if (context.IsAlphaBuild) + { + channel = "alpha"; + } + else if (context.IsBetaBuild) + { + channel = "beta"; + } + + return channel; +} + +//------------------------------------------------------------- + +private static string DeterminePublishType(GeneralContext context) +{ + var publishType = "Unknown"; + + if (context.IsOfficialBuild) + { + publishType = "Official"; + } + else if (context.IsBetaBuild) + { + publishType = "Beta"; + } + else if (context.IsAlphaBuild) + { + publishType = "Alpha"; + } + + return publishType; +} diff --git a/deployment/cake/github-pages-tasks.cake b/deployment/cake/github-pages-tasks.cake new file mode 100644 index 0000000..95364c4 --- /dev/null +++ b/deployment/cake/github-pages-tasks.cake @@ -0,0 +1,223 @@ +#l "github-pages-variables.cake" + +#addin "nuget:?package=Cake.Git&version=5.0.1" + +//------------------------------------------------------------- + +public class GitHubPagesProcessor : ProcessorBase +{ + public GitHubPagesProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + public override bool HasItems() + { + return BuildContext.GitHubPages.Items.Count > 0; + } + + private string GetGitHubPagesRepositoryUrl(string projectName) + { + // Allow per project overrides via "GitHubPagesRepositoryUrlFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "GitHubPagesRepositoryUrlFor", BuildContext.GitHubPages.RepositoryUrl); + } + + private string GetGitHubPagesBranchName(string projectName) + { + // Allow per project overrides via "GitHubPagesBranchNameFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "GitHubPagesBranchNameFor", BuildContext.GitHubPages.BranchName); + } + + private string GetGitHubPagesEmail(string projectName) + { + // Allow per project overrides via "GitHubPagesEmailFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "GitHubPagesEmailFor", BuildContext.GitHubPages.Email); + } + + private string GetGitHubPagesUserName(string projectName) + { + // Allow per project overrides via "GitHubPagesUserNameFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "GitHubPagesUserNameFor", BuildContext.GitHubPages.UserName); + } + + private string GetGitHubPagesApiToken(string projectName) + { + // Allow per project overrides via "GitHubPagesApiTokenFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "GitHubPagesApiTokenFor", BuildContext.GitHubPages.ApiToken); + } + + public override async Task PrepareAsync() + { + if (!HasItems()) + { + return; + } + + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + foreach (var gitHubPage in BuildContext.GitHubPages.Items.ToList()) + { + if (!ShouldProcessProject(BuildContext, gitHubPage)) + { + BuildContext.GitHubPages.Items.Remove(gitHubPage); + } + } + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var gitHubPage in BuildContext.GitHubPages.Items) + { + CakeContext.Information("Updating version for GitHub page '{0}'", gitHubPage); + + var projectFileName = GetProjectFileName(BuildContext, gitHubPage); + + CakeContext.TransformConfig(projectFileName, new TransformationCollection + { + { "Project/PropertyGroup/PackageVersion", BuildContext.General.Version.NuGet } + }); + } + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var gitHubPage in BuildContext.GitHubPages.Items) + { + BuildContext.CakeContext.LogSeparator("Building GitHub page '{0}'", gitHubPage); + + var projectFileName = GetProjectFileName(BuildContext, gitHubPage); + + var msBuildSettings = new MSBuildSettings { + Verbosity = Verbosity.Quiet, // Verbosity.Diagnostic + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, gitHubPage, "build"); + + // Always disable SourceLink + msBuildSettings.WithProperty("EnableSourceLink", "false"); + + RunMsBuild(BuildContext, gitHubPage, projectFileName, msBuildSettings, "build"); + } + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var gitHubPage in BuildContext.GitHubPages.Items) + { + if (!ShouldPackageProject(BuildContext, gitHubPage)) + { + CakeContext.Information("GitHub page '{0}' should not be packaged", gitHubPage); + continue; + } + + BuildContext.CakeContext.LogSeparator("Packaging GitHub pages '{0}'", gitHubPage); + + var projectFileName = GetProjectFileName(BuildContext, gitHubPage); + var outputDirectory = GetProjectOutputDirectory(BuildContext, gitHubPage); + + CakeContext.Information("Output directory: '{0}'", outputDirectory); + + CakeContext.Information("1) Using 'dotnet publish' to package '{0}'", gitHubPage); + + var msBuildSettings = new DotNetMSBuildSettings(); + + ConfigureMsBuildForDotNet(BuildContext, msBuildSettings, gitHubPage, "pack"); + + msBuildSettings.WithProperty("ConfigurationName", BuildContext.General.Solution.ConfigurationName); + msBuildSettings.WithProperty("PackageVersion", BuildContext.General.Version.NuGet); + + var publishSettings = new DotNetPublishSettings + { + MSBuildSettings = msBuildSettings, + OutputDirectory = outputDirectory, + Configuration = BuildContext.General.Solution.ConfigurationName + }; + + CakeContext.DotNetPublish(projectFileName, publishSettings); + } + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var gitHubPage in BuildContext.GitHubPages.Items) + { + if (!ShouldDeployProject(BuildContext, gitHubPage)) + { + CakeContext.Information("GitHub page '{0}' should not be deployed", gitHubPage); + continue; + } + + BuildContext.CakeContext.LogSeparator("Deploying GitHub page '{0}'", gitHubPage); + + CakeContext.Warning("Only Blazor apps are supported as GitHub pages"); + + var temporaryDirectory = GetTempDirectory(BuildContext, "gh-pages", gitHubPage); + + CakeContext.CleanDirectory(temporaryDirectory); + + var repositoryUrl = GetGitHubPagesRepositoryUrl(gitHubPage); + var branchName = GetGitHubPagesBranchName(gitHubPage); + var email = GetGitHubPagesEmail(gitHubPage); + var userName = GetGitHubPagesUserName(gitHubPage); + var apiToken = GetGitHubPagesApiToken(gitHubPage); + + CakeContext.Information("1) Cloning repository '{0}' using branch name '{1}'", repositoryUrl, branchName); + + CakeContext.GitClone(repositoryUrl, temporaryDirectory, userName, apiToken, new GitCloneSettings + { + BranchName = branchName + }); + + CakeContext.Information("2) Updating the GitHub pages branch with latest source"); + + // Special directory we need to distribute (e.g. output\Release\Blazorc.PatternFly.Example\Blazorc.PatternFly.Example\dist) + var sourceDirectory = string.Format("{0}/{1}/wwwroot", BuildContext.General.OutputRootDirectory, gitHubPage); + var sourcePattern = string.Format("{0}/**/*", sourceDirectory); + + CakeContext.Debug("Copying all files from '{0}' => '{1}'", sourcePattern, temporaryDirectory); + + CakeContext.CopyFiles(sourcePattern, temporaryDirectory, true); + + CakeContext.Information("3) Committing latest GitHub pages"); + + CakeContext.GitAddAll(temporaryDirectory); + CakeContext.GitCommit(temporaryDirectory, "Build server", email, string.Format("Auto-update GitHub pages: '{0}'", BuildContext.General.Version.NuGet)); + + CakeContext.Information("4) Pushing code back to repository '{0}'", repositoryUrl); + + CakeContext.GitPush(temporaryDirectory, userName, apiToken); + + await BuildContext.Notifications.NotifyAsync(gitHubPage, string.Format("Deployed to GitHub pages"), TargetType.GitHubPages); + } + } + + public override async Task FinalizeAsync() + { + + } +} diff --git a/deployment/cake/github-pages-variables.cake b/deployment/cake/github-pages-variables.cake new file mode 100644 index 0000000..44652aa --- /dev/null +++ b/deployment/cake/github-pages-variables.cake @@ -0,0 +1,89 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class GitHubPagesContext : BuildContextWithItemsBase +{ + public GitHubPagesContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string RepositoryUrl { get; set; } + public string BranchName { get; set; } + public string Email { get; set; } + public string UserName { get; set; } + public string ApiToken { get; set; } + + protected override void ValidateContext() + { + if (Items.Count == 0) + { + return; + } + + if (string.IsNullOrWhiteSpace(RepositoryUrl)) + { + throw new Exception("GitHubPagesRepositoryUrl must be defined"); + } + + if (string.IsNullOrWhiteSpace(BranchName)) + { + throw new Exception("GitHubPagesBranchName must be defined"); + } + + if (string.IsNullOrWhiteSpace(Email)) + { + throw new Exception("GitHubPagesEmail must be defined"); + } + + if (string.IsNullOrWhiteSpace(UserName)) + { + throw new Exception("GitHubPagesUserName must be defined"); + } + + if (string.IsNullOrWhiteSpace(ApiToken)) + { + throw new Exception("GitHubPagesApiToken must be defined"); + } + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' GitHub pages projects"); + } +} + +//------------------------------------------------------------- + +private GitHubPagesContext InitializeGitHubPagesContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new GitHubPagesContext(parentBuildContext) + { + Items = GitHubPages ?? new List(), + RepositoryUrl = buildContext.BuildServer.GetVariable("GitHubPagesRepositoryUrl", ((BuildContext)parentBuildContext).General.Repository.Url, showValue: true), + BranchName = buildContext.BuildServer.GetVariable("GitHubPagesRepositoryUrl", "gh-pages", showValue: true), + Email = buildContext.BuildServer.GetVariable("GitHubPagesEmail", showValue: true), + UserName = buildContext.BuildServer.GetVariable("GitHubPagesUserName", showValue: true), + ApiToken = buildContext.BuildServer.GetVariable("GitHubPagesApiToken", showValue: false), + }; + + return data; +} + +//------------------------------------------------------------- + +List _gitHubPages; + +public List GitHubPages +{ + get + { + if (_gitHubPages is null) + { + _gitHubPages = new List(); + } + + return _gitHubPages; + } +} \ No newline at end of file diff --git a/deployment/cake/installers-innosetup.cake b/deployment/cake/installers-innosetup.cake new file mode 100644 index 0000000..cfad26a --- /dev/null +++ b/deployment/cake/installers-innosetup.cake @@ -0,0 +1,305 @@ +//------------------------------------------------------------- + +public class InnoSetupInstaller : IInstaller +{ + public InnoSetupInstaller(BuildContext buildContext) + { + BuildContext = buildContext; + + IsEnabled = BuildContext.BuildServer.GetVariableAsBool("InnoSetupEnabled", true, showValue: true); + + if (IsEnabled) + { + // In the future, check if InnoSetup is installed. Log error if not + IsAvailable = IsEnabled; + } + } + + public BuildContext BuildContext { get; private set; } + + public bool IsEnabled { get; private set; } + + public bool IsAvailable { get; private set; } + + //------------------------------------------------------------- + + public async Task PackageAsync(string projectName, string channel) + { + if (!IsAvailable) + { + BuildContext.CakeContext.Information("Inno Setup is not enabled or available, skipping integration"); + return; + } + + var innoSetupTemplateDirectory = System.IO.Path.Combine(".", "deployment", "innosetup", projectName); + if (!BuildContext.CakeContext.DirectoryExists(innoSetupTemplateDirectory)) + { + BuildContext.CakeContext.Information($"Skip packaging of app '{projectName}' using Inno Setup since no Inno Setup template is present"); + return; + } + + BuildContext.CakeContext.LogSeparator($"Packaging app '{projectName}' using Inno Setup"); + + var deploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + var installersOnDeploymentsShare = System.IO.Path.Combine(deploymentShare, "installer"); + BuildContext.CakeContext.CreateDirectory(installersOnDeploymentsShare); + + var setupSuffix = BuildContext.Installer.GetDeploymentChannelSuffix(); + + var innoSetupOutputRoot = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, "innosetup", projectName); + var innoSetupReleasesRoot = System.IO.Path.Combine(innoSetupOutputRoot, "releases"); + var innoSetupOutputIntermediate = System.IO.Path.Combine(innoSetupOutputRoot, "intermediate"); + + BuildContext.CakeContext.CreateDirectory(innoSetupReleasesRoot); + BuildContext.CakeContext.CreateDirectory(innoSetupOutputIntermediate); + + // Copy all files to the intermediate directory so Inno Setup knows what to do + var appSourceDirectory = string.Format("{0}/{1}/**/*", BuildContext.General.OutputRootDirectory, projectName); + var appTargetDirectory = innoSetupOutputIntermediate; + + BuildContext.CakeContext.Information("Copying files from '{0}' => '{1}'", appSourceDirectory, appTargetDirectory); + + BuildContext.CakeContext.CopyFiles(appSourceDirectory, appTargetDirectory, true); + + // Set up InnoSetup template + BuildContext.CakeContext.CopyDirectory(innoSetupTemplateDirectory, innoSetupOutputIntermediate); + + var innoSetupScriptFileName = System.IO.Path.Combine(innoSetupOutputIntermediate, "setup.iss"); + var fileContents = System.IO.File.ReadAllText(innoSetupScriptFileName); + fileContents = fileContents.Replace("[CHANNEL_SUFFIX]", setupSuffix); + fileContents = fileContents.Replace("[CHANNEL]", BuildContext.Installer.GetDeploymentChannelSuffix(" (", ")")); + fileContents = fileContents.Replace("[VERSION]", BuildContext.General.Version.MajorMinorPatch); + fileContents = fileContents.Replace("[VERSION_DISPLAY]", BuildContext.General.Version.FullSemVer); + fileContents = fileContents.Replace("[WIZARDIMAGEFILE]", string.Format("logo_large{0}", setupSuffix)); + + var signToolIndex = GetRandomSignToolIndex(); + + try + { + var codeSignContext = BuildContext.General.CodeSign; + var azureCodeSignContext = BuildContext.General.AzureCodeSign; + + var signTool = string.Empty; + + var signToolFileName = GetSignToolFileName(BuildContext); + if (!string.IsNullOrWhiteSpace(signToolFileName)) + { + var signToolName = DateTime.Now.ToString("yyyyMMddHHmmss"); + var signToolCommandLine = GetSignToolCommandLine(BuildContext); + + BuildContext.CakeContext.Information("Adding random sign tool config for Inno Setup"); + + using (var registryKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(GetRegistryKey(), true)) + { + var registryValueName = GetSignToolIndexName(signToolIndex); + + // Important: must end with "$f" + var signToolRegistryValue = $"{signToolName}=\"{signToolFileName}\" {signToolCommandLine} \"$f\""; + + registryKey.SetValue(registryValueName, signToolRegistryValue); + } + + signTool = string.Format("SignTool={0}", signToolName); + } + + fileContents = fileContents.Replace("[SIGNTOOL]", signTool); + System.IO.File.WriteAllText(innoSetupScriptFileName, fileContents); + + BuildContext.CakeContext.Information("Generating Inno Setup packages, this can take a while, especially when signing is enabled..."); + + BuildContext.CakeContext.InnoSetup(innoSetupScriptFileName, new InnoSetupSettings + { + OutputDirectory = innoSetupReleasesRoot + }); + + if (BuildContext.Wpf.UpdateDeploymentsShare) + { + BuildContext.CakeContext.Information("Copying Inno Setup files to deployments share at '{0}'", installersOnDeploymentsShare); + + // Copy the following files: + // - Setup.exe => [projectName]-[version].exe + // - Setup.exe => [projectName]-[channel].exe + + var installerSourceFile = System.IO.Path.Combine(innoSetupReleasesRoot, $"{projectName}_{BuildContext.General.Version.FullSemVer}.exe"); + BuildContext.CakeContext.CopyFile(installerSourceFile, System.IO.Path.Combine(installersOnDeploymentsShare, $"{projectName}_{BuildContext.General.Version.FullSemVer}.exe")); + BuildContext.CakeContext.CopyFile(installerSourceFile, System.IO.Path.Combine(installersOnDeploymentsShare, $"{projectName}{setupSuffix}.exe")); + } + } + finally + { + BuildContext.CakeContext.Information("Removing random sign tool config for Inno Setup"); + + RemoveSignToolFromRegistry(signToolIndex); + } + } + + //------------------------------------------------------------- + + public async Task GenerateDeploymentTargetAsync(string projectName) + { + var deploymentTarget = new DeploymentTarget + { + Name = "Inno Setup" + }; + + var channels = new [] + { + "alpha", + "beta", + "stable" + }; + + var deploymentGroupNames = new List(); + var projectDeploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + // Just a single group + deploymentGroupNames.Add("all"); + + foreach (var deploymentGroupName in deploymentGroupNames) + { + BuildContext.CakeContext.Information($"Searching for releases for deployment group '{deploymentGroupName}'"); + + var deploymentGroup = new DeploymentGroup + { + Name = deploymentGroupName + }; + + foreach (var channel in channels) + { + BuildContext.CakeContext.Information($"Searching for releases for deployment channel '{deploymentGroupName}/{channel}'"); + + var deploymentChannel = new DeploymentChannel + { + Name = channel + }; + + var targetDirectory = GetDeploymentsShareRootDirectory(projectName, channel); + + BuildContext.CakeContext.Information($"Searching for release files in '{targetDirectory}'"); + + var filter = $"{projectName}_*{channel}*.exe"; + if (channel == "stable") + { + filter = $"{projectName}_*.exe"; + } + + var installationFiles = System.IO.Directory.GetFiles(targetDirectory, filter); + + foreach (var installationFile in installationFiles) + { + var releaseFileInfo = new System.IO.FileInfo(installationFile); + var relativeFileName = new DirectoryPath(projectDeploymentShare).GetRelativePath(new FilePath(releaseFileInfo.FullName)).FullPath.Replace("\\", "/"); + + var releaseVersion = releaseFileInfo.Name + .Replace($"{projectName}", string.Empty) + .Replace($".exe", string.Empty) + .Trim('_'); + + // Either empty or matching a release channel should be ignored + if (string.IsNullOrWhiteSpace(releaseVersion) || + channels.Any(x => x == releaseVersion)) + { + BuildContext.CakeContext.Information($"Ignoring '{installationFile}'"); + continue; + } + + // Special case for stable releases + if (channel == "stable") + { + if (releaseVersion.Contains("-alpha") || + releaseVersion.Contains("-beta")) + { + BuildContext.CakeContext.Information($"Ignoring '{installationFile}'"); + continue; + } + } + + BuildContext.CakeContext.Information($"Applying release based on '{installationFile}'"); + + var release = new DeploymentRelease + { + Name = releaseVersion, + Timestamp = releaseFileInfo.CreationTimeUtc + }; + + // Full release + release.Full = new DeploymentReleasePart + { + RelativeFileName = relativeFileName, + Size = (ulong)releaseFileInfo.Length + }; + + deploymentChannel.Releases.Add(release); + } + + deploymentGroup.Channels.Add(deploymentChannel); + } + + deploymentTarget.Groups.Add(deploymentGroup); + } + + return deploymentTarget; + } + + //------------------------------------------------------------- + + private string GetDeploymentsShareRootDirectory(string projectName, string channel) + { + var deploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + var installersOnDeploymentsShare = System.IO.Path.Combine(deploymentShare, "installer"); + BuildContext.CakeContext.CreateDirectory(installersOnDeploymentsShare); + + return installersOnDeploymentsShare; + } + + //------------------------------------------------------------- + + private string GetRegistryKey() + { + return "Software\\Jordan Russell\\Inno Setup\\SignTools"; + } + + //------------------------------------------------------------- + + private int GetRandomSignToolIndex() + { + using (var registryKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(GetRegistryKey())) + { + for (int i = 0; i < 100; i++) + { + var valueName = GetSignToolIndexName(i); + + if (registryKey.GetValue(valueName) is null) + { + // Immediately lock it + registryKey.SetValue(valueName, "reserved"); + + return i; + } + } + } + + throw new Exception("Could not find any empty slots for the sign tool, please clean up the sign tool registry for Inno Setup"); + } + + //------------------------------------------------------------- + + private string GetSignToolIndexName(int index) + { + return $"SignTool{index}"; + } + + //------------------------------------------------------------- + + private void RemoveSignToolFromRegistry(int index) + { + using (var registryKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(GetRegistryKey())) + { + var valueName = GetSignToolIndexName(index); + + registryKey.DeleteValue(valueName, false); + } + } +} diff --git a/deployment/cake/installers-msix.cake b/deployment/cake/installers-msix.cake new file mode 100644 index 0000000..78096ca --- /dev/null +++ b/deployment/cake/installers-msix.cake @@ -0,0 +1,358 @@ +//------------------------------------------------------------- + +public class MsixInstaller : IInstaller +{ + public MsixInstaller(BuildContext buildContext) + { + BuildContext = buildContext; + + Publisher = BuildContext.BuildServer.GetVariable("MsixPublisher", showValue: true); + UpdateUrl = BuildContext.BuildServer.GetVariable("MsixUpdateUrl", showValue: true); + IsEnabled = BuildContext.BuildServer.GetVariableAsBool("MsixEnabled", true, showValue: true); + + if (IsEnabled) + { + // In the future, check if Msix is installed. Log error if not + IsAvailable = IsEnabled; + } + } + + public BuildContext BuildContext { get; private set; } + + public string Publisher { get; private set; } + + public string UpdateUrl { get; private set; } + + public bool IsEnabled { get; private set; } + + public bool IsAvailable { get; private set; } + + //------------------------------------------------------------- + + public async Task PackageAsync(string projectName, string channel) + { + if (!IsAvailable) + { + BuildContext.CakeContext.Information("MSIX is not enabled or available, skipping integration"); + return; + } + + var makeAppxFileName = FindLatestMakeAppxFileName(); + if (!BuildContext.CakeContext.FileExists(makeAppxFileName)) + { + BuildContext.CakeContext.Information("Could not find MakeAppX.exe, skipping MSIX integration"); + return; + } + + var msixTemplateDirectory = System.IO.Path.Combine(".", "deployment", "msix", projectName); + if (!BuildContext.CakeContext.DirectoryExists(msixTemplateDirectory)) + { + BuildContext.CakeContext.Information($"Skip packaging of app '{projectName}' using MSIX since no MSIX template is present"); + return; + } + + BuildContext.CakeContext.LogSeparator($"Packaging app '{projectName}' using MSIX"); + + var deploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + var installersOnDeploymentsShare = GetDeploymentsShareRootDirectory(projectName, channel); + + var setupSuffix = BuildContext.Installer.GetDeploymentChannelSuffix(); + + var msixOutputRoot = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, "msix", projectName); + var msixReleasesRoot = System.IO.Path.Combine(msixOutputRoot, "releases"); + var msixOutputIntermediate = System.IO.Path.Combine(msixOutputRoot, "intermediate"); + + BuildContext.CakeContext.CreateDirectory(msixReleasesRoot); + BuildContext.CakeContext.CreateDirectory(msixOutputIntermediate); + + // Set up MSIX template, all based on the documentation here: https://docs.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-manual-conversion + BuildContext.CakeContext.CopyDirectory(msixTemplateDirectory, msixOutputIntermediate); + + var msixInstallerName = $"{projectName}_{BuildContext.General.Version.FullSemVer}.msix"; + var installerSourceFile = System.IO.Path.Combine(msixReleasesRoot, msixInstallerName); + + var variables = new Dictionary(); + variables["[PRODUCT]"] = projectName; + variables["[PRODUCT_WITH_CHANNEL]"] = projectName + BuildContext.Installer.GetDeploymentChannelSuffix(""); + variables["[PRODUCT_WITH_CHANNEL_DISPLAY]"] = projectName + BuildContext.Installer.GetDeploymentChannelSuffix(" (", ")"); + variables["[PUBLISHER]"] = Publisher; + variables["[PUBLISHER_DISPLAY]"] = BuildContext.General.Copyright.Company; + variables["[CHANNEL_SUFFIX]"] = setupSuffix; + variables["[CHANNEL]"] = BuildContext.Installer.GetDeploymentChannelSuffix(" (", ")"); + variables["[VERSION]"] = BuildContext.General.Version.MajorMinorPatch; + variables["[VERSION_WITH_REVISION]"] = $"{BuildContext.General.Version.MajorMinorPatch}.{BuildContext.General.Version.CommitsSinceVersionSource}"; + variables["[VERSION_DISPLAY]"] = BuildContext.General.Version.FullSemVer; + variables["[WIZARDIMAGEFILE]"] = string.Format("logo_large{0}", setupSuffix); + + // Important: urls must be lower case, they are case sensitive in azure blob storage + variables["[URL_APPINSTALLER]"] = $"{UpdateUrl}/{projectName}/{channel}/msix/{projectName}.appinstaller".ToLower(); + variables["[URL_MSIX]"] = $"{UpdateUrl}/{projectName}/{channel}/msix/{msixInstallerName}".ToLower(); + + // Installer file + var msixScriptFileName = System.IO.Path.Combine(msixOutputIntermediate, "AppxManifest.xml"); + + ReplaceVariablesInFile(msixScriptFileName, variables); + + // Update file + var msixUpdateScriptFileName = System.IO.Path.Combine(msixOutputIntermediate, "App.AppInstaller"); + if (BuildContext.CakeContext.FileExists(msixUpdateScriptFileName)) + { + ReplaceVariablesInFile(msixUpdateScriptFileName, variables); + } + + // Copy all files to the intermediate directory so MSIX knows what to do + var appSourceDirectory = string.Format("{0}/{1}/**/*", BuildContext.General.OutputRootDirectory, projectName); + var appTargetDirectory = msixOutputIntermediate; + + BuildContext.CakeContext.Information("Copying files from '{0}' => '{1}'", appSourceDirectory, appTargetDirectory); + + BuildContext.CakeContext.CopyFiles(appSourceDirectory, appTargetDirectory, true); + + if (BuildContext.General.CodeSign.IsAvailable || + BuildContext.General.AzureCodeSign.IsAvailable) + { + SignFilesInDirectory(BuildContext, appTargetDirectory, string.Empty); + } + else + { + BuildContext.CakeContext.Warning("No sign tool is defined, MSIX will not be installable to (most or all) users"); + } + + BuildContext.CakeContext.Information("Generating MSIX packages using MakeAppX..."); + + var processSettings = new ProcessSettings + { + WorkingDirectory = appTargetDirectory, + }; + + processSettings.WithArguments(a => a.Append("pack") + .AppendSwitchQuoted("/p", installerSourceFile) + //.AppendSwitchQuoted("/m", msixScriptFileName) // If we specify this one, we *must* provide a mappings file, which we don't want to do + //.AppendSwitchQuoted("/f", msixScriptFileName) + .AppendSwitchQuoted("/d", appTargetDirectory) + //.Append("/v") + .Append("/o")); + + using (var process = BuildContext.CakeContext.StartAndReturnProcess(makeAppxFileName, processSettings)) + { + process.WaitForExit(); + var exitCode = process.GetExitCode(); + + if (exitCode != 0) + { + throw new Exception($"Packaging failed, exit code is '{exitCode}'"); + } + } + + SignFile(BuildContext, installerSourceFile); + + // Always copy the AppInstaller if available + if (BuildContext.CakeContext.FileExists(msixUpdateScriptFileName)) + { + BuildContext.CakeContext.Information("Copying update manifest to output directory"); + + // - App.AppInstaller => [projectName].AppInstaller + BuildContext.CakeContext.CopyFile(msixUpdateScriptFileName, System.IO.Path.Combine(msixReleasesRoot, $"{projectName}.AppInstaller")); + } + + if (BuildContext.Wpf.UpdateDeploymentsShare) + { + BuildContext.CakeContext.Information("Copying MSIX files to deployments share at '{0}'", installersOnDeploymentsShare); + + // Copy the following files: + // - [ProjectName]_[version].msix => [projectName]_[version].msix + // - [ProjectName]_[version].msix => [projectName]_[channel].msix + + BuildContext.CakeContext.CopyFile(installerSourceFile, System.IO.Path.Combine(installersOnDeploymentsShare, msixInstallerName)); + BuildContext.CakeContext.CopyFile(installerSourceFile, System.IO.Path.Combine(installersOnDeploymentsShare, $"{projectName}{setupSuffix}.msix")); + + if (BuildContext.CakeContext.FileExists(msixUpdateScriptFileName)) + { + // - App.AppInstaller => [projectName].AppInstaller + BuildContext.CakeContext.CopyFile(msixUpdateScriptFileName, System.IO.Path.Combine(installersOnDeploymentsShare, $"{projectName}.AppInstaller")); + } + } + } + + //------------------------------------------------------------- + + public async Task GenerateDeploymentTargetAsync(string projectName) + { + var deploymentTarget = new DeploymentTarget + { + Name = "MSIX" + }; + + var channels = new [] + { + "alpha", + "beta", + "stable" + }; + + var deploymentGroupNames = new List(); + var projectDeploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + if (BuildContext.Wpf.GroupUpdatesByMajorVersion) + { + // Check every directory that we can parse as number + var directories = System.IO.Directory.GetDirectories(projectDeploymentShare); + + foreach (var directory in directories) + { + var deploymentGroupName = new System.IO.DirectoryInfo(directory).Name; + + if (int.TryParse(deploymentGroupName, out _)) + { + deploymentGroupNames.Add(deploymentGroupName); + } + } + } + else + { + // Just a single group + deploymentGroupNames.Add("all"); + } + + foreach (var deploymentGroupName in deploymentGroupNames) + { + BuildContext.CakeContext.Information($"Searching for releases for deployment group '{deploymentGroupName}'"); + + var deploymentGroup = new DeploymentGroup + { + Name = deploymentGroupName + }; + + var version = deploymentGroupName; + if (version == "all") + { + version = string.Empty; + } + + foreach (var channel in channels) + { + BuildContext.CakeContext.Information($"Searching for releases for deployment channel '{deploymentGroupName}/{channel}'"); + + var deploymentChannel = new DeploymentChannel + { + Name = channel + }; + + var targetDirectory = GetDeploymentsShareRootDirectory(projectName, channel, version); + + BuildContext.CakeContext.Information($"Searching for release files in '{targetDirectory}'"); + + var msixFiles = System.IO.Directory.GetFiles(targetDirectory, "*.msix"); + + foreach (var msixFile in msixFiles) + { + var releaseFileInfo = new System.IO.FileInfo(msixFile); + var relativeFileName = new DirectoryPath(projectDeploymentShare).GetRelativePath(new FilePath(releaseFileInfo.FullName)).FullPath.Replace("\\", "/"); + var releaseVersion = releaseFileInfo.Name + .Replace($"{projectName}_", string.Empty) + .Replace($".msix", string.Empty); + + // Either empty or matching a release channel should be ignored + if (string.IsNullOrWhiteSpace(releaseVersion) || + channels.Any(x => x == releaseVersion)) + { + BuildContext.CakeContext.Information($"Ignoring '{msixFile}'"); + continue; + } + + // Special case for stable releases + if (channel == "stable") + { + if (releaseVersion.Contains("-alpha") || + releaseVersion.Contains("-beta")) + { + BuildContext.CakeContext.Information($"Ignoring '{msixFile}'"); + continue; + } + } + + BuildContext.CakeContext.Information($"Applying release based on '{msixFile}'"); + + var release = new DeploymentRelease + { + Name = releaseVersion, + Timestamp = releaseFileInfo.CreationTimeUtc + }; + + // Only support full versions + release.Full = new DeploymentReleasePart + { + RelativeFileName = relativeFileName, + Size = (ulong)releaseFileInfo.Length + }; + + deploymentChannel.Releases.Add(release); + } + + deploymentGroup.Channels.Add(deploymentChannel); + } + + deploymentTarget.Groups.Add(deploymentGroup); + } + + return deploymentTarget; + } + + //------------------------------------------------------------- + + private string GetDeploymentsShareRootDirectory(string projectName, string channel) + { + var version = string.Empty; + + if (BuildContext.Wpf.GroupUpdatesByMajorVersion) + { + version = BuildContext.General.Version.Major; + } + + return GetDeploymentsShareRootDirectory(projectName, channel, version); + } + + //------------------------------------------------------------- + + private string GetDeploymentsShareRootDirectory(string projectName, string channel, string version) + { + var deploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + if (!string.IsNullOrWhiteSpace(version)) + { + deploymentShare = System.IO.Path.Combine(deploymentShare, version); + } + + var installersOnDeploymentsShare = System.IO.Path.Combine(deploymentShare, channel, "msix"); + BuildContext.CakeContext.CreateDirectory(installersOnDeploymentsShare); + + return installersOnDeploymentsShare; + } + + //------------------------------------------------------------- + + private void ReplaceVariablesInFile(string fileName, Dictionary variables) + { + var fileContents = System.IO.File.ReadAllText(fileName); + + foreach (var keyValuePair in variables) + { + fileContents = fileContents.Replace(keyValuePair.Key, keyValuePair.Value); + } + + System.IO.File.WriteAllText(fileName, fileContents); + } + + //------------------------------------------------------------- + + private string FindLatestMakeAppxFileName() + { + var directory = FindLatestWindowsKitsDirectory(BuildContext); + if (directory != null) + { + return System.IO.Path.Combine(directory, "x64", "makeappx.exe"); + } + + return null; + } +} \ No newline at end of file diff --git a/deployment/cake/installers-squirrel.cake b/deployment/cake/installers-squirrel.cake new file mode 100644 index 0000000..32061fc --- /dev/null +++ b/deployment/cake/installers-squirrel.cake @@ -0,0 +1,362 @@ +#addin "nuget:?package=Cake.Squirrel&version=0.15.2" + +#tool "nuget:?package=Squirrel.Windows&version=2.0.1" + +//------------------------------------------------------------- + +public class SquirrelInstaller : IInstaller +{ + public SquirrelInstaller(BuildContext buildContext) + { + BuildContext = buildContext; + + IsEnabled = BuildContext.BuildServer.GetVariableAsBool("SquirrelEnabled", true, showValue: true); + + if (IsEnabled) + { + // In the future, check if Squirrel is installed. Log error if not + IsAvailable = IsEnabled; + } + } + + public BuildContext BuildContext { get; private set; } + + public bool IsEnabled { get; private set; } + + public bool IsAvailable { get; private set; } + + //------------------------------------------------------------- + + public async Task PackageAsync(string projectName, string channel) + { + if (!IsAvailable) + { + BuildContext.CakeContext.Information("Squirrel is not enabled or available, skipping integration"); + return; + } + + // There are 2 flavors: + // + // 1: Non-grouped: /[app]/[channel] (e.g. /MyApp/alpha) + // Updates will always be applied, even to new major versions + // + // 2: Grouped by major version: /[app]/[major_version]/[channel] (e.g. /MyApp/4/alpha) + // Updates will only be applied to non-major updates. This allows manual migration to + // new major versions, which is very useful when there are dependencies that need to + // be updated before a new major version can be switched to. + var squirrelOutputRoot = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, "squirrel", projectName); + + if (BuildContext.Wpf.GroupUpdatesByMajorVersion) + { + squirrelOutputRoot = System.IO.Path.Combine(squirrelOutputRoot, BuildContext.General.Version.Major); + } + + squirrelOutputRoot = System.IO.Path.Combine(squirrelOutputRoot, channel); + + var squirrelReleasesRoot = System.IO.Path.Combine(squirrelOutputRoot, "releases"); + var squirrelOutputIntermediate = System.IO.Path.Combine(squirrelOutputRoot, "intermediate"); + + var nuSpecTemplateFileName = System.IO.Path.Combine(".", "deployment", "squirrel", "template", $"{projectName}.nuspec"); + var nuSpecFileName = System.IO.Path.Combine(squirrelOutputIntermediate, $"{projectName}.nuspec"); + var nuGetFileName = System.IO.Path.Combine(squirrelOutputIntermediate, $"{projectName}.{BuildContext.General.Version.NuGet}.nupkg"); + + if (!BuildContext.CakeContext.FileExists(nuSpecTemplateFileName)) + { + BuildContext.CakeContext.Information($"Skip packaging of WPF app '{projectName}' using Squirrel since no Squirrel template is present"); + return; + } + + BuildContext.CakeContext.LogSeparator($"Packaging WPF app '{projectName}' using Squirrel"); + + BuildContext.CakeContext.CreateDirectory(squirrelReleasesRoot); + BuildContext.CakeContext.CreateDirectory(squirrelOutputIntermediate); + + // Set up Squirrel nuspec + BuildContext.CakeContext.CopyFile(nuSpecTemplateFileName, nuSpecFileName); + + var setupSuffix = BuildContext.Installer.GetDeploymentChannelSuffix(); + + // Squirrel does not seem to support . in the names + var projectSlug = GetProjectSlug(projectName, "_"); + + BuildContext.CakeContext.TransformConfig(nuSpecFileName, + new TransformationCollection + { + { "package/metadata/id", $"{projectSlug}{setupSuffix}" }, + { "package/metadata/version", BuildContext.General.Version.NuGet }, + { "package/metadata/authors", BuildContext.General.Copyright.Company }, + { "package/metadata/owners", BuildContext.General.Copyright.Company }, + { "package/metadata/copyright", string.Format("Copyright © {0} {1} - {2}", BuildContext.General.Copyright.Company, BuildContext.General.Copyright.StartYear, DateTime.Now.Year) }, + }); + + var fileContents = System.IO.File.ReadAllText(nuSpecFileName); + fileContents = fileContents.Replace("[CHANNEL_SUFFIX]", setupSuffix); + fileContents = fileContents.Replace("[CHANNEL]", BuildContext.Installer.GetDeploymentChannelSuffix(" (", ")")); + System.IO.File.WriteAllText(nuSpecFileName, fileContents); + + // Copy all files to the lib so Squirrel knows what to do + var appSourceDirectory = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, projectName); + var appTargetDirectory = System.IO.Path.Combine(squirrelOutputIntermediate, "lib"); + + BuildContext.CakeContext.Information($"Copying files from '{appSourceDirectory}' => '{appTargetDirectory}'"); + + BuildContext.CakeContext.CopyDirectory(appSourceDirectory, appTargetDirectory); + + var squirrelSourceFile = BuildContext.CakeContext.GetFiles("./tools/squirrel.windows.*/tools/Squirrel.exe").Single(); + + // We need to be 1 level deeper, let's just walk each directory in case we can support multi-platform releases + // in the future + foreach (var subDirectory in BuildContext.CakeContext.GetSubDirectories(appTargetDirectory)) + { + var squirrelTargetFile = System.IO.Path.Combine(appTargetDirectory, subDirectory.Segments[subDirectory.Segments.Length - 1], "Squirrel.exe"); + + BuildContext.CakeContext.Information($"Copying Squirrel.exe to support self-updates from '{squirrelSourceFile}' => '{squirrelTargetFile}'"); + + BuildContext.CakeContext.CopyFile(squirrelSourceFile, squirrelTargetFile); + } + + // Make sure all files are signed before we package them for Squirrel (saves potential errors occurring later in squirrel releasify) + var signToolCommand = string.Empty; + + if (!string.IsNullOrWhiteSpace(BuildContext.General.CodeSign.CertificateSubjectName)) + { + // Note: Squirrel uses it's own sign tool, so make sure to follow their specs + signToolCommand = string.Format("/a /t {0} /n {1}", BuildContext.General.CodeSign.TimeStampUri, + BuildContext.General.CodeSign.CertificateSubjectName); + } + + var nuGetSettings = new NuGetPackSettings + { + NoPackageAnalysis = true, + OutputDirectory = squirrelOutputIntermediate, + Verbosity = NuGetVerbosity.Detailed, + }; + + // Fix for target framework issues (platform == windows 7) + nuGetSettings.Properties.Add("TargetPlatformVersion", "7.0"); + + // Create NuGet package + BuildContext.CakeContext.NuGetPack(nuSpecFileName, nuGetSettings); + + // Rename so we have the right nuget package file names (without the channel) + if (!string.IsNullOrWhiteSpace(setupSuffix)) + { + var sourcePackageFileName = System.IO.Path.Combine(squirrelOutputIntermediate, $"{projectSlug}{setupSuffix}.{BuildContext.General.Version.NuGet}.nupkg"); + var targetPackageFileName = System.IO.Path.Combine(squirrelOutputIntermediate, $"{projectName}.{BuildContext.General.Version.NuGet}.nupkg"); + + BuildContext.CakeContext.Information($"Moving file from '{sourcePackageFileName}' => '{targetPackageFileName}'"); + + BuildContext.CakeContext.MoveFile(sourcePackageFileName, targetPackageFileName); + } + + // Copy deployments share to the intermediate root so we can locally create the Squirrel releases + + var releasesSourceDirectory = GetDeploymentsShareRootDirectory(projectName, channel); + var releasesTargetDirectory = squirrelReleasesRoot; + + BuildContext.CakeContext.CreateDirectory(releasesSourceDirectory); + BuildContext.CakeContext.CreateDirectory(releasesTargetDirectory); + + BuildContext.CakeContext.Information($"Copying releases from '{releasesSourceDirectory}' => '{releasesTargetDirectory}'"); + + BuildContext.CakeContext.CopyDirectory(releasesSourceDirectory, releasesTargetDirectory); + + // Squirrelify! + var squirrelSettings = new SquirrelSettings(); + squirrelSettings.Silent = false; + squirrelSettings.NoMsi = false; + squirrelSettings.ReleaseDirectory = squirrelReleasesRoot; + squirrelSettings.LoadingGif = System.IO.Path.Combine(".", "deployment", "squirrel", "loading.gif"); + + // Note: this is not really generic, but this is where we store our icons file, we can + // always change this in the future + var iconFileName = System.IO.Path.Combine(".", "design", "logo", $"logo{setupSuffix}.ico"); + squirrelSettings.Icon = iconFileName; + squirrelSettings.SetupIcon = iconFileName; + + if (!string.IsNullOrWhiteSpace(signToolCommand)) + { + squirrelSettings.SigningParameters = signToolCommand; + } + + BuildContext.CakeContext.Information("Generating Squirrel packages, this can take a while, especially when signing is enabled..."); + + BuildContext.CakeContext.Squirrel(nuGetFileName, squirrelSettings, true, false); + + if (BuildContext.Wpf.UpdateDeploymentsShare) + { + BuildContext.CakeContext.Information($"Copying updated Squirrel files back to deployments share at '{releasesSourceDirectory}'"); + + // Copy the following files: + // - [version]-delta.nupkg + // - [version]-full.nupkg + // - Setup.exe => Setup.exe & WpfApp.exe + // - Setup.msi + // - RELEASES + + var squirrelFiles = BuildContext.CakeContext.GetFiles($"{squirrelReleasesRoot}/{projectSlug}{setupSuffix}-{BuildContext.General.Version.NuGet}*.nupkg"); + BuildContext.CakeContext.CopyFiles(squirrelFiles, releasesSourceDirectory); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(squirrelReleasesRoot, "Setup.exe"), System.IO.Path.Combine(releasesSourceDirectory, "Setup.exe")); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(squirrelReleasesRoot, "Setup.exe"), System.IO.Path.Combine(releasesSourceDirectory, $"{projectName}.exe")); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(squirrelReleasesRoot, "Setup.msi"), System.IO.Path.Combine(releasesSourceDirectory, "Setup.msi")); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(squirrelReleasesRoot, "RELEASES"), System.IO.Path.Combine(releasesSourceDirectory, "RELEASES")); + } + } + + //------------------------------------------------------------- + + public async Task GenerateDeploymentTargetAsync(string projectName) + { + var deploymentTarget = new DeploymentTarget + { + Name = "Squirrel" + }; + + var channels = new [] + { + "alpha", + "beta", + "stable" + }; + + var deploymentGroupNames = new List(); + var projectDeploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + if (BuildContext.Wpf.GroupUpdatesByMajorVersion) + { + // Check every directory that we can parse as number + var directories = System.IO.Directory.GetDirectories(projectDeploymentShare); + + foreach (var directory in directories) + { + var deploymentGroupName = new System.IO.DirectoryInfo(directory).Name; + + if (int.TryParse(deploymentGroupName, out _)) + { + deploymentGroupNames.Add(deploymentGroupName); + } + } + } + else + { + // Just a single group + deploymentGroupNames.Add("all"); + } + + foreach (var deploymentGroupName in deploymentGroupNames) + { + BuildContext.CakeContext.Information($"Searching for releases for deployment group '{deploymentGroupName}'"); + + var deploymentGroup = new DeploymentGroup + { + Name = deploymentGroupName + }; + + var version = deploymentGroupName; + if (version == "all") + { + version = string.Empty; + } + + foreach (var channel in channels) + { + BuildContext.CakeContext.Information($"Searching for releases for deployment channel '{deploymentGroupName}/{channel}'"); + + var deploymentChannel = new DeploymentChannel + { + Name = channel + }; + + var targetDirectory = GetDeploymentsShareRootDirectory(projectName, channel, version); + + BuildContext.CakeContext.Information($"Searching for release files in '{targetDirectory}'"); + + var fullNupkgFiles = System.IO.Directory.GetFiles(targetDirectory, "*-full.nupkg"); + + foreach (var fullNupkgFile in fullNupkgFiles) + { + BuildContext.CakeContext.Information($"Applying release based on '{fullNupkgFile}'"); + + var fullReleaseFileInfo = new System.IO.FileInfo(fullNupkgFile); + var fullRelativeFileName = new DirectoryPath(projectDeploymentShare).GetRelativePath(new FilePath(fullReleaseFileInfo.FullName)).FullPath.Replace("\\", "/"); + + var releaseVersion = fullReleaseFileInfo.Name + .Replace($"{projectName}_{channel}-", string.Empty) + .Replace($"-full.nupkg", string.Empty); + + // Exception for full releases, they don't contain the channel name + if (channel == "stable") + { + releaseVersion = releaseVersion.Replace($"{projectName}-", string.Empty); + } + + var release = new DeploymentRelease + { + Name = releaseVersion, + Timestamp = fullReleaseFileInfo.CreationTimeUtc + }; + + // Full release + release.Full = new DeploymentReleasePart + { + RelativeFileName = fullRelativeFileName, + Size = (ulong)fullReleaseFileInfo.Length + }; + + // Delta release + var deltaNupkgFile = fullNupkgFile.Replace("-full.nupkg", "-delta.nupkg"); + if (System.IO.File.Exists(deltaNupkgFile)) + { + var deltaReleaseFileInfo = new System.IO.FileInfo(deltaNupkgFile); + var deltafullRelativeFileName = new DirectoryPath(projectDeploymentShare).GetRelativePath(new FilePath(deltaReleaseFileInfo.FullName)).FullPath.Replace("\\", "/"); + + release.Delta = new DeploymentReleasePart + { + RelativeFileName = deltafullRelativeFileName, + Size = (ulong)deltaReleaseFileInfo.Length + }; + } + + deploymentChannel.Releases.Add(release); + } + + deploymentGroup.Channels.Add(deploymentChannel); + } + + deploymentTarget.Groups.Add(deploymentGroup); + } + + return deploymentTarget; + } + + //------------------------------------------------------------- + + private string GetDeploymentsShareRootDirectory(string projectName, string channel) + { + var version = string.Empty; + + if (BuildContext.Wpf.GroupUpdatesByMajorVersion) + { + version = BuildContext.General.Version.Major; + } + + return GetDeploymentsShareRootDirectory(projectName, channel, version); + } + + //------------------------------------------------------------- + + private string GetDeploymentsShareRootDirectory(string projectName, string channel, string version) + { + var deploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + if (!string.IsNullOrWhiteSpace(version)) + { + deploymentShare = System.IO.Path.Combine(deploymentShare, version); + } + + var installersOnDeploymentsShare = System.IO.Path.Combine(deploymentShare, channel); + BuildContext.CakeContext.CreateDirectory(installersOnDeploymentsShare); + + return installersOnDeploymentsShare; + } +} \ No newline at end of file diff --git a/deployment/cake/installers-velopack.cake b/deployment/cake/installers-velopack.cake new file mode 100644 index 0000000..bd07045 --- /dev/null +++ b/deployment/cake/installers-velopack.cake @@ -0,0 +1,361 @@ +#tool "dotnet:?package=vpk&version=0.0.1298" + +//------------------------------------------------------------- + +public class VelopackInstaller : IInstaller +{ + public VelopackInstaller(BuildContext buildContext) + { + BuildContext = buildContext; + + IsEnabled = BuildContext.BuildServer.GetVariableAsBool("VelopackEnabled", false, showValue: true); + + if (IsEnabled) + { + IsAvailable = IsEnabled; + + // Protection + if (BuildContext.BuildServer.GetVariableAsBool("SquirrelEnabled", true, showValue: true)) + { + throw new Exception("Both Velopack and Squirrel are enabled, make sure to disable Squirrel when migrating to Velopack"); + } + } + } + + public BuildContext BuildContext { get; private set; } + + public bool IsEnabled { get; private set; } + + public bool IsAvailable { get; private set; } + + //------------------------------------------------------------- + + public async Task PackageAsync(string projectName, string channel) + { + if (!IsAvailable) + { + BuildContext.CakeContext.Information("Velopack is not enabled or available, skipping integration"); + return; + } + + // There are 2 flavors: + // + // 1: Non-grouped: /[app]/[channel] (e.g. /MyApp/alpha) + // Updates will always be applied, even to new major versions + // + // 2: Grouped by major version: /[app]/[major_version]/[channel] (e.g. /MyApp/4/alpha) + // Updates will only be applied to non-major updates. This allows manual migration to + // new major versions, which is very useful when there are dependencies that need to + // be updated before a new major version can be switched to. + var velopackOutputRoot = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, "velopack", projectName); + + if (BuildContext.Wpf.GroupUpdatesByMajorVersion) + { + velopackOutputRoot = System.IO.Path.Combine(velopackOutputRoot, BuildContext.General.Version.Major); + } + + velopackOutputRoot = System.IO.Path.Combine(velopackOutputRoot, channel); + + var velopackReleasesRoot = System.IO.Path.Combine(velopackOutputRoot, "releases"); + + BuildContext.CakeContext.LogSeparator($"Packaging WPF app '{projectName}' using Velopack"); + + BuildContext.CakeContext.CreateDirectory(velopackReleasesRoot); + + var setupSuffix = BuildContext.Installer.GetDeploymentChannelSuffix(); + + // Velopack does not seem to support . in the names (keeping same behavior as Squirrel) + var projectSlug = GetProjectSlug(projectName, "_"); + + // Copy all files to the lib so Velopack knows what to do + var appSourceDirectory = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, projectName); + + // Note: there should be only a single target framework, but pick the highest + var subDirectories = System.IO.Directory.GetDirectories(appSourceDirectory); + appSourceDirectory = subDirectories.Last(); + + // Copy deployments share to the intermediate root so we can locally create the releases + + var releasesSourceDirectory = GetDeploymentsShareRootDirectory(projectName, channel); + var releasesTargetDirectory = velopackReleasesRoot; + + BuildContext.CakeContext.CreateDirectory(releasesSourceDirectory); + BuildContext.CakeContext.CreateDirectory(releasesTargetDirectory); + + BuildContext.CakeContext.Information($"Copying releases from '{releasesSourceDirectory}' => '{releasesTargetDirectory}'"); + + BuildContext.CakeContext.CopyDirectory(releasesSourceDirectory, releasesTargetDirectory); + + BuildContext.CakeContext.Information("Generating Velopack packages, this can take a while, especially when signing is enabled..."); + + // Pack using velopack (example command line: vpk pack -u YourAppId -v 1.0.0 -p publish -e yourMainBinary.exe) + + var appId = $"{projectSlug}{setupSuffix}"; + + var argumentBuilder = new ProcessArgumentBuilder() + .Append("pack") + .Append("--verbose") + .AppendSwitch("--packId", appId) + .AppendSwitch("--packVersion", BuildContext.General.Version.NuGet) + .AppendSwitch("--packDir", appSourceDirectory) + .AppendSwitch("--packAuthors", BuildContext.General.Copyright.Company) + .AppendSwitch("--delta", "BestSpeed") + .AppendSwitch("--outputDir", velopackReleasesRoot); + + // Note: for now BIG assumption that the exe is the same as project name + argumentBuilder = argumentBuilder + .AppendSwitch("--mainExe", $"{projectName}.exe"); + + // Check several different allowed formats + var allowedSplashImages = new [] + { + // Support "channel specific images" + $"splash_{setupSuffix}.gif", + $"splash_{setupSuffix}.png", + "splash.gif", + "splash.png", + }; + + foreach (var allowedSplashImage in allowedSplashImages) + { + var splashImageFileName = System.IO.Path.Combine(".", "deployment", "velopack", allowedSplashImage); + if (System.IO.File.Exists(splashImageFileName)) + { + argumentBuilder = argumentBuilder + .AppendSwitch("--splashImage", splashImageFileName); + break; + } + } + + // Note: this is not really generic, but this is where we store our icons file, we can + // always change this in the future + var iconFileName = System.IO.Path.Combine(".", "design", "logo", $"logo{setupSuffix}.ico"); + argumentBuilder = argumentBuilder + .AppendSwitch("--icon", iconFileName); + + // --signTemplate {{file}} will be substituted + // Note that we need to replace / by \ on Windows + var signToolExe = GetSignToolFileName(BuildContext).Replace("/", "\\"); + var signToolCommandLine = GetSignToolCommandLine(BuildContext); + if (!string.IsNullOrWhiteSpace(signToolExe) && + !string.IsNullOrWhiteSpace(signToolCommandLine)) + { + // In order to work around a double quote issue (C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe), + // if 'signtool.exe' is used, use signParams instead + if (signToolExe.EndsWith("\\signtool.exe")) + { + if (signToolCommandLine.StartsWith("sign ")) + { + signToolCommandLine = signToolCommandLine.Substring("sign ".Length); + } + + argumentBuilder = argumentBuilder + .AppendSwitch("--signParams", $"\"{signToolCommandLine}\""); + } + else + { + argumentBuilder = argumentBuilder + .AppendSwitch("--signTemplate", $"\"{signToolExe} {signToolCommandLine} {{{{file}}}}\""); + } + } + + var vpkToolExe = BuildContext.CakeContext.Tools.Resolve("vpk.exe"); + + var vpkToolExitCode = BuildContext.CakeContext.StartProcess(vpkToolExe, + new ProcessSettings + { + Arguments = argumentBuilder + } + ); + + if (vpkToolExitCode != 0) + { + throw new Exception("Failed to pack application"); + } + + // Copy setup + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(velopackReleasesRoot, $"{appId}-win-Setup.exe"), System.IO.Path.Combine(velopackReleasesRoot, "Setup.exe")); + + if (BuildContext.Wpf.UpdateDeploymentsShare) + { + BuildContext.CakeContext.Information($"Copying updated Velopack files back to deployments share at '{releasesSourceDirectory}'"); + + // Copy the following files: + // - [version]-delta.nupkg + // - [version]-full.nupkg + // - Setup.exe => Setup.exe & WpfApp.exe + // - releases.win.json + // - RELEASES + + // Note to consider in future: this stores (and uploads) the same file 4 times. Maybe we need to stop processing so many files + // to save time on uploads (and eventually money on storage) + var velopackFiles = BuildContext.CakeContext.GetFiles($"{velopackReleasesRoot}/{appId}-{BuildContext.General.Version.NuGet}*.nupkg"); + BuildContext.CakeContext.CopyFiles(velopackFiles, releasesSourceDirectory); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(velopackReleasesRoot, $"{appId}-win-Portable.zip"), System.IO.Path.Combine(releasesSourceDirectory, $"{appId}-win-Portable.zip")); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(velopackReleasesRoot, $"{appId}-win-Setup.exe"), System.IO.Path.Combine(releasesSourceDirectory, $"{appId}-win-Setup.exe")); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(velopackReleasesRoot, "Setup.exe"), System.IO.Path.Combine(releasesSourceDirectory, "Setup.exe")); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(velopackReleasesRoot, "Setup.exe"), System.IO.Path.Combine(releasesSourceDirectory, $"{projectName}.exe")); + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(velopackReleasesRoot, "releases.win.json"), System.IO.Path.Combine(releasesSourceDirectory, "releases.win.json")); + + // Note: RELEASES is there for backwards compatibility + BuildContext.CakeContext.CopyFile(System.IO.Path.Combine(velopackReleasesRoot, "RELEASES"), System.IO.Path.Combine(releasesSourceDirectory, "RELEASES")); + } + } + + //------------------------------------------------------------- + + public async Task GenerateDeploymentTargetAsync(string projectName) + { + var deploymentTarget = new DeploymentTarget + { + Name = "Velopack" + }; + + var channels = new [] + { + "alpha", + "beta", + "stable" + }; + + var deploymentGroupNames = new List(); + var projectDeploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + if (BuildContext.Wpf.GroupUpdatesByMajorVersion) + { + // Check every directory that we can parse as number + var directories = System.IO.Directory.GetDirectories(projectDeploymentShare); + + foreach (var directory in directories) + { + var deploymentGroupName = new System.IO.DirectoryInfo(directory).Name; + + if (int.TryParse(deploymentGroupName, out _)) + { + deploymentGroupNames.Add(deploymentGroupName); + } + } + } + else + { + // Just a single group + deploymentGroupNames.Add("all"); + } + + foreach (var deploymentGroupName in deploymentGroupNames) + { + BuildContext.CakeContext.Information($"Searching for releases for deployment group '{deploymentGroupName}'"); + + var deploymentGroup = new DeploymentGroup + { + Name = deploymentGroupName + }; + + var version = deploymentGroupName; + if (version == "all") + { + version = string.Empty; + } + + foreach (var channel in channels) + { + BuildContext.CakeContext.Information($"Searching for releases for deployment channel '{deploymentGroupName}/{channel}'"); + + var deploymentChannel = new DeploymentChannel + { + Name = channel + }; + + var targetDirectory = GetDeploymentsShareRootDirectory(projectName, channel, version); + + BuildContext.CakeContext.Information($"Searching for release files in '{targetDirectory}'"); + + var fullNupkgFiles = System.IO.Directory.GetFiles(targetDirectory, "*-full.nupkg"); + + foreach (var fullNupkgFile in fullNupkgFiles) + { + BuildContext.CakeContext.Information($"Applying release based on '{fullNupkgFile}'"); + + var fullReleaseFileInfo = new System.IO.FileInfo(fullNupkgFile); + var fullRelativeFileName = new DirectoryPath(projectDeploymentShare).GetRelativePath(new FilePath(fullReleaseFileInfo.FullName)).FullPath.Replace("\\", "/"); + + var releaseVersion = fullReleaseFileInfo.Name + .Replace($"{projectName}_{channel}-", string.Empty) + .Replace($"-full.nupkg", string.Empty); + + // Exception for full releases, they don't contain the channel name + if (channel == "stable") + { + releaseVersion = releaseVersion.Replace($"{projectName}-", string.Empty); + } + + var release = new DeploymentRelease + { + Name = releaseVersion, + Timestamp = fullReleaseFileInfo.CreationTimeUtc + }; + + // Full release + release.Full = new DeploymentReleasePart + { + RelativeFileName = fullRelativeFileName, + Size = (ulong)fullReleaseFileInfo.Length + }; + + // Delta release + var deltaNupkgFile = fullNupkgFile.Replace("-full.nupkg", "-delta.nupkg"); + if (System.IO.File.Exists(deltaNupkgFile)) + { + var deltaReleaseFileInfo = new System.IO.FileInfo(deltaNupkgFile); + var deltafullRelativeFileName = new DirectoryPath(projectDeploymentShare).GetRelativePath(new FilePath(deltaReleaseFileInfo.FullName)).FullPath.Replace("\\", "/"); + + release.Delta = new DeploymentReleasePart + { + RelativeFileName = deltafullRelativeFileName, + Size = (ulong)deltaReleaseFileInfo.Length + }; + } + + deploymentChannel.Releases.Add(release); + } + + deploymentGroup.Channels.Add(deploymentChannel); + } + + deploymentTarget.Groups.Add(deploymentGroup); + } + + return deploymentTarget; + } + + //------------------------------------------------------------- + + private string GetDeploymentsShareRootDirectory(string projectName, string channel) + { + var version = string.Empty; + + if (BuildContext.Wpf.GroupUpdatesByMajorVersion) + { + version = BuildContext.General.Version.Major; + } + + return GetDeploymentsShareRootDirectory(projectName, channel, version); + } + + //------------------------------------------------------------- + + private string GetDeploymentsShareRootDirectory(string projectName, string channel, string version) + { + var deploymentShare = BuildContext.Wpf.GetDeploymentShareForProject(projectName); + + if (!string.IsNullOrWhiteSpace(version)) + { + deploymentShare = System.IO.Path.Combine(deploymentShare, version); + } + + var installersOnDeploymentsShare = System.IO.Path.Combine(deploymentShare, channel); + BuildContext.CakeContext.CreateDirectory(installersOnDeploymentsShare); + + return installersOnDeploymentsShare; + } +} \ No newline at end of file diff --git a/deployment/cake/installers.cake b/deployment/cake/installers.cake new file mode 100644 index 0000000..02cdc9f --- /dev/null +++ b/deployment/cake/installers.cake @@ -0,0 +1,205 @@ +// Customize this file when using a different issue tracker +#l "installers-innosetup.cake" +#l "installers-msix.cake" +#l "installers-squirrel.cake" +#l "installers-velopack.cake" + +using System.Diagnostics; + +//------------------------------------------------------------- + +public interface IInstaller +{ + bool IsAvailable { get; } + + Task PackageAsync(string projectName, string channel); + + Task GenerateDeploymentTargetAsync(string projectName); +} + +//------------------------------------------------------------- + +public class DeploymentCatalog +{ + public DeploymentCatalog() + { + Targets = new List(); + } + + public List Targets { get; private set; } +} + +//------------------------------------------------------------- + +public class DeploymentTarget +{ + public DeploymentTarget() + { + Groups = new List(); + } + + public string Name { get; set; } + + public List Groups { get; private set; } +} + +//------------------------------------------------------------- + +public class DeploymentGroup +{ + public DeploymentGroup() + { + Channels = new List(); + } + + public string Name { get; set; } + + public List Channels { get; private set; } +} + +//------------------------------------------------------------- + +public class DeploymentChannel +{ + public DeploymentChannel() + { + Releases = new List(); + } + + public string Name { get; set; } + + public List Releases { get; private set; } +} + +//------------------------------------------------------------- + +public class DeploymentRelease +{ + public string Name { get; set; } + + public DateTime? Timestamp { get; set;} + + public bool HasFull + { + get { return Full is not null; } + } + + public DeploymentReleasePart Full { get; set; } + + public bool HasDelta + { + get { return Delta is not null; } + } + + public DeploymentReleasePart Delta { get; set; } +} + +//------------------------------------------------------------- + +public class DeploymentReleasePart +{ + public string Hash { get; set; } + + public string RelativeFileName { get; set; } + + public ulong Size { get; set; } +} + +//------------------------------------------------------------- + +public class InstallerIntegration : IntegrationBase +{ + private readonly List _installers = new List(); + + public InstallerIntegration(BuildContext buildContext) + : base(buildContext) + { + _installers.Add(new InnoSetupInstaller(buildContext)); + _installers.Add(new MsixInstaller(buildContext)); + _installers.Add(new SquirrelInstaller(buildContext)); + _installers.Add(new VelopackInstaller(buildContext)); + } + + public string GetDeploymentChannelSuffix(string prefix = "_", string suffix = "") + { + var channelSuffix = string.Empty; + + if (BuildContext.Wpf.AppendDeploymentChannelSuffix) + { + if (BuildContext.General.IsAlphaBuild || + BuildContext.General.IsBetaBuild) + { + channelSuffix = $"{prefix}{BuildContext.Wpf.Channel}{suffix}"; + } + + BuildContext.CakeContext.Information($"Using deployment channel suffix '{channelSuffix}'"); + } + + return channelSuffix; + } + + public async Task PackageAsync(string projectName, string channel) + { + BuildContext.CakeContext.LogSeparator($"Packaging installer for '{projectName}'"); + + foreach (var installer in _installers) + { + if (!installer.IsAvailable) + { + continue; + } + + BuildContext.CakeContext.LogSeparator($"Applying installer '{installer.GetType().Name}' for '{projectName}'"); + + var stopwatch = Stopwatch.StartNew(); + + try + { + await installer.PackageAsync(projectName, channel); + } + finally + { + stopwatch.Stop(); + + BuildContext.CakeContext.Information($"Installer took {stopwatch.Elapsed}"); + } + } + + if (BuildContext.Wpf.GenerateDeploymentCatalog) + { + BuildContext.CakeContext.LogSeparator($"Generating deployment catalog for '{projectName}'"); + + var catalog = new DeploymentCatalog(); + + foreach (var installer in _installers) + { + if (!installer.IsAvailable) + { + continue; + } + + BuildContext.CakeContext.LogSeparator($"Generating deployment target for catalog for installer '{installer.GetType().Name}' for '{projectName}'"); + + var deploymentTarget = await installer.GenerateDeploymentTargetAsync(projectName); + if (deploymentTarget is not null) + { + catalog.Targets.Add(deploymentTarget); + } + } + + var localCatalogDirectory = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, "catalog", projectName); + BuildContext.CakeContext.CreateDirectory(localCatalogDirectory); + + var localCatalogFileName = System.IO.Path.Combine(localCatalogDirectory, "catalog.json"); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(catalog); + + System.IO.File.WriteAllText(localCatalogFileName, json); + + if (BuildContext.Wpf.UpdateDeploymentsShare) + { + var targetFileName = System.IO.Path.Combine(BuildContext.Wpf.GetDeploymentShareForProject(projectName), "catalog.json"); + BuildContext.CakeContext.CopyFile(localCatalogFileName, targetFileName); + } + } + } +} \ No newline at end of file diff --git a/deployment/cake/issuetrackers-github.cake b/deployment/cake/issuetrackers-github.cake new file mode 100644 index 0000000..bb85f04 --- /dev/null +++ b/deployment/cake/issuetrackers-github.cake @@ -0,0 +1,88 @@ +#tool "nuget:?package=gitreleasemanager&version=0.20.0" + +//------------------------------------------------------------- + +public class GitHubIssueTracker : IIssueTracker +{ + public GitHubIssueTracker(BuildContext buildContext) + { + BuildContext = buildContext; + + ApiKey = buildContext.BuildServer.GetVariable("GitHubApiKey", showValue: false); + OwnerName = buildContext.BuildServer.GetVariable("GitHubOwnerName", buildContext.General.Copyright.Company, showValue: true); + ProjectName = buildContext.BuildServer.GetVariable("GitHubProjectName", buildContext.General.Solution.Name, showValue: true); + + if (!string.IsNullOrWhiteSpace(ApiKey) && + !string.IsNullOrWhiteSpace(OwnerName) && + !string.IsNullOrWhiteSpace(ProjectName)) + { + IsAvailable = true; + } + } + + public BuildContext BuildContext { get; private set; } + + public string ApiKey { get; set; } + public string OwnerName { get; set; } + public string ProjectName { get; set; } + + public string OwnerAndProjectName + { + get { return $"{OwnerName}/{ProjectName}"; } + } + + public bool IsAvailable { get; private set; } + + public async Task CreateAndReleaseVersionAsync() + { + if (!IsAvailable) + { + BuildContext.CakeContext.Information("GitHub is not available, skipping GitHub integration"); + return; + } + + var version = BuildContext.General.Version.FullSemVer; + + BuildContext.CakeContext.Information("Releasing version '{0}' in GitHub", version); + + // For docs, see https://cakebuild.net/dsl/gitreleasemanager/ + + BuildContext.CakeContext.Information("Step 1 / 4: Creating release"); + + BuildContext.CakeContext.GitReleaseManagerCreate(ApiKey, OwnerName, ProjectName, new GitReleaseManagerCreateSettings + { + TargetDirectory = BuildContext.General.RootDirectory, + Milestone = BuildContext.General.Version.MajorMinorPatch, + Name = version, + Prerelease = !BuildContext.General.IsOfficialBuild, + TargetCommitish = BuildContext.General.Repository.CommitId + }); + + BuildContext.CakeContext.Information("Step 2 / 4: Adding assets to the release (not supported yet)"); + + // Not yet supported + + if (!BuildContext.General.IsOfficialBuild) + { + BuildContext.CakeContext.Information("GitHub release publishing only runs against non-prerelease builds"); + } + else + { + BuildContext.CakeContext.Information("Step 3 / 4: Publishing release"); + + BuildContext.CakeContext.GitReleaseManagerPublish(ApiKey, OwnerName, ProjectName, BuildContext.General.Version.MajorMinorPatch, new GitReleaseManagerPublishSettings + { + TargetDirectory = BuildContext.General.RootDirectory + }); + + BuildContext.CakeContext.Information("Step 4 / 4: Closing the milestone"); + + BuildContext.CakeContext.GitReleaseManagerClose(ApiKey, OwnerName, ProjectName, BuildContext.General.Version.MajorMinorPatch, new GitReleaseManagerCloseMilestoneSettings + { + TargetDirectory = BuildContext.General.RootDirectory + }); + } + + BuildContext.CakeContext.Information("Released version in GitHub"); + } +} \ No newline at end of file diff --git a/deployment/cake/issuetrackers-jira.cake b/deployment/cake/issuetrackers-jira.cake new file mode 100644 index 0000000..9177eb4 --- /dev/null +++ b/deployment/cake/issuetrackers-jira.cake @@ -0,0 +1,62 @@ +#tool "nuget:?package=JiraCli&version=1.3.0-alpha0338&prerelease" + +//------------------------------------------------------------- + +public class JiraIssueTracker : IIssueTracker +{ + public JiraIssueTracker(BuildContext buildContext) + { + BuildContext = buildContext; + + Url = buildContext.BuildServer.GetVariable("JiraUrl", showValue: true); + Username = buildContext.BuildServer.GetVariable("JiraUsername", showValue: true); + Password = buildContext.BuildServer.GetVariable("JiraPassword", showValue: false); + ProjectName = buildContext.BuildServer.GetVariable("JiraProjectName", showValue: true); + + if (!string.IsNullOrWhiteSpace(Url) && + !string.IsNullOrWhiteSpace(ProjectName)) + { + IsAvailable = true; + } + } + + public BuildContext BuildContext { get; private set; } + + public string Url { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string ProjectName { get; set; } + public bool IsAvailable { get; private set; } + + public async Task CreateAndReleaseVersionAsync() + { + if (!IsAvailable) + { + BuildContext.CakeContext.Information("JIRA is not available, skipping JIRA integration"); + return; + } + + var version = BuildContext.General.Version.FullSemVer; + + BuildContext.CakeContext.Information("Releasing version '{0}' in JIRA", version); + + // Example call: + // JiraCli.exe -url %JiraUrl% -user %JiraUsername% -pw %JiraPassword% -action createandreleaseversion + // -project %JiraProjectName% -version %GitVersion_FullSemVer% -merge %IsOfficialBuild% + + var nugetPath = BuildContext.CakeContext.Tools.Resolve("JiraCli.exe"); + BuildContext.CakeContext.StartProcess(nugetPath, new ProcessSettings + { + Arguments = new ProcessArgumentBuilder() + .AppendSwitch("-url", Url) + .AppendSwitch("-user", Username) + .AppendSwitchSecret("-pw", Password) + .AppendSwitch("-action", "createandreleaseversion") + .AppendSwitch("-project", ProjectName) + .AppendSwitch("-version", version) + .AppendSwitch("-merge", BuildContext.General.IsOfficialBuild.ToString()) + }); + + BuildContext.CakeContext.Information("Released version in JIRA"); + } +} \ No newline at end of file diff --git a/deployment/cake/issuetrackers.cake b/deployment/cake/issuetrackers.cake new file mode 100644 index 0000000..7350b42 --- /dev/null +++ b/deployment/cake/issuetrackers.cake @@ -0,0 +1,41 @@ +// Customize this file when using a different issue tracker +#l "issuetrackers-github.cake" +#l "issuetrackers-jira.cake" + +//------------------------------------------------------------- + +public interface IIssueTracker +{ + Task CreateAndReleaseVersionAsync(); +} + +//------------------------------------------------------------- + +public class IssueTrackerIntegration : IntegrationBase +{ + private readonly List _issueTrackers = new List(); + + public IssueTrackerIntegration(BuildContext buildContext) + : base(buildContext) + { + _issueTrackers.Add(new GitHubIssueTracker(buildContext)); + _issueTrackers.Add(new JiraIssueTracker(buildContext)); + } + + public async Task CreateAndReleaseVersionAsync() + { + BuildContext.CakeContext.LogSeparator("Creating and releasing version"); + + foreach (var issueTracker in _issueTrackers) + { + try + { + await issueTracker.CreateAndReleaseVersionAsync(); + } + catch (Exception ex) + { + BuildContext.CakeContext.Error(ex.Message); + } + } + } +} \ No newline at end of file diff --git a/deployment/cake/lib-generic.cake b/deployment/cake/lib-generic.cake new file mode 100644 index 0000000..7033d9c --- /dev/null +++ b/deployment/cake/lib-generic.cake @@ -0,0 +1,926 @@ +using System.Reflection; + +//------------------------------------------------------------- + +private static readonly Dictionary _dotNetCoreCache = new Dictionary(); +private static readonly Dictionary _blazorCache = new Dictionary(); + +//------------------------------------------------------------- + +public interface IIntegration +{ + +} + +//------------------------------------------------------------- + +public abstract class IntegrationBase : IIntegration +{ + protected IntegrationBase(BuildContext buildContext) + { + BuildContext = buildContext; + } + + public BuildContext BuildContext { get; private set; } +} + +//------------------------------------------------------------- + +public interface IProcessor +{ + bool HasItems(); + + Task PrepareAsync(); + Task UpdateInfoAsync(); + Task BuildAsync(); + Task PackageAsync(); + Task DeployAsync(); + Task FinalizeAsync(); +} + +//------------------------------------------------------------- + +public abstract class ProcessorBase : IProcessor +{ + protected readonly BuildContext BuildContext; + protected readonly ICakeContext CakeContext; + + protected ProcessorBase(BuildContext buildContext) + { + BuildContext = buildContext; + CakeContext = buildContext.CakeContext; + + Name = GetProcessorName(); + } + + public string Name { get; private set; } + + protected virtual string GetProcessorName() + { + var name = GetType().Name.Replace("Processor", string.Empty); + return name; + } + + public abstract bool HasItems(); + + public abstract Task PrepareAsync(); + public abstract Task UpdateInfoAsync(); + public abstract Task BuildAsync(); + public abstract Task PackageAsync(); + public abstract Task DeployAsync(); + public abstract Task FinalizeAsync(); +} + +//------------------------------------------------------------- + +public interface IBuildContext +{ + ICakeContext CakeContext { get; } + IBuildContext ParentContext { get; } + + void Validate(); + void LogStateInfo(); +} + +//------------------------------------------------------------- + +public abstract class BuildContextBase : IBuildContext +{ + private List _childContexts; + private readonly string _contextName; + + protected BuildContextBase(ICakeContext cakeContext) + { + CakeContext = cakeContext; + + _contextName = GetContextName(); + } + + protected BuildContextBase(IBuildContext parentContext) + : this(parentContext.CakeContext) + { + ParentContext = parentContext; + } + + public ICakeContext CakeContext { get; private set; } + + public IBuildContext ParentContext { get; private set; } + + private List GetChildContexts() + { + var items = _childContexts; + if (items is null) + { + items = new List(); + + var properties = GetType().GetProperties(BindingFlags.FlattenHierarchy | BindingFlags.Instance | BindingFlags.Public); + + foreach (var property in properties) + { + //if (property.Name.EndsWith("Context")) + if (property.PropertyType.GetInterfaces().Any(x => x == (typeof(IBuildContext)))) + { + items.Add((IBuildContext)property.GetValue(this, null)); + } + } + + _childContexts = items; + + CakeContext.Debug($"Found '{items.Count}' child contexts for '{_contextName}' context"); + } + + return items; + } + + protected virtual string GetContextName() + { + var name = GetType().Name.Replace("Context", string.Empty); + return name; + } + + public void Validate() + { + CakeContext.Information($"Validating '{_contextName}' context"); + + ValidateContext(); + + foreach (var childContext in GetChildContexts()) + { + childContext.Validate(); + } + } + + protected abstract void ValidateContext(); + + public void LogStateInfo() + { + LogStateInfoForContext(); + + foreach (var childContext in GetChildContexts()) + { + childContext.LogStateInfo(); + } + } + + protected abstract void LogStateInfoForContext(); +} + +//------------------------------------------------------------- + +public abstract class BuildContextWithItemsBase : BuildContextBase +{ + protected BuildContextWithItemsBase(ICakeContext cakeContext) + : base(cakeContext) + { + } + + protected BuildContextWithItemsBase(IBuildContext parentContext) + : base(parentContext) + { + } + + public List Items { get; set; } +} + +//------------------------------------------------------------- + +public enum TargetType +{ + Unknown, + + AspireProject, + + Component, + + DockerImage, + + GitHubPages, + + Tool, + + UwpApp, + + VsExtension, + + WpfApp +} + +//------------------------------------------------------------- + +private static string GetTime() +{ + return DateTime.Now.ToString("HH:mm:ss.fff"); +} + +//------------------------------------------------------------- + +private static void LogSeparator(this ICakeContext cakeContext, string messageFormat, params object[] args) +{ + cakeContext.Information(""); + cakeContext.Information("--------------------------------------------------------------------------------"); + cakeContext.Information(messageFormat, args); + cakeContext.Information("--------------------------------------------------------------------------------"); + cakeContext.Information(""); +} + +//------------------------------------------------------------- + +private static void LogSeparator(this ICakeContext cakeContext) +{ + cakeContext.Information(""); + cakeContext.Information("--------------------------------------------------------------------------------"); + cakeContext.Information(""); +} + +//------------------------------------------------------------- + +private static string GetTempDirectory(BuildContext buildContext, string section, string projectName) +{ + var tempDirectory = buildContext.CakeContext.Directory(string.Format("./temp/{0}/{1}", section, projectName)); + + buildContext.CakeContext.CreateDirectory(tempDirectory); + + return tempDirectory; +} + +//------------------------------------------------------------- + +private static List SplitCommaSeparatedList(string value) +{ + return SplitSeparatedList(value, ','); +} + +//------------------------------------------------------------- + +private static List SplitSeparatedList(string value, params char[] separators) +{ + var list = new List(); + + if (!string.IsNullOrWhiteSpace(value)) + { + var splitted = value.Split(separators, StringSplitOptions.RemoveEmptyEntries); + + foreach (var split in splitted) + { + list.Add(split.Trim()); + } + } + + return list; +} + +//------------------------------------------------------------- + +private static string GetProjectDirectory(string projectName) +{ + var projectDirectory = System.IO.Path.Combine(".", "src", projectName); + return projectDirectory; +} + +//------------------------------------------------------------- + +private static string GetProjectOutputDirectory(BuildContext buildContext, string projectName) +{ + var projectDirectory = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, projectName); + return projectDirectory; +} + +//------------------------------------------------------------- + +private static string GetProjectFileName(BuildContext buildContext, string projectName) +{ + var allowedExtensions = new [] + { + "csproj", + "vcxproj" + }; + + foreach (var allowedExtension in allowedExtensions) + { + var fileName = System.IO.Path.Combine(GetProjectDirectory(projectName), $"{projectName}.{allowedExtension}"); + + //buildContext.CakeContext.Information(fileName); + + if (buildContext.CakeContext.FileExists(fileName)) + { + return fileName; + } + } + + // Old behavior + var fallbackFileName = System.IO.Path.Combine(GetProjectDirectory(projectName), $"{projectName}.{allowedExtensions[0]}"); + return fallbackFileName; +} + +//------------------------------------------------------------- + +private static string GetProjectSlug(string projectName, string replacement = "") +{ + var slug = projectName.Replace(".", replacement).Replace(" ", replacement); + return slug; +} + +//------------------------------------------------------------- + +private static string[] GetTargetFrameworks(BuildContext buildContext, string projectName) +{ + var targetFrameworks = new List(); + + var projectFileName = GetProjectFileName(buildContext, projectName); + var projectFileContents = System.IO.File.ReadAllText(projectFileName); + + var xmlDocument = XDocument.Parse(projectFileContents); + var projectElement = xmlDocument.Root; + + foreach (var propertyGroupElement in projectElement.Elements("PropertyGroup")) + { + // Step 1: check TargetFramework + var targetFrameworkElement = propertyGroupElement.Element("TargetFramework"); + if (targetFrameworkElement != null) + { + targetFrameworks.Add(targetFrameworkElement.Value); + break; + } + + // Step 2: check TargetFrameworks + var targetFrameworksElement = propertyGroupElement.Element("TargetFrameworks"); + if (targetFrameworksElement != null) + { + var value = targetFrameworksElement.Value; + targetFrameworks.AddRange(value.Split(new [] { ';' })); + break; + } + } + + if (targetFrameworks.Count == 0) + { + throw new Exception(string.Format("No target frameworks could be detected for project '{0}'", projectName)); + } + + return targetFrameworks.ToArray(); +} + +//------------------------------------------------------------- + +private static string[] GetPlatformTargets(BuildContext buildContext, string projectName) +{ + var platformTargets = new List(); + + var projectFileName = GetProjectFileName(buildContext, projectName); + var projectFileContents = System.IO.File.ReadAllText(projectFileName); + + var xmlDocument = XDocument.Parse(projectFileContents); + var projectElement = xmlDocument.Root; + + buildContext.CakeContext.Information("Searching for platform targets for project '{0}'", projectName); + + foreach (var propertyGroupElement in projectElement.Elements("PropertyGroup")) + { + // Step 1: check TargetFramework + var platformTargetElement = propertyGroupElement.Element("PlatformTarget"); + if (platformTargetElement is not null) + { + platformTargets.Add(platformTargetElement.Value); + break; + } + + // Step 2: check TargetFrameworks + var platformTargetsElement = propertyGroupElement.Element("PlatformTargets"); + if (platformTargetsElement is not null) + { + var value = platformTargetsElement.Value; + platformTargets.AddRange(value.Split(new[] { ';' })); + break; + } + } + + if (platformTargets.Count == 0) + { + buildContext.CakeContext.Information("No platform targets could be detected for project '{0}', using default value 'AnyCPU'", projectName); + + platformTargets.Add("AnyCPU"); // Default value if nothing is specified + } + + return platformTargets.ToArray(); +} + +//------------------------------------------------------------- + +private static string GetTargetSpecificConfigurationValue(BuildContext buildContext, TargetType targetType, string configurationPrefix, string fallbackValue) +{ + // Allow per project overrides via "[configurationPrefix][targetType]" + var keyToCheck = string.Format("{0}{1}", configurationPrefix, targetType); + + var value = buildContext.BuildServer.GetVariable(keyToCheck, fallbackValue); + return value; +} + +//------------------------------------------------------------- + +private static string GetProjectSpecificConfigurationValue(BuildContext buildContext, string projectName, string configurationPrefix, string fallbackValue) +{ + // Allow per project overrides via "[configurationPrefix][projectName]" + var slug = GetProjectSlug(projectName); + var keyToCheck = string.Format("{0}{1}", configurationPrefix, slug); + + var value = buildContext.BuildServer.GetVariable(keyToCheck, fallbackValue); + return value; +} + +//------------------------------------------------------------- + +private static void CleanProject(BuildContext buildContext, string projectName) +{ + buildContext.CakeContext.LogSeparator("Cleaning project '{0}'", projectName); + + var projectDirectory = GetProjectDirectory(projectName); + + buildContext.CakeContext.Information($"Investigating paths to clean up in '{projectDirectory}'"); + + var directoriesToDelete = new HashSet(); + + var binDirectory = System.IO.Path.Combine(projectDirectory, "bin"); + directoriesToDelete.Add(binDirectory); + + var objDirectory = System.IO.Path.Combine(projectDirectory, "obj"); + directoriesToDelete.Add(objDirectory); + + // Special C++ scenarios + var projectFileName = GetProjectFileName(buildContext, projectName); + if (IsCppProject(projectFileName)) + { + var debugDirectory = System.IO.Path.Combine(projectDirectory, "Debug"); + directoriesToDelete.Add(debugDirectory); + + var releaseDirectory = System.IO.Path.Combine(projectDirectory, "Release"); + directoriesToDelete.Add(releaseDirectory); + + var x64Directory = System.IO.Path.Combine(projectDirectory, "x64"); + directoriesToDelete.Add(x64Directory); + + var x86Directory = System.IO.Path.Combine(projectDirectory, "x86"); + directoriesToDelete.Add(x86Directory); + + var arm32Directory = System.IO.Path.Combine(projectDirectory, "ARM32"); + directoriesToDelete.Add(arm32Directory); + + var arm64Directory = System.IO.Path.Combine(projectDirectory, "ARM64"); + directoriesToDelete.Add(arm64Directory); + } + + foreach (var directoryToDelete in directoriesToDelete) + { + DeleteDirectoryWithLogging(buildContext, directoryToDelete); + } +} + +//------------------------------------------------------------- + +private static void DeleteDirectoryWithLogging(BuildContext buildContext, string directoryToDelete) +{ + if (buildContext.CakeContext.DirectoryExists(directoryToDelete)) + { + buildContext.CakeContext.Information($"Cleaning up directory '{directoryToDelete}'"); + + buildContext.CakeContext.DeleteDirectory(directoryToDelete, new DeleteDirectorySettings + { + Force = true, + Recursive = true + }); + } +} + +//------------------------------------------------------------- + +private static bool IsCppProject(string projectName) +{ + return projectName.EndsWith(".vcxproj"); +} + +//-------------------------------------------------------------- + +private static bool IsPackageContainerProject(BuildContext buildContext, string projectName) +{ + var isPackageContainer = false; + + var projectFileName = CreateInlinedProjectXml(buildContext, projectName); + + var projectFileContents = System.IO.File.ReadAllText(projectFileName); + + var xmlDocument = XDocument.Parse(projectFileContents); + var projectElement = xmlDocument.Root; + + foreach (var propertyGroupElement in projectElement.Elements("PropertyGroup")) + { + var packageContainerElement = propertyGroupElement.Element("PackageContainer"); + if (packageContainerElement != null) + { + if (packageContainerElement.Value.ToLower() == "true") + { + isPackageContainer = true; + } + break; + } + } + + return isPackageContainer; +} + +//------------------------------------------------------------- + +private static bool IsBlazorProject(BuildContext buildContext, string projectName) +{ + var projectFileName = GetProjectFileName(buildContext, projectName); + + if (!_blazorCache.TryGetValue(projectFileName, out var isBlazor)) + { + isBlazor = false; + + var lines = System.IO.File.ReadAllLines(projectFileName); + foreach (var line in lines) + { + // Match both *TargetFramework* and *TargetFrameworks* + var lowerCase = line.ToLower(); + if (lowerCase.Contains(" Excludes + var includes = buildContext.General.Includes; + if (includes.Count > 0) + { + var process = includes.Any(x => string.Equals(x, projectName, StringComparison.OrdinalIgnoreCase)); + + if (!process) + { + buildContext.CakeContext.Warning("Project '{0}' should not be processed, removing from projects to process", projectName); + } + + return process; + } + + var excludes = buildContext.General.Excludes; + if (excludes.Count > 0) + { + var process = !excludes.Any(x => string.Equals(x, projectName, StringComparison.OrdinalIgnoreCase)); + + if (!process) + { + buildContext.CakeContext.Warning("Project '{0}' should not be processed, removing from projects to process", projectName); + } + + return process; + } + + // Is this a known project? + if (!buildContext.RegisteredProjects.Any(x => string.Equals(projectName, x, StringComparison.OrdinalIgnoreCase))) + { + buildContext.CakeContext.Warning("Project '{0}' should not be processed, does not exist as registered project", projectName); + return false; + } + + if (buildContext.General.IsCiBuild) + { + // In CI builds, we always want to include all projects + return true; + } + + // Experimental mode where we ignore projects that are not on the deploy list when not in CI mode, but + // it can only work if they are not part of unit tests (but that should never happen) + // if (buildContext.Tests.Items.Count == 0) + // { + if (checkDeployment && + !ShouldBuildProject(buildContext, projectName) && + !ShouldPackageProject(buildContext, projectName) && + !ShouldDeployProject(buildContext, projectName)) + { + buildContext.CakeContext.Warning("Project '{0}' should not be processed because this is not a CI build, does not contain tests and the project should not be built, packaged or deployed, removing from projects to process", projectName); + return false; + } + //} + + return true; +} + +//------------------------------------------------------------- + +private static string CreateInlinedProjectXml(BuildContext buildContext, string projectName) +{ + buildContext.CakeContext.Information($"Running 'msbuild /pp' for project '{projectName}' to create inlined project XML"); + + var projectInlinedFileName = System.IO.Path.Combine(GetProjectOutputDirectory(buildContext, projectName), + "..", $"{projectName}.inlined.xml"); + + // Note: disabled caching until we correctly clean up everything + //if (!buildContext.CakeContext.FileExists(projectInlinedFileName)) + { + // Run "msbuild /pp" to create a single project file + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, + ToolVersion = MSBuildToolVersion.Default, + Configuration = buildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(buildContext, msBuildSettings, projectName, "pp"); + + msBuildSettings.Target = string.Empty; + msBuildSettings.ArgumentCustomization = args => args.Append($"/pp:{projectInlinedFileName}"); + + var projectFileName = GetProjectFileName(buildContext, projectName); + + RunMsBuild(buildContext, projectName, projectFileName, msBuildSettings, "pp"); + } + + return projectInlinedFileName; +} + +//------------------------------------------------------------- + +private static List GetProjectRuntimesIdentifiers(BuildContext buildContext, Cake.Core.IO.FilePath solutionOrProjectFileName, IReadOnlyList runtimeIdentifiersToInvestigate) +{ + var projectFileContents = System.IO.File.ReadAllText(solutionOrProjectFileName.FullPath)?.ToLower(); + + var supportedRuntimeIdentifiers = new List(); + + foreach (var runtimeIdentifier in runtimeIdentifiersToInvestigate) + { + if (!string.IsNullOrWhiteSpace(runtimeIdentifier)) + { + if (!projectFileContents.Contains(runtimeIdentifier, StringComparison.OrdinalIgnoreCase)) + { + buildContext.CakeContext.Information("Project '{0}' does not support runtime identifier '{1}', removing from supported runtime identifiers list", solutionOrProjectFileName, runtimeIdentifier); + continue; + } + } + + supportedRuntimeIdentifiers.Add(runtimeIdentifier); + } + + if (supportedRuntimeIdentifiers.Count == 0) + { + buildContext.CakeContext.Information("Project '{0}' does not have any explicit runtime identifiers left, adding empty one as default", solutionOrProjectFileName); + + // Default + supportedRuntimeIdentifiers.Add(string.Empty); + } + + return supportedRuntimeIdentifiers; +} + +//------------------------------------------------------------- + +private static bool ShouldBuildProject(BuildContext buildContext, string projectName) +{ + // Allow the build server to configure this via "Build[ProjectName]" + var slug = GetProjectSlug(projectName); + var keyToCheck = string.Format("Build{0}", slug); + + // No need to build if we don't package + var shouldBuild = ShouldPackageProject(buildContext, projectName); + + // By default, everything should be built. This feature is to explicitly not include + // a project in the build when a solution contains multiple projects / components that + // need to be built / packaged / deployed separately + // + // The default value is "ShouldPackageProject" since we assume it does not need + // to be built if it's not supposed to be packaged + shouldBuild = buildContext.BuildServer.GetVariableAsBool(keyToCheck, shouldBuild); + + buildContext.CakeContext.Information($"Value for '{keyToCheck}': {shouldBuild}"); + + return shouldBuild; +} + +//------------------------------------------------------------- + +private static bool ShouldPackageProject(BuildContext buildContext, string projectName) +{ + // Allow the build server to configure this via "Package[ProjectName]" + var slug = GetProjectSlug(projectName); + var keyToCheck = string.Format("Package{0}", slug); + + // No need to package if we don't deploy + var shouldPackage = ShouldDeployProject(buildContext, projectName); + + // The default value is "ShouldDeployProject" since we assume it does not need + // to be packaged if it's not supposed to be deployed + shouldPackage = buildContext.BuildServer.GetVariableAsBool(keyToCheck, shouldPackage); + + // If this is *only* a dependency, it should never be deployed + if (IsOnlyDependencyProject(buildContext, projectName)) + { + shouldPackage = false; + } + + if (shouldPackage && !ShouldProcessProject(buildContext, projectName, false)) + { + buildContext.CakeContext.Information($"Project '{projectName}' should not be processed, excluding it anyway"); + + shouldPackage = false; + } + + buildContext.CakeContext.Information($"Value for '{keyToCheck}': {shouldPackage}"); + + return shouldPackage; +} + +//------------------------------------------------------------- + +private static bool ShouldDeployProject(BuildContext buildContext, string projectName) +{ + // Allow the build server to configure this via "Deploy[ProjectName]" + var slug = GetProjectSlug(projectName); + var keyToCheck = string.Format("Deploy{0}", slug); + + // By default, deploy + var shouldDeploy = buildContext.BuildServer.GetVariableAsBool(keyToCheck, true); + + // If this is *only* a dependency, it should never be deployed + if (IsOnlyDependencyProject(buildContext, projectName)) + { + shouldDeploy = false; + } + + if (shouldDeploy && !ShouldProcessProject(buildContext, projectName, false)) + { + buildContext.CakeContext.Information($"Project '{projectName}' should not be processed, excluding it anyway"); + + shouldDeploy = false; + } + + buildContext.CakeContext.Information($"Value for '{keyToCheck}': {shouldDeploy}"); + + return shouldDeploy; +} + +//------------------------------------------------------------- + +private static bool IsOnlyDependencyProject(BuildContext buildContext, string projectName) +{ + buildContext.CakeContext.Information($"Checking if project '{projectName}' is a dependency only"); + + // If not in the dependencies list, we can stop checking + if (!buildContext.Dependencies.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is not in list of dependencies, assuming not dependency only"); + return false; + } + + if (buildContext.Components.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is list of components, assuming not dependency only"); + return false; + } + + if (buildContext.DockerImages.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is list of docker images, assuming not dependency only"); + return false; + } + + if (buildContext.GitHubPages.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is list of GitHub pages, assuming not dependency only"); + return false; + } + + if (buildContext.Templates.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is list of templates, assuming not dependency only"); + return false; + } + + if (buildContext.Tools.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is list of tools, assuming not dependency only"); + return false; + } + + if (buildContext.Uwp.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is list of UWP apps, assuming not dependency only"); + return false; + } + + if (buildContext.VsExtensions.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is list of VS extensions, assuming not dependency only"); + return false; + } + + if (buildContext.Wpf.Items.Contains(projectName)) + { + buildContext.CakeContext.Information($"Project is list of WPF apps, assuming not dependency only"); + return false; + } + + buildContext.CakeContext.Information($"Project '{projectName}' is a dependency only"); + + // It's in the dependencies list and not in any other list + return true; +} + +//------------------------------------------------------------- + +public static void Add(this Dictionary> dictionary, string project, params string[] projects) +{ + dictionary.Add(project, new List(projects)); +} \ No newline at end of file diff --git a/deployment/cake/lib-logging.cake b/deployment/cake/lib-logging.cake new file mode 100644 index 0000000..22587f6 --- /dev/null +++ b/deployment/cake/lib-logging.cake @@ -0,0 +1,51 @@ +// Note: code originally comes from https://stackoverflow.com/questions/50826394/how-to-print-tool-command-line-in-cake + +/// +/// Temporary sets logging verbosity. +/// +/// +/// +/// // Temporary sets logging verbosity to Diagnostic. +/// using(context.UseVerbosity(Verbosity.Diagnostic)) +/// { +/// context.DotNetBuild(project, settings); +/// } +/// +/// +public static VerbosityChanger UseVerbosity(this ICakeContext context, Verbosity newVerbosity) => + new VerbosityChanger(context.Log, newVerbosity); + + +/// +/// Temporary sets logging verbosity to Diagnostic. +/// +/// +/// +/// // Temporary sets logging verbosity to Diagnostic. +/// using(context.UseDiagnosticVerbosity()) +/// { +/// context.DotNetBuild(project, settings); +/// } +/// +/// +public static VerbosityChanger UseDiagnosticVerbosity(this ICakeContext context) => + context.UseVerbosity(Verbosity.Diagnostic); + +/// +/// Cake log verbosity changer. +/// Restores old verbosity on Dispose. +/// +public class VerbosityChanger : IDisposable +{ + ICakeLog _log; + Verbosity _oldVerbosity; + + public VerbosityChanger(ICakeLog log, Verbosity newVerbosity) + { + _log = log; + _oldVerbosity = log.Verbosity; + _log.Verbosity = newVerbosity; + } + + public void Dispose() => _log.Verbosity = _oldVerbosity; +} \ No newline at end of file diff --git a/deployment/cake/lib-msbuild.cake b/deployment/cake/lib-msbuild.cake new file mode 100644 index 0000000..c158c5b --- /dev/null +++ b/deployment/cake/lib-msbuild.cake @@ -0,0 +1,497 @@ +#addin "nuget:?package=Cake.Issues&version=5.9.1" +#addin "nuget:?package=Cake.Issues.MsBuild&version=5.9.1" +#addin "nuget:?package=System.Configuration.ConfigurationManager&version=10.0.1" + +#tool "nuget:?package=MSBuild.Extension.Pack&version=1.9.1" + +//------------------------------------------------------------- + +private static void BuildSolution(BuildContext buildContext) +{ + var solutionName = buildContext.General.Solution.Name; + var solutionFileName = buildContext.General.Solution.FileName; + + buildContext.CakeContext.LogSeparator("Building solution '{0}'", solutionName); + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, + //Verbosity = Verbosity.Diagnostic, + ToolVersion = MSBuildToolVersion.Default, + Configuration = buildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform, + PlatformTarget = PlatformTarget.MSIL, + NoLogo = true + }; + + //ConfigureMsBuild(buildContext, msBuildSettings, dependency, "build"); + + RunMsBuild(buildContext, "Solution", solutionFileName, msBuildSettings, "build"); +} + +//------------------------------------------------------------- + +private static void ConfigureMsBuild(BuildContext buildContext, MSBuildSettings msBuildSettings, + string projectName, string action, bool? allowVsPrerelease = null) +{ + var toolPath = GetVisualStudioPath(buildContext, allowVsPrerelease); + if (!string.IsNullOrWhiteSpace(toolPath)) + { + buildContext.CakeContext.Information($"Overriding ms build tool path to '{toolPath}'"); + + msBuildSettings.ToolPath = toolPath; + } + + // Note: we need to set OverridableOutputPath because we need to be able to respect + // AppendTargetFrameworkToOutputPath which isn't possible for global properties (which + // are properties passed in using the command line) + var outputDirectory = GetProjectOutputDirectory(buildContext, projectName); + buildContext.CakeContext.Information("Output directory: '{0}'", outputDirectory); + msBuildSettings.WithProperty("OverridableOutputRootPath", buildContext.General.OutputRootDirectory); + + // GHK: 2022-05-25: Disabled overriding the (whole) output path since this caused all + // reference projects to be re-build again since this override is used for all projects, + // including project references + //msBuildSettings.WithProperty("OverridableOutputPath", outputDirectory); + + msBuildSettings.WithProperty("PackageOutputPath", buildContext.General.OutputRootDirectory); + + buildContext.CakeContext.Information("This is NOT a local build, disabling building of project references"); + + // Don't build project references (should already be built) + msBuildSettings.WithProperty("BuildProjectReferences", "false"); + + //InjectAssemblySearchPathsInProjectFile(buildContext, projectName, GetProjectFileName(buildContext, projectName)); + + // Continuous integration build + msBuildSettings.ContinuousIntegrationBuild = true; + //msBuildSettings.WithProperty("ContinuousIntegrationBuild", "true"); + + // No NuGet restore (should already be done) + msBuildSettings.WithProperty("ResolveNuGetPackages", "false"); + msBuildSettings.Restore = false; + + // Solution info + // msBuildSettings.WithProperty("SolutionFileName", System.IO.Path.GetFileName(buildContext.General.Solution.FileName)); + // msBuildSettings.WithProperty("SolutionPath", System.IO.Path.GetFullPath(buildContext.General.Solution.FileName)); + // msBuildSettings.WithProperty("SolutionDir", System.IO.Path.GetFullPath(buildContext.General.Solution.Directory)); + // msBuildSettings.WithProperty("SolutionName", buildContext.General.Solution.Name); + // msBuildSettings.WithProperty("SolutionExt", ".slnx"); + // msBuildSettings.WithProperty("DefineExplicitDefaults", "true"); + + // Path maps + if (!buildContext.General.IsLocalBuild) + { + // Note: disabled since it breaks Verify tests and requires further investigation + //msBuildSettings.WithProperty("PathMap", $"{buildContext.General.RootDirectory}=.{System.IO.Path.DirectorySeparatorChar}"); + } + + // Disable copyright info + msBuildSettings.NoLogo = true; + + // Use as much CPU as possible + msBuildSettings.MaxCpuCount = 0; + + // Enable for file logging + if (buildContext.General.EnableMsBuildFileLog) + { + msBuildSettings.AddFileLogger(new MSBuildFileLogger + { + Verbosity = msBuildSettings.Verbosity, + //Verbosity = Verbosity.Diagnostic, + LogFile = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, string.Format(@"MsBuild_{0}_{1}_log.log", projectName, action)) + }); + } + + // Enable for bin logging + if (buildContext.General.EnableMsBuildBinaryLog) + { + msBuildSettings.BinaryLogger = new MSBuildBinaryLogSettings + { + Enabled = true, + Imports = MSBuildBinaryLogImports.Embed, + FileName = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, string.Format(@"MsBuild_{0}_{1}_log.binlog", projectName, action)) + }; + } +} + +//------------------------------------------------------------- + +private static void ConfigureMsBuildForDotNet(BuildContext buildContext, DotNetMSBuildSettings msBuildSettings, + string projectName, string action, bool? allowVsPrerelease = null) +{ + var toolPath = GetVisualStudioPath(buildContext, allowVsPrerelease); + if (!string.IsNullOrWhiteSpace(toolPath)) + { + buildContext.CakeContext.Information($"Overriding ms build tool path to '{toolPath}'"); + + msBuildSettings.ToolPath = toolPath; + } + + // Note: we need to set OverridableOutputPath because we need to be able to respect + // AppendTargetFrameworkToOutputPath which isn't possible for global properties (which + // are properties passed in using the command line) + var outputDirectory = GetProjectOutputDirectory(buildContext, projectName); + buildContext.CakeContext.Information("Output directory: '{0}'", outputDirectory); + msBuildSettings.WithProperty("OverridableOutputRootPath", buildContext.General.OutputRootDirectory); + + // GHK: 2022-05-25: Disabled overriding the (whole) output path since this caused all + // reference projects to be re-build again since this override is used for all projects, + // including project references + //msBuildSettings.WithProperty("OverridableOutputPath", outputDirectory); + + msBuildSettings.WithProperty("PackageOutputPath", buildContext.General.OutputRootDirectory); + + // Don't build project references (should already be built) + msBuildSettings.WithProperty("BuildProjectReferences", "false"); + + //InjectAssemblySearchPathsInProjectFile(buildContext, projectName, GetProjectFileName(buildContext, projectName)); + + // Continuous integration build + msBuildSettings.ContinuousIntegrationBuild = true; + //msBuildSettings.WithProperty("ContinuousIntegrationBuild", "true"); + + // No NuGet restore (should already be done) + msBuildSettings.WithProperty("ResolveNuGetPackages", "false"); + //msBuildSettings.Restore = false; + + // Solution info + // msBuildSettings.WithProperty("SolutionFileName", System.IO.Path.GetFileName(buildContext.General.Solution.FileName)); + // msBuildSettings.WithProperty("SolutionPath", System.IO.Path.GetFullPath(buildContext.General.Solution.FileName)); + // msBuildSettings.WithProperty("SolutionDir", System.IO.Path.GetFullPath(buildContext.General.Solution.Directory)); + // msBuildSettings.WithProperty("SolutionName", buildContext.General.Solution.Name); + // msBuildSettings.WithProperty("SolutionExt", ".slnx"); + // msBuildSettings.WithProperty("DefineExplicitDefaults", "true"); + + // Path maps + if (!buildContext.General.IsLocalBuild) + { + // Note: disabled since it breaks Verify tests and requires further investigation + //msBuildSettings.WithProperty("PathMap", $"{buildContext.General.RootDirectory}=.{System.IO.Path.DirectorySeparatorChar}"); + } + + // Disable copyright info + msBuildSettings.NoLogo = true; + + // Use as much CPU as possible + msBuildSettings.MaxCpuCount = 0; + + // Enable for file logging + if (buildContext.General.EnableMsBuildFileLog) + { + msBuildSettings.AddFileLogger(new MSBuildFileLoggerSettings + { + Verbosity = msBuildSettings.Verbosity, + //Verbosity = Verbosity.Diagnostic, + LogFile = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, string.Format(@"MsBuild_{0}_{1}_log.log", projectName, action)) + }); + } + + // Enable for bin logging + if (buildContext.General.EnableMsBuildBinaryLog) + { + msBuildSettings.BinaryLogger = new MSBuildBinaryLoggerSettings + { + Enabled = true, + Imports = MSBuildBinaryLoggerImports.Embed, + FileName = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, string.Format(@"MsBuild_{0}_{1}.binlog", projectName, action)) + }; + + // Note: this only works for direct .net core msbuild usage, not when this is + // being wrapped in a tool (such as 'dotnet pack') + var binLogArgs = string.Format("-bl:\"{0}\";ProjectImports=Embed", + System.IO.Path.Combine(buildContext.General.OutputRootDirectory, string.Format(@"MsBuild_{0}_{1}_log.binlog", projectName, action))); + + msBuildSettings.ArgumentCustomization = args => args.Append(binLogArgs); + } +} + +//------------------------------------------------------------- + +private static void RunMsBuild(BuildContext buildContext, string projectName, string projectFileName, MSBuildSettings msBuildSettings, string action) +{ + // IMPORTANT NOTE --- READ <============================================= + // + // Note: + // - Binary logger outputs version 9, but the binlog reader only supports up to 8 + // - Xml logger only seems to read warnings + // + // IMPORTANT NOTE --- READ <============================================= + + var totalStopwatch = Stopwatch.StartNew(); + var buildStopwatch = Stopwatch.StartNew(); + + // Enforce additional logging for issues + //var logPath = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, string.Format(@"MsBuild_{0}_{1}_log.binlog", projectName, action)); + + buildContext.CakeContext.CreateDirectory(buildContext.General.OutputRootDirectory); + + var logPath = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, string.Format(@"MsBuild_{0}_{1}_log.xml", projectName, action)); + + if (buildContext.General.EnableMsBuildXmlLog) + { + msBuildSettings.WithLogger(buildContext.CakeContext.Tools.Resolve("MSBuild.ExtensionPack.Loggers.dll").FullPath, + "XmlFileLogger", $"logfile=\"{logPath}\";verbosity=Detailed;encoding=UTF-8"); + } + + var failBuild = false; + + try + { + // using (buildContext.CakeContext.UseDiagnosticVerbosity()) + // { + buildContext.CakeContext.MSBuild(projectFileName, msBuildSettings); + //} + } + catch (System.Exception) + { + // Accept for now, we will throw later + failBuild = true; + } + + buildContext.CakeContext.Information(string.Empty); + buildContext.CakeContext.Information($"Done {action}ing project, took '{buildStopwatch.Elapsed}'"); + buildContext.CakeContext.Information(string.Empty); + + if (buildContext.General.EnableMsBuildXmlLog && + System.IO.File.Exists(logPath)) + { + buildContext.CakeContext.Information($"Investigating potential issues using '{logPath}'"); + buildContext.CakeContext.Information(string.Empty); + + var investigationStopwatch = Stopwatch.StartNew(); + + var issuesContext = buildContext.CakeContext.MsBuildIssuesFromFilePath(logPath, buildContext.CakeContext.MsBuildXmlFileLoggerFormat()); + //var issuesContext = buildContext.CakeContext.MsBuildIssuesFromFilePath(logPath, buildContext.CakeContext.MsBuildBinaryLogFileFormat()); + + buildContext.CakeContext.Debug("Created issue context"); + + var issues = buildContext.CakeContext.ReadIssues(issuesContext, buildContext.General.RootDirectory); + + buildContext.CakeContext.Debug($"Found '{issues.Count()}' potential issues"); + + buildContext.CakeContext.Information(string.Empty); + + var loggedIssues = new HashSet(); + + foreach (var issue in issues) + { + var priority = issue.Priority ?? 0; + + var message = $"{issue.AffectedFileRelativePath}({issue.Line},{issue.Column}): {issue.Rule}: {issue.MessageText}"; + if (loggedIssues.Contains(message)) + { + continue; + } + + //buildContext.CakeContext.Information($"[{issue.Priority}] {message}"); + + if (priority == (int)IssuePriority.Warning) + { + buildContext.CakeContext.Warning($"WARNING: {message}"); + + loggedIssues.Add(message); + } + else if (priority == (int)IssuePriority.Error) + { + buildContext.CakeContext.Error($"ERROR: {message}"); + + loggedIssues.Add(message); + + failBuild = true; + } + + buildContext.CakeContext.Information(string.Empty); + buildContext.CakeContext.Information($"Done investigating project, took '{investigationStopwatch.Elapsed}'"); + buildContext.CakeContext.Information(string.Empty); + } + + buildContext.CakeContext.Information($"Done investigating project, took '{investigationStopwatch.Elapsed}'"); + buildContext.CakeContext.Information($"Total msbuild ({action} + investigation) took '{totalStopwatch.Elapsed}'"); + buildContext.CakeContext.Information(string.Empty); + } + + if (failBuild) + { + buildContext.CakeContext.Information(string.Empty); + + throw new Exception($"{action} failed for project '{projectName}'"); + } +} + +//------------------------------------------------------------- + +private static string FindLatestWindowsKitsDirectory(BuildContext buildContext) +{ + // Find highest number with 10.0, e.g. 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.18362.0\x64\makeappx.exe' + var directories = buildContext.CakeContext.GetDirectories($@"{Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86)}/Windows Kits/10/bin/10.0.*"); + + //buildContext.CakeContext.Information($"Found '{directories.Count}' potential directories for MakeAppX.exe"); + + var directory = directories.LastOrDefault(); + if (directory != null) + { + return directory.FullPath; + } + + return null; +} + +//------------------------------------------------------------- + +private static string GetVisualStudioDirectory(BuildContext buildContext, bool? allowVsPrerelease = null) +{ + // TODO: Support different editions (e.g. Professional, Enterprise, Community, etc) + + // Force 64-bit, even when running as 32-bit process + var programFilesx64 = Environment.ExpandEnvironmentVariables("%ProgramW6432%"); + var programFilesx86 = Environment.ExpandEnvironmentVariables("%ProgramFiles(x86)%"); + + var prereleasePaths = new List>(new [] + { + new KeyValuePair("Visual Studio 2026 Insiders", $@"{programFilesx64}\Microsoft Visual Studio\18\Insiders\"), + new KeyValuePair("Visual Studio 2026 Preview", $@"{programFilesx64}\Microsoft Visual Studio\18\Preview\"), + new KeyValuePair("Visual Studio 2022 Preview", $@"{programFilesx64}\Microsoft Visual Studio\2022\Preview\"), + new KeyValuePair("Visual Studio 2019 Preview", $@"{programFilesx86}\Microsoft Visual Studio\2019\Preview\"), + }); + + var normalPaths = new List> (new [] + { + new KeyValuePair("Visual Studio 2026 Enterprise", $@"{programFilesx64}\Microsoft Visual Studio\18\Enterprise\"), + new KeyValuePair("Visual Studio 2026 Professional", $@"{programFilesx64}\Microsoft Visual Studio\18\Professional\"), + new KeyValuePair("Visual Studio 2026 Community", $@"{programFilesx64}\Microsoft Visual Studio\18\Community\"), + new KeyValuePair("Visual Studio 2022 Enterprise", $@"{programFilesx64}\Microsoft Visual Studio\2022\Enterprise\"), + new KeyValuePair("Visual Studio 2022 Professional", $@"{programFilesx64}\Microsoft Visual Studio\2022\Professional\"), + new KeyValuePair("Visual Studio 2022 Community", $@"{programFilesx64}\Microsoft Visual Studio\2022\Community\"), + new KeyValuePair("Visual Studio 2019 Enterprise", $@"{programFilesx86}\Microsoft Visual Studio\2019\Enterprise\"), + new KeyValuePair("Visual Studio 2019 Professional", $@"{programFilesx86}\Microsoft Visual Studio\2019\Professional\"), + new KeyValuePair("Visual Studio 2019 Community", $@"{programFilesx86}\Microsoft Visual Studio\2019\Community\"), + }); + + // Prerelease paths + if ((allowVsPrerelease ?? true) && buildContext.General.UseVisualStudioPrerelease) + { + buildContext.CakeContext.Debug("Checking for installation of Visual Studio (preview)"); + + foreach (var prereleasePath in prereleasePaths) + { + if (System.IO.Directory.Exists(prereleasePath.Value)) + { + buildContext.CakeContext.Debug($"Found {prereleasePath.Key}"); + + return prereleasePath.Value; + } + } + } + + buildContext.CakeContext.Debug("Checking for installation of Visual Studio (non-preview)"); + + // Normal paths + foreach (var normalPath in normalPaths) + { + if (System.IO.Directory.Exists(normalPath.Value)) + { + buildContext.CakeContext.Debug($"Found {normalPath.Key}"); + + return normalPath.Value; + } + } + + // Fallback in case someone *only* has prerelease + foreach (var prereleasePath in prereleasePaths) + { + if (System.IO.Directory.Exists(prereleasePath.Value)) + { + buildContext.CakeContext.Information($"Only Visual Studio preview is available, using {prereleasePath.Key}"); + + return prereleasePath.Value; + } + } + + // Failed + return null; +} + +//------------------------------------------------------------- + +private static string GetVisualStudioPath(BuildContext buildContext, bool? allowVsPrerelease = null) +{ + var potentialPaths = new [] + { + @"MSBuild\Current\Bin\msbuild.exe", + @"MSBuild\17.0\Bin\msbuild.exe", + @"MSBuild\16.0\Bin\msbuild.exe", + @"MSBuild\15.0\Bin\msbuild.exe" + }; + + var directory = GetVisualStudioDirectory(buildContext, allowVsPrerelease); + + foreach (var potentialPath in potentialPaths) + { + var pathToCheck = System.IO.Path.Combine(directory, potentialPath); + if (System.IO.File.Exists(pathToCheck)) + { + return pathToCheck; + } + } + + throw new Exception("Could not find the path to Visual Studio (msbuild.exe)"); +} + +//------------------------------------------------------------- + +private static void InjectAssemblySearchPathsInProjectFile(BuildContext buildContext, string projectName, string projectFileName) +{ + try + { + // Allow this project to find any other projects that we have built (since we disabled + // building of project dependencies) + var assemblySearchPaths = new List(); + var separator = System.IO.Path.DirectorySeparatorChar.ToString(); + + foreach (var project in buildContext.AllProjects) + { + var projectOutputDirectory = GetProjectOutputDirectory(buildContext, project); + assemblySearchPaths.Add(projectOutputDirectory); + } + + if (assemblySearchPaths.Count == 0) + { + buildContext.CakeContext.Information("No assembly search paths found to inject"); + return; + } + + // For SourceLink to work, the .csproj should contain something like this: + // + var projectFileContents = System.IO.File.ReadAllText(projectFileName); + if (projectFileContents.Contains("AssemblySearchPaths")) + { + buildContext.CakeContext.Information("Assembly search paths is already added to the project file"); + return; + } + + buildContext.CakeContext.Information("Injecting assembly search paths into project file"); + + var xmlDocument = XDocument.Parse(projectFileContents); + var projectElement = xmlDocument.Root; + + // Item group with package reference + var propertyGroupElement = new XElement("PropertyGroup"); + var assemblySearchPathsElement = new XElement("AssemblySearchPaths"); + + assemblySearchPathsElement.Value = $"$(AssemblySearchPaths);{string.Join(";", assemblySearchPaths)}"; + + propertyGroupElement.Add(assemblySearchPathsElement); + projectElement.Add(propertyGroupElement); + + xmlDocument.Save(projectFileName); + } + catch (Exception ex) + { + buildContext.CakeContext.Error($"Failed to process assembly search paths for project '{projectFileName}': {ex.Message}"); + } +} + + + + diff --git a/deployment/cake/lib-nuget.cake b/deployment/cake/lib-nuget.cake new file mode 100644 index 0000000..81ebe7a --- /dev/null +++ b/deployment/cake/lib-nuget.cake @@ -0,0 +1,159 @@ +public class NuGetServer +{ + public string Url { get;set; } + + public string ApiKey { get;set; } + + public override string ToString() + { + var result = Url; + + result += string.Format(" (ApiKey present: '{0}')", !string.IsNullOrWhiteSpace(ApiKey)); + + return result; + } +} + +//------------------------------------------------------------- + +public static List GetNuGetServers(string urls, string apiKeys) +{ + var splittedUrls = urls.Split(new [] { ";" }, StringSplitOptions.None); + var splittedApiKeys = apiKeys.Split(new [] { ";" }, StringSplitOptions.None); + + if (splittedUrls.Length != splittedApiKeys.Length) + { + throw new Exception("Number of api keys does not match number of urls. Even if an API key is not required, add an empty one"); + } + + var servers = new List(); + + for (int i = 0; i < splittedUrls.Length; i++) + { + var url = splittedUrls[i]; + if (string.IsNullOrWhiteSpace(url)) + { + throw new Exception("Url for NuGet server cannot be empty"); + } + + servers.Add(new NuGetServer + { + Url = url, + ApiKey = splittedApiKeys[i] + }); + } + + return servers; +} + +//------------------------------------------------------------- + +private static void RestoreNuGetPackages(BuildContext buildContext, Cake.Core.IO.FilePath solutionOrProjectFileName) +{ + buildContext.CakeContext.LogSeparator("Restoring packages for '{0}'", solutionOrProjectFileName); + + var sources = SplitSeparatedList(buildContext.General.NuGet.PackageSources, ';'); + + var runtimeIdentifiers = new [] + { + "win-x86", + "win-x64", + "win-arm64", + "browser-wasm" + }; + + var supportedRuntimeIdentifiers = GetProjectRuntimesIdentifiers(buildContext, solutionOrProjectFileName, runtimeIdentifiers); + + RestoreNuGetPackagesUsingNuGet(buildContext, solutionOrProjectFileName, sources, supportedRuntimeIdentifiers); + RestoreNuGetPackagesUsingDotnetRestore(buildContext, solutionOrProjectFileName, sources, supportedRuntimeIdentifiers); +} + +//------------------------------------------------------------- + +private static void RestoreNuGetPackagesUsingNuGet(BuildContext buildContext, Cake.Core.IO.FilePath solutionOrProjectFileName, IReadOnlyList sources, IReadOnlyList runtimeIdentifiers) +{ + if (!buildContext.General.NuGet.RestoreUsingNuGet) + { + return; + } + + buildContext.CakeContext.LogSeparator("Restoring packages for '{0}' using 'NuGet'", solutionOrProjectFileName); + + // No need to deal with runtime identifiers + + try + { + var nuGetRestoreSettings = new NuGetRestoreSettings + { + DisableParallelProcessing = false, + NoCache = false, + NonInteractive = true, + RequireConsent = false + }; + + if (sources.Count > 0) + { + nuGetRestoreSettings.Source = sources.ToList(); + } + + buildContext.CakeContext.NuGetRestore(solutionOrProjectFileName, nuGetRestoreSettings); + } + catch (Exception) + { + // Ignore + } +} + +//------------------------------------------------------------- + +private static void RestoreNuGetPackagesUsingDotnetRestore(BuildContext buildContext, Cake.Core.IO.FilePath solutionOrProjectFileName, IReadOnlyList sources, IReadOnlyList runtimeIdentifiers) +{ + if (!buildContext.General.NuGet.RestoreUsingDotNetRestore) + { + return; + } + + buildContext.CakeContext.LogSeparator("Restoring packages for '{0}' using 'dotnet restore'", solutionOrProjectFileName); + + foreach (var runtimeIdentifier in runtimeIdentifiers) + { + try + { + buildContext.CakeContext.LogSeparator("Restoring packages for '{0}' using 'dotnet restore' using runtime identifier '{1}'", solutionOrProjectFileName, runtimeIdentifier); + + var restoreSettings = new DotNetRestoreSettings + { + DisableParallel = false, + Force = false, + ForceEvaluate = false, + IgnoreFailedSources = true, + NoCache = false, + NoDependencies = buildContext.General.NuGet.NoDependencies, // use true to speed up things + Verbosity = DotNetVerbosity.Normal + }; + + if (!string.IsNullOrWhiteSpace(runtimeIdentifier)) + { + buildContext.CakeContext.Information("Project restore uses explicit runtime identifier, forcing re-evaluation"); + + restoreSettings.Force = true; + restoreSettings.ForceEvaluate = true; + restoreSettings.Runtime = runtimeIdentifier; + } + + if (sources.Count > 0) + { + restoreSettings.Sources = sources.ToList(); + } + + using (buildContext.CakeContext.UseDiagnosticVerbosity()) + { + buildContext.CakeContext.DotNetRestore(solutionOrProjectFileName.FullPath, restoreSettings); + } + } + catch (Exception) + { + // Ignore + } + } +} \ No newline at end of file diff --git a/deployment/cake/lib-signing.cake b/deployment/cake/lib-signing.cake new file mode 100644 index 0000000..433a221 --- /dev/null +++ b/deployment/cake/lib-signing.cake @@ -0,0 +1,400 @@ +#tool "dotnet:?package=AzureSignTool&version=7.0.1" +#tool "dotnet:?package=NuGetKeyVaultSignTool&version=3.2.3" + +private static string _signToolFileName; +private static string _azureSignToolFileName; + +//------------------------------------------------------------- + +public static bool ShouldSignImmediately(BuildContext buildContext, string projectName) +{ + // Sometimes unit tests require signed assemblies, but only sign immediately when it's in the list + if (buildContext.CodeSigning.ProjectsToSignImmediately.Contains(projectName)) + { + buildContext.CakeContext.Information($"Immediately code signing '{projectName}' files"); + return true; + } + + if (buildContext.General.IsLocalBuild || + buildContext.General.IsCiBuild) + { + // Never code-sign local or ci builds + return false; + } + + return false; +} + +//------------------------------------------------------------- + +public static void SignProjectFiles(BuildContext buildContext, string projectName) +{ + var outputDirectory = string.Format("{0}/{1}", buildContext.General.OutputRootDirectory, projectName); + + var codeSignContext = buildContext.General.CodeSign; + var codeSignWildCard = codeSignContext.WildCard; + if (string.IsNullOrWhiteSpace(codeSignWildCard)) + { + // Empty, we need to override with project name for valid default value + codeSignWildCard = projectName; + } + + SignFilesInDirectory(buildContext, outputDirectory, codeSignWildCard); +} + +//------------------------------------------------------------- + +public static void SignFilesInDirectory(BuildContext buildContext, string directory, string codeSignWildCard) +{ + var codeSignContext = buildContext.General.CodeSign; + var azureCodeSignContext = buildContext.General.AzureCodeSign; + + if (buildContext.General.IsLocalBuild || + buildContext.General.IsCiBuild) + { + // Never code-sign local or ci builds + return; + } + + if (!codeSignContext.IsAvailable && + !azureCodeSignContext.IsAvailable) + { + buildContext.CakeContext.Information("Skipping code signing because none of the options is available"); + return; + } + + var projectFilesToSign = new List(); + + if (!string.IsNullOrWhiteSpace(codeSignWildCard)) + { + // Make sure the pattern becomes *[wildcard]* + codeSignWildCard += "*"; + } + else + { + codeSignWildCard = string.Empty; + } + + var exeSignFilesSearchPattern = string.Format("{0}/**/*{1}.exe", directory, codeSignWildCard); + buildContext.CakeContext.Information(exeSignFilesSearchPattern); + projectFilesToSign.AddRange(buildContext.CakeContext.GetFiles(exeSignFilesSearchPattern)); + + var dllSignFilesSearchPattern = string.Format("{0}/**/*{1}.dll", directory, codeSignWildCard); + buildContext.CakeContext.Information(dllSignFilesSearchPattern); + projectFilesToSign.AddRange(buildContext.CakeContext.GetFiles(dllSignFilesSearchPattern)); + + buildContext.CakeContext.Information("Found '{0}' files to code sign", projectFilesToSign.Count); + + var signToolCommand = GetSignToolCommandLine(buildContext); + + SignFiles(buildContext, signToolCommand, projectFilesToSign, null); +} + +//------------------------------------------------------------- + +public static void SignFile(BuildContext buildContext, FilePath filePath) +{ + SignFile(buildContext, filePath.FullPath); +} + +//------------------------------------------------------------- + +public static void SignFile(BuildContext buildContext, string fileName) +{ + var signToolCommand = GetSignToolCommandLine(buildContext); + + SignFiles(buildContext, signToolCommand, new [] { fileName }, null); +} + +//------------------------------------------------------------- + +public static void SignFiles(BuildContext buildContext, string signToolCommand, IEnumerable fileNames, string additionalCommandLineArguments) +{ + if (fileNames.Any()) + { + buildContext.CakeContext.Information($"Signing '{fileNames.Count()}' files, this could take a while..."); + } + + foreach (var fileName in fileNames) + { + SignFile(buildContext, signToolCommand, fileName.FullPath, additionalCommandLineArguments); + } +} + +//------------------------------------------------------------- + +public static void SignFiles(BuildContext buildContext, string signToolCommand, IEnumerable fileNames, string additionalCommandLineArguments) +{ + if (fileNames.Any()) + { + buildContext.CakeContext.Information($"Signing '{fileNames.Count()}' files, this could take a while..."); + } + + foreach (var fileName in fileNames) + { + SignFile(buildContext, signToolCommand, fileName, additionalCommandLineArguments); + } +} + +//------------------------------------------------------------- + +public static void SignFile(BuildContext buildContext, string signToolCommand, string fileName, string additionalCommandLineArguments) +{ + var codeSignContext = buildContext.General.CodeSign; + var azureCodeSignContext = buildContext.General.AzureCodeSign; + + if (string.IsNullOrWhiteSpace(_signToolFileName)) + { + // Always fetch, it is used for verification + _signToolFileName = FindWindowsSignToolFileName(buildContext); + } + + if (string.IsNullOrWhiteSpace(_azureSignToolFileName)) + { + _azureSignToolFileName = FindAzureSignToolFileName(buildContext); + } + + var signToolFileName = _signToolFileName; + + // Azure always wins + if (azureCodeSignContext.IsAvailable) + { + signToolFileName = _azureSignToolFileName; + } + + SignFile(buildContext, signToolFileName, signToolCommand, fileName, additionalCommandLineArguments); +} + +//------------------------------------------------------------- + +public static void SignFile(BuildContext buildContext, string signToolFileName, string signToolCommand, string fileName, string additionalCommandLineArguments) +{ + // Skip code signing in specific scenarios + if (string.IsNullOrWhiteSpace(signToolCommand)) + { + return; + } + + if (string.IsNullOrWhiteSpace(signToolFileName)) + { + throw new InvalidOperationException("Cannot find signtool, make sure to install a Windows Development Kit"); + } + + buildContext.CakeContext.Information(string.Empty); + + // Retry mechanism, signing with timestamping is not as reliable as we thought + var safetyCounter = 3; + + while (safetyCounter > 0) + { + buildContext.CakeContext.Information($"Ensuring file '{fileName}' is signed..."); + + // Check + var checkProcessSettings = new ProcessSettings + { + Arguments = $"verify /pa \"{fileName}\"", + Silent = true, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + + // Note: we can safely use SignTool.exe here + + using (var checkProcess = buildContext.CakeContext.StartAndReturnProcess(_signToolFileName, checkProcessSettings)) + { + checkProcess.WaitForExit(); + + var exitCode = checkProcess.GetExitCode(); + if (exitCode == 0) + { + buildContext.CakeContext.Information($"File '{fileName}' is already signed, skipping..."); + buildContext.CakeContext.Information(string.Empty); + return; + } + } + + // Sign + if (!string.IsNullOrWhiteSpace(additionalCommandLineArguments)) + { + signToolCommand += $" {additionalCommandLineArguments}"; + } + + var finalCommand = $"{signToolCommand} \"{fileName}\""; + + buildContext.CakeContext.Information($"File '{fileName}' is not signed, signing using '{finalCommand}'"); + + var signProcessSettings = new ProcessSettings + { + Arguments = finalCommand, + Silent = true + }; + + using (var signProcess = buildContext.CakeContext.StartAndReturnProcess(signToolFileName, signProcessSettings)) + { + signProcess.WaitForExit(); + + var exitCode = signProcess.GetExitCode(); + if (exitCode == 0) + { + return; + } + + buildContext.CakeContext.Warning($"Failed to sign '{fileName}', retries left: '{safetyCounter}'"); + + // Important: add a delay! + System.Threading.Thread.Sleep(5 * 1000); + } + + safetyCounter--; + } + + // If we get here, we failed + throw new Exception($"Signing of '{fileName}' failed"); +} + +//------------------------------------------------------------- + +public static void SignNuGetPackage(BuildContext buildContext, string fileName) +{ + var codeSignContext = buildContext.General.CodeSign; + var azureCodeSignContext = buildContext.General.AzureCodeSign; + + if (buildContext.General.IsCiBuild || + buildContext.General.IsLocalBuild) + { + return; + } + + if (!codeSignContext.IsAvailable && + !azureCodeSignContext.IsAvailable) + { + buildContext.CakeContext.Information("Skipping code signing because none of the options is available"); + return; + } + + buildContext.CakeContext.Information($"Signing NuGet package '{fileName}'"); + + if (azureCodeSignContext.IsAvailable) + { + var signToolFileName = FindNuGetAzureSignToolFileName(buildContext); + var signToolCommandLine = string.Format("sign -kvu {0} -kvt {1} -kvi {2} -kvs {3} -kvc {4} -tr {5} -fd {6}", + azureCodeSignContext.VaultUrl, + azureCodeSignContext.TenantId, + azureCodeSignContext.ClientId, + azureCodeSignContext.ClientSecret, + azureCodeSignContext.CertificateName, + azureCodeSignContext.TimeStampUri, + azureCodeSignContext.HashAlgorithm); + + var finalCommand = $"{signToolFileName} {signToolCommandLine} {fileName}"; + + buildContext.CakeContext.Information($"{finalCommand}'"); + + SignFile(buildContext, signToolFileName, signToolCommandLine, fileName, null); + + return; + } + + if (codeSignContext.IsAvailable) + { + var exitCode = buildContext.CakeContext.StartProcess(buildContext.General.NuGet.Executable, new ProcessSettings + { + Arguments = $"sign \"{fileName}\" -CertificateSubjectName \"{codeSignContext.CertificateSubjectName}\" -Timestamper \"{codeSignContext.TimeStampUri}\"" + }); + + buildContext.CakeContext.Information("Signing NuGet package exited with '{0}'", exitCode); + + return; + } + + throw new NotSupportedException("No supported code signing method could be found"); +} + +//------------------------------------------------------------- + +public static string FindWindowsSignToolFileName(BuildContext buildContext) +{ + var directory = FindLatestWindowsKitsDirectory(buildContext); + if (directory != null) + { + return System.IO.Path.Combine(directory, "x64", "signtool.exe"); + } + + return null; +} + +//------------------------------------------------------------- + +public static string FindAzureSignToolFileName(BuildContext buildContext) +{ + var path = buildContext.CakeContext.Tools.Resolve("AzureSignTool.exe"); + + buildContext.CakeContext.Information("Found path '{0}'", path); + + return path.FullPath; +} + +//------------------------------------------------------------- + +public static string FindNuGetAzureSignToolFileName(BuildContext buildContext) +{ + var path = buildContext.CakeContext.Tools.Resolve("NuGetKeyVaultSignTool.exe"); + + buildContext.CakeContext.Information("Found path '{0}'", path); + + return path.FullPath; +} + +//------------------------------------------------------------- + +public static string GetSignToolFileName(BuildContext buildContext) +{ + var codeSignContext = buildContext.General.CodeSign; + var azureCodeSignContext = buildContext.General.AzureCodeSign; + + // Azure first + if (azureCodeSignContext.IsAvailable) + { + return FindAzureSignToolFileName(buildContext); + } + + if (codeSignContext.IsAvailable) + { + return FindWindowsSignToolFileName(buildContext); + } + + return string.Empty; +} + +//------------------------------------------------------------- + +public static string GetSignToolCommandLine(BuildContext buildContext) +{ + var codeSignContext = buildContext.General.CodeSign; + var azureCodeSignContext = buildContext.General.AzureCodeSign; + + var signToolCommand = string.Empty; + + if (codeSignContext.IsAvailable) + { + signToolCommand = string.Format("sign /a /t {0} /n {1} /fd {2}", + codeSignContext.TimeStampUri, + codeSignContext.CertificateSubjectName, + codeSignContext.HashAlgorithm); + } + + // Note: Azure always wins + if (azureCodeSignContext.IsAvailable) + { + signToolCommand = string.Format("sign -kvu {0} -kvt {1} -kvi {2} -kvs {3} -kvc {4} -tr {5} -fd {6}", + azureCodeSignContext.VaultUrl, + azureCodeSignContext.TenantId, + azureCodeSignContext.ClientId, + azureCodeSignContext.ClientSecret, + azureCodeSignContext.CertificateName, + azureCodeSignContext.TimeStampUri, + azureCodeSignContext.HashAlgorithm); + } + + return signToolCommand; +} diff --git a/deployment/cake/lib-sourcelink.cake b/deployment/cake/lib-sourcelink.cake new file mode 100644 index 0000000..b6d907a --- /dev/null +++ b/deployment/cake/lib-sourcelink.cake @@ -0,0 +1,106 @@ +public static bool IsSourceLinkSupported(BuildContext buildContext, string projectName, string projectFileName) +{ + if (buildContext.General.SourceLink.IsDisabled) + { + return false; + } + + if (buildContext.General.IsLocalBuild) + { + return false; + } + + if (string.IsNullOrWhiteSpace(buildContext.General.Repository.Url)) + { + return false; + } + + // Only support C# projects + if (!projectFileName.EndsWith(".csproj")) + { + return false; + } + + // Is this a test project? + if (buildContext.Tests.Items.Contains(projectName)) + { + return false; + } + + // Only support when running a real build, e.g. ot for 'Package' only + if (!buildContext.General.Target.ToLower().Contains("build")) + { + return false; + } + + return true; +} + +//------------------------------------------------------------- + +public static void InjectSourceLinkInProjectFile(BuildContext buildContext, string projectName, string projectFileName) +{ + try + { + // Only support C# projects + if (!IsSourceLinkSupported(buildContext, projectName, projectFileName)) + { + return; + } + + // For SourceLink to work, the .csproj should contain something like this: + // + var projectFileContents = System.IO.File.ReadAllText(projectFileName); + if (projectFileContents.Contains("Microsoft.SourceLink.GitHub")) + { + return; + } + + buildContext.CakeContext.Warning("No SourceLink reference found, automatically injecting SourceLink package reference now"); + + //const string MSBuildNS = (XNamespace) "http://schemas.microsoft.com/developer/msbuild/2003"; + + var xmlDocument = XDocument.Parse(projectFileContents); + var projectElement = xmlDocument.Root; + + // Item group with package reference + var referencesItemGroup = new XElement("ItemGroup"); + var sourceLinkPackageReference = new XElement("PackageReference"); + sourceLinkPackageReference.Add(new XAttribute("Include", "Microsoft.SourceLink.GitHub")); + sourceLinkPackageReference.Add(new XAttribute("Version", "8.0.0")); + sourceLinkPackageReference.Add(new XAttribute("PrivateAssets", "all")); + + referencesItemGroup.Add(sourceLinkPackageReference); + projectElement.Add(referencesItemGroup); + + // Item group with source root + // + var sourceRootItemGroup = new XElement("ItemGroup"); + var sourceRoot = new XElement("SourceRoot"); + + // Required to end with a \ + var sourceRootValue = buildContext.General.RootDirectory; + var directorySeparator = System.IO.Path.DirectorySeparatorChar.ToString(); + if (!sourceRootValue.EndsWith(directorySeparator)) + { + sourceRootValue += directorySeparator; + }; + + sourceRoot.Add(new XAttribute("Include", sourceRootValue)); + sourceRoot.Add(new XAttribute("RepositoryUrl", buildContext.General.Repository.Url)); + + // Note: since we are not allowing source control manager queries (we don't want to require a .git directory), + // we must specify the additional information below + sourceRoot.Add(new XAttribute("SourceControl", "git")); + sourceRoot.Add(new XAttribute("RevisionId", buildContext.General.Repository.CommitId)); + + sourceRootItemGroup.Add(sourceRoot); + projectElement.Add(sourceRootItemGroup); + + xmlDocument.Save(projectFileName); + } + catch (Exception ex) + { + buildContext.CakeContext.Error($"Failed to process source link for project '{projectFileName}': {ex.Message}"); + } +} \ No newline at end of file diff --git a/deployment/cake/notifications-msteams.cake b/deployment/cake/notifications-msteams.cake new file mode 100644 index 0000000..5d92265 --- /dev/null +++ b/deployment/cake/notifications-msteams.cake @@ -0,0 +1,95 @@ +#addin "nuget:?package=Cake.MicrosoftTeams&version=6.0.0" + +//------------------------------------------------------------- + +public class MsTeamsNotifier : INotifier +{ + public MsTeamsNotifier(BuildContext buildContext) + { + BuildContext = buildContext; + + WebhookUrl = buildContext.BuildServer.GetVariable("MsTeamsWebhookUrl", showValue: false); + WebhookUrlForErrors = buildContext.BuildServer.GetVariable("MsTeamsWebhookUrlForErrors", WebhookUrl, showValue: false); + } + + public BuildContext BuildContext { get; private set; } + + public string WebhookUrl { get; private set; } + public string WebhookUrlForErrors { get; private set; } + + public string GetMsTeamsWebhookUrl(string project, TargetType targetType) + { + // Allow per target overrides via "MsTeamsWebhookUrlFor[TargetType]" + var targetTypeUrl = GetTargetSpecificConfigurationValue(BuildContext, targetType, "MsTeamsWebhookUrlFor", string.Empty); + if (!string.IsNullOrEmpty(targetTypeUrl)) + { + return targetTypeUrl; + } + + // Allow per project overrides via "MsTeamsWebhookUrlFor[ProjectName]" + var projectTypeUrl = GetProjectSpecificConfigurationValue(BuildContext, project, "MsTeamsWebhookUrlFor", string.Empty); + if (!string.IsNullOrEmpty(projectTypeUrl)) + { + return projectTypeUrl; + } + + // Return default fallback + return WebhookUrl; + } + + //------------------------------------------------------------- + + private string GetMsTeamsTarget(string project, TargetType targetType, NotificationType notificationType) + { + if (notificationType == NotificationType.Error) + { + return WebhookUrlForErrors; + } + + return GetMsTeamsWebhookUrl(project, targetType); + } + + //------------------------------------------------------------- + + public async Task NotifyAsync(string project, string message, TargetType targetType, NotificationType notificationType) + { + var targetWebhookUrl = GetMsTeamsTarget(project, targetType, notificationType); + if (string.IsNullOrWhiteSpace(targetWebhookUrl)) + { + return; + } + + var messageCard = new MicrosoftTeamsMessageCard + { + title = project, + summary = notificationType.ToString(), + sections = new [] + { + new MicrosoftTeamsMessageSection + { + activityTitle = notificationType.ToString(), + activitySubtitle = message, + activityText = " ", + activityImage = "https://raw.githubusercontent.com/cake-build/graphics/master/png/cake-small.png", + facts = new [] + { + new MicrosoftTeamsMessageFacts { name ="Project", value = project }, + new MicrosoftTeamsMessageFacts { name ="Version", value = BuildContext.General.Version.FullSemVer }, + new MicrosoftTeamsMessageFacts { name ="CakeVersion", value = BuildContext.CakeContext.Environment.Runtime.CakeVersion.ToString() }, + //new MicrosoftTeamsMessageFacts { name ="TargetFramework", value = Context.Environment.Runtime .TargetFramework.ToString() }, + }, + } + } + }; + + var result = BuildContext.CakeContext.MicrosoftTeamsPostMessage(messageCard, new MicrosoftTeamsSettings + { + IncomingWebhookUrl = targetWebhookUrl + }); + + if (result != System.Net.HttpStatusCode.OK) + { + BuildContext.CakeContext.Warning(string.Format("MsTeams result: {0}", result)); + } + } +} diff --git a/deployment/cake/notifications.cake b/deployment/cake/notifications.cake new file mode 100644 index 0000000..26db97b --- /dev/null +++ b/deployment/cake/notifications.cake @@ -0,0 +1,53 @@ +#l "notifications-msteams.cake" +//#l "notifications-slack.cake" + +//------------------------------------------------------------- + +public enum NotificationType +{ + Info, + + Error +} + +//------------------------------------------------------------- + +public interface INotifier +{ + Task NotifyAsync(string project, string message, TargetType targetType = TargetType.Unknown, NotificationType notificationType = NotificationType.Info); +} + +//------------------------------------------------------------- + +public class NotificationsIntegration : IntegrationBase +{ + private readonly List _notifiers = new List(); + + public NotificationsIntegration(BuildContext buildContext) + : base(buildContext) + { + _notifiers.Add(new MsTeamsNotifier(buildContext)); + } + + public async Task NotifyDefaultAsync(string project, string message, TargetType targetType = TargetType.Unknown) + { + await NotifyAsync(project, message, targetType, NotificationType.Info); + } + + //------------------------------------------------------------- + + public async Task NotifyErrorAsync(string project, string message, TargetType targetType = TargetType.Unknown) + { + await NotifyAsync(project, string.Format("ERROR: {0}", message), targetType, NotificationType.Error); + } + + //------------------------------------------------------------- + + public async Task NotifyAsync(string project, string message, TargetType targetType = TargetType.Unknown, NotificationType notificationType = NotificationType.Info) + { + foreach (var notifier in _notifiers) + { + await notifier.NotifyAsync(project, message, targetType, notificationType); + } + } +} \ No newline at end of file diff --git a/deployment/cake/sourcecontrol-github.cake b/deployment/cake/sourcecontrol-github.cake new file mode 100644 index 0000000..fe1be4c --- /dev/null +++ b/deployment/cake/sourcecontrol-github.cake @@ -0,0 +1,75 @@ +#addin "nuget:?package=Cake.GitHub&version=1.0.0" +#addin "nuget:?package=Octokit&version=14.0.0" + +//------------------------------------------------------------- + +public class GitHubSourceControl : ISourceControl +{ + public GitHubSourceControl(BuildContext buildContext) + { + BuildContext = buildContext; + + ApiKey = buildContext.BuildServer.GetVariable("GitHubApiKey", buildContext.General.Repository.Password, showValue: false); + OwnerName = buildContext.BuildServer.GetVariable("GitHubOwnerName", buildContext.General.Copyright.Company, showValue: true); + ProjectName = buildContext.BuildServer.GetVariable("GitHubProjectName", buildContext.General.Solution.Name, showValue: true); + + if (!string.IsNullOrWhiteSpace(ApiKey) && + !string.IsNullOrWhiteSpace(OwnerName) && + !string.IsNullOrWhiteSpace(ProjectName)) + { + IsAvailable = true; + } + } + + public BuildContext BuildContext { get; private set; } + + public string ApiKey { get; set; } + public string OwnerName { get; set; } + public string ProjectName { get; set; } + + public string OwnerAndProjectName + { + get { return $"{OwnerName}/{ProjectName}"; } + } + + public bool IsAvailable { get; private set; } + + public async Task MarkBuildAsPendingAsync(string context, string description) + { + UpdateStatus(GitHubStatusState.Pending, context, description); + } + + public async Task MarkBuildAsFailedAsync(string context, string description) + { + UpdateStatus(GitHubStatusState.Failure, context, description); + } + + public async Task MarkBuildAsSucceededAsync(string context, string description) + { + UpdateStatus(GitHubStatusState.Success, context, description); + } + + private void UpdateStatus(GitHubStatusState state, string context, string description) + { + // Disabled for now + return; + + if (!IsAvailable) + { + return; + } + + BuildContext.CakeContext.Information("Updating GitHub status to '{0}' | '{1}'", state, description); + + var commitSha = BuildContext.General.Repository.CommitId; + + // Note: UserName is not really required, use string.Empty, then only api key is needed + BuildContext.CakeContext.GitHubStatus(string.Empty, ApiKey, OwnerName, ProjectName, commitSha, new GitHubStatusSettings + { + State = state, + TargetUrl = null,// "url-to-build-server", + Description = description, + Context = $"Cake - {context}" + }); + } +} \ No newline at end of file diff --git a/deployment/cake/sourcecontrol.cake b/deployment/cake/sourcecontrol.cake new file mode 100644 index 0000000..49709a3 --- /dev/null +++ b/deployment/cake/sourcecontrol.cake @@ -0,0 +1,84 @@ +// Customize this file when using a different source controls +#l "sourcecontrol-github.cake" + +//------------------------------------------------------------- + +public interface ISourceControl +{ + Task MarkBuildAsPendingAsync(string context, string description); + Task MarkBuildAsFailedAsync(string context, string description); + Task MarkBuildAsSucceededAsync(string context, string description); +} + +//------------------------------------------------------------- + +public class SourceControlIntegration : IntegrationBase +{ + private readonly List _sourceControls = new List(); + + public SourceControlIntegration(BuildContext buildContext) + : base(buildContext) + { + _sourceControls.Add(new GitHubSourceControl(buildContext)); + } + + public async Task MarkBuildAsPendingAsync(string context, string description = null) + { + BuildContext.CakeContext.LogSeparator($"Marking build as pending: '{description ?? string.Empty}'"); + + context = context ?? "default"; + description = description ?? "Build pending"; + + foreach (var sourceControl in _sourceControls) + { + try + { + await sourceControl.MarkBuildAsPendingAsync(context, description); + } + catch (Exception ex) + { + BuildContext.CakeContext.Warning($"Failed to update status: {ex.Message}"); + } + } + } + + public async Task MarkBuildAsFailedAsync(string context, string description = null) + { + BuildContext.CakeContext.LogSeparator($"Marking build as failed: '{description ?? string.Empty}'"); + + context = context ?? "default"; + description = description ?? "Build failed"; + + foreach (var sourceControl in _sourceControls) + { + try + { + await sourceControl.MarkBuildAsFailedAsync(context, description); + } + catch (Exception ex) + { + BuildContext.CakeContext.Warning($"Failed to update status: {ex.Message}"); + } + } + } + + public async Task MarkBuildAsSucceededAsync(string context, string description = null) + { + BuildContext.CakeContext.LogSeparator($"Marking build as succeeded: '{description ?? string.Empty}'"); + + context = context ?? "default"; + description = description ?? "Build succeeded"; + + foreach (var sourceControl in _sourceControls) + { + try + { + await sourceControl.MarkBuildAsSucceededAsync(context, description); + } + catch (Exception ex) + { + BuildContext.CakeContext.Warning($"Failed to update status: {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/deployment/cake/tasks.cake b/deployment/cake/tasks.cake new file mode 100644 index 0000000..40d7b41 --- /dev/null +++ b/deployment/cake/tasks.cake @@ -0,0 +1,823 @@ +#pragma warning disable CS1998 + +#l "lib-generic.cake" +#l "lib-logging.cake" +#l "lib-msbuild.cake" +#l "lib-nuget.cake" +#l "lib-signing.cake" +#l "lib-sourcelink.cake" +#l "issuetrackers.cake" +#l "installers.cake" +#l "sourcecontrol.cake" +#l "notifications.cake" +#l "generic-tasks.cake" +#l "apps-uwp-tasks.cake" +#l "apps-wpf-tasks.cake" +#l "aspire-tasks.cake" +#l "codesigning-tasks.cake" +#l "components-tasks.cake" +#l "dependencies-tasks.cake" +#l "tools-tasks.cake" +#l "docker-tasks.cake" +#l "github-pages-tasks.cake" +#l "vsextensions-tasks.cake" +#l "tests.cake" +#l "templates-tasks.cake" + +#addin "nuget:?package=Cake.FileHelpers&version=7.0.0" +#addin "nuget:?package=Cake.Sonar&version=5.0.0" +#addin "nuget:?package=MagicChunks&version=2.0.0.119" +#addin "nuget:?package=Newtonsoft.Json&version=13.0.4" + +// Note: the SonarQube tool must be installed as a global .NET tool. If you are getting issues like this: +// +// The SonarScanner for MSBuild integration failed: [...] was unable to collect the required information about your projects. +// +// It probably means the tool is not correctly installed. +// `dotnet tool install --global dotnet-sonarscanner --ignore-failed-sources` +//#tool "nuget:?package=MSBuild.SonarQube.Runner.Tool&version=4.8.0" +#tool "nuget:?package=dotnet-sonarscanner&version=11.0.0" + +//------------------------------------------------------------- +// BACKWARDS COMPATIBILITY CODE - START +//------------------------------------------------------------- + +// Required so we have backwards compatibility, so developers can keep using +// GetBuildServerVariable in build.cake +private BuildServerIntegration _buildServerIntegration = null; + +private BuildServerIntegration GetBuildServerIntegration() +{ + if (_buildServerIntegration is null) + { + _buildServerIntegration = new BuildServerIntegration(Context, Parameters); + } + + return _buildServerIntegration; +} + +public string GetBuildServerVariable(string variableName, string defaultValue = null, bool showValue = false) +{ + var buildServerIntegration = GetBuildServerIntegration(); + return buildServerIntegration.GetVariable(variableName, defaultValue, showValue); +} + +//------------------------------------------------------------- +// BACKWARDS COMPATIBILITY CODE - END +//------------------------------------------------------------- + +//------------------------------------------------------------- +// BUILD CONTEXT +//------------------------------------------------------------- + +public class BuildContext : BuildContextBase +{ + public BuildContext(ICakeContext cakeContext) + : base(cakeContext) + { + Processors = new List(); + AllProjects = new List(); + RegisteredProjects = new List(); + Variables = new Dictionary(); + } + + public List Processors { get; private set; } + public Dictionary Parameters { get; set; } + public Dictionary Variables { get; private set; } + + // Integrations + public BuildServerIntegration BuildServer { get; set; } + public IssueTrackerIntegration IssueTracker { get; set; } + public InstallerIntegration Installer { get; set; } + public NotificationsIntegration Notifications { get; set; } + public SourceControlIntegration SourceControl { get; set; } + + // Contexts + public GeneralContext General { get; set; } + public TestsContext Tests { get; set; } + + public AspireContext Aspire { get; set; } + public CodeSigningContext CodeSigning { get; set; } + public ComponentsContext Components { get; set; } + public DependenciesContext Dependencies { get; set; } + public DockerImagesContext DockerImages { get; set; } + public GitHubPagesContext GitHubPages { get; set; } + public TemplatesContext Templates { get; set; } + public ToolsContext Tools { get; set; } + public UwpContext Uwp { get; set; } + public VsExtensionsContext VsExtensions { get; set; } + public WpfContext Wpf { get; set; } + + public List AllProjects { get; private set; } + public List RegisteredProjects { get; private set; } + + protected override void ValidateContext() + { + } + + protected override void LogStateInfoForContext() + { + } +} + +//------------------------------------------------------------- +// TASKS +//------------------------------------------------------------- + +Setup(setupContext => +{ + setupContext.Information("Running setup of build scripts"); + + var buildContext = new BuildContext(setupContext); + + // Important, set parameters first + buildContext.Parameters = Parameters ?? new Dictionary(); + + setupContext.LogSeparator("Creating integrations"); + + // Important: build server first so other integrations can read values from config + buildContext.BuildServer = GetBuildServerIntegration(); + buildContext.BuildServer.SetBuildContext(buildContext); + + setupContext.LogSeparator("Creating build context"); + + buildContext.General = InitializeGeneralContext(buildContext, buildContext); + buildContext.Tests = InitializeTestsContext(buildContext, buildContext); + + buildContext.Aspire = InitializeAspireContext(buildContext, buildContext); + buildContext.CodeSigning = InitializeCodeSigningContext(buildContext, buildContext); + buildContext.Components = InitializeComponentsContext(buildContext, buildContext); + buildContext.Dependencies = InitializeDependenciesContext(buildContext, buildContext); + buildContext.DockerImages = InitializeDockerImagesContext(buildContext, buildContext); + buildContext.GitHubPages = InitializeGitHubPagesContext(buildContext, buildContext); + buildContext.Templates = InitializeTemplatesContext(buildContext, buildContext); + buildContext.Tools = InitializeToolsContext(buildContext, buildContext); + buildContext.Uwp = InitializeUwpContext(buildContext, buildContext); + buildContext.VsExtensions = InitializeVsExtensionsContext(buildContext, buildContext); + buildContext.Wpf = InitializeWpfContext(buildContext, buildContext); + + // Other integrations last + buildContext.IssueTracker = new IssueTrackerIntegration(buildContext); + buildContext.Installer = new InstallerIntegration(buildContext); + buildContext.Notifications = new NotificationsIntegration(buildContext); + buildContext.SourceControl = new SourceControlIntegration(buildContext); + + setupContext.LogSeparator("Validating build context"); + + buildContext.Validate(); + + setupContext.LogSeparator("Creating processors"); + + // Note: always put templates and dependencies processor first (it's a dependency after all) + buildContext.Processors.Add(new TemplatesProcessor(buildContext)); + buildContext.Processors.Add(new DependenciesProcessor(buildContext)); + buildContext.Processors.Add(new AspireProcessor(buildContext)); + buildContext.Processors.Add(new ComponentsProcessor(buildContext)); + buildContext.Processors.Add(new DockerImagesProcessor(buildContext)); + buildContext.Processors.Add(new GitHubPagesProcessor(buildContext)); + buildContext.Processors.Add(new ToolsProcessor(buildContext)); + buildContext.Processors.Add(new UwpProcessor(buildContext)); + buildContext.Processors.Add(new VsExtensionsProcessor(buildContext)); + buildContext.Processors.Add(new WpfProcessor(buildContext)); + // !!! Note: we add test projects *after* preparing all the other processors, see Prepare task !!! + + setupContext.LogSeparator("Registering variables for templates"); + + // Preparing variables for templates + buildContext.Variables["GitVersion_MajorMinorPatch"] = buildContext.General.Version.MajorMinorPatch; + buildContext.Variables["GitVersion_FullSemVer"] = buildContext.General.Version.FullSemVer; + buildContext.Variables["GitVersion_NuGetVersion"] = buildContext.General.Version.NuGet; + + setupContext.LogSeparator("Build context is ready, displaying state info"); + + buildContext.LogStateInfo(); + + return buildContext; +}); + +//------------------------------------------------------------- + +Task("Initialize") + .Does(async buildContext => +{ + await buildContext.BuildServer.BeforeInitializeAsync(); + + buildContext.CakeContext.LogSeparator("Writing special values back to build server"); + + var displayVersion = buildContext.General.Version.FullSemVer; + if (buildContext.General.IsCiBuild) + { + displayVersion += " ci"; + } + + await buildContext.BuildServer.SetVersionAsync(displayVersion); + + var variablesToUpdate = new Dictionary(); + variablesToUpdate["channel"] = buildContext.Wpf.Channel; + variablesToUpdate["publishType"] = buildContext.General.Solution.PublishType.ToString(); + variablesToUpdate["isAlphaBuild"] = buildContext.General.IsAlphaBuild.ToString(); + variablesToUpdate["isBetaBuild"] = buildContext.General.IsBetaBuild.ToString(); + variablesToUpdate["isOfficialBuild"] = buildContext.General.IsOfficialBuild.ToString(); + + // Also write back versioning (then it can be cached), "worst case scenario" it's writing back the same versions + variablesToUpdate["GitVersion_MajorMinorPatch"] = buildContext.General.Version.MajorMinorPatch; + variablesToUpdate["GitVersion_FullSemVer"] = buildContext.General.Version.FullSemVer; + variablesToUpdate["GitVersion_NuGetVersion"] = buildContext.General.Version.NuGet; + variablesToUpdate["GitVersion_CommitsSinceVersionSource"] = buildContext.General.Version.CommitsSinceVersionSource; + + foreach (var variableToUpdate in variablesToUpdate) + { + await buildContext.BuildServer.SetVariableAsync(variableToUpdate.Key, variableToUpdate.Value); + } + + await buildContext.BuildServer.AfterInitializeAsync(); +}); + +//------------------------------------------------------------- + +Task("Prepare") + .Does(async buildContext => +{ + // Add all projects to registered projects + buildContext.RegisteredProjects.AddRange(buildContext.Aspire.Items); + buildContext.RegisteredProjects.AddRange(buildContext.Components.Items); + buildContext.RegisteredProjects.AddRange(buildContext.Dependencies.Items); + buildContext.RegisteredProjects.AddRange(buildContext.DockerImages.Items); + buildContext.RegisteredProjects.AddRange(buildContext.GitHubPages.Items); + buildContext.RegisteredProjects.AddRange(buildContext.Tests.Items); + buildContext.RegisteredProjects.AddRange(buildContext.Tools.Items); + buildContext.RegisteredProjects.AddRange(buildContext.Uwp.Items); + buildContext.RegisteredProjects.AddRange(buildContext.VsExtensions.Items); + buildContext.RegisteredProjects.AddRange(buildContext.Wpf.Items); + + await buildContext.BuildServer.BeforePrepareAsync(); + + foreach (var processor in buildContext.Processors) + { + if (processor is DependenciesProcessor) + { + // Process later + continue; + } + + await processor.PrepareAsync(); + } + + // Now add all projects, but dependencies first & tests last, which will be added at the end + buildContext.AllProjects.AddRange(buildContext.Aspire.Items); + buildContext.AllProjects.AddRange(buildContext.Components.Items); + buildContext.AllProjects.AddRange(buildContext.DockerImages.Items); + buildContext.AllProjects.AddRange(buildContext.GitHubPages.Items); + buildContext.AllProjects.AddRange(buildContext.Tools.Items); + buildContext.AllProjects.AddRange(buildContext.Uwp.Items); + buildContext.AllProjects.AddRange(buildContext.VsExtensions.Items); + buildContext.AllProjects.AddRange(buildContext.Wpf.Items); + + buildContext.CakeContext.LogSeparator("Final check which test projects should be included (1/2)"); + + // Once we know all the projects that will be built, we calculate which + // test projects need to be built as well + + var testProcessor = new TestProcessor(buildContext); + await testProcessor.PrepareAsync(); + buildContext.Processors.Add(testProcessor); + + buildContext.CakeContext.Information(string.Empty); + buildContext.CakeContext.Information($"Found '{buildContext.Tests.Items.Count}' test projects"); + + foreach (var test in buildContext.Tests.Items) + { + buildContext.CakeContext.Information($" - {test}"); + } + + buildContext.AllProjects.AddRange(buildContext.Tests.Items); + + buildContext.CakeContext.LogSeparator("Final check which dependencies should be included (2/2)"); + + // Now we really really determined all projects to build, we can check the dependencies + var dependenciesProcessor = (DependenciesProcessor)buildContext.Processors.First(x => x is DependenciesProcessor); + await dependenciesProcessor.PrepareAsync(); + + buildContext.CakeContext.Information(string.Empty); + buildContext.CakeContext.Information($"Found '{buildContext.Dependencies.Items.Count}' dependencies"); + + foreach (var dependency in buildContext.Dependencies.Items) + { + buildContext.CakeContext.Information($" - {dependency}"); + } + + // Add to the front, these are dependencies after all + buildContext.AllProjects.InsertRange(0, buildContext.Dependencies.Items); + + // Now we have the full collection, distinct + var allProjects = buildContext.AllProjects.ToArray(); + + buildContext.AllProjects.Clear(); + buildContext.AllProjects.AddRange(allProjects.Distinct()); + + buildContext.CakeContext.LogSeparator("Final projects to process"); + + foreach (var item in buildContext.AllProjects.ToList()) + { + buildContext.CakeContext.Information($"- {item}"); + } + + await buildContext.BuildServer.AfterPrepareAsync(); +}); + +//------------------------------------------------------------- + +Task("UpdateInfo") + .IsDependentOn("Prepare") + .Does(async buildContext => +{ + await buildContext.BuildServer.BeforeUpdateInfoAsync(); + + UpdateSolutionAssemblyInfo(buildContext); + + foreach (var processor in buildContext.Processors) + { + await processor.UpdateInfoAsync(); + } + + await buildContext.BuildServer.AfterUpdateInfoAsync(); +}); + +//------------------------------------------------------------- + +Task("Build") + .IsDependentOn("Clean") + .IsDependentOn("RestorePackages") + .IsDependentOn("UpdateInfo") + //.IsDependentOn("VerifyDependencies") + .IsDependentOn("CleanupCode") + .Does(async buildContext => +{ + await buildContext.BuildServer.BeforeBuildAsync(); + + await buildContext.SourceControl.MarkBuildAsPendingAsync("Build"); + + var sonarUrl = buildContext.General.SonarQube.Url; + + var enableSonar = !buildContext.General.SonarQube.IsDisabled && + buildContext.General.IsCiBuild && // Only build on CI (all projects need to be included) + !string.IsNullOrWhiteSpace(sonarUrl); + if (enableSonar) + { + var sonarSettings = new SonarBeginSettings + { + // SonarQube info + Url = sonarUrl, + + // Project info + Key = buildContext.General.SonarQube.Project, + Version = buildContext.General.Version.FullSemVer, + + // Use core clr version of SonarQube + UseCoreClr = true, + + // Minimize extreme logging + Verbose = false, + Silent = true, + + ArgumentCustomization = args => args + .Append("/d:sonar.qualitygate.wait=true") + .Append("/d:sonar.scanner.scanAll=false") + }; + + if (!string.IsNullOrWhiteSpace(buildContext.General.SonarQube.Organization)) + { + sonarSettings.Organization = buildContext.General.SonarQube.Organization; + } + + if (!string.IsNullOrWhiteSpace(buildContext.General.SonarQube.Username)) + { + sonarSettings.Login = buildContext.General.SonarQube.Username; + } + + if (!string.IsNullOrWhiteSpace(buildContext.General.SonarQube.Token)) + { + sonarSettings.Token = buildContext.General.SonarQube.Token; + } + + // see https://cakebuild.net/api/Cake.Sonar/SonarBeginSettings/ for more information on + // what to set for SonarCloud + + // Branch only works with the branch plugin. Documentation A says it's outdated, but + // B still mentions it: + // A: https://docs.sonarqube.org/latest/branches/overview/ + // B: https://docs.sonarqube.org/latest/analysis/analysis-parameters/ + if (buildContext.General.SonarQube.SupportBranches) + { + // TODO: How to support PR? + sonarSettings.Branch = buildContext.General.Repository.BranchName; + } + + Information("Beginning SonarQube"); + + SonarBegin(sonarSettings); + } + else + { + Information("Skipping Sonar integration since url is not specified or it has been explicitly disabled"); + } + + try + { + if (buildContext.General.Solution.BuildSolution) + { + BuildSolution(buildContext); + } + + foreach (var processor in buildContext.Processors) + { + if (processor is TestProcessor) + { + // Build test projects *after* SonarQube (not part of SQ analysis) + continue; + } + + await processor.BuildAsync(); + } + } + finally + { + if (enableSonar) + { + try + { + await buildContext.SourceControl.MarkBuildAsPendingAsync("SonarQube"); + + var sonarSettings = new SonarEndSettings + { + // Use core clr version of SonarQube + UseCoreClr = true + }; + + if (!string.IsNullOrWhiteSpace(buildContext.General.SonarQube.Username)) + { + sonarSettings.Login = buildContext.General.SonarQube.Username; + } + + if (!string.IsNullOrWhiteSpace(buildContext.General.SonarQube.Token)) + { + sonarSettings.Token = buildContext.General.SonarQube.Token; + } + + Information("Ending SonarQube"); + + SonarEnd(sonarSettings); + + await buildContext.SourceControl.MarkBuildAsSucceededAsync("SonarQube"); + } + catch (Exception) + { + var projectSpecificSonarUrl = $"{sonarUrl}/dashboard?id={buildContext.General.SonarQube.Project}"; + + if (buildContext.General.SonarQube.SupportBranches) + { + projectSpecificSonarUrl += $"&branch={buildContext.General.Repository.BranchName}"; + } + + var failedDescription = $"SonarQube failed, please visit '{projectSpecificSonarUrl}' for more details"; + + await buildContext.SourceControl.MarkBuildAsFailedAsync("SonarQube", failedDescription); + + throw; + } + } + } + + var testProcessor = buildContext.Processors.FirstOrDefault(x => x is TestProcessor) as TestProcessor; + if (testProcessor is not null) + { + // Build test projects *after* SonarQube (not part of SQ analysis). Unfortunately, because of this, we cannot yet mark + // the build as succeeded once we end the SQ session. Therefore, if SQ fails, both the SQ *and* build checks + // will be marked as failed if SQ fails. + await testProcessor.BuildAsync(); + } + + await buildContext.SourceControl.MarkBuildAsSucceededAsync("Build"); + + Information("Completed build for version '{0}'", buildContext.General.Version.NuGet); + + await buildContext.BuildServer.AfterBuildAsync(); +}) +.OnError(async (ex, buildContext) => +{ + await buildContext.SourceControl.MarkBuildAsFailedAsync("Build"); + + await buildContext.BuildServer.OnBuildFailedAsync(); + + throw ex; +}); + +//------------------------------------------------------------- + +Task("Test") + .IsDependentOn("Prepare") + // Note: no dependency on 'build' since we might have already built the solution + .Does(async buildContext => +{ + await buildContext.BuildServer.BeforeTestAsync(); + + await buildContext.SourceControl.MarkBuildAsPendingAsync("Test"); + + if (buildContext.Tests.Items.Count > 0) + { + // If docker is involved, login to all registries for the unit / integration tests + var dockerRegistries = new HashSet(); + var dockerProcessor = (DockerImagesProcessor)buildContext.Processors.Single(x => x is DockerImagesProcessor); + + try + { + foreach (var dockerImage in buildContext.DockerImages.Items) + { + var dockerRegistryUrl = dockerProcessor.GetDockerRegistryUrl(dockerImage); + if (dockerRegistries.Contains(dockerRegistryUrl)) + { + continue; + } + + // Note: we are logging in each time because the registry might be different per container + Information($"Logging in to docker @ '{dockerRegistryUrl}'"); + + dockerRegistries.Add(dockerRegistryUrl); + + var dockerRegistryUserName = dockerProcessor.GetDockerRegistryUserName(dockerImage); + var dockerRegistryPassword = dockerProcessor.GetDockerRegistryPassword(dockerImage); + + var dockerLoginSettings = new DockerRegistryLoginSettings + { + Username = dockerRegistryUserName, + Password = dockerRegistryPassword + }; + + DockerLogin(dockerLoginSettings, dockerRegistryUrl); + } + + // Always run all unit test projects before throwing + var failed = false; + + foreach (var testProject in buildContext.Tests.Items) + { + buildContext.CakeContext.LogSeparator("Running tests for '{0}'", testProject); + + try + { + RunUnitTests(buildContext, testProject); + } + catch (Exception ex) + { + failed = true; + + Warning($"Running tests for '{testProject}' caused an exception: {ex.Message}"); + } + } + + if (failed) + { + throw new Exception("At least 1 test project failed execution"); + } + } + finally + { + foreach (var dockerRegistry in dockerRegistries) + { + try + { + Information($"Logging out of docker @ '{dockerRegistry}'"); + + var dockerLogoutSettings = new DockerRegistryLogoutSettings + { + }; + + DockerLogout(dockerLogoutSettings, dockerRegistry); + } + catch (Exception ex) + { + Warning($"Failed to logout from docker: {ex.Message}"); + } + } + } + } + + await buildContext.SourceControl.MarkBuildAsSucceededAsync("Test"); + + Information("Completed tests for version '{0}'", buildContext.General.Version.NuGet); + + await buildContext.BuildServer.AfterTestAsync(); +}) +.OnError(async (ex, buildContext) => +{ + await buildContext.SourceControl.MarkBuildAsFailedAsync("Test"); + + await buildContext.BuildServer.OnTestFailedAsync(); + + throw ex; +}); + +//------------------------------------------------------------- + +Task("Package") + // Make sure to update info so our SolutionAssemblyInfo.cs is up to date + .IsDependentOn("UpdateInfo") + // Note: no dependency on 'build' since we might have already built the solution + // Make sure we have the temporary "project.assets.json" in case we need to package with Visual Studio + .IsDependentOn("RestorePackages") + // Make sure to update if we are running on a new agent so we can sign nuget packages + .IsDependentOn("UpdateNuGet") + .IsDependentOn("CodeSign") + .Does(async buildContext => +{ + await buildContext.BuildServer.BeforePackageAsync(); + + foreach (var processor in buildContext.Processors) + { + await processor.PackageAsync(); + } + + Information("Completed packaging for version '{0}'", buildContext.General.Version.NuGet); + + await buildContext.BuildServer.AfterPackageAsync(); +}); + +//------------------------------------------------------------- + +Task("PackageLocal") + .IsDependentOn("Package") + .Does(buildContext => +{ + // Note: no build server integration calls since this is *local* + + // For now only package components, we might need to move this to components-tasks.cake in the future + if (buildContext.Components.Items.Count == 0 && + buildContext.Tools.Items.Count == 0) + { + return; + } + + var localPackagesDirectory = buildContext.General.NuGet.LocalPackagesDirectory; + + Information("Copying build artifacts to '{0}'", localPackagesDirectory); + + CreateDirectory(localPackagesDirectory); + + foreach (var component in buildContext.Components.Items) + { + try + { + Information("Copying build artifact for '{0}'", component); + + var sourceFile = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, + $"{component}.{buildContext.General.Version.NuGet}.nupkg"); + + CopyFiles(new [] { sourceFile }, localPackagesDirectory); + } + catch (Exception) + { + // Ignore + Warning("Failed to copy build artifacts for '{0}'", component); + } + } + + Information("Copied build artifacts for version '{0}'", buildContext.General.Version.NuGet); +}); + +//------------------------------------------------------------- + +Task("Deploy") + // Note: no dependency on 'package' since we might have already packaged the solution + // Make sure we have the temporary "project.assets.json" in case we need to package with Visual Studio + .IsDependentOn("RestorePackages") + .Does(async buildContext => +{ + await buildContext.BuildServer.BeforeDeployAsync(); + + foreach (var processor in buildContext.Processors) + { + await processor.DeployAsync(); + } + + await buildContext.BuildServer.AfterDeployAsync(); +}); + +//------------------------------------------------------------- + +Task("Finalize") + // Note: no dependency on 'deploy' since we might have already deployed the solution + .Does(async buildContext => +{ + await buildContext.BuildServer.BeforeFinalizeAsync(); + + Information("Finalizing release '{0}'", buildContext.General.Version.FullSemVer); + + foreach (var processor in buildContext.Processors) + { + await processor.FinalizeAsync(); + } + + if (buildContext.General.IsOfficialBuild) + { + await buildContext.BuildServer.PinBuildAsync("Official build"); + } + + await buildContext.IssueTracker.CreateAndReleaseVersionAsync(); + + await buildContext.BuildServer.AfterFinalizeAsync(); +}); + +//------------------------------------------------------------- +// Wrapper tasks since we don't want to add "Build" as a +// dependency to "Package" because we want to run in multiple +// stages +//------------------------------------------------------------- + +Task("BuildAndTest") + .IsDependentOn("Initialize") + .IsDependentOn("Build") + .IsDependentOn("Test"); + +//------------------------------------------------------------- + +Task("BuildAndPackage") + .IsDependentOn("Initialize") + .IsDependentOn("Build") + .IsDependentOn("Test") + .IsDependentOn("Package"); + +//------------------------------------------------------------- + +Task("BuildAndPackageLocal") + .IsDependentOn("Initialize") + .IsDependentOn("Build") + //.IsDependentOn("Test") // Note: don't test for performance on local builds + .IsDependentOn("PackageLocal"); + +//------------------------------------------------------------- + +Task("BuildAndDeploy") + .IsDependentOn("Initialize") + .IsDependentOn("Build") + .IsDependentOn("Test") + .IsDependentOn("Package") + .IsDependentOn("Deploy"); + +//------------------------------------------------------------- + +Task("Default") + .Does(async buildContext => +{ + Error("No target specified, please specify one of the following targets:\n" + + " - Prepare\n" + + " - UpdateInfo\n" + + " - Build\n" + + " - Test\n" + + " - Package\n" + + " - Deploy\n" + + " - Finalize\n\n" + + "or one of the combined ones:\n" + + " - BuildAndTest\n" + + " - BuildAndPackage\n" + + " - BuildAndPackageLocal\n" + + " - BuildAndDeploy\n"); +}); + +//------------------------------------------------------------- +// Test wrappers +//------------------------------------------------------------- + +Task("TestNotifications") + .Does(async buildContext => +{ + await buildContext.Notifications.NotifyAsync("MyProject", "This is a generic test"); + await buildContext.Notifications.NotifyAsync("MyProject", "This is a component test", TargetType.Component); + await buildContext.Notifications.NotifyAsync("MyProject", "This is a docker image test", TargetType.DockerImage); + await buildContext.Notifications.NotifyAsync("MyProject", "This is a wpf app test", TargetType.WpfApp); + await buildContext.Notifications.NotifyErrorAsync("MyProject", "This is an error"); +}); + +//------------------------------------------------------------- + +Task("TestSourceControl") + .Does(async buildContext => +{ + await buildContext.SourceControl.MarkBuildAsPendingAsync("Build"); + + await System.Threading.Tasks.Task.Delay(5 * 1000); + + await buildContext.SourceControl.MarkBuildAsSucceededAsync("Build"); + + await buildContext.SourceControl.MarkBuildAsPendingAsync("Test"); + + await System.Threading.Tasks.Task.Delay(5 * 1000); + + await buildContext.SourceControl.MarkBuildAsSucceededAsync("Test"); +}); + +//------------------------------------------------------------- +// ACTUAL RUNNER - MUST BE DEFINED AT THE BOTTOM +//------------------------------------------------------------- + +var localTarget = GetBuildServerVariable("Target", "Default", showValue: true); +RunTarget(localTarget); diff --git a/deployment/cake/templates-tasks.cake b/deployment/cake/templates-tasks.cake new file mode 100644 index 0000000..7e69af9 --- /dev/null +++ b/deployment/cake/templates-tasks.cake @@ -0,0 +1,103 @@ +#l "templates-variables.cake" + +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using System.IO; + +//------------------------------------------------------------- + +public class TemplatesProcessor : ProcessorBase +{ + public TemplatesProcessor(BuildContext buildContext) + : base(buildContext) + { + var templatesRelativePath = "./deployment/templates"; + + if (CakeContext.DirectoryExists(templatesRelativePath)) + { + var currentDirectoryPath = System.IO.Directory.GetCurrentDirectory(); + var templateAbsolutePath = System.IO.Path.Combine(currentDirectoryPath, templatesRelativePath); + var files = System.IO.Directory.GetFiles(templateAbsolutePath, "*.*", System.IO.SearchOption.AllDirectories); + + CakeContext.Information($"Found '{files.Count()}' template files"); + + foreach (var file in files) + { + BuildContext.Templates.Items.Add(file.Substring(templateAbsolutePath.Length + 1)); + } + } + } + + public override bool HasItems() + { + return BuildContext.Templates.Items.Count > 0; + } + + public override async Task PrepareAsync() + { + + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + var variableRegex = new Regex(@"\$\{([^}]+)\}", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled); + + foreach (var template in BuildContext.Templates.Items) + { + CakeContext.Information($"Updating template file '{template}'"); + + var templateSourceFile = $"./deployment/templates/{template}"; + var content = CakeContext.FileReadText(templateSourceFile); + + var matches = variableRegex.Matches(content); + + foreach (var match in matches.Cast()) + { + var variableName = match.Groups[1].Value; + + CakeContext.Information($"Found usage of variable '{variableName}'"); + + if (!BuildContext.Variables.TryGetValue(variableName, out var replacement)) + { + CakeContext.Error($"Could not find value for variable '{variableName}'"); + continue; + } + + content = content.Replace($"${{{variableName}}}", replacement); + } + + CakeContext.FileWriteText($"{template}", content); + } + } + + public override async Task BuildAsync() + { + // Run templates every time + await UpdateInfoAsync(); + } + + public override async Task PackageAsync() + { + // Run templates every time + await UpdateInfoAsync(); + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + } + + public override async Task FinalizeAsync() + { + + } +} diff --git a/deployment/cake/templates-variables.cake b/deployment/cake/templates-variables.cake new file mode 100644 index 0000000..a493e1d --- /dev/null +++ b/deployment/cake/templates-variables.cake @@ -0,0 +1,50 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class TemplatesContext : BuildContextWithItemsBase +{ + public TemplatesContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' template items"); + } +} + +//------------------------------------------------------------- + +private TemplatesContext InitializeTemplatesContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new TemplatesContext(parentBuildContext) + { + Items = Templates ?? new List(), + }; + + return data; +} + +//------------------------------------------------------------- + +List _templates; + +public List Templates +{ + get + { + if (_templates is null) + { + _templates = new List(); + } + + return _templates; + } +} diff --git a/deployment/cake/tests-nunit.cake b/deployment/cake/tests-nunit.cake new file mode 100644 index 0000000..5b975bb --- /dev/null +++ b/deployment/cake/tests-nunit.cake @@ -0,0 +1,39 @@ +#tool "nuget:?package=NUnit.ConsoleRunner&version=3.21.1" + +//------------------------------------------------------------- + +private static void RunTestsUsingNUnit(BuildContext buildContext, string projectName, string testTargetFramework, string testResultsDirectory) +{ + var testFile = System.IO.Path.Combine(GetProjectOutputDirectory(buildContext, projectName), + testTargetFramework, $"{projectName}.dll"); + var resultsFile = System.IO.Path.Combine(testResultsDirectory, "testresults.xml"); + + var nunitSettings = new NUnit3Settings + { + Results = new NUnit3Result[] + { + new NUnit3Result + { + FileName = resultsFile, + Format = "nunit3" + } + }, + NoHeader = true, + NoColor = true, + NoResults = false, + X86 = string.Equals(buildContext.Tests.ProcessBit, "X86", StringComparison.OrdinalIgnoreCase), + Timeout = 60 * 1000, // 60 seconds + Workers = 1 + //Work = testResultsDirectory + }; + + // Note: although the docs say you can use without array initialization, you can't + buildContext.CakeContext.NUnit3(new string[] { testFile }, nunitSettings); + + buildContext.CakeContext.Information("Verifying whether results file '{0}' exists", resultsFile); + + if (!buildContext.CakeContext.FileExists(resultsFile)) + { + throw new Exception(string.Format("Expected results file '{0}' does not exist", resultsFile)); + } +} \ No newline at end of file diff --git a/deployment/cake/tests-variables.cake b/deployment/cake/tests-variables.cake new file mode 100644 index 0000000..82e06ac --- /dev/null +++ b/deployment/cake/tests-variables.cake @@ -0,0 +1,73 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class TestsContext : BuildContextWithItemsBase +{ + public TestsContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string Framework { get; set; } + public string TargetFramework { get; set; } + public string OperatingSystem { get; set; } + public string ProcessBit { get; set; } + + protected override void ValidateContext() + { + if (Items.Count == 0) + { + return; + } + + if (string.IsNullOrWhiteSpace(Framework)) + { + throw new Exception("Framework is required, specify via 'TestFramework'"); + } + + if (string.IsNullOrWhiteSpace(ProcessBit)) + { + throw new Exception("ProcessBit is required, specify via 'TestProcessBit'"); + } + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' test projects"); + } +} + +//------------------------------------------------------------- + +private TestsContext InitializeTestsContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new TestsContext(parentBuildContext) + { + Items = TestProjects, + + Framework = buildContext.BuildServer.GetVariable("TestFramework", "nunit", showValue: true), + TargetFramework = buildContext.BuildServer.GetVariable("TestTargetFramework", "", showValue: true), + OperatingSystem = buildContext.BuildServer.GetVariable("TestOperatingSystem", "win", showValue: true), + ProcessBit = buildContext.BuildServer.GetVariable("TestProcessBit", "X64", showValue: true) + }; + + return data; +} + +//------------------------------------------------------------- + +List _testProjects; + +public List TestProjects +{ + get + { + if (_testProjects is null) + { + _testProjects = new List(); + } + + return _testProjects; + } +} diff --git a/deployment/cake/tests.cake b/deployment/cake/tests.cake new file mode 100644 index 0000000..f2cb9dc --- /dev/null +++ b/deployment/cake/tests.cake @@ -0,0 +1,309 @@ +// Customize this file when using a different test framework +#l "tests-variables.cake" +#l "tests-nunit.cake" + +public class TestProcessor : ProcessorBase +{ + public TestProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + public override bool HasItems() + { + return BuildContext.Tests.Items.Count > 0; + } + + public override async Task PrepareAsync() + { + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + foreach (var testProject in BuildContext.Tests.Items.ToList()) + { + if (IgnoreTestProject(testProject)) + { + BuildContext.Tests.Items.Remove(testProject); + } + } + } + + public override async Task UpdateInfoAsync() + { + // Not required + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var testProject in BuildContext.Tests.Items) + { + BuildContext.CakeContext.LogSeparator("Building test project '{0}'", testProject); + + var projectFileName = GetProjectFileName(BuildContext, testProject); + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, // Verbosity.Diagnostic + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, testProject, "build"); + + // Always disable SourceLink + msBuildSettings.WithProperty("EnableSourceLink", "false"); + + // Force disable SonarQube + msBuildSettings.WithProperty("SonarQubeExclude", "true"); + + RunMsBuild(BuildContext, testProject, projectFileName, msBuildSettings, "build"); + } + } + + public override async Task PackageAsync() + { + // Not required + } + + public override async Task DeployAsync() + { + // Not required + } + + public override async Task FinalizeAsync() + { + // Not required + } + + //------------------------------------------------------------- + + private bool IgnoreTestProject(string projectName) + { + if (BuildContext.General.IsLocalBuild && BuildContext.General.MaximizePerformance) + { + BuildContext.CakeContext.Information($"Local build with maximized performance detected, ignoring test project for project '{projectName}'"); + return true; + } + + // In case of a local build and we have included / excluded anything, skip tests + if (BuildContext.General.IsLocalBuild && + (BuildContext.General.Includes.Count > 0 || BuildContext.General.Excludes.Count > 0)) + { + BuildContext.CakeContext.Information($"Skipping test project '{projectName}' because this is a local build with specific includes / excludes"); + return true; + } + + // Special unit test part assuming a few naming conventions: + // 1. [ProjectName].Tests + // 2. [SolutionName].Tests.[ProjectName] + // + // In both cases, we can simply remove ".Tests" and check if that project is being ignored + var expectedProjectName = projectName + .Replace(".Integration.Tests", string.Empty) + .Replace(".IntegrationTests", string.Empty) + .Replace(".Tests", string.Empty); + + // Special case: if this is a "solution wide" test project, it must always run + if (!BuildContext.RegisteredProjects.Any(x => string.Equals(x, expectedProjectName, StringComparison.OrdinalIgnoreCase))) + { + BuildContext.CakeContext.Information($"Including test project '{projectName}' because there are no linked projects, assuming this is a solution wide test project"); + return false; + } + + if (!ShouldProcessProject(BuildContext, expectedProjectName)) + { + BuildContext.CakeContext.Information($"Skipping test project '{projectName}' because project '{expectedProjectName}' should not be processed either"); + return true; + } + + return false; + } +} + +//------------------------------------------------------------- + +private static void RunUnitTests(BuildContext buildContext, string projectName) +{ + var testResultsDirectory = System.IO.Path.Combine(buildContext.General.OutputRootDirectory, "testresults", projectName); + + buildContext.CakeContext.CreateDirectory(testResultsDirectory); + + var ranTests = false; + var failed = false; + var testTargetFrameworks = GetTestTargetFrameworks(buildContext, projectName); + + try + { + foreach (var testTargetFramework in testTargetFrameworks) + { + LogSeparator(buildContext.CakeContext, "Running tests for target framework {0}", testTargetFramework); + + if (IsDotNetCoreTargetFramework(buildContext, testTargetFramework)) + { + buildContext.CakeContext.Information($"Project '{projectName}' is a .NET core project, using 'dotnet test' to run the unit tests"); + + var projectFileName = GetProjectFileName(buildContext, projectName); + + var dotNetTestSettings = new DotNetTestSettings + { + Configuration = buildContext.General.Solution.ConfigurationName, + // Loggers = new [] + // { + // "nunit;LogFilePath=test-result.xml" + // }, + NoBuild = true, + NoLogo = true, + NoRestore = true, + OutputDirectory = System.IO.Path.Combine(GetProjectOutputDirectory(buildContext, projectName), testTargetFramework), + ResultsDirectory = testResultsDirectory + }; + + if (IsNUnitTestProject(buildContext, projectName)) + { + dotNetTestSettings.ArgumentCustomization = args => args + .Append($"-- NUnit.TestOutputXml={testResultsDirectory}"); + } + + if (IsXUnitTestProject(buildContext, projectName)) + { + var outputFileName = System.IO.Path.Combine(testResultsDirectory, $"{projectName}.xml"); + + dotNetTestSettings.ArgumentCustomization = args => args + .Append($"-l:trx;LogFileName={outputFileName}"); + } + + var processBit = buildContext.Tests.ProcessBit.ToLower(); + if (!string.IsNullOrWhiteSpace(processBit)) + { + dotNetTestSettings.Runtime = $"{buildContext.Tests.OperatingSystem}-{processBit}"; + } + + buildContext.CakeContext.Information($"Runtime: '{dotNetTestSettings.Runtime}'"); + + buildContext.CakeContext.DotNetTest(projectFileName, dotNetTestSettings); + + ranTests = true; + } + else + { + buildContext.CakeContext.Information($"Project '{projectName}' is a .NET project, using '{buildContext.Tests.Framework} runner' to run the unit tests"); + + if (IsNUnitTestProject(buildContext, projectName)) + { + RunTestsUsingNUnit(buildContext, projectName, testTargetFramework, testResultsDirectory); + + ranTests = true; + } + } + } + } + catch (Exception ex) + { + buildContext.CakeContext.Warning($"An exception occurred: {ex.Message}"); + + failed = true; + } + + if (ranTests) + { + buildContext.CakeContext.Information($"Results are available in '{testResultsDirectory}'"); + } + else if (failed) + { + throw new Exception("Unit test execution failed"); + } + else + { + buildContext.CakeContext.Warning("No tests were executed, check whether the used test framework '{0}' is available", buildContext.Tests.Framework); + } +} + +//------------------------------------------------------------- + +private static bool IsTestProject(BuildContext buildContext, string projectName) +{ + if (IsNUnitTestProject(buildContext, projectName)) + { + return true; + } + + if (IsXUnitTestProject(buildContext, projectName)) + { + return true; + } + + return false; +} + +//------------------------------------------------------------- + +private static bool IsNUnitTestProject(BuildContext buildContext, string projectName) +{ + var projectFileName = GetProjectFileName(buildContext, projectName); + var projectFileContents = System.IO.File.ReadAllText(projectFileName); + + if (projectFileContents.ToLower().Contains("nunit")) + { + return true; + } + + return false; + + // Not sure, return framework from config + //return buildContext.Tests.Framework.ToLower().Equals("nunit"); +} + +//------------------------------------------------------------- + +private static bool IsXUnitTestProject(BuildContext buildContext, string projectName) +{ + var projectFileName = GetProjectFileName(buildContext, projectName); + var projectFileContents = System.IO.File.ReadAllText(projectFileName); + + if (projectFileContents.ToLower().Contains("xunit")) + { + return true; + } + + return false; + + // Not sure, return framework from config + //return buildContext.Tests.Framework.ToLower().Equals("xunit"); +} + +//------------------------------------------------------------- + +private static IReadOnlyList GetTestTargetFrameworks(BuildContext buildContext, string projectName) +{ + // Step 1: if defined, use defined value + var testTargetFramework = buildContext.Tests.TargetFramework; + if (!string.IsNullOrWhiteSpace(testTargetFramework)) + { + buildContext.CakeContext.Information("Using test target framework '{0}', specified via the configuration", testTargetFramework); + + return new [] + { + testTargetFramework + }; + } + + buildContext.CakeContext.Information("Test target framework not specified, auto detecting test target frameworks"); + + var targetFrameworks = GetTargetFrameworks(buildContext, projectName); + + buildContext.CakeContext.Information("Auto detected test target frameworks '{0}'", string.Join(", ", targetFrameworks)); + + if (targetFrameworks.Length == 0) + { + throw new Exception(string.Format("Test target framework could not automatically be detected for project '{0]'", projectName)); + } + + return targetFrameworks; +} \ No newline at end of file diff --git a/deployment/cake/tools-tasks.cake b/deployment/cake/tools-tasks.cake new file mode 100644 index 0000000..7229ab1 --- /dev/null +++ b/deployment/cake/tools-tasks.cake @@ -0,0 +1,433 @@ +#l "tools-variables.cake" + +using System.Xml.Linq; + +//------------------------------------------------------------- + +public class ToolsProcessor : ProcessorBase +{ + public ToolsProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + private void EnsureChocolateyLicenseFile(string projectName) + { + // Required for Chocolatey + + var projectDirectory = GetProjectDirectory(projectName); + var outputDirectory = GetProjectOutputDirectory(BuildContext, projectName); + + var legalDirectory = System.IO.Path.Combine(outputDirectory, "legal"); + System.IO.Directory.CreateDirectory(legalDirectory); + + // Check if it already exists + var fileName = System.IO.Path.Combine(legalDirectory, "LICENSE.txt"); + if (!CakeContext.FileExists(fileName)) + { + CakeContext.Information("Creating Chocolatey license file for '{0}'", projectName); + + // Option 1: Copy from root + var sourceFile = System.IO.Path.Combine(".", "LICENSE"); + if (CakeContext.FileExists(sourceFile)) + { + CakeContext.Information("Using license file from repository"); + + CakeContext.CopyFile(sourceFile, fileName); + return; + } + + // Option 2: use expression (PackageLicenseExpression) + throw new Exception("Cannot find ./LICENSE, which is required for Chocolatey"); + } + } + + private void EnsureChocolateyVerificationFile(string projectName) + { + // Required for Chocolatey + + var projectDirectory = GetProjectDirectory(projectName); + var outputDirectory = GetProjectOutputDirectory(BuildContext, projectName); + + var legalDirectory = System.IO.Path.Combine(outputDirectory, "legal"); + System.IO.Directory.CreateDirectory(legalDirectory); + + // Check if it already exists + var fileName = System.IO.Path.Combine(legalDirectory, "VERIFICATION.txt"); + if (!CakeContext.FileExists(fileName)) + { + CakeContext.Information("Creating Chocolatey verification file for '{0}'", projectName); + + var verificationBuilder = new StringBuilder(@"VERIFICATION +Verification is intended to assist the Chocolatey moderators and community +in verifying that this package's contents are trustworthy. + +This package is submitted by the software vendor - checksum verification is optional but still included. + +SHA512 CHECKSUMS GENERATED BY BUILD TOOL: +"); + + verificationBuilder.AppendLine(); + + var files = new List(); + + var exePattern = $"{outputDirectory}/**/*.exe"; + files.AddRange(CakeContext.GetFiles(exePattern)); + + var dllPattern = $"{outputDirectory}/**/*.dll"; + files.AddRange(CakeContext.GetFiles(dllPattern)); + + var outputDirectoryPath = new DirectoryPath(outputDirectory); + + foreach (var packageFile in files/*.OrderBy(x => x.FullPath)*/) + { + var relativeFileName = outputDirectoryPath.GetRelativePath(packageFile); + + // Using 'outputDirectory' results in a file like '[ProductName]/netcoreapp3.1/nl/Catel.MVVM.resources.dll', + // so trying to fix the directory to be relative to the output including the target framework by faking + // that the file is 2 directories up + var fixedRelativePathSegments = relativeFileName.Segments.Skip(1).ToArray(); + var fixedRelativePath = new FilePath(System.IO.Path.Combine(fixedRelativePathSegments)); + + var fileHash = CakeContext.CalculateFileHash(packageFile, HashAlgorithm.SHA512); + + verificationBuilder.AppendLine($"* tools/{fixedRelativePath.FullPath} | {fileHash.ToHex()}"); + } + + System.IO.File.WriteAllText(fileName, verificationBuilder.ToString()); + } + } + + private string GetToolsNuGetRepositoryUrls(string projectName) + { + // Allow per project overrides via "NuGetRepositoryUrlFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "ToolsNuGetRepositoryUrlsFor", BuildContext.Tools.NuGetRepositoryUrls); + } + + private string GetToolsNuGetRepositoryApiKeys(string projectName) + { + // Allow per project overrides via "NuGetRepositoryApiKeyFor[ProjectName]" + return GetProjectSpecificConfigurationValue(BuildContext, projectName, "ToolsNuGetRepositoryApiKeysFor", BuildContext.Tools.NuGetRepositoryApiKeys); + } + + public override bool HasItems() + { + return BuildContext.Tools.Items.Count > 0; + } + + public override async Task PrepareAsync() + { + if (!HasItems()) + { + return; + } + + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + foreach (var tool in BuildContext.Tools.Items.ToList()) + { + if (!ShouldProcessProject(BuildContext, tool)) + { + BuildContext.Tools.Items.Remove(tool); + } + } + + if (BuildContext.General.IsLocalBuild && BuildContext.General.Target.ToLower().Contains("packagelocal")) + { + foreach (var tool in BuildContext.Tools.Items) + { + var expandableCacheDirectory = System.IO.Path.Combine("%userprofile%", ".nuget", "packages", tool, BuildContext.General.Version.NuGet); + var cacheDirectory = Environment.ExpandEnvironmentVariables(expandableCacheDirectory); + + CakeContext.Information("Checking for existing local NuGet cached version at '{0}'", cacheDirectory); + + var retryCount = 3; + + while (retryCount > 0) + { + if (!CakeContext.DirectoryExists(cacheDirectory)) + { + break; + } + + CakeContext.Information("Deleting already existing NuGet cached version from '{0}'", cacheDirectory); + + CakeContext.DeleteDirectory(cacheDirectory, new DeleteDirectorySettings() + { + Force = true, + Recursive = true + }); + + await System.Threading.Tasks.Task.Delay(1000); + + retryCount--; + } + } + } + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var tool in BuildContext.Tools.Items) + { + CakeContext.Information("Updating version for tool '{0}'", tool); + + var projectFileName = GetProjectFileName(BuildContext, tool); + + CakeContext.TransformConfig(projectFileName, new TransformationCollection + { + { "Project/PropertyGroup/PackageVersion", BuildContext.General.Version.NuGet } + }); + } + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var tool in BuildContext.Tools.Items) + { + BuildContext.CakeContext.LogSeparator("Building tool '{0}'", tool); + + var projectFileName = GetProjectFileName(BuildContext, tool); + + var msBuildSettings = new MSBuildSettings { + Verbosity = Verbosity.Quiet, + //Verbosity = Verbosity.Diagnostic, + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, tool, "build"); + + // SourceLink specific stuff + var repositoryUrl = BuildContext.General.Repository.Url; + var repositoryCommitId = BuildContext.General.Repository.CommitId; + if (!BuildContext.General.SourceLink.IsDisabled && + !BuildContext.General.IsLocalBuild && + !string.IsNullOrWhiteSpace(repositoryUrl)) + { + CakeContext.Information("Repository url is specified, enabling SourceLink to commit '{0}/commit/{1}'", + repositoryUrl, repositoryCommitId); + + // TODO: For now we are assuming everything is git, we might need to change that in the future + // See why we set the values at https://github.com/dotnet/sourcelink/issues/159#issuecomment-427639278 + msBuildSettings.WithProperty("EnableSourceLink", "true"); + msBuildSettings.WithProperty("EnableSourceControlManagerQueries", "false"); + msBuildSettings.WithProperty("PublishRepositoryUrl", "true"); + msBuildSettings.WithProperty("RepositoryType", "git"); + msBuildSettings.WithProperty("RepositoryUrl", repositoryUrl); + msBuildSettings.WithProperty("RevisionId", repositoryCommitId); + + InjectSourceLinkInProjectFile(BuildContext, tool, projectFileName); + } + + RunMsBuild(BuildContext, tool, projectFileName, msBuildSettings, "build"); + } + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + var configurationName = BuildContext.General.Solution.ConfigurationName; + var version = BuildContext.General.Version.NuGet; + + foreach (var tool in BuildContext.Tools.Items) + { + if (!ShouldPackageProject(BuildContext, tool)) + { + CakeContext.Information("Tool '{0}' should not be packaged", tool); + continue; + } + + BuildContext.CakeContext.LogSeparator("Packaging tool '{0}'", tool); + + var projectDirectory = System.IO.Path.Combine(".", "src", tool); + var projectFileName = System.IO.Path.Combine(projectDirectory, $"{tool}.csproj"); + var outputDirectory = GetProjectOutputDirectory(BuildContext, tool); + CakeContext.Information("Output directory: '{0}'", outputDirectory); + + // Step 1: remove intermediate files to ensure we have the same results on the build server, somehow NuGet + // targets tries to find the resource assemblies in [ProjectName]\obj\Release\net46\de\[ProjectName].resources.dll', + // we won't run a clean on the project since it will clean out the actual output (which we still need for packaging) + + CakeContext.Information("Cleaning intermediate files for tool '{0}'", tool); + + var binFolderPattern = string.Format("{0}/bin/{1}/**.dll", projectDirectory, configurationName); + + CakeContext.Information("Deleting 'bin' directory contents using '{0}'", binFolderPattern); + + var binFiles = CakeContext.GetFiles(binFolderPattern); + CakeContext.DeleteFiles(binFiles); + + var objFolderPattern = string.Format("{0}/obj/{1}/**.dll", projectDirectory, configurationName); + + CakeContext.Information("Deleting 'bin' directory contents using '{0}'", objFolderPattern); + + var objFiles = CakeContext.GetFiles(objFolderPattern); + CakeContext.DeleteFiles(objFiles); + + // We know we *highly likely* need to sign, so try doing this upfront + if (BuildContext.General.CodeSign.IsAvailable || + BuildContext.General.AzureCodeSign.IsAvailable) + { + SignFilesInDirectory(BuildContext, outputDirectory, string.Empty); + } + else + { + BuildContext.CakeContext.Warning("No signing certificate subject name provided, not signing any files"); + } + + CakeContext.Information(string.Empty); + + // Step 2: Ensure chocolatey stuff + EnsureChocolateyLicenseFile(tool); + EnsureChocolateyVerificationFile(tool); + + // Step 3: Go packaging! + CakeContext.Information("Using 'msbuild' to package '{0}'", tool); + + var msBuildSettings = new MSBuildSettings + { + Verbosity = Verbosity.Quiet, + //Verbosity = Verbosity.Diagnostic, + ToolVersion = MSBuildToolVersion.Default, + Configuration = configurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, tool, "pack"); + + msBuildSettings.WithProperty("ConfigurationName", configurationName); + msBuildSettings.WithProperty("PackageVersion", version); + + // SourceLink specific stuff + var repositoryUrl = BuildContext.General.Repository.Url; + var repositoryCommitId = BuildContext.General.Repository.CommitId; + if (!BuildContext.General.SourceLink.IsDisabled && + !BuildContext.General.IsLocalBuild && + !string.IsNullOrWhiteSpace(repositoryUrl)) + { + CakeContext.Information("Repository url is specified, adding commit specific data to package"); + + // TODO: For now we are assuming everything is git, we might need to change that in the future + // See why we set the values at https://github.com/dotnet/sourcelink/issues/159#issuecomment-427639278 + msBuildSettings.WithProperty("PublishRepositoryUrl", "true"); + msBuildSettings.WithProperty("RepositoryType", "git"); + msBuildSettings.WithProperty("RepositoryUrl", repositoryUrl); + msBuildSettings.WithProperty("RevisionId", repositoryCommitId); + } + + // Fix for .NET Core 3.0, see https://github.com/dotnet/core-sdk/issues/192, it + // uses obj/release instead of [outputdirectory] + msBuildSettings.WithProperty("DotNetPackIntermediateOutputPath", outputDirectory); + + // No dependencies for tools + msBuildSettings.WithProperty("SuppressDependenciesWhenPacking", "true"); + + // As described in the this issue: https://github.com/NuGet/Home/issues/4360 + // we should not use IsTool, but set BuildOutputTargetFolder instead + msBuildSettings.WithProperty("CopyLocalLockFileAssemblies", "true"); + msBuildSettings.WithProperty("IncludeBuildOutput", "true"); + msBuildSettings.WithProperty("BuildOutputTargetFolder", "tools"); + msBuildSettings.WithProperty("NoDefaultExcludes", "true"); + + // Ensures that files are written to "tools", not "tools\\netcoreapp3.1" + msBuildSettings.WithProperty("IsTool", "false"); + + msBuildSettings.WithProperty("NoBuild", "true"); + msBuildSettings.Targets.Add("Pack"); + + RunMsBuild(BuildContext, tool, projectFileName, msBuildSettings, "pack"); + + BuildContext.CakeContext.LogSeparator(); + } + + await SignNuGetPackageAsync(); + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + var version = BuildContext.General.Version.NuGet; + + foreach (var tool in BuildContext.Tools.Items) + { + if (!ShouldDeployProject(BuildContext, tool)) + { + CakeContext.Information("Tool '{0}' should not be deployed", tool); + continue; + } + + BuildContext.CakeContext.LogSeparator("Deploying tool '{0}'", tool); + + var packageToPush = System.IO.Path.Combine(BuildContext.General.OutputRootDirectory, $"{tool}.{version}.nupkg"); + var nuGetRepositoryUrls = GetToolsNuGetRepositoryUrls(tool); + var nuGetRepositoryApiKeys = GetToolsNuGetRepositoryApiKeys(tool); + + var nuGetServers = GetNuGetServers(nuGetRepositoryUrls, nuGetRepositoryApiKeys); + if (nuGetServers.Count == 0) + { + throw new Exception("No NuGet repositories specified, as a protection mechanism this must *always* be specified to make sure packages aren't accidentally deployed to the default public NuGet feed"); + } + + CakeContext.Information("Found '{0}' target NuGet servers to push tool '{1}'", nuGetServers.Count, tool); + + foreach (var nuGetServer in nuGetServers) + { + CakeContext.Information("Pushing to '{0}'", nuGetServer); + + CakeContext.NuGetPush(packageToPush, new NuGetPushSettings + { + Source = nuGetServer.Url, + ApiKey = nuGetServer.ApiKey + }); + } + + await BuildContext.Notifications.NotifyAsync(tool, string.Format("Deployed to NuGet store(s)"), TargetType.Tool); + } + } + + public override async Task FinalizeAsync() + { + + } + + private async Task SignNuGetPackageAsync() + { + if (BuildContext.General.IsCiBuild || + BuildContext.General.IsLocalBuild) + { + return; + } + + // For details, see https://docs.microsoft.com/en-us/nuget/create-packages/sign-a-package + // nuget sign MyPackage.nupkg -CertificateSubjectName -Timestamper + var filesToSign = CakeContext.GetFiles($"{BuildContext.General.OutputRootDirectory}/*.nupkg"); + + foreach (var fileToSign in filesToSign) + { + SignNuGetPackage(BuildContext, fileToSign.FullPath); + } + } +} \ No newline at end of file diff --git a/deployment/cake/tools-variables.cake b/deployment/cake/tools-variables.cake new file mode 100644 index 0000000..92b9eca --- /dev/null +++ b/deployment/cake/tools-variables.cake @@ -0,0 +1,55 @@ +#l "buildserver.cake" + +//------------------------------------------------------------- + +public class ToolsContext : BuildContextWithItemsBase +{ + public ToolsContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string NuGetRepositoryUrls { get; set; } + public string NuGetRepositoryApiKeys { get; set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' tool projects"); + } +} + +//------------------------------------------------------------- + +private ToolsContext InitializeToolsContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new ToolsContext(parentBuildContext) + { + Items = Tools ?? new List(), + NuGetRepositoryUrls = buildContext.BuildServer.GetVariable("ToolsNuGetRepositoryUrls", showValue: true), + NuGetRepositoryApiKeys = buildContext.BuildServer.GetVariable("ToolsNuGetRepositoryApiKeys", showValue: false) + }; + + return data; +} + +//------------------------------------------------------------- + +List _tools; + +public List Tools +{ + get + { + if (_tools is null) + { + _tools = new List(); + } + + return _tools; + } +} \ No newline at end of file diff --git a/deployment/cake/vsextensions-tasks.cake b/deployment/cake/vsextensions-tasks.cake new file mode 100644 index 0000000..5e9c218 --- /dev/null +++ b/deployment/cake/vsextensions-tasks.cake @@ -0,0 +1,171 @@ +#l "vsextensions-variables.cake" + +using System.Xml.Linq; + +//------------------------------------------------------------- + +public class VsExtensionsProcessor : ProcessorBase +{ + public VsExtensionsProcessor(BuildContext buildContext) + : base(buildContext) + { + + } + + public override bool HasItems() + { + return BuildContext.VsExtensions.Items.Count > 0; + } + + public override async Task PrepareAsync() + { + if (!HasItems()) + { + return; + } + + // Check whether projects should be processed, `.ToList()` + // is required to prevent issues with foreach + foreach (var vsExtension in BuildContext.VsExtensions.Items.ToList()) + { + if (!ShouldProcessProject(BuildContext, vsExtension)) + { + BuildContext.VsExtensions.Items.Remove(vsExtension); + } + } + } + + public override async Task UpdateInfoAsync() + { + if (!HasItems()) + { + return; + } + + // Note: since we can't use prerelease tags in VSIX, we will use the commit count + // as last part of the version + var version = string.Format("{0}.{1}", BuildContext.General.Version.MajorMinorPatch, BuildContext.General.Version.CommitsSinceVersionSource); + + foreach (var vsExtension in BuildContext.VsExtensions.Items) + { + CakeContext.Information("Updating version for vs extension '{0}'", vsExtension); + + var projectDirectory = GetProjectDirectory(vsExtension); + + // Step 1: update vsix manifest + var vsixManifestFileName = System.IO.Path.Combine(projectDirectory, "source.extension.vsixmanifest"); + + CakeContext.TransformConfig(vsixManifestFileName, new TransformationCollection + { + { "PackageManifest/Metadata/Identity/@Version", version }, + { "PackageManifest/Metadata/Identity/@Publisher", BuildContext.VsExtensions.PublisherName } + }); + } + } + + public override async Task BuildAsync() + { + if (!HasItems()) + { + return; + } + + foreach (var vsExtension in BuildContext.VsExtensions.Items) + { + BuildContext.CakeContext.LogSeparator("Building vs extension '{0}'", vsExtension); + + var projectFileName = GetProjectFileName(BuildContext, vsExtension); + + var msBuildSettings = new MSBuildSettings { + Verbosity = Verbosity.Quiet, + //Verbosity = Verbosity.Diagnostic, + ToolVersion = MSBuildToolVersion.Default, + Configuration = BuildContext.General.Solution.ConfigurationName, + MSBuildPlatform = MSBuildPlatform.x86, // Always require x86, see platform for actual target platform + PlatformTarget = PlatformTarget.MSIL + }; + + ConfigureMsBuild(BuildContext, msBuildSettings, vsExtension, "build"); + + // Note: we need to set OverridableOutputPath because we need to be able to respect + // AppendTargetFrameworkToOutputPath which isn't possible for global properties (which + // are properties passed in using the command line) + var outputDirectory = GetProjectOutputDirectory(BuildContext, vsExtension); + CakeContext.Information("Output directory: '{0}'", outputDirectory); + + // Since vs extensions (for now) use the old csproj style, make sure + // to override the output path as well + msBuildSettings.WithProperty("OutputPath", outputDirectory); + + RunMsBuild(BuildContext, vsExtension, projectFileName, msBuildSettings, "build"); + } + } + + public override async Task PackageAsync() + { + if (!HasItems()) + { + return; + } + + // No packaging required + } + + public override async Task DeployAsync() + { + if (!HasItems()) + { + return; + } + + var vsixPublisherExeDirectory = System.IO.Path.Combine(GetVisualStudioDirectory(BuildContext), "VSSDK", "VisualStudioIntegration", "Tools", "Bin"); + var vsixPublisherExeFileName = System.IO.Path.Combine(vsixPublisherExeDirectory, "VsixPublisher.exe"); + + foreach (var vsExtension in BuildContext.VsExtensions.Items) + { + if (!ShouldDeployProject(BuildContext, vsExtension)) + { + CakeContext.Information("Vs extension '{0}' should not be deployed", vsExtension); + continue; + } + + BuildContext.CakeContext.LogSeparator("Deploying vs extension '{0}'", vsExtension); + + // Step 1: copy the output stuff + var vsExtensionOutputDirectory = GetProjectOutputDirectory(BuildContext, vsExtension); + var payloadFileName = System.IO.Path.Combine(vsExtensionOutputDirectory, $"{vsExtension}.vsix"); + + var overviewSourceFileName = System.IO.Path.Combine("src", vsExtension, "overview.md"); + var overviewTargetFileName = System.IO.Path.Combine(vsExtensionOutputDirectory, "overview.md"); + CakeContext.CopyFile(overviewSourceFileName, overviewTargetFileName); + + var vsGalleryManifestSourceFileName = System.IO.Path.Combine("src", vsExtension, "source.extension.vsgallerymanifest"); + var vsGalleryManifestTargetFileName = System.IO.Path.Combine(vsExtensionOutputDirectory, "source.extension.vsgallerymanifest"); + CakeContext.CopyFile(vsGalleryManifestSourceFileName, vsGalleryManifestTargetFileName); + + // Step 2: update vs gallery manifest + var fileContents = System.IO.File.ReadAllText(vsGalleryManifestTargetFileName); + + fileContents = fileContents.Replace("[PUBLISHERNAME]", BuildContext.VsExtensions.PublisherName); + + System.IO.File.WriteAllText(vsGalleryManifestTargetFileName, fileContents); + + // Step 3: go ahead and publish + CakeContext.StartProcess(vsixPublisherExeFileName, new ProcessSettings + { + Arguments = new ProcessArgumentBuilder() + .Append("publish") + .AppendSwitch("-payload", payloadFileName) + .AppendSwitch("-publishManifest", vsGalleryManifestTargetFileName) + .AppendSwitchSecret("-personalAccessToken", BuildContext.VsExtensions.PersonalAccessToken) + }); + + await BuildContext.Notifications.NotifyAsync(vsExtension, string.Format("Deployed to Visual Studio Gallery"), TargetType.VsExtension); + } + } + + public override async Task FinalizeAsync() + { + + } +} diff --git a/deployment/cake/vsextensions-variables.cake b/deployment/cake/vsextensions-variables.cake new file mode 100644 index 0000000..63e55e9 --- /dev/null +++ b/deployment/cake/vsextensions-variables.cake @@ -0,0 +1,53 @@ +#l "buildserver.cake" + +public class VsExtensionsContext : BuildContextWithItemsBase +{ + public VsExtensionsContext(IBuildContext parentBuildContext) + : base(parentBuildContext) + { + } + + public string PublisherName { get; set; } + public string PersonalAccessToken { get; set; } + + protected override void ValidateContext() + { + + } + + protected override void LogStateInfoForContext() + { + CakeContext.Information($"Found '{Items.Count}' vs extension projects"); + } +} + +//------------------------------------------------------------- + +private VsExtensionsContext InitializeVsExtensionsContext(BuildContext buildContext, IBuildContext parentBuildContext) +{ + var data = new VsExtensionsContext(parentBuildContext) + { + Items = VsExtensions ?? new List(), + PublisherName = buildContext.BuildServer.GetVariable("VsExtensionsPublisherName", showValue: true), + PersonalAccessToken = buildContext.BuildServer.GetVariable("VsExtensionsPersonalAccessToken", showValue: false), + }; + + return data; +} + +//------------------------------------------------------------- + +List _vsExtensions; + +public List VsExtensions +{ + get + { + if (_vsExtensions is null) + { + _vsExtensions = new List(); + } + + return _vsExtensions; + } +} \ No newline at end of file diff --git a/design/Package/Icon.png b/design/Package/Icon.png new file mode 100644 index 0000000..23364be Binary files /dev/null and b/design/Package/Icon.png differ diff --git a/src/.vsconfig b/src/.vsconfig new file mode 100644 index 0000000..030707b --- /dev/null +++ b/src/.vsconfig @@ -0,0 +1,54 @@ +{ + "version": "1.0", + "components": [ + "Microsoft.VisualStudio.Component.CoreEditor", + "Microsoft.VisualStudio.Workload.CoreEditor", + "Microsoft.Net.Component.4.8.SDK", + "Microsoft.Net.Component.4.7.2.TargetingPack", + "Microsoft.Net.ComponentGroup.DevelopmentPrerequisites", + "Microsoft.VisualStudio.Component.TypeScript.TSServer", + "Microsoft.VisualStudio.ComponentGroup.WebToolsExtensions", + "Microsoft.VisualStudio.Component.JavaScript.TypeScript", + "Microsoft.VisualStudio.Component.JavaScript.Diagnostics", + "Microsoft.VisualStudio.Component.Roslyn.Compiler", + "Microsoft.Component.MSBuild", + "Microsoft.VisualStudio.Component.Roslyn.LanguageServices", + "Microsoft.VisualStudio.Component.TextTemplating", + "Component.Microsoft.VisualStudio.RazorExtension", + "Microsoft.VisualStudio.Component.IISExpress", + "Microsoft.VisualStudio.Component.NuGet", + "Microsoft.VisualStudio.Component.MSODBC.SQL", + "Microsoft.VisualStudio.Component.SQL.LocalDB.Runtime", + "Microsoft.VisualStudio.Component.Common.Azure.Tools", + "Microsoft.VisualStudio.Component.SQL.CLR", + "Microsoft.VisualStudio.Component.MSSQL.CMDLnUtils", + "Microsoft.VisualStudio.Component.ManagedDesktop.Core", + "Microsoft.VisualStudio.Component.SQL.SSDT", + "Microsoft.VisualStudio.Component.SQL.DataSources", + "Component.Microsoft.Web.LibraryManager", + "Component.Microsoft.WebTools.BrowserLink.WebLivePreview", + "Microsoft.VisualStudio.ComponentGroup.Web", + "Microsoft.NetCore.Component.Runtime.8.0", + "Microsoft.NetCore.Component.SDK", + "Microsoft.VisualStudio.Component.FSharp", + "Microsoft.NetCore.Component.DevelopmentTools", + "Microsoft.VisualStudio.Component.FSharp.WebTemplates", + "Microsoft.VisualStudio.Component.DockerTools", + "Microsoft.NetCore.Component.Web", + "Microsoft.VisualStudio.Component.WebDeploy", + "Microsoft.VisualStudio.Component.AppInsights.Tools", + "Microsoft.VisualStudio.Component.Web", + "Microsoft.VisualStudio.Component.AspNet45", + "Microsoft.VisualStudio.Component.AspNet", + "Component.Microsoft.VisualStudio.Web.AzureFunctions", + "Microsoft.VisualStudio.ComponentGroup.AzureFunctions", + "Microsoft.VisualStudio.ComponentGroup.Web.CloudTools", + "Microsoft.VisualStudio.Component.DiagnosticTools", + "Microsoft.VisualStudio.Component.Debugger.JustInTime", + "Microsoft.VisualStudio.Component.WslDebugging", + "Microsoft.VisualStudio.Workload.NetWeb", + "Microsoft.VisualStudio.Component.ManagedDesktop.Prerequisites", + "Microsoft.VisualStudio.Workload.ManagedDesktop" + ], + "extensions": [] +} \ No newline at end of file diff --git a/src/Directory.Build.analyzers.props b/src/Directory.Build.analyzers.props new file mode 100644 index 0000000..f70d0a5 --- /dev/null +++ b/src/Directory.Build.analyzers.props @@ -0,0 +1,50 @@ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + latest + + + + + $(NoWarn);CA1030;CA1031;CA1054;CA1062;CA1724;CA1810;CA2007;CA2237 + $(NoError);CA1030;CA1031;CA1054;CA1062;CA1724;CA810;CA2007;CA2237 + + \ No newline at end of file diff --git a/src/Directory.Build.implicitusings.props b/src/Directory.Build.implicitusings.props new file mode 100644 index 0000000..e1f5d56 --- /dev/null +++ b/src/Directory.Build.implicitusings.props @@ -0,0 +1,5 @@ + + + enable + + \ No newline at end of file diff --git a/src/Directory.Build.nullable.props b/src/Directory.Build.nullable.props new file mode 100644 index 0000000..39475de --- /dev/null +++ b/src/Directory.Build.nullable.props @@ -0,0 +1,5 @@ + + + enable + + \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..604e2fb --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Directory.Build.shared.explicit.props b/src/Directory.Build.shared.explicit.props new file mode 100644 index 0000000..b85172e --- /dev/null +++ b/src/Directory.Build.shared.explicit.props @@ -0,0 +1,463 @@ + + + + + + + $(ProjectDir)..\..\output\$(Configuration) + + + <__OverridableOutputPathDefault>$(OverridableOutputRootPath)\$(MSBuildProjectName)\ + $(OverridableOutputRootPath)\$(MSBuildProjectName)\ + $(OverridableOutputRootPath)\$(OutputTargetAssemblyDirectory)\ + + + + $(OverridableOutputPath)\..\$(MSBuildProjectName)\ + $(OverridableOutputPath) + bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + + + + + false + + + + + $(DotNetPackIntermediateOutputPath) + + + + + CS8034;$(WarningsNotAsErrors) + + + + + + + + + + + + + + + + + + $(PackageId) + + + + icon.png + + + + + + + + + + true + tools + + + + true + tools + + + + + + + TextTemplatingFileGenerator + + + + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + True + True + Resources.resx + + + + + + $(DefineConstants);NETCORE;NETCORE2_1;NETCOREAPP;NETCOREAPP2_1 + + + + + $(DefineConstants);NETCORE;NETCORE3_0;NETCOREAPP;NETCOREAPP3_0 + + + + + + + + + + + + $(DefineConstants);NETCORE;NETCORE3_0;NETCOREAPP;NETCOREAPP3_0 + + + + + + + + + + + + $(DefineConstants);NETCORE;NETCORE5;NETCORE5_0;NET5;NET5_0 + + + + + + + + + + + + $(DefineConstants);NETCORE;NETCORE6;NETCORE6_0;NET6;NET6_0 + + + + + + + + + + + + $(DefineConstants);NETCORE;NETCORE7;NETCORE7_0;NET7;NET7_0 + + + + + + + + + + + + $(DefineConstants);NETCORE;NETCORE8;NETCORE8_0;NET8;NET8_0 + + + + + + + + + + + + $(DefineConstants);NETCORE;NETCORE9;NETCORE9_0;NET9;NET9_0 + + + + + + + + + + + + $(DefineConstants);NETCORE;NETCORE10;NETCORE10_0;NET10;NET10_0 + + + + + + + + + + + + $(DefineConstants);NETSTANDARD;NETSTANDARD2_0;NS;NS20 + + + + + + + + + + + + true + $(DefineConstants);NET;NET45;NET450 + + + + true + $(DefineConstants);NET;NET45;NET451 + + + + true + $(DefineConstants);NET;NET45;NET452 + + + + + + + + + + + + true + $(DefineConstants);NET;NET46;NET460 + + + + true + $(DefineConstants);NET;NET46;NET461 + + + + true + $(DefineConstants);NET;NET46;NET462 + + + + + + + + + + + + true + $(DefineConstants);NET;NET47;NET470 + + + + true + $(DefineConstants);NET;NET47;NET471 + + + + true + $(DefineConstants);NET;NET47;NET472 + + + + + + + + + + + + true + $(DefineConstants);NET;NET48;NET480 + + + + true + $(DefineConstants);NET;NET48;NET481 + + + + true + $(DefineConstants);NET;NET48;NET482 + + + + + + + + + + + + true + $(DefineConstants);UAP;UAP_DEFAULT;NETFX_CORE;UWP;WINDOWS_UWP + + + + true + $(DefineConstants);UAP;UAP_16299;NETFX_CORE;UWP;WINDOWS_UWP + + + + + + + + + + + + $(DefineConstants);XAMARIN;IOS + + + + + + + + + + + + $(DefineConstants);XAMARIN;ANDROID + + + + + + + + + + + + <_InstallerTargetFramework>net48 + + + + + <_FakeOutputPath Include="..\$(OutputPath)\$(_InstallerTargetFramework)\$(AssemblyName).dll" /> + + + <_FakeOutputPath Include="..\$(OutputPath)\$(_InstallerTargetFramework)\$(AssemblyName).exe" /> + + + <_FakeOutputPath Include="..\$(OutputPath)\$(_InstallerTargetFramework)\$(AssemblyName).exe" /> + + + \ No newline at end of file diff --git a/src/Directory.Build.shared.implicit.props b/src/Directory.Build.shared.implicit.props new file mode 100644 index 0000000..02e8f8e --- /dev/null +++ b/src/Directory.Build.shared.implicit.props @@ -0,0 +1,72 @@ + + + + + $(NoWarn);CA1416;CS1591;CS1998;NU1603;NU1605;NU1608;NU1701;AD0001;HAA0301;HAA0302;HAA0303;HAA0401;HAA0603 + $(NoError);CS1591;CS1998;NU1603;NU1605;NU1608;NU1701;AD0001;HAA0301;HAA0302;HAA0303;HAA0401;HAA0603 + true + false + true + false + false + Release + $(ProjectDir)..\..\output\$(Configuration)\ + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb;.xml + False + true + + + + + + + true + + + + + + latest + + + + + portable + + true + + + + + portable + true + + + + + portable + true + + + + + + direct + + low + + + + + true + + + \ No newline at end of file diff --git a/src/Directory.Build.shared.mat.props b/src/Directory.Build.shared.mat.props new file mode 100644 index 0000000..c2680e5 --- /dev/null +++ b/src/Directory.Build.shared.mat.props @@ -0,0 +1,16 @@ + + + + + + + <_ResxFiles Remove="Properties\*.resx" /> + + + + + \ No newline at end of file diff --git a/src/Directory.Build.shared.tests.props b/src/Directory.Build.shared.tests.props new file mode 100644 index 0000000..42b7a9e --- /dev/null +++ b/src/Directory.Build.shared.tests.props @@ -0,0 +1,26 @@ + + + + + false + true + true + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Directory.Build.shared.tools.props b/src/Directory.Build.shared.tools.props new file mode 100644 index 0000000..4398deb --- /dev/null +++ b/src/Directory.Build.shared.tools.props @@ -0,0 +1,40 @@ + + + + + $(Description) + $(PackageProjectUrl) + IncludeDefaultProjectBuildOutputInPack + + + + + + + + + + + + + + + + + + $([MSBuild]::MakeRelative('$(OutputPath)\$(TargetFrameworks)\', %(ToolDllFiles.FullPath))) + tools + + + + $([MSBuild]::MakeRelative('$(OutputPath)\$(TargetFrameworks)\', %(ToolExeFiles.FullPath))) + tools + + + + \ No newline at end of file diff --git a/src/Directory.Build.shared.xamltools.props b/src/Directory.Build.shared.xamltools.props new file mode 100644 index 0000000..ed92ced --- /dev/null +++ b/src/Directory.Build.shared.xamltools.props @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + false + + + + true + + + + + False + False + False + + + + + True + + + + + False + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 0000000..4de98b5 --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/GlobalSuppressions.global.cs b/src/GlobalSuppressions.global.cs new file mode 100644 index 0000000..729679e --- /dev/null +++ b/src/GlobalSuppressions.global.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +[assembly: SuppressMessage("WpfAnalyzers.DependencyProperties", "WPF1010:Property '[property]' must notify when value changes.", Justification = "Don't enforce this")] +[assembly: SuppressMessage("WpfAnalyzers.DependencyProperties", "WPF1011:Implement INotifyPropertyChanged.", Justification = "Don't enforce this")] +[assembly: SuppressMessage("Usage", "CA2255:The 'ModuleInitializer' attribute should not be used in libraries", Justification = "Used to register language resources and types")] diff --git a/src/MethodTimeLogger.cs b/src/MethodTimeLogger.cs new file mode 100644 index 0000000..3ffd615 --- /dev/null +++ b/src/MethodTimeLogger.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using Catel.Logging; +using System; +using System.Globalization; + +/// +/// Note: do not rename this class or put it inside a namespace. +/// +internal static class MethodTimeLogger +{ + public static void Log(MethodBase methodBase, long milliseconds, string message) + { + Log(methodBase.DeclaringType ?? typeof(object), methodBase.Name, milliseconds, message); + } + + public static void Log(Type type, string methodName, long milliseconds, string message) + { + if (type is null) + { + return; + } + + if (milliseconds == 0) + { + // Don't log superfast methods + return; + } + + var finalMessage = $"[METHODTIMER] {type.Name}.{methodName} took '{milliseconds.ToString(CultureInfo.InvariantCulture)}' ms"; + + if (!string.IsNullOrWhiteSpace(message)) + { + finalMessage += $" | {message}"; + } + + var logger = LogManager.GetLogger(type); + logger.Debug(finalMessage); + } +} \ No newline at end of file diff --git a/src/global.json b/src/global.json new file mode 100644 index 0000000..8648d6a --- /dev/null +++ b/src/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/src/nuget.config b/src/nuget.config new file mode 100644 index 0000000..4304622 --- /dev/null +++ b/src/nuget.config @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + +