From 76449d4547ab4376bdbc7b4b41ab9bb15011f232 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 18 Apr 2026 13:23:56 -0400 Subject: [PATCH 01/43] Update paths and gitversion --- .config/dotnet-tools.json | 13 ++++++ GitVersion.yml | 89 +++++++++++++++++++++++++++------------ PSModuleRestore.Task.ps1 | 5 ++- PSModuleTest.Task.ps1 | 12 +++--- _Bootstrap.ps1 | 53 ++++++++++++----------- debug.log | 3 ++ 6 files changed, 114 insertions(+), 61 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 debug.log diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..ea618d6 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "gitversion.tool": { + "version": "6.7.0", + "commands": [ + "dotnet-gitversion" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/GitVersion.yml b/GitVersion.yml index bb2a7ee..59586cc 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,46 +1,79 @@ -mode: mainline # Each merged branch against main will increment the patch version unless otherwise specified in a commit message +# Each merged branch against main will increment the version unless otherwise specified in a commit message +# TrunkBased is the only workflow where each commit to a feature changes the pre-release tag +workflow: GitHubFlow/v1 +# No pre-release versions from mainline +mode: ContinuousDeployment +# No dashes in date commit-date-format: "yyyyMMddTHHmmss" -assembly-file-versioning-format: "{Major}.{Minor}.{Patch}.{env:GITHUB_RUN_NUMBER ?? 0}" - -# This repo needs to use NuGetVersionV2 for compatibility with PowerShellGallery -assembly-informational-format: "{NuGetVersionV2}+Build.{env:GITHUB_RUN_NUMBER ?? local}.Sha.{Sha}.Date.{CommitDate}" +# Use BUILD_COUNT environment variable with fallback of zero +assembly-versioning-format: '{Major}.{Minor}.{Patch}.{env:BUILD_COUNT ?? 0}' +# NOTE: Normally I prefer: +# '{Major}.{Minor}.{Patch}{PreReleaseTagWithDash}+Build.{env:BUILD_COUNT ?? 0}.Date.{CommitDate}.Branch.{BranchName ?? unknown}.Sha.{Sha}' +assembly-informational-format: '{Major}.{Minor}.{Patch}{PreReleaseTagWithDash}+Build.{env:BUILD_COUNT ?? 0}.Branch.{BranchName ?? unknown}.Sha.{ShortSha}' +# Version bump messages in git trailers format major-version-bump-message: 'semver:\s?(breaking|major)' minor-version-bump-message: 'semver:\s?(feature|minor)' patch-version-bump-message: 'semver:\s?(fix|patch)' no-bump-message: 'semver:\s?(none|skip)' commit-message-incrementing: Enabled +# semantic-version-format: Loose +strategies: +- Mainline +- TaggedCommit +- VersionInBranchName +- TrackReleaseBranches +- MergeMessage branches: main: - tag: "" # explicitly no tag for main builds - regex: ^master$|^main$ + # tracks-release-branches: true increment: Patch - is-mainline: true - tracks-release-branches: true - hotfix: - tag: rc - regex: hotfix(es)?/\d+\.\d+\.\d+ + prevent-increment: + # If false, rebuilds of the same code will increment the version! + when-current-commit-tagged: true + release: + mode: ContinuousDelivery + label: rc increment: None + prevent-increment: + of-merged-branch: true + when-current-commit-tagged: false + track-merge-target: false is-release-branch: true - prevent-increment-of-merged-branch-version: true - source-branches: [ "main" ] - release: - tag: rc - regex: releases?/\d+\.\d+\.\d+ + # A hotfix is just a release with bad habits + regex: ^(?:releases?)/(?\d+\.\d+(\.\d+)?)$ + + hotfix: + mode: ContinuousDelivery + label: rc + regex: ^(?:hotfix(?:es)?)/(?\d+\.\d+(\.\d+)?)$ increment: None + prevent-increment: + of-merged-branch: true + when-current-commit-tagged: false + track-merge-target: false is-release-branch: true - prevent-increment-of-merged-branch-version: true - source-branches: [ "main" ] + + feature: + mode: ContinuousDelivery + # any branch name that starts with feature + # (with any number of / separated segments) + # we use the last segment as the BranchName label... + regex: ^features?[/-](.+[/-])*(?[^/-]+)$ + # label: alpha.{BranchName}. + # Since we *know* it's a feature, then we can increment the minor version + increment: Minor + source-branches: [ "main", "feature", "release" ] pull-request: - regex: pull/ - tag: pr - tag-number-pattern: '[/-](?\d+)' + label: pr{BranchName} + regex: ^pull/(?[^/-]+)/merge$ + unknown: + mode: ContinuousDelivery + # we usually don't distinguish feature from fix in our branch names + # So EVERYTHING ELSE just increments the patch version + regex: ^.*[-/](?[^/-]+)$ increment: Patch - source-branches: [ "main", "feature", "release", "hotfix" ] - feature: - regex: .*/ - tag: useBranchName - source-branches: [ "main", "feature" ] + # label: alpha.{BranchName}. + source-branches: [ "main", "release", "feature" ] track-merge-target: true tracks-release-branches: true - increment: Patch diff --git a/PSModuleRestore.Task.ps1 b/PSModuleRestore.Task.ps1 index 3ea2ff4..53c2d0c 100644 --- a/PSModuleRestore.Task.ps1 +++ b/PSModuleRestore.Task.ps1 @@ -1,8 +1,9 @@ Add-BuildTask PSModuleRestore @{ If = Test-Path "$BuildRoot${/}*.requires.psd1" Inputs = "$BuildRoot${/}*.requires.psd1" | Convert-Path -ErrorAction ignore - Outputs = "$OutputRoot${/}*.requires.psd1" + Outputs = "$OutputRoot${/}requires.lock.json" Jobs = { - Install-ModuleFast -Scope CurrentUser -Verbose + Install-ModuleFast -Scope CurrentUser -Verbose -CI + Copy-Item -Path "$BuildRoot${/}requires.lock.json" -Destination $OutputRoot } } diff --git a/PSModuleTest.Task.ps1 b/PSModuleTest.Task.ps1 index 11e4fa1..77fb72e 100644 --- a/PSModuleTest.Task.ps1 +++ b/PSModuleTest.Task.ps1 @@ -23,15 +23,15 @@ Add-BuildTask PSModuleTest @{ if ($Clean) { $BuildRoot # guaranteed to be old } else { - "$TestResultsRoot${/}TestResults.xml" + "$TestResultsRoot${/}$PSModuleName-results.xml" } } Jobs = "PSModuleImport", { - $PSModuleTestPath ??= "$BuildRoot${/}[Tt]ests" - # The output path, by convention: TestResults.xml in your output folder - $TestResultOutputPath ??= Join-Path $TestResultsRoot "TestResult.xml" + $PSModuleTestPath ??= "$BuildRoot${/}[Tt]ests" + # The output path, by convention: TestResults.xml in your output folder + $TestResultOutputPath ??= Join-Path $TestResultsRoot "$PSModuleName-results.xml" - $PesterFilter ??= if ($BuildSystem -ne "None") { @{ "ExcludeTag" = 'NoCI' } } + $PesterFilter ??= if ($BuildSystem -ne "None") { @{ "ExcludeTag" = 'NoCI' } } $Version = $GitVersion.$PSModuleName.MajorMinorPatch @@ -64,7 +64,7 @@ Add-BuildTask PSModuleTest @{ if ($Script:RequiredCodeCoverage -gt 0.00) { $CodeCoveragePath = $PSModuleManifestPath - $CodeCoverageOutputPath = "$TestResultsRoot${/}coverage.xml" + $CodeCoverageOutputPath = "$TestResultsRoot${/}$PSModuleName-coverage.xml" $CodeCoveragePercentTarget = $RequiredCodeCoverage } diff --git a/_Bootstrap.ps1 b/_Bootstrap.ps1 index 28fd475..21bf172 100644 --- a/_Bootstrap.ps1 +++ b/_Bootstrap.ps1 @@ -14,8 +14,8 @@ param( [switch]$Force, # I require dotnet, and git version - # Defaults to the "7.0" channel, change it to change the minimum version - [double]$DotNet = "7.0", + # Defaults to the "10.0" channel, change it to change the minimum version + [double]$DotNet = "10.0", # Path to a file listing required PowerShell modules. # See also: https://github.com/marketplace/actions/modulefast#requiresspec @@ -24,16 +24,24 @@ param( # NOTE: If this file is missing, we'll still install InvokeBuild, but if you have a requires spec, don't forget to include InvokeBuild in it! [Alias("RequiredModulesPath")] $RequiresPath = (@(@(Join-Path $pwd "*.requires.psd1" - Join-Path $pwd "RequiredModules.psd1" - ) | Resolve-Path -ErrorAction Ignore)[0].Path), + Join-Path $pwd "RequiredModules.psd1" + ) | Resolve-Path -ErrorAction Ignore)[0].Path), - # Path to a .*proj file or .sln - # If this file is present, dotnet restore will be run on it. - $ProjectFile = (Join-Path $pwd "*.*proj"), + $ToolsFile = (@( + Join-Path $pwd "dotnet-tools.json" + Join-Path $pwd ".config" "dotnet-tools.json" + Join-Path $PSScriptRoot "dotnet-tools.json" + Join-Path $PSScriptRoot ".config" "dotnet-tools.json" + ) | Resolve-Path -ErrorAction Ignore)[0].Path), - # Scope for installation (of scripts and modules). Defaults to CurrentUser - [ValidateSet("AllUsers", "CurrentUser")] - $Scope = "CurrentUser" + +# Path to a .*proj file or .sln +# If this file is present, dotnet restore will be run on it. +$ProjectFile = (Join-Path $pwd "*.*proj"), + +# Scope for installation (of scripts and modules). Defaults to CurrentUser +[ValidateSet("AllUsers", "CurrentUser")] +$Scope = "CurrentUser" ) $InformationPreference = "Continue" $ErrorView = 'DetailedView' @@ -45,11 +53,11 @@ if (!((Get-Command dotnet -ErrorAction SilentlyContinue) -and ([semver](dotnet - Write-Host "This script can call dotnet-install to install a local copy of dotnet $DotNet -- if you'd rather install it yourself, answer no:" if (!$IsLinux -and !$IsMacOS) { Invoke-WebRequest https://dot.net/v1/dotnet-install.ps1 -OutFile bootstrap-dotnet-install.ps1 - .\bootstrap-dotnet-install.ps1 -Channel $DotNet -InstallDir $HOME\.dotnet + ./bootstrap-dotnet-install.ps1 -Channel "$DotNet" -InstallDir $HOME/.dotnet } else { Invoke-WebRequest https://dot.net/v1/dotnet-install.sh -OutFile bootstrap-dotnet-install.sh chmod +x bootstrap-dotnet-install.sh - ./bootstrap-dotnet-install.sh --channel $DotNet --install-dir $HOME/.dotnet + ./bootstrap-dotnet-install.sh --channel "$DotNet" --install-dir $HOME/.dotnet } if (!((Get-Command dotnet -ErrorAction SilentlyContinue) -and ([semver](dotnet --version) -gt $DotNet))) { throw "Unable to find dotnet $DotNet or later" @@ -58,26 +66,21 @@ if (!((Get-Command dotnet -ErrorAction SilentlyContinue) -and ([semver](dotnet - if (Test-Path $ProjectFile) { Write-Information "Ensure dotnet package dependencies" - split-path $ProjectFile -Parent | push-location + Split-Path $ProjectFile -Parent | Push-Location dotnet restore $ProjectFile --ucr } -Write-Information "Restore dotnet tools" -dotnet tool restore --tool-manifest $ToolsFile +if ($ToolsFile -and (Test-Path $ToolsFile)) { + Write-Information "Ensure dotnet tools from $ToolsFile" + dotnet tool restore --tool-manifest $ToolsFile +} -# Regardless of whether you have a dotnet-tools.json file, we need gitversion global tool -# dotnet 8+ can "list" tool names, but this old syntax still works: -if (!(dotnet tool list -g | Select-String "gitversion.tool")) { +# Regardless of whether you already have a dotnet-tools.json file, we need gitversion.tool +if (!(dotnet tool list gitversion.tool)) { Write-Information "Ensure GitVersion.tool" - # We need gitversion 5.x (the new 6.x version will not support SemVer 1 that PowerShell still uses) - dotnet tool update gitversion.tool --version 5.* --global + dotnet tool install gitversion.tool } -if (Test-Path $HOME/.dotnet/tools) { - Write-Information "Ensure dotnet global tools in PATH" - # TODO: implement semi-permanent PATH modification for github and azure - $ENV:PATH += ([IO.Path]::PathSeparator) + (Convert-Path $HOME/.dotnet/tools) -} # I don't want ModuleFast messing with the PSModulePath so we use the default user location $ModuleDestination = if ($IsWindows) { diff --git a/debug.log b/debug.log new file mode 100644 index 0000000..2093b1f --- /dev/null +++ b/debug.log @@ -0,0 +1,3 @@ +[0512/164832.803:ERROR:registration_protocol_win.cc(108)] CreateFile: The system cannot find the file specified. (0x2) +[0512/164833.572:ERROR:registration_protocol_win.cc(108)] CreateFile: The system cannot find the file specified. (0x2) +[0512/164834.982:ERROR:registration_protocol_win.cc(108)] CreateFile: The system cannot find the file specified. (0x2) From dba54c0190d4b1050f2d29cc5805d7109f2c927b Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 18 Apr 2026 13:48:30 -0400 Subject: [PATCH 02/43] Move tasks to subfolders Rename GitVersion to GetVersion and only export what we use. --- .config/dotnet-tools.json | 13 - BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD | 1114 +++++++++++++++++ Clean.Task.ps1 | 9 - Directory.Build.props | 17 + DotNetBuild.Task.ps1 | 33 - DotNetPack.Task.ps1 | 23 - DotNetPublish.Task.ps1 | 41 - DotNetPush.Task.ps1 | 34 - DotNetRestore.Task.ps1 | 21 - DotNetTest.Task.ps1 | 29 - GitVersion.Task.ps1 | 140 --- GitVersion.yml | 35 +- README.md | 52 +- RequiredModules.psd1 | 7 + TagSource.Task.ps1 | 9 - _Bootstrap.ps1 | 137 -- _Initialize.ps1 | 296 ----- debug.log | 3 - dotnet-tools.json | 47 + scripts/Install-RequiredModule.ps1 | 81 ++ scripts/PSFormatting.ps1 | 20 + tasks/Clean.Task.ps1 | 4 + .../DockerBuild.Task.ps1 | 0 tasks/DotNetBuild.Task.ps1 | 49 + tasks/DotNetClean.Task.ps1 | 8 + tasks/DotNetPack.Task.ps1 | 62 + tasks/DotNetPublish.Task.ps1 | 72 ++ tasks/DotNetPush.Task.ps1 | 21 + tasks/DotNetRestore.Task.ps1 | 34 + tasks/DotNetTest.Task.ps1 | 51 + tasks/DotNetToolRestore.Task.ps1 | 12 + tasks/DotNetTrx2JUnit.Task.ps1 | 17 + tasks/GetVersion.Task.ps1 | 57 + tasks/GitInit.Task.ps1 | 12 + tasks/GitVersion.yml | 74 ++ tasks/InstallBuildDependencies.Task.ps1 | 1 + tasks/InstallGitHubTools.Task.ps1 | 7 + tasks/InstallGitVersion.Task.ps1 | 10 + tasks/InstallRequiredModules.Task.ps1 | 7 + .../PSModuleAnalyze.Task.ps1 | 0 .../PSModuleBuild.Task.ps1 | 0 .../PSModuleImport.Task.ps1 | 0 .../PSModulePush.Task.ps1 | 0 .../PSModuleRestore.Task.ps1 | 0 .../PSModuleTest.Task.ps1 | 0 tasks/ReportGenerator.Task.ps1 | 29 + tasks/SonarQubeEnd.Task.ps1 | 6 + tasks/SonarQubeStart.Task.ps1 | 12 + tasks/TagSource.Task.ps1 | 9 + tasks/UniversalPackagePack.Task.ps1 | 41 + tasks/_BootStrap.ps1 | 25 + tasks/_Initialize.ps1 | 252 ++++ 52 files changed, 2182 insertions(+), 851 deletions(-) delete mode 100644 .config/dotnet-tools.json create mode 100644 BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD delete mode 100644 Clean.Task.ps1 create mode 100644 Directory.Build.props delete mode 100644 DotNetBuild.Task.ps1 delete mode 100644 DotNetPack.Task.ps1 delete mode 100644 DotNetPublish.Task.ps1 delete mode 100644 DotNetPush.Task.ps1 delete mode 100644 DotNetRestore.Task.ps1 delete mode 100644 DotNetTest.Task.ps1 delete mode 100644 GitVersion.Task.ps1 create mode 100644 RequiredModules.psd1 delete mode 100644 TagSource.Task.ps1 delete mode 100644 _Bootstrap.ps1 delete mode 100644 _Initialize.ps1 delete mode 100644 debug.log create mode 100644 dotnet-tools.json create mode 100644 scripts/Install-RequiredModule.ps1 create mode 100644 scripts/PSFormatting.ps1 create mode 100644 tasks/Clean.Task.ps1 rename DockerBuild.Task.ps1 => tasks/DockerBuild.Task.ps1 (100%) create mode 100644 tasks/DotNetBuild.Task.ps1 create mode 100644 tasks/DotNetClean.Task.ps1 create mode 100644 tasks/DotNetPack.Task.ps1 create mode 100644 tasks/DotNetPublish.Task.ps1 create mode 100644 tasks/DotNetPush.Task.ps1 create mode 100644 tasks/DotNetRestore.Task.ps1 create mode 100644 tasks/DotNetTest.Task.ps1 create mode 100644 tasks/DotNetToolRestore.Task.ps1 create mode 100644 tasks/DotNetTrx2JUnit.Task.ps1 create mode 100644 tasks/GetVersion.Task.ps1 create mode 100644 tasks/GitInit.Task.ps1 create mode 100644 tasks/GitVersion.yml create mode 100644 tasks/InstallBuildDependencies.Task.ps1 create mode 100644 tasks/InstallGitHubTools.Task.ps1 create mode 100644 tasks/InstallGitVersion.Task.ps1 create mode 100644 tasks/InstallRequiredModules.Task.ps1 rename PSModuleAnalyze.Task.ps1 => tasks/PSModuleAnalyze.Task.ps1 (100%) rename PSModuleBuild.Task.ps1 => tasks/PSModuleBuild.Task.ps1 (100%) rename PSModuleImport.Task.ps1 => tasks/PSModuleImport.Task.ps1 (100%) rename PSModulePush.Task.ps1 => tasks/PSModulePush.Task.ps1 (100%) rename PSModuleRestore.Task.ps1 => tasks/PSModuleRestore.Task.ps1 (100%) rename PSModuleTest.Task.ps1 => tasks/PSModuleTest.Task.ps1 (100%) create mode 100644 tasks/ReportGenerator.Task.ps1 create mode 100644 tasks/SonarQubeEnd.Task.ps1 create mode 100644 tasks/SonarQubeStart.Task.ps1 create mode 100644 tasks/TagSource.Task.ps1 create mode 100644 tasks/UniversalPackagePack.Task.ps1 create mode 100644 tasks/_BootStrap.ps1 create mode 100644 tasks/_Initialize.ps1 diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json deleted file mode 100644 index ea618d6..0000000 --- a/.config/dotnet-tools.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "gitversion.tool": { - "version": "6.7.0", - "commands": [ - "dotnet-gitversion" - ], - "rollForward": false - } - } -} \ No newline at end of file diff --git a/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD b/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD new file mode 100644 index 0000000..671eed0 --- /dev/null +++ b/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD @@ -0,0 +1,1114 @@ +# Monorepo Build System Implementation Guide + +## Context and Purpose + +This document provides comprehensive instructions for implementing a standardized Invoke-Build monorepo build system in .NET repositories. The build system consolidates multiple solution files from subdirectories into root-level solution files, implements centralized output management, and provides automated versioning with GitVersion. + +## Prerequisites + +Before implementing this build system, ensure: + +1. The repository contains one or more .NET solutions in subdirectories +2. Each subdirectory has its own solution file (e.g., `SubFolder/ProjectName.sln`) +3. You have identified which projects need to be published (web apps, services) vs packed (libraries) + +## Implementation Guidance + +**Approach:** Implement steps sequentially, checking in after each step completion for review. + +**Solution Scope Determination:** Before implementing any changes, identify which solution file(s) in the repository root define the scope of work. Only apply build system changes to subdirectories and projects that are referenced by the root-level solution file(s). Subdirectories not referenced by any root solution file should be left unchanged, as they may be independent projects with their own build systems or may be legacy code not part of the current build scope. + +**Repository Structure:** Use Step 1 to discover all solution files and their locations in subdirectories. + +**Project Classification:** Use Step 5 instructions to identify: +- **Publishable projects** (web apps, services): Look for ``, Docker support, or entry points +- **Packable projects** (libraries): Look for `` with reusable code +- **Neither** (test projects): Projects with test frameworks or internal utilities + +**Dockerfiles:** Follow Step 6 to simplify any existing Dockerfiles found in publishable project directories. + +**Testing:** Developer will handle manual testing after implementation is complete. + +--- + +## Implementation Steps + +### Step 0: Create or Merge Root Directory.Build.props + +**Action:** Ensure the repository has a properly configured root `Directory.Build.props` file for centralized output management. + +**Instructions:** + +1. **Check if `Directory.Build.props` exists in the repository root** + +2. **If NO `Directory.Build.props` exists:** + - Create a new `Directory.Build.props` file in the repository root with the following content: + +```xml + + + shared + + $(MSBuildThisFileDirectory)Output/$(SolutionName)/ + $(LDBUILD_BINARIESDIRECTORY)/$(SolutionName)/ + + $(RootOutputPath)bin/$(MSBuildProjectName) + $(RootOutputPath)obj/$(MSBuildProjectName) + $(RootOutputPath)publish/$(MSBuildProjectName) + + + $(LDBUILD_TARGET_RUNTIME) + False + False + + +``` + +3. **If `Directory.Build.props` ALREADY EXISTS:** + - Read the existing file and identify any custom properties or settings + - Attempt to merge the required build system properties with existing content: + - Add the `SolutionName` property if not present + - Add the `RootOutputPath` conditional properties for output management + - Add the `BaseOutputPath`, `BaseIntermediateOutputPath`, and `PublishDir` properties + - Add the `RuntimeIdentifier` conditional property + - Add the `IsPackable` and `IsPublishable` default properties if not present + - Preserve any existing custom properties (e.g., `enable`, `true`) + - **Present the merged version to the user for approval** before making changes + - Ask: "I've merged the build system properties with your existing Directory.Build.props. Please review the merged content below. Is this acceptable?" + +**Why This Matters:** +- The `Directory.Build.props` file is the foundation of centralized output management +- It ensures all projects build to a consistent `Output//` directory structure +- The `RuntimeIdentifier` property enables the build system to correctly locate test DLLs +- The `IsPackable` and `IsPublishable` defaults prevent accidental packaging of test projects + +### Step 0a: Update .gitignore to Exclude Output Directory + +**Action:** Add the `Output/` directory to `.gitignore` to prevent build artifacts from being committed. + +**Instructions:** + +1. **Open the `.gitignore` file in the repository root** + +2. **Add the following line to exclude the Output directory:** + ``` + Output/ + ``` + +3. **Placement:** Add this line in the appropriate section (typically near other build output exclusions like `bin/`, `obj/`, etc.) + +**Why This Matters:** +- The centralized build system creates all build artifacts in the `Output/` directory +- Build artifacts should never be committed to source control +- This prevents accidentally committing: + - Compiled binaries (`Output//bin/`) + - Intermediate build files (`Output//obj/`) + - Published applications (`Output//publish/`) + - NuGet packages (`Output//nuget/`) + - Test results (`Output//TestResults/`) + +**Example Merge Scenario:** + +Existing `Directory.Build.props`: +```xml + + + enable + true + latest + + +``` + +Merged `Directory.Build.props`: +```xml + + + shared + + $(MSBuildThisFileDirectory)Output/$(SolutionName)/ + $(LDBUILD_BINARIESDIRECTORY)/$(SolutionName)/ + + $(RootOutputPath)bin/$(MSBuildProjectName) + $(RootOutputPath)obj/$(MSBuildProjectName) + $(RootOutputPath)publish/$(MSBuildProjectName) + $(LDBUILD_TARGET_RUNTIME) + False + False + + + enable + true + latest + + +``` + +### Step 0b: Create Root build.build.ps1 (If Not Present) + +**Action:** Ensure the repository has a `build.build.ps1` file in the root for orchestrating builds. + +**Instructions:** + +1. **Check if `build.build.ps1` exists in the repository root** + +2. **If `build.build.ps1` DOES NOT exist:** + - Create a new `build.build.ps1` file in the repository root with the following content: + +```powershell +<# +.SYNOPSIS + ./project.build.ps1 +.EXAMPLE + Invoke-Build +.NOTES + 0.5.0 - Parameterize + Add parameters to this script to control the build +#> +[CmdletBinding()] +param( + # dotnet build configuration parameter (Debug or Release) + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + # Add the clean task before the default build + [switch]$Clean, + + # Collect code coverage when tests are run + [switch]$CollectCoverage, + + # Which solution to build + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + + Get-ChildItem -Path $PSScriptRoot/*/* -Filter *.sln | + Split-Path -LeafBase | + Where-Object { $_ -like "*$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } + })] + [Alias("Project")] + [Parameter(Position = 0)] + [string]$Solution = "*", + + # The Key to use for reporting to SonarQube + [string]$SonarProjectKey = $($Solution -ne "*" ? $Solution : ""), + + # [string[]]$MonoRepoVersionNames = @(""), + + # Which projects to build + [Alias("Projects")] + $solutionProject = @( + # By default build the first solution file in the root + if (Get-ChildItem -Filter "${Solution}.sln" -ErrorAction Ignore -OutVariable sln) { + if ($sln.Count -gt 1) { + Write-Warning "Multiple solution files found: `n- $($sln.FullName -join '`n- ')`Building only the first one: $($sln[0].FullName)" + } + $sln[0] | Convert-Path + } + )[0], + + # This should always be calculated automagically if you have docker files. If not, you'll need to specify these. + $DotNetPublishProjects = @(), + + # Which projects are test projects + [Alias("TestProjects")] + $dotnetTestProjects = @( + $dotnetProjects | Where-Object { + $_ -match "\.slnx?$" -or + $_ -match "Test[^\\/]*\..*proj$" + } + ), + + # Further options to pass to dotnet + [Alias("Options")] + $dotnetOptions = @{ + "-verbosity" = "minimal" + # "-runtime" = "linux-x64" + }, + + $TargetFramework = "net8.0", + [ValidateSet('linux-x64','win-x64')] + $TargetRuntime +) + +. $PSScriptRoot/../LD.Platform.BuildTasks/scripts/PSFormatting.ps1 + +# The name of the module to publish +$script:PSModuleName = "TerminalBlocks" +# Use Env because Earthly can override it +$Env:OUTPUT_ROOT ??= Join-Path $PSScriptRoot output + +$Tasks = "../LD.Platform.BuildTasks/tasks", "tasks", "../tasks", "../../tasks" | Convert-Path -ErrorAction Ignore +Write-Information "$($PSStyle.Foreground.BrightCyan)Found shared tasks in $Tasks" -Tag "InvokeBuild" + +## Self-contained build script - can be invoked directly or via Invoke-Build +if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { + foreach ($taskDir in $Tasks) { + $bootstrap = Join-Path $taskDir "_BootStrap.ps1" + Write-Information "Check for $bootstrap" -Tag "InvokeBuild" + if (Test-Path $bootstrap) { + Write-Information "Dotsource $bootstrap" -Tag "InvokeBuild" + . $bootstrap + } + } + + Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result + + if ($Result.Error) { + $Error[-1].ScriptStackTrace | Out-Host + exit 1 + } + exit 0 +} + +## Initialize the build variables, and import shared tasks, including DotNet tasks +foreach($taskDir in $Tasks) { + $initialize = Join-Path $taskDir "_Initialize.ps1" + if (Test-Path $initialize) { + Write-Information ". $initialize" + . $initialize + } +} +``` + +3. **If `build.build.ps1` ALREADY EXISTS:** + - **Do nothing** - leave the existing file as-is + - The developer has already customized this file for their repository + +**Why This Matters:** +- The `build.build.ps1` file is the entry point for all build operations +- It defines parameters for controlling builds (Configuration, Solution, Clean, etc.) +- The `$TargetFramework` parameter must match the framework used by projects in the repository (commonly `net8.0` or `net9.0`) +- **Critical:** If `$TargetFramework` doesn't match the actual project target frameworks, tests will be skipped with "Skipping empty input" because the build system won't find the compiled test DLLs in the expected paths +- The `$dotnetTestProjects` parameter enables automatic test project discovery by pattern matching + +**Important Configuration Notes:** +- **TargetFramework:** Set to `net8.0` or `net9.0` based on your projects' target framework. This must match or tests won't run. +- **Solution Discovery:** The script auto-discovers solution files in the root directory +- **Test Project Discovery:** Automatically finds projects matching `Test[^\\/]*\..*proj$` pattern +- **Task Paths:** Looks for shared tasks in `../LD.Platform.BuildTasks/tasks` and local `tasks/` directories + +### Step 1: Analyze Repository Structure + +**Action:** Identify all solution files and their locations. + +**Instructions:** +1. Search for all `.sln` files in the repository +2. For each solution file found in a subdirectory (not in root), note: + - The subdirectory name (e.g., `LD.JV.Builder`) + - The solution file name (e.g., `LD.JV.Builder.sln`) + - The projects contained in that solution +3. Create a mapping of subdirectory → new root-level solution name: + - Pattern: If subdirectory is `LD.JV.Builder` and solution is `LD.JV.Builder.sln`, create root-level `LD.JV.Builder.sln` + - Pattern: If subdirectory is `LD.JV.BuilderAsync` and solution is `LD.JV.BuilderAsync.sln`, create root-level `LD.JV.BuilderAsync.sln` + - Pattern: If subdirectory is `LD.JV.PlatformEvent.Common` and solution is `LD.JV.PlatformEvent.Common.sln`, create root-level `LD.JV.PlatformEvent.Common.sln` + +**Expected Output Format:** + +After completing Step 1, provide a summary in this format: + +``` +## Step 1 Complete: Repository Structure Analysis + +### Solution Files Found + +**1. [Solution Name]** +- **Location:** [Full path to current solution file] +- **Subdirectory:** [Subdirectory name] +- **New root-level name:** [Name for root solution file] + +**Projects contained ([count] total):** +- **Main/API Projects:** + - [Project names that are web apps or APIs] + +- **Library Projects:** + - [Project names that are libraries] + +- **Test Projects:** + - [Project names that are test projects] + +[Repeat for each solution found] + +### Summary + +- **[N] solution files** found in subdirectories +- **[Solution1.sln]** will be moved from `[SubDir]/` to root with updated paths (prefix: `[SubDir]\`) +- **[Solution2.sln]** will be moved from `[SubDir]/` to root with updated paths (prefix: `[SubDir]\`) +``` + +### Step 2: Create Root-Level Solution Files + +**Action:** For each subdirectory solution one level deep from the root, move the solution file to the root and update the project paths. + +**Instructions:** +1. Read the original solution file from the subdirectory +2. Update all project paths in the solution file to be relative from the repository root: + - **Original path pattern:** `ProjectName\ProjectName.csproj` (relative to subdirectory) + - **New path pattern:** `SubdirectoryName\ProjectName\ProjectName.csproj` (relative to root) +4. Preserve all project GUIDs, configurations, and solution items +5. Update any solution items paths (like NuGet.config) to reference the subdirectory: + - **Original:** `NuGet.config = NuGet.config` + - **New:** `NuGet.config = SubdirectoryName\NuGet.config` + +**Example Transformation:** + +Original solution in `LD.JV.Builder/LD.JV.Builder.sln`: +``` +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LD.JV.Builder.Host.Web", "LD.JV.Builder.Host.Web\LD.JV.Builder.Host.Web.csproj", "{GUID}" +``` + +New solution in root `LD.JV.Builder.sln`: +``` +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LD.JV.Builder.Host.Web", "LD.JV.Builder\LD.JV.Builder.Host.Web\LD.JV.Builder.Host.Web.csproj", "{GUID}" +``` + +### Step 3: Delete Original Subdirectory Solution Files + +**Action:** Remove the old solution files from subdirectories. + +**Instructions:** +1. For each solution file that was in a subdirectory (e.g., `LD.JV.Builder/LD.JV.Builder.sln`), delete it using `git rm` +2. **Do not delete:** + - Project files (.csproj, .fsproj, etc.) + - Source code + - Tests + - Docker files + - Any other non-solution files + +**Why:** The root-level solution files replace the subdirectory solutions. Keeping both would cause confusion and maintenance issues. + +### Step 4: Update Subdirectory Directory.Build.props Files + +**Action:** Modify existing `Directory.Build.props` files in subdirectories to import the root-level `Directory.Build.props`. + +**Instructions:** +1. Search for existing `Directory.Build.props` files in subdirectories +2. For each file found, add the following import statement as the **first line** after the opening `` tag: + +```xml + + + +``` + +**Why:** This ensures that the centralized output path configuration from the root `Directory.Build.props` is inherited by all projects, while still allowing subdirectory-specific properties. + +**Example:** + +Before: +```xml + + + enable + $(WarningsAsErrors);CS8600 + + +``` + +After: +```xml + + + + enable + $(WarningsAsErrors);CS8600 + + +``` + +### Step 5: Mark Projects as Publishable or Packable + +**Action:** Update .csproj files to explicitly declare their packaging/publishing intent. + +**Instructions:** + +1. **For projects that should be published** (web applications, services, executables): + - Add `True` to the main `` + - These are typically projects with: + - `` + - Docker support + - Entry points (Program.cs with Main method) + - Service hosts + +2. **For projects that should be packed as NuGet packages** (libraries, shared code): + - Add `True` to the main `` + - These are typically projects with: + - `` + - Reusable library code + - No entry point + +3. **For projects that should not be published or packed** (test projects, internal utilities): + - No changes needed - the root `Directory.Build.props` sets both to `False` by default + +**Example Changes:** + +For a web service project (`LD.JV.BuilderAsync.Host.Messaging.csproj`): +```xml + + net9.0 + enable + enable + 2318815b-a78c-420b-80db-571a264a2cf9 + True + Linux + +``` + +For a library project (`LD.JV.PlatformEvent.Internal.csproj`): +```xml + + net8.0;net9.0 + enable + enable + True + +``` + +### Step 6: Simplify Dockerfiles for Publishable Projects + +**Action:** Update Dockerfiles to use pre-built artifacts instead of multi-stage builds. + +**Instructions:** + +For projects marked with `True` that have Dockerfiles, simplify them to expect pre-built publish artifacts: + +1. **Identify Dockerfiles** in publishable project directories +2. **Replace multi-stage build Dockerfiles** with simplified single-stage versions +3. **Pattern to follow:** + +**Before (multi-stage build):** +```dockerfile +FROM dotnet/sdk:9.0 AS build +WORKDIR /src +COPY ["NuGet.config", "."] +COPY ["Project/Project.csproj", "Project/"] +RUN dotnet restore "Project/Project.csproj" +COPY . . +WORKDIR "/src/Project" +RUN dotnet build "Project.csproj" -c Release -o /app/build +RUN dotnet publish "Project.csproj" -c Release -o /app/publish + +FROM dotnet/runtime:9.0 AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "Project.dll"] +``` + +**After (simplified for pre-built artifacts):** +```dockerfile +FROM crazusw2dvosl1.azurecr.io/dotnet/runtime:9.0 +WORKDIR /app +EXPOSE 8080 +COPY . . +ENTRYPOINT ["dotnet", "ProjectName.dll"] +``` + +**Key Changes:** +- Remove all build stages (SDK image, restore, build, publish) +- Use only the runtime base image +- Simple `COPY . .` to copy pre-built artifacts +- Keep EXPOSE, ENTRYPOINT, and WORKDIR directives + +**Why:** The build system now handles `dotnet publish` via the build tasks, creating publish artifacts in `Output//publish//`. Dockerfiles should only package these pre-built artifacts, not rebuild the application. This: +- Separates build concerns from containerization +- Speeds up Docker image creation +- Ensures consistent builds across environments +- Allows the build system to control versioning and build parameters + +**Examples from this repository:** + +For ASP.NET web applications: +```dockerfile +FROM crazusw2dvosl1.azurecr.io/dotnet/runtime:9.0 +WORKDIR /app +EXPOSE 8080 +COPY . . +ENTRYPOINT ["dotnet", "LD.JV.Builder.Host.Web.dll"] +``` + +For console applications/services: +```dockerfile +FROM crazusw2dvosl1.azurecr.io/dotnet/runtime:9.0 +WORKDIR /app +EXPOSE 5000 +COPY . . +ENTRYPOINT ["./LD.JV.PlatformEvent.Consumer"] +``` + +**Note:** The Dockerfile should remain in the project directory alongside the .csproj file. The build system will copy it to the publish output directory when `True` is set. + +### Step 7: Test the Build System + +**Action:** Verify the build system works correctly by testing individual build tasks sequentially with the developer. + +**Instructions:** + +**IMPORTANT:** The developer must run each command in their terminal. Prompt them to execute each command and wait for them to share the output before proceeding to the next step. Do not attempt to run commands automatically. + +Work with the developer to test each build task in sequence. After each command succeeds, proceed to the next one: + +1. **Test package restore:** + ```powershell + Invoke-Build DotNetRestore + ``` + - Verifies NuGet packages can be restored + - Checks package source configuration + - Ensures all dependencies are available + +2. **Test build:** + ```powershell + Invoke-Build DotNetBuild + ``` + - Compiles all projects in the solution + - Validates project references + - Confirms output paths are correct + +3. **Test unit tests:** + ```powershell + Invoke-Build DotNetTest + ``` + - Runs all test projects + - Generates test results + - Validates test discovery + +4. **Test publish (for publishable projects):** + ```powershell + Invoke-Build DotNetPublish + ``` + - Creates deployment artifacts for projects marked `IsPublishable=True` + - Output goes to `Output//publish//` + - Includes runtime dependencies + +5. **Test pack (for packable projects):** + ```powershell + Invoke-Build DotNetPack + ``` + - Creates NuGet packages for projects marked `IsPackable=True` + - Output goes to `Output//nuget/` + - Includes version information from GitVersion + +6. **Verify outputs:** + - Check that `Output/` directory is created in the repository root + - Verify subdirectories exist: `Output//bin/`, `obj/`, `publish/`, `nuget/` + - Confirm build artifacts are in expected locations + - Verify version.json is created with GitVersion information + +7. **Test full CI pipeline (after individual tasks succeed):** + ```powershell + Invoke-Build CI + ``` + - Runs all tasks in sequence + - Simulates continuous integration build + +--- + +## Key Concepts and Rationale + +### Why Move Solutions to Root? + +**Problem:** Multiple solutions in subdirectories make it difficult to: +- Build all solutions with a single command +- Share build configuration and tasks +- Manage output directories consistently +- Version solutions independently or together + +**Solution:** Root-level solution files with subdirectory project references allow: +- Centralized build orchestration +- Shared build tasks and configuration +- Per-solution output directories +- Flexible versioning strategies + +### Output Directory Structure + +The build system creates a structured output directory: + +``` +Output/ +├── JVBuilder/ +│ ├── bin/ +│ │ └── ProjectName/ +│ │ └── Release/ +│ │ └── net9.0/ +│ ├── obj/ +│ │ └── ProjectName/ +│ ├── publish/ +│ │ └── ProjectName/ +│ ├── nuget/ +│ └── version.json +├── JVBuilderAsync/ +│ └── (same structure) +└── JVPlatformEventCommon/ + └── (same structure) +``` + +### GitVersion Integration + +The build system uses GitVersion for semantic versioning: + +**Configuration highlights:** +- **Workflow:** TrunkBased/preview1 - Each commit can increment version +- **Main branch:** Auto-increments minor version on merge +- **Feature branches:** Include branch name in pre-release tag +- **Release branches:** Use manual deployment mode with RC label +- **Commit messages:** Support `semver:` trailers for explicit version control + - `semver: major` or `semver: breaking` - Increment major version + - `semver: minor` or `semver: feature` - Increment minor version + - `semver: patch` or `semver: fix` - Increment patch version + - `semver: none` or `semver: skip` - No version increment + +**Version format:** +- Assembly version: `{Major}.{Minor}.{Patch}.{BuildCount}` +- Informational version: `{Major}.{Minor}.{Patch}{PreReleaseTag}+Build.{BuildCount}.Date.{CommitDate}.Branch.{BranchName}.Sha.{Sha}` + +### IsPackable vs IsPublishable + +**IsPackable=True:** +- Creates NuGet packages with `dotnet pack` +- For libraries and shared code +- Output goes to `Output//nuget/` + +**IsPublishable=True:** +- Creates deployment artifacts with `dotnet publish` +- For applications, services, and executables +- Output goes to `Output//publish/` +- Can include Dockerfiles and runtime dependencies + +**Default (both False):** +- Test projects +- Internal utilities +- Projects not meant for distribution + +### Invoke-Build Task Dependencies + +The build system defines task dependencies: + +``` +CI Task: + └─ Clean (if in CI or -Clean specified) + └─ DotNetRestore + └─ DotNetToolRestore + └─ GetVersion + └─ InstallGitVersion + └─ GitInit + └─ DotNetBuild + └─ DotNetRestore + └─ GetVersion + └─ SonarQubeStart (if configured) + └─ DotNetTest + └─ DotNetTrx2JUnit + └─ ReportGenerator + └─ DotNetPack + └─ DotNetPush +``` + +--- + +## Troubleshooting: Edge Cases and Complex Scenarios + +This section documents real-world edge cases encountered during build system implementations and their solutions. + +### Edge Case 1: SpecRun Path Handling with Centralized Output + +**Repository:** FeeEngine (LD.FeeEngine.API) + +**Symptom:** +Build fails with path duplication error in SpecRun-based test projects: +``` +error: The filename, directory name, or volume label syntax is incorrect. : +'C:\XDL\LD.FeeEngine.API\FeeEngine\LD.EPS.FeeEngine.BDD.Tests\C:\XDL\LD.FeeEngine.API\Output\FeeEngine\obj\...' +``` + +**Root Cause:** +SpecRun.SpecFlow targets file (version 3.9.31) does not correctly handle absolute paths in `BaseIntermediateOutputPath`. When the centralized build system sets this to an absolute path, SpecRun's targets attempt to concatenate it with the project directory path, resulting in malformed paths. + +This is a known limitation of older SpecRun versions (pre-.NET 5) that assume `BaseIntermediateOutputPath` is always a relative path. + +**Solution:** +Override the intermediate output path properties in the affected project to use a local relative path: + +```xml + + + + obj\ + $(BaseIntermediateOutputPath)\$(Configuration)\$(TargetFramework)\ + +``` + +**Trade-offs:** +- This project won't benefit from centralized intermediate output cleanup +- Creates a non-fatal warning about `BaseIntermediateOutputPath` being modified after MSBuild uses it +- Build artifacts (bin) still go to centralized location; only intermediate files (obj) are kept local +- Acceptable compromise until SpecRun is upgraded to version 3.10+ or SpecFlow 4.x + +**When to Apply:** +- Projects using SpecRun.SpecFlow versions < 3.10 +- Projects using SpecFlow.Plus.Runner with older versions +- Any test framework with custom MSBuild targets that assume relative paths + +--- + +### Edge Case 2: Package Downgrade Errors from Legacy Dependencies + +**Repository:** FeeEngine (LD.FeeEngine.API) + +**Symptom:** +Build fails with NU1605 errors (package downgrade warnings treated as errors): +``` +error NU1605: Warning As Error: Detected package downgrade: System.Diagnostics.Debug from 4.3.0 to 4.0.11 +error NU1605: Warning As Error: Detected package downgrade: System.IO.FileSystem.Primitives from 4.3.0 to 4.0.1 +error NU1605: Warning As Error: Detected package downgrade: System.Runtime.InteropServices from 4.3.0 to 4.1.0 +error NU1605: Warning As Error: Detected package downgrade: System.Threading from 4.3.0 to 4.0.11 +``` + +**Root Cause:** +Legacy internal packages (e.g., `LD.Common.AspNetCore 2.0.0.49`) or old test frameworks (e.g., `AutoFixture.AutoMoq 4.8.0`, `Moq 4.7.0`) create conflicting transitive dependency chains: + +``` +Project → LD.Common.AspNetCore 2.0.0.49 + → Microsoft.AspNetCore.Mvc.Core 2.2.0 + → Microsoft.Extensions.DependencyModel 2.1.0 + → Microsoft.DotNet.PlatformAbstractions 2.1.0 + → System.IO.FileSystem 4.0.1 + → runtime.win.System.IO.FileSystem 4.3.0 + → System.Diagnostics.Debug (>= 4.3.0) ← Requires 4.3.0 + +But also: +Project → LD.Common.AspNetCore 2.0.0.49 + → Microsoft.Extensions.DependencyModel 2.1.0 + → System.Diagnostics.Debug (>= 4.0.11) ← Allows 4.0.11 +``` + +NuGet's dependency resolver chooses the lower version to satisfy both constraints, but this violates the "no downgrade" rule when `TreatWarningsAsErrors` is enabled. + +**Solution:** +Add explicit package references for the higher versions required by transitive dependencies: + +**For library projects with LD.Common.AspNetCore dependencies:** +```xml + + + + + + +``` + +**For test projects with AutoFixture.AutoMoq dependencies:** +```xml + + + + +``` + +**Why This Works:** +- Explicit package references take precedence over transitive dependencies in NuGet's resolution +- Forces NuGet to use version 4.3.0, satisfying all dependency constraints without downgrades +- System.* packages at version 4.3.0 (from .NET Core 1.x era) remain compatible with modern .NET +- At runtime, .NET 8.0+ uses built-in implementations; package references primarily satisfy NuGet's dependency graph + +**Alternative Solutions (Not Recommended):** +- **Upgrade legacy packages:** Requires coordination across teams, potential breaking changes +- **Disable TreatWarningsAsErrors:** Reduces build quality, allows security vulnerabilities +- **Add NoWarn for NU1605:** Masks the problem without fixing it + +**When to Apply:** +- Projects referencing legacy internal packages targeting .NET Framework or early .NET Core +- Test projects using older versions of Moq, AutoFixture, NSubstitute, or similar frameworks +- Any project with `TreatWarningsAsErrors=true` encountering NU1605 warnings + +**Affected Projects in FeeEngine Example:** +- `LD.EPS.FeeEngine.ThirdPartyFramework` - Added 3 System.* packages +- `LD.EPS.FeeEngine.ClosingCorp` - Added 3 System.* packages +- `LD.EPS.FeeEngine.ClosingCorp.Tests` - Added System.Threading +- `LD.EPS.FeeEngine.ThirdPartyFees.Tests` - Added System.Threading +- `LD.EPS.FeeEngine.InRule.Tests` - Added System.Threading + +--- + +### Edge Case 3: Understanding NuGet Dependency Resolution + +**Background:** +Understanding how NuGet resolves package versions helps diagnose and fix dependency conflicts. + +**NuGet Resolution Strategy:** +1. **Direct references win:** Explicit `` in the project file takes highest precedence +2. **Nearest wins:** Among transitive dependencies, the package "nearest" to the project (fewest hops) is chosen +3. **Lowest compatible version:** When multiple versions satisfy constraints, NuGet picks the lowest version that works +4. **Downgrade detection:** If resolution results in using a lower version than required by any dependency, NU1605 is issued + +**Example Dependency Graph:** +``` +MyProject.csproj +├─ PackageA 2.0 +│ └─ System.Text.Json >= 6.0.0 +└─ PackageB 1.0 + └─ System.Text.Json >= 4.7.0 + +Resolution: System.Text.Json 6.0.0 (satisfies both >= 6.0.0 and >= 4.7.0) +``` + +**Downgrade Example:** +``` +MyProject.csproj +├─ PackageA 2.0 +│ └─ System.Text.Json 4.7.0 (exact version) +└─ PackageB 1.0 + └─ System.Text.Json >= 6.0.0 + +Resolution: System.Text.Json 4.7.0 (nearest wins, but downgrades from 6.0.0) +Warning NU1605: Detected package downgrade +``` + +**Fix:** Add explicit reference to override: +```xml + +``` + +--- + +### Edge Case 4: Package Source Mapping Required with Central Package Management + +**Repository:** LD.Shared.EnterprisePlatformServices.API (EPS) + +**Symptom:** +Build fails with NU1507 errors when using Central Package Management with multiple NuGet sources: +``` +error NU1507: Warning As Error: There are 6 package sources defined in your configuration. +When using central package management, please map your package sources with package source mapping +(https://aka.ms/nuget-package-source-mapping) or specify a single package source. +``` + +**Root Cause:** +When `Directory.Packages.props` enables Central Package Management (`true`), NuGet requires explicit package source mapping if multiple package sources are defined. This is a security feature to prevent dependency confusion attacks and ensure packages come from expected sources. + +Without package source mapping, NuGet doesn't know which source to query for each package, leading to: +- Slower restore operations (queries all sources) +- Potential security risks (malicious packages from unexpected sources) +- Build failures when `TreatWarningsAsErrors` is enabled + +**Solution:** +Add `` section to `nuget.config` to map package patterns to specific sources: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Package Pattern Guidelines:** +- Use `*` wildcard to match all packages from a source (typically nuget.org for public packages) +- Use specific patterns like `LD.*` to match company-internal packages +- Use `CompanyName.*` patterns for vendor-specific packages +- More specific patterns take precedence over wildcards + +**Why This Works:** +- NuGet now knows to query nuget.org for all public packages (Microsoft.*, System.*, etc.) +- Internal LD.* packages are only queried from company feeds +- Eliminates ambiguity and improves restore performance +- Prevents accidental package substitution attacks + +**Alternative Solutions (Not Recommended):** +- **Disable Central Package Management:** Loses version consistency benefits +- **Use single package source:** Requires consolidating all packages into one feed +- **Disable TreatWarningsAsErrors:** Reduces build quality, allows security issues + +**When to Apply:** +- Any repository using Central Package Management (`Directory.Packages.props`) +- Repositories with multiple NuGet package sources +- Builds failing with NU1507 errors +- Organizations with internal package feeds alongside nuget.org + +**Related Documentation:** +- [NuGet Package Source Mapping](https://aka.ms/nuget-package-source-mapping) +- [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) + +--- + +### Edge Case 5: System.* Package Compatibility + +**Question:** Why are System.* packages from .NET Core 1.x (version 4.3.0) still compatible with .NET 8.0? + +**Answer:** +- System.* packages (System.Threading, System.Diagnostics.Debug, etc.) are part of .NET Standard 2.0 +- .NET Standard 2.0 is supported by all modern .NET versions (.NET Core 2.0+, .NET 5+, .NET Framework 4.6.1+) +- Modern .NET includes these types in the core framework (no separate package needed at runtime) +- Package references are primarily for NuGet's dependency graph resolution +- At runtime, .NET 8.0's built-in implementations are used (type forwarding) + +**Verification:** +```powershell +# Check if package is actually used at runtime +dotnet publish MyProject.csproj -c Release +# System.* packages won't appear in publish output - they're built into the runtime +``` + +--- + +### Recommendations for Preventing Edge Cases + +**1. Regular Dependency Audits** +```powershell +# Check for outdated packages +dotnet list package --outdated + +# Check for vulnerable packages +dotnet list package --vulnerable +``` + +**2. Central Package Management** +Use `Directory.Packages.props` for version management: +```xml + + + true + + + + + + +``` + +**3. Upgrade Legacy Dependencies** +- Prioritize upgrading internal packages to target modern .NET +- Update test frameworks to current versions when feasible +- Document upgrade blockers for future planning + +**4. Monitor Build Warnings** +- Review all NU1605 warnings even if build succeeds +- Investigate NU1608 (version override) warnings +- Track MSB3539 warnings about property modifications + +**5. Test Framework Compatibility Matrix** +| Framework | Last Version Supporting .NET Framework | First Version Supporting .NET 5+ | +|-----------|---------------------------------------|-----------------------------------| +| SpecFlow | 3.9.x | 3.10+ | +| NUnit | 3.13.x | 3.13+ (both) | +| xUnit | 2.4.x | 2.4+ (both) | +| MSTest | 2.2.x | 2.2+ (both) | + +--- + +## Common Issues and Solutions + +### Issue: Build cannot find projects + +**Symptom:** Error like "Project file does not exist" + +**Solution:** Verify project paths in root solution files are correct and relative to repository root, not subdirectory. + +### Issue: Output directory conflicts + +**Symptom:** Build artifacts from different solutions overwrite each other + +**Solution:** Ensure each root solution file has a unique name, which becomes the `SolutionName` variable used in output paths. + +### Issue: GitVersion fails + +**Symptom:** "No commits found on the current branch" + +**Solution:** +- Ensure repository has at least one commit +- Run `git fetch --unshallow` if in a shallow clone +- Verify `GitVersion.yml` is in repository root + +### Issue: Projects not inheriting root Directory.Build.props + +**Symptom:** Output goes to default `bin/` and `obj/` directories in project folders + +**Solution:** Add the import statement to subdirectory `Directory.Build.props` files as described in Step 4. + +### Issue: Test projects being packed or published + +**Symptom:** NuGet packages or publish folders created for test projects + +**Solution:** Ensure test projects do NOT have `True` or `True`. The default from root `Directory.Build.props` is False for both. + +### Issue: NU1507 errors with Central Package Management + +**Symptom:** Build warnings or errors like "NU1507: There are 2 package sources defined in your configuration. When using central package management, please map your package sources with package source mapping..." + +**Solution:** Add `packageSourceMapping` section to the root `nuget.config` file. This is required when using Central Package Management (Directory.Packages.props). Example: + +```xml + + + + + + + + + +``` + +Place this section inside the `` element, typically after ``. Map each package source to the package patterns it should provide. + +--- + +## Validation Checklist + +After implementation, verify: + +- [ ] All original subdirectory solution files are deleted +- [ ] New root-level solution files exist for each subdirectory solution +- [ ] All project paths in root solutions are correct and relative to root +- [ ] Subdirectory `Directory.Build.props` files import the root `Directory.Build.props` +- [ ] Publishable projects have `True` +- [ ] Packable projects have `True` +- [ ] Test projects have neither IsPublishable nor IsPackable set to True +- [ ] `build.build.ps1` runs successfully for each solution +- [ ] Output directory structure is created correctly: `Output//` +- [ ] GitVersion generates version information successfully +- [ ] Tests run and produce results +- [ ] CI/CD pipeline is updated to use new build system +- [ ] `.gitignore` includes `Output/` directory + +--- + +## Summary + +This build system provides: +1. **Monorepo support** - Multiple solutions in one repository +2. **Centralized configuration** - Shared build tasks and properties +3. **Organized outputs** - Per-solution output directories +4. **Automated versioning** - GitVersion integration +5. **CI/CD ready** - Designed for pipeline automation +6. **Flexible** - Build all solutions or specific ones +7. **Extensible** - Add custom tasks as needed + +The implementation consolidates build logic, reduces duplication, and provides a consistent developer and CI/CD experience across all solutions in the repository. diff --git a/Clean.Task.ps1 b/Clean.Task.ps1 deleted file mode 100644 index 9a4e5c1..0000000 --- a/Clean.Task.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -Add-BuildTask Clean { - # This will blow away everything that's .gitignored, and fast - git clean -Xdf - - # Ensure the output directories from Initialize are still there - New-Item -Type Directory -Path $OutputRoot -Force | Out-Null - New-Item -Type Directory -Path $TestResultsRoot -Force | Out-Null - New-Item -Type Directory -Path $TempRoot -Force | Out-Null -} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..ed3631c --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + shared + + $(MSBuildThisFileDirectory)Output/$(SolutionName)/ + $(LDBUILD_BINARIESDIRECTORY)/$(SolutionName)/ + + $(RootOutputPath)bin/$(MSBuildProjectName) + $(RootOutputPath)obj/$(MSBuildProjectName) + $(RootOutputPath)publish/$(MSBuildProjectName) + + + $(LDBUILD_TARGET_RUNTIME) + False + False + + \ No newline at end of file diff --git a/DotNetBuild.Task.ps1 b/DotNetBuild.Task.ps1 deleted file mode 100644 index 72ad6c5..0000000 --- a/DotNetBuild.Task.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -Add-BuildTask DotNetBuild @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetProjects - Inputs = { - # Exclude generated source files in /obj/ folders - Get-ChildItem (Split-Path $dotnetProjects) -Recurse -File -Filter *.cs | - Where-Object FullName -NotMatch "[\\/]obj[\\/]" - } - Outputs = { - foreach ($project in $dotnetProjects) { - $BaseName = Split-Path $project -LeafBase - (Get-ChildItem (Join-Path (Split-Path $project) bin) -Filter "$BaseName.dll" -Recurse -ErrorAction Ignore) ?? $BuildRoot - } - } - Jobs = "DotNetRestore", "GitVersion", { - $local:options = @{} + $script:dotnetOptions - - # We never do self-contained builds - if ($options.ContainsKey("-runtime") -or $options.ContainsKey("-ucr")) { - $options["-no-self-contained"] = $true - } - - foreach ($project in $dotnetProjects) { - $Name = (Split-Path $project -LeafBase).ToLower() - if ($GitVersion.$Name) { - $options["p"] = "Version=$($GitVersion.$Name.InformationalVersion)" - } - - Write-Build Gray "dotnet build $project --configuration $configuration -p $($options["p"])" - dotnet build $project --configuration $configuration @options - } - } -} diff --git a/DotNetPack.Task.ps1 b/DotNetPack.Task.ps1 deleted file mode 100644 index dbbac1a..0000000 --- a/DotNetPack.Task.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -Add-BuildTask DotNetPack @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetProjects - Jobs = "DotNetBuild", { - $local:options = @{} # + $script:dotnetOptions - - $script:DotNetPublishRoot = New-Item $script:DotNetPublishRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path - - foreach ($project in $dotnetProjects) { - $Name = Split-Path $project -LeafBase - if ($GitVersion.$Name) { - $options["p"] = "Version=$($GitVersion.$Name.FullSemVer)" - } - - Write-Host "Publishing $Name" - - Set-Location (Split-Path $project) - $OutputFolder = $DotNetPublishRoot - Write-Build Gray "dotnet pack $project --output '$OutputFolder' --no-build --configuration $configuration -p $($options["p"])" - dotnet pack $project --output "$OutputFolder" --no-build --configuration $configuration @options --include-symbols - } - } -} diff --git a/DotNetPublish.Task.ps1 b/DotNetPublish.Task.ps1 deleted file mode 100644 index 2c621f7..0000000 --- a/DotNetPublish.Task.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -Add-BuildTask DotNetPublish @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetProjects - Inputs = { - # Exclude generated source files in /obj/ folders - Get-ChildItem (Split-Path $dotnetProjects) -Recurse -File -Filter *.cs | - Where-Object FullName -NotMatch "[\\/]obj[\\/]" - } - Outputs = { - foreach ($project in $dotnetProjects) { - $Name = Split-Path $project -LeafBase - $OutputFolder = @($dotnetProjects).Count -gt 1 ? "$DotNetPublishRoot${/}$Name" : $DotNetPublishRoot - $Expected = Join-Path $OutputFolder -ChildPath "$Name.dll" - Write-Host "Expected Output: $Expected" - $Expected - } - } - Jobs = "DotNetBuild", { - $local:options = @{} + $script:dotnetOptions - - $script:DotNetPublishRoot = New-Item $script:DotNetPublishRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path - - # We never do self-contained builds - if ($options.ContainsKey("-runtime") -or $options.ContainsKey("-ucr")) { - $options["-no-self-contained"] = $true - } - - foreach ($project in $dotnetProjects) { - $Name = Split-Path $project -LeafBase - Write-Host "Publishing $Name" - if ($GitVersion.$Name) { - $options["p"] = "Version=$($GitVersion.$Name.InformationalVersion)" - } - - Set-Location (Split-Path $project) - $OutputFolder = @($dotnetProjects).Count -gt 1 ? "$DotNetPublishRoot${/}$Name" : $DotNetPublishRoot - Write-Build Gray "dotnet publish $project --output $OutputFolder --no-build --configuration $configuration -p $($options["p"])" - dotnet publish $project --output "$OutputFolder" --no-build --configuration $configuration - } - } -} diff --git a/DotNetPush.Task.ps1 b/DotNetPush.Task.ps1 deleted file mode 100644 index bf6caf5..0000000 --- a/DotNetPush.Task.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -Add-BuildTask DotNetPush @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetProjects - Jobs = "DotNetPack", { - $local:options = @{} # + $script:dotnetOptions - - $script:DotNetPublishRoot = New-Item $script:DotNetPublishRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path - - foreach ($project in $dotnetProjects) { - $Name = Split-Path $project -LeafBase - $options["p"] = "Version=$($GitVersion.$Name.InformationalVersion)" - - Write-Host "Publishing $name" - - Set-Location (Split-Path $project) - $OutputFolder = @($dotnetProjects).Count -gt 1 ? "$DotNetPublishRoot${/}$Name" : $DotNetPublishRoot - - $Package = Get-ChildItem $OutputFolder -Recurse -Filter "*$Name*$($GitVersion.$Name.MajorMinorPatch).nupkg" - - if ($BuildSystem -ne 'None' -and - $BranchName -in "master", "main" -and - -not [string]::IsNullOrWhiteSpace($NuGetPublishKey)) { - Write-Host "$OutputFolder" "-Recurse" "-Filter" "*$Name*$($GitVersion.$Name.MajorMinorPatch).nupkg" - Write-Build Gray "dotnet nuget push $package --api-key $NuGetPublishKey --source $NuGetPublishUri" - dotnet nuget push $package --api-key $NuGetPublishKey --source $NuGetPublishUri - } else { - Write-Warning ("Skipping push: To push $Package ensure that...`n" + - "`t* You are in a known build system (Current: $BuildSystem)`n" + - "`t* You are committing to the main branch (Current: $BranchName) `n" + - "`t* The repository APIKey is defined in `$NuGetPublishKey (Current: $(![string]::IsNullOrWhiteSpace($NuGetPublishKey)))") - } - } - } -} diff --git a/DotNetRestore.Task.ps1 b/DotNetRestore.Task.ps1 deleted file mode 100644 index 02c7d7d..0000000 --- a/DotNetRestore.Task.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -Add-BuildTask DotNetRestore @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetProjects - Inputs = { - Get-Item $dotnetProjects - } - Outputs = { - $dotnetProjects.ForEach{ Join-Path (Split-Path $_) obj project.assets.json } - } - Jobs = { - $local:options = @{} + $script:dotnetOptions - - if (Test-Path "$BuildRoot/NuGet.config") { - $options["-configfile"] = "$BuildRoot/NuGet.config" - } - foreach ($project in $dotnetProjects) { - Write-Build Gray "dotnet restore $project" @options - dotnet restore $project @options - } - } -} diff --git a/DotNetTest.Task.ps1 b/DotNetTest.Task.ps1 deleted file mode 100644 index d3ad976..0000000 --- a/DotNetTest.Task.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -Add-BuildTask DotNetTest @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetTestProjects - Inputs = { - Get-ChildItem (Split-Path $dotnetProjects) -Recurse -File -Filter *.cs - | Where-Object FullName -NotMatch "[\\/]obj[\\/]" - } - Outputs = { - (Get-ChildItem $TestResultsRoot -Filter *.trx -Recurse -ErrorAction Ignore) ?? $TestResultsRoot - } - Jobs = "DotNetBuild", { - # make sure the coverage tool is available - dotnet tool update --global dotnet-coverage - - $local:options = @{ - "-configuration" = $configuration - "-results-directory" = $TestResultsRoot - } + $script:dotnetOptions - - if ($Script:CollectCoverage) { - $options["-collect"] = "Code Coverage" - } - - foreach ($project in $dotnetTestProjects) { - Write-Build Gray "dotnet test $project --no-build --logger trx" @options - dotnet test $project --no-build --logger trx @options - } - } -} diff --git a/GitVersion.Task.ps1 b/GitVersion.Task.ps1 deleted file mode 100644 index 9bd5d1e..0000000 --- a/GitVersion.Task.ps1 +++ /dev/null @@ -1,140 +0,0 @@ -# NOTE: This script is complicated because it adds mono-repo support to GitVersion -# We should either rely on `Earthfile` for mono-repo builds, or get a better GitVersion -# Currently, we only use the full InformationalVersion and MajorMinorPatch (which can be `-split "-"` from it) -$Script:GitVersionMessagePrefix ??= "semver" -$Script:GitVersionTagPrefix ??= "v" - -Add-BuildTask GitVersion @{ - Inputs = { - # Get-ChildItem will not include hidden files like .git - # TODO: Exclude generated source files in /obj/ folders, etc - Get-ChildItem $BuildRoot -Recurse -File - } - Outputs = { - if ($script:BuildSystem -eq "None") { - # Because git operations like tags change the version without changing source - # Locally, we can never skip versioning - $BuildRoot - } else { - # In the build system, run it ONCE PER BUILD PER PROJECT - # and copy the output to e a $TempRoot that the build cleans - $VersionFile = Join-Path $TempRoot -ChildPath "$GitSha.json" - if (Test-Path $VersionFile) { - $script:GitVersion = Get-Content $VersionFile | ConvertFrom-Json - } - $VersionFile - } - } - Jobs = { - $VersionFile = Join-Path $TempRoot -ChildPath "$GitSha.json" - $script:GitVersion = @{} - foreach ($Name in $PackageNames) { - - if ($PackageNames.Count -gt 1) { - $GitVersionMessagePrefix = ($GitVersionMessagePrefix, $Name) -join "-" - $GitVersionTagPrefix = ($Name, $GitVersionTagPrefix) -join "-" - } - - # Since we know the things we need to version, let's make *sure* that we version it: - # Write-Host git commit "--ammend" "-m" "$commitMessage`n$GitVersionMessagePrefix:patch" - # git commit --ammend -m "$commitMessage`n$GitVersionMessagePrefix:patch" - - $GitVersionYaml = if (Test-Path (Join-Path $BuildRoot GitVersion.yml)) { - Join-Path $BuildRoot GitVersion.yml - } else { - Convert-Path (Join-Path $PSScriptRoot GitVersion.yml) - } - - Write-Verbose "For ${Name}: Using GitVersion config $GitVersionYaml" -Verbose - - $LogFile = Join-Path $TempRoot -ChildPath "$GitVersionTagPrefix$GitSha.log" - if (Test-Path $LogFile) { - Remove-Item $LogFile - } - if (Test-Path $VersionFile) { - Remove-Item $VersionFile - } - - try { - # We can't splat because it's 5 copies of the same parameter, so, use line-wrapping escapes: - # Also, the no-bump-message has to stay at .* or else every commit to main will increment all components - # Write-Host dotnet gitversion -config $GitVersionYaml -output file -outputfile $VersionFile -verbosity verbose - dotnet gitversion -verbosity diagnostic -config $GitVersionYaml ` - -overrideconfig tag-prefix="$($GitVersionTagPrefix)" ` - -overrideconfig major-version-bump-message="$($GitVersionMessagePrefix):\s*(breaking|major)" ` - -overrideconfig minor-version-bump-message="$($GitVersionMessagePrefix):\s*(feature|minor)" ` - -overrideconfig patch-version-bump-message="$($GitVersionMessagePrefix):\s*(fix|patch)" ` - -overrideconfig no-bump-message="$($GitVersionMessagePrefix):\s*(skip|none)" > $VersionFile 2> $LogFile - - if (Test-Path $LogFile) { - Write-Host $PSStyle.Formatting.Error ((Get-Content $LogFile) -join "`n") $PSStyle.Reset - } - - if (!(Test-Path $VersionFile)) { - throw "GitVersion failed to produce a version file or a log file" - } else { - $VersionContent = Get-Content $VersionFile - if (!$VersionContent) { - throw "GitVersion produced an empty version file" - } - try { - $VersionContent.Where({ $_ -and $_ -match "\{" }, "Until").ForEach({ Write-Warning $_ }) - $ShouldBeVersionContent = $VersionContent.Where({ $_ -match "^\{$" }, "SkipUntil") - $Version = $ShouldBeVersionContent | ConvertFrom-Json - } catch { - Write-Warning "GitVersion produced an invalid version file ($($VersionContent.Length)):`n$($VersionContent -join "`n")" - Write-Warning "ShouldBeVersionContent:`n$($ShouldBeVersionContent -join "`n")" - throw $_ - } - } - } catch { - Write-Warning "GitVersion failed $($_.Exception.Message) trying with URL $GitUrl" - dotnet gitversion -url $GitUrl -b $BranchName -c $GitSha -config $GitVersionYaml ` - -overrideconfig tag-prefix="$($GitVersionTagPrefix)" ` - -overrideconfig major-version-bump-message="$($GitVersionMessagePrefix):\s*(breaking|major)" ` - -overrideconfig minor-version-bump-message="$($GitVersionMessagePrefix):\s*(feature|minor)" ` - -overrideconfig patch-version-bump-message="$($GitVersionMessagePrefix):\s*(fix|patch)" ` - -overrideconfig no-bump-message="$($GitVersionMessagePrefix):\s*(skip|none)" > $VersionFile 2> $LogFile - - if (Test-Path $LogFile) { - Write-Host $PSStyle.Formatting.Error ((Get-Content $LogFile) -join "`n") $PSStyle.Reset - } - - if (!(Test-Path $VersionFile)) { - throw "GitVersion failed to produce a version file or a log file" - } else { - $VersionContent = Get-Content $VersionFile - if (!$VersionContent) { - throw "GitVersion produced an empty version file" - } - try { - $VersionContent.Where({ $_ -match "\{" }, "Until").ForEach({ $_ | Write-Warning }) - $Version = $VersionContent.Where({ $_ -match "\{" }, "SkipUntil") | ConvertFrom-Json - } catch { - throw "GitVersion produced an invalid version file: $VersionContent" - } - } - } - - $Version | Add-Member ScriptProperty Tag -Value { $GitVersionTagPrefix + $this.SemVer } -PassThru | Format-List | Out-Host - $GitVersion[$Name] = $Version - - # Output for Azure DevOps - if ($ENV:SYSTEM_COLLECTIONURI) { - foreach ($envar in $Version.PSObject.Properties) { - $EnvVarName = if ($Name) { - @($Name, $Envar.Name) -join "." - } else { - $Envar.Name - } - Write-Host "INFO [task.setvariable variable=$EnvVarName;isOutput=true]$($envar.Value)" - Write-Host "##vso[task.setvariable variable=$EnvVarName;isOutput=true]$($envar.Value)" - } - } else { - Write-Host "GitVersion: $($Version.InformationalVersion)" - } - } - - $GitVersion | ConvertTo-Json | Set-Content $VersionFile - } -} diff --git a/GitVersion.yml b/GitVersion.yml index 59586cc..7993e83 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,16 +1,15 @@ # Each merged branch against main will increment the version unless otherwise specified in a commit message # TrunkBased is the only workflow where each commit to a feature changes the pre-release tag -workflow: GitHubFlow/v1 -# No pre-release versions from mainline -mode: ContinuousDeployment +workflow: TrunkBased/preview1 +# mode: ContinuousDeployment +# mode: ContinuousDelivery +# mode: ManualDeployment # No dashes in date commit-date-format: "yyyyMMddTHHmmss" -# Use BUILD_COUNT environment variable with fallback of zero +# Use BuildId from Azure DevOps (with fallback) assembly-versioning-format: '{Major}.{Minor}.{Patch}.{env:BUILD_COUNT ?? 0}' -# NOTE: Normally I prefer: -# '{Major}.{Minor}.{Patch}{PreReleaseTagWithDash}+Build.{env:BUILD_COUNT ?? 0}.Date.{CommitDate}.Branch.{BranchName ?? unknown}.Sha.{Sha}' -assembly-informational-format: '{Major}.{Minor}.{Patch}{PreReleaseTagWithDash}+Build.{env:BUILD_COUNT ?? 0}.Branch.{BranchName ?? unknown}.Sha.{ShortSha}' -# Version bump messages in git trailers format +assembly-informational-format: '{Major}.{Minor}.{Patch}{PreReleaseTagWithDash}+Build.{env:BUILD_COUNT ?? 0}.Date.{CommitDate}.Branch.{env:SafeBranchName ?? unknown}.Sha.{Sha}' +# Format version bump messages as git trailers major-version-bump-message: 'semver:\s?(breaking|major)' minor-version-bump-message: 'semver:\s?(feature|minor)' patch-version-bump-message: 'semver:\s?(fix|patch)' @@ -18,21 +17,20 @@ no-bump-message: 'semver:\s?(none|skip)' commit-message-incrementing: Enabled # semantic-version-format: Loose strategies: -- Mainline - TaggedCommit -- VersionInBranchName +- Mainline - TrackReleaseBranches +- VersionInBranchName - MergeMessage branches: main: - # tracks-release-branches: true - increment: Patch + increment: Minor prevent-increment: # If false, rebuilds of the same code will increment the version! when-current-commit-tagged: true release: - mode: ContinuousDelivery + mode: ManualDeployment label: rc increment: None prevent-increment: @@ -42,10 +40,9 @@ branches: is-release-branch: true # A hotfix is just a release with bad habits regex: ^(?:releases?)/(?\d+\.\d+(\.\d+)?)$ - hotfix: - mode: ContinuousDelivery - label: rc + mode: ManualDeployment + label: hotfix regex: ^(?:hotfix(?:es)?)/(?\d+\.\d+(\.\d+)?)$ increment: None prevent-increment: @@ -55,7 +52,6 @@ branches: is-release-branch: true feature: - mode: ContinuousDelivery # any branch name that starts with feature # (with any number of / separated segments) # we use the last segment as the BranchName label... @@ -68,11 +64,10 @@ branches: label: pr{BranchName} regex: ^pull/(?[^/-]+)/merge$ unknown: - mode: ContinuousDelivery # we usually don't distinguish feature from fix in our branch names - # So EVERYTHING ELSE just increments the patch version + # So EVERYTHING just increments the minor version regex: ^.*[-/](?[^/-]+)$ - increment: Patch + increment: Minor # label: alpha.{BranchName}. source-branches: [ "main", "release", "feature" ] track-merge-target: true diff --git a/README.md b/README.md index b2e442c..e0a64e7 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,11 @@ -# Opinionated Build Tasks for Invoke-Build +# LD.Platform.BuildTasks -- Requires PowerShell 7.2 or later. -- Should work with any module from my [PowerShellTemplate](/jaykul/PowerShellTemplate). +## TODO -I've started using Invoke-Build to run my builds in PowerShell (due mostly to unhappiness with GitHub and Azure Pipelines). -This is a collection of tasks I've written that get shared by all my project builds. - -## Usage - -Your .build.ps1 script _must_ set variables: - -### For PowerShell modules - -- `$PSModuleName` - - The name of the module you're building. - - There **must** be a .psd1 module manifest with this name in your source. - - The build will create a folder with this name in the output folder - -If you're including building a dotnet project, it's also recommended to set - -- `$DotNetPublishRoot` - - The target folder for dotnet publish. - - Defaults to `$OutputRoot/publish` - - For PowerShell modules, I always override this to `$BuildRoot/lib` and add that to the `CopyDirectories` list - in my ModuleBuilder `build.psd1` so that it gets copied to the output folder by ModuleBuilder. - -### For DotNet assemblies - -- `$dotnetProjects` - - Specifies which projects to build - - I recommend you put this as a parameter on your Build.ps1 - - Set the default to the full list of your assembly projects - - Add an alias: "Projects" -- `$dotnetTestProjects` - - Specifies which projects are test projects - - I recommend you put this as a parameter on your Build.ps1 - - Add an alias: "TestProjects" -- `$dotnetOptions` - - Specifies further options to pass to dotnet - - I recommend you put this as a parameter on your Build.ps1 - - Add an alias: "Options" - - Example values: - "-verbosity" = "minimal" - "-runtime" = "linux-x64" +- [ ] Yeah, I think one other thing we need to do before we try to turn this into a "process" is we need to clean up our tasks to use exec (or our Invoke-Native command) so that when the native tools fail, the build fails. (See Invoke-Build Basics and Guidelines) +- [ x ] CSC : error CS5001: Program does not contain a static 'Main' method suitable for an entry point [C:\XDL\LD.Shared.EnterprisePlatformServices.API\EPS\ThirdParty\LD.EPS.ThirdParty.Calyx\LD.EPS.ThirdParty.Calyx.csproj] +- [ x ] what are we going to do about dotnet-tools.json? + - [ x ] Copy the file to the build root +- [ x ] Use a solution filter to filter out test projects that trying to publish because they're using microsoft.net.sdk.web + - [ ] ACTUALLY use dotnet sln remove to exclude them from the build and then add them back after the task completes +- [ ] How do I handle tasks for publishing to ACR vs universal package proget diff --git a/RequiredModules.psd1 b/RequiredModules.psd1 new file mode 100644 index 0000000..e8a84d6 --- /dev/null +++ b/RequiredModules.psd1 @@ -0,0 +1,7 @@ +@{ + InvokeBuild = "5.*" + Configuration = "[1.5.0,2.0)" + Metadata = '[1.5.0,6.0)' + BicepFlex = '[5.2.0,6.0)' + LDEnvironments = '[1.0.0,2.0)' +} diff --git a/TagSource.Task.ps1 b/TagSource.Task.ps1 deleted file mode 100644 index 4de2625..0000000 --- a/TagSource.Task.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -Add-BuildTask TagSource @{ - If = { $script:BranchName -match "main|master" } - Jobs = "GitVersion", { - foreach ($Name in $PackageNames) { - git tag $GitVersion.$Name.Tag -m "Release $($GitVersion.$Name.InformationalVersion)" - git push origin --tags - } - } -} diff --git a/_Bootstrap.ps1 b/_Bootstrap.ps1 deleted file mode 100644 index 21bf172..0000000 --- a/_Bootstrap.ps1 +++ /dev/null @@ -1,137 +0,0 @@ -<# - .SYNOPSIS - Bootstrap the build environment. - .DESCRIPTION - This script is intended to be run from your build script. - It will ensure that the build environment is ready to go. - It will ensure that Invoke-Build is available. - It will ensure that dotnet is available (even when we're not going to compile, I use it for GitVersion) - It will ensure that GitVersion is available. -#> -[CmdletBinding()] -param( - # When running locally, you can -Force to skip the confirmation prompts - [switch]$Force, - - # I require dotnet, and git version - # Defaults to the "10.0" channel, change it to change the minimum version - [double]$DotNet = "10.0", - - # Path to a file listing required PowerShell modules. - # See also: https://github.com/marketplace/actions/modulefast#requiresspec - # I now use Install-ModuleFast to install modules, but I'll translate "RequiredModules.psd1" for you - # Any other file name will be passed to Install-ModuleFast -Path - # NOTE: If this file is missing, we'll still install InvokeBuild, but if you have a requires spec, don't forget to include InvokeBuild in it! - [Alias("RequiredModulesPath")] - $RequiresPath = (@(@(Join-Path $pwd "*.requires.psd1" - Join-Path $pwd "RequiredModules.psd1" - ) | Resolve-Path -ErrorAction Ignore)[0].Path), - - $ToolsFile = (@( - Join-Path $pwd "dotnet-tools.json" - Join-Path $pwd ".config" "dotnet-tools.json" - Join-Path $PSScriptRoot "dotnet-tools.json" - Join-Path $PSScriptRoot ".config" "dotnet-tools.json" - ) | Resolve-Path -ErrorAction Ignore)[0].Path), - - -# Path to a .*proj file or .sln -# If this file is present, dotnet restore will be run on it. -$ProjectFile = (Join-Path $pwd "*.*proj"), - -# Scope for installation (of scripts and modules). Defaults to CurrentUser -[ValidateSet("AllUsers", "CurrentUser")] -$Scope = "CurrentUser" -) -$InformationPreference = "Continue" -$ErrorView = 'DetailedView' -$ErrorActionPreference = 'Stop' - -Write-Information "Ensure dotnet version" -if (!((Get-Command dotnet -ErrorAction SilentlyContinue) -and ([semver](dotnet --version) -gt $DotNet))) { - # Obviously this must not happen on CI environments, so make sure you have dotnet preinstalled there... - Write-Host "This script can call dotnet-install to install a local copy of dotnet $DotNet -- if you'd rather install it yourself, answer no:" - if (!$IsLinux -and !$IsMacOS) { - Invoke-WebRequest https://dot.net/v1/dotnet-install.ps1 -OutFile bootstrap-dotnet-install.ps1 - ./bootstrap-dotnet-install.ps1 -Channel "$DotNet" -InstallDir $HOME/.dotnet - } else { - Invoke-WebRequest https://dot.net/v1/dotnet-install.sh -OutFile bootstrap-dotnet-install.sh - chmod +x bootstrap-dotnet-install.sh - ./bootstrap-dotnet-install.sh --channel "$DotNet" --install-dir $HOME/.dotnet - } - if (!((Get-Command dotnet -ErrorAction SilentlyContinue) -and ([semver](dotnet --version) -gt $DotNet))) { - throw "Unable to find dotnet $DotNet or later" - } -} - -if (Test-Path $ProjectFile) { - Write-Information "Ensure dotnet package dependencies" - Split-Path $ProjectFile -Parent | Push-Location - dotnet restore $ProjectFile --ucr -} - -if ($ToolsFile -and (Test-Path $ToolsFile)) { - Write-Information "Ensure dotnet tools from $ToolsFile" - dotnet tool restore --tool-manifest $ToolsFile -} - -# Regardless of whether you already have a dotnet-tools.json file, we need gitversion.tool -if (!(dotnet tool list gitversion.tool)) { - Write-Information "Ensure GitVersion.tool" - dotnet tool install gitversion.tool -} - - -# I don't want ModuleFast messing with the PSModulePath so we use the default user location -$ModuleDestination = if ($IsWindows) { - Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell/Modules' -} else { - # PowerShell on Linux and Mac follows XDG - Join-Path $HOME '.local/share/powershell/Modules' -} - -if (!(Get-Module ModuleFast -ListAvailable -ErrorAction SilentlyContinue)) { - Write-Information "Ensure ModuleFast in $ModuleDestination" -Verbose - # Skip using the api endpoint to avoid throtting, we can get latest from the redirect - $VersionTag = try { Invoke-WebRequest https://github.com/JustinGrote/ModuleFast/releases/latest -MaximumRedirection 0 } catch { Split-Path -Leaf $_.Exception.Response.Headers.Location.ToString() } - $zipFile = "ModuleFast.$($VersionTag.Trim('v')).zip" - $zip = "https://github.com/JustinGrote/ModuleFast/releases/download/$VersionTag/$zipFile" - Write-Information "Installing ModuleFast $VersionTag from $zip" -Verbose - Invoke-WebRequest $zip -OutFile $zipFile - Expand-Archive $zipFile -DestinationPath $ModuleDestination - Remove-Item $zipFile -} - -$ModuleFast = @{ - Destination = $ModuleDestination -} -if ($RequiresPath) { - if ((Split-Path $RequiresPath -Leaf) -eq "RequiredModules.psd1") { - Write-Information "Translating $RequiresPath to Module Specification" - $Modules = Import-PowerShellDataFile $RequiresPath - # Careful. It's possible $RequiresPath is in the root: /RequiredModules.psd1 has no parent. - $NewRequiresPath = (Split-Path $RequiresPath) ? (Join-Path (Split-Path $RequiresPath) "build.requires.psd1") : "build.requires.psd1" - @( - "@{" - foreach ($ModuleName in $Modules.Keys) { - " ""$ModuleName"" = "":" + $Modules[$ModuleName] + """" - } - "}" - ) | Out-File $NewRequiresPath - # If that worked, we can delete the old file - Remove-Item $RequiresPath - $RequiresPath = $NewRequiresPath - } - $ModuleFast["Path"] = $RequiresPath -} else { - $ModuleFast["Specification"] = "InvokeBuild:5.*" -} - -Install-ModuleFast @ModuleFast -Verbose - -if ($IRM_InstallErrors) { - foreach ($installErr in @($IRM_InstallErrors)) { - Write-Warning "ERROR: $installErr" - Write-Warning "STACKTRACE: $($installErr.ScriptStackTrace)" - } -} \ No newline at end of file diff --git a/_Initialize.ps1 b/_Initialize.ps1 deleted file mode 100644 index 2bece6d..0000000 --- a/_Initialize.ps1 +++ /dev/null @@ -1,296 +0,0 @@ -<# - .SYNOPSIS - Calculate variables that we need repeatedly in build tasks, including some paths and defaults for some preferences - .DESCRIPTION - My Invoke-Build tasks are convention-based, and the calculations here define most of those conventions ;) -#> -[CmdletBinding()] -param( - # Skip importing tasks - [switch]$NoTasks -) - -$ErrorView = 'DetailedView' -$ErrorActionPreference = 'Stop' -$InformationPreference = "Continue" - -if (!(Get-Variable Verbose -Scope Script -ErrorAction Ignore)) { - $script:Verbose = $false -} -if (!(Get-Variable Debug -Scope Script -ErrorAction Ignore)) { - $script:Debug = $false -} - -Write-Information "Initializing build variables" -# BuildRoot is provided by Invoke-Build -Write-Information " BuildRoot: $BuildRoot" - -#region Constants for simpler build tasks -# Cross-platform separator character -${script:\} = ${script:/} = [IO.Path]::DirectorySeparatorChar - -#endregion - -#region Preference variables -# You can override any of these by just setting them in your .build.ps1: - -# Our default goal is 90% code coverage -$Script:RequiredCodeCoverage ??= 0.9 - -# Our default build configuration is Release (probably only applies to DotNet) -$script:Configuration ??= $Env:CONFIGURATION ?? "Release" -Write-Information " Configuration: $script:Configuration" - -#endregion - -#region Calculated shared variables -# These are calculated based on the detected build system - -# NOTE: this variable is currently also used for Pester formatting ... -# So we must use either "AzureDevOps", "GithubActions", or "None" -$script:BuildSystem = if (Test-Path Env:GITHUB_ACTIONS) { - "GithubActions" -} elseif (Test-Path Env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { - "AzureDevops" -} elseif (Test-Path Env:EARTHLY_BUILD_SHA) { - "Earthly" -} else { - "None" -} -Write-Information " BuildSystem: $script:BuildSystem" - -# A little extra BuildEnvironment magic -if ($script:BuildSystem -eq "AzureDevops") { - Set-BuildHeader { Write-Build 11 "##[group]Begin $($args[0])" } - Set-BuildFooter { Write-Build 11 "##[endgroup]Finish $($args[0]) $($Task.Elapsed)" } -} - -<# A note about paths noted by Azure Pipeline environment variables: - $Env:PIPELINE_WORKSPACE - Defaults to /work/job_id and holds all the others: - - These other three are defined relative to $Env:PIPELINE_WORKSPACE - $Env:BUILD_SOURCESDIRECTORY - Cleaned BEFORE checkout IF: Workspace.Clean = All or Resources, or if Checkout.Clean = $True - For single source, defaults to work/job_id/s - For multiple, defaults to work/job_id/s/sourcename - $Env:BUILD_BINARIESDIRECTORY - Cleaned BEFORE build IF: Workspace.Clean = Outputs - $Env:BUILD_STAGINGDIRECTORY - Cleaned AFTER each Build - $Env:AGENT_TEMPDIRECTORY - Cleaned AFTER each Job -#> - -# There are a few different environment/variables it could be, and then our fallback -# Prefer the environment variable (because earthly uses these) -# Otherwise, the local $script: variable (my .build.ps1 uses these) -# Otherwise, the GitHub Workflow and Azure Pipeline environment variables -# Finally, some calculated default value -$Script:OutputRoot = $Env:OUTPUT_ROOT ?? $script:OutputRoot ?? $Env:BUILD_BINARIESDIRECTORY ?? (Join-Path -Path $BuildRoot -ChildPath 'output') -New-Item -Type Directory -Path $OutputRoot -Force | Out-Null -Write-Information " OutputRoot: $OutputRoot" - -$Script:TestResultsRoot = $Env:TEST_ROOT ?? $script:TestResultsRoot ?? $Env:COMMON_TESTRESULTSDIRECTORY <# Azure #> ?? $Env:TEST_RESULTS_DIRECTORY <# Github #> ?? (Join-Path -Path $OutputRoot -ChildPath 'tests') -New-Item -Type Directory -Path $TestResultsRoot -Force | Out-Null -Write-Information " TestResultsRoot: $TestResultsRoot" - -### IMPORTANT: Our local TempRoot does not cleaned the way the Azure one does -$Script:TempRoot = $Env:TEMP_ROOT ?? $script:TempRoot ?? $Env:RUNNER_TEMP <# Github #> ?? $Env:AGENT_TEMPDIRECTORY <# Azure #> ?? (Join-Path ($Env:TEMP ?? $Env:TMP ?? "$BuildRoot/Tmp_$(Get-Date -f yyyyMMddThhmmss)") -ChildPath 'InvokeBuild') -New-Item -Type Directory -Path $TempRoot -Force | Out-Null -Write-Information " TempRoot: $TempRoot" - -$Env:BUILD_BUILDID = $Env:BUILD_BUILDID ?? $Env:GITHUB_RUN_NUMBER - -# Git variables that we could probably use: -$Script:GitSha = $script:GitSha ?? $Env:EARTHLY_BUILD_SHA ?? $Env:GITHUB_SHA ?? $Env:BUILD_SOURCEVERSION -if (!$Script:GitSha) { - $Script:GitSha = git rev-parse HEAD -} -Write-Information " GitSha: $Script:GitSha" - -$script:BranchName = $script:BranchName ?? $Env:EARTHLY_GIT_BRANCH ?? $Env:BUILD_SOURCEBRANCHNAME -if (!$script:BranchName -and (Get-Command git -CommandType Application -ErrorAction Ignore)) { - $script:BranchName = (git branch --show-current) -replace ".*/" -} -Write-Information " BranchName: $script:BranchName" - -$script:GitUrl = $script:GitUrl ?? $Env:EARTHLY_GIT_ORIGIN_URL ?? $Env:BUILD_REPOSITORY_URI ?? (git remote get-url origin) -#endregion - -#region DotNet task variables. Find the DotNet projects once. -if (([bool]$DotNet = $dotnetProjects -or $DotNetPublishRoot)) { - Write-Information "Initializing DotNet build variables (dotnetProjects: $dotnetProjects, DotNetPublishRoot: $DotNetPublishRoot)" - # The DotNetPublishRoot is the "publish" folder within the OutputRoot (used for dotnet publish output) - $script:DotNetPublishRoot ??= Join-Path $script:OutputRoot publish - - # Our $buildProjects are either: - # - Just the name - # - The full path to a csproj file - # We're going to normalize to the full csproj path - $script:dotnetProjects = @( - if (!$dotnetProjects) { - Write-Information " No `$DotNetProjects specified" - Get-ChildItem -Path $BuildRoot -Include *.*proj -Recurse - } elseif (![IO.Path]::IsPathRooted(@($dotnetProjects)[0])) { - Write-Information " Relative `$DotNetProjects specified" - Get-ChildItem -Path $BuildRoot -Include *.*proj -Recurse | - Where-Object { $dotnetProjects -contains $_.BaseName } - } else { - $dotnetProjects - } - ) | Convert-Path - Write-Information " DotNetProjects: $($script:dotnetProjects -join ", ")" - - $script:dotnetTestProjects = @( - if (!$dotnetTestProjects) { - Write-Information " No `$DotNetTestProjects specified" - Get-ChildItem -Path $BuildRoot -Include *Test.*proj -Recurse - } elseif (![IO.Path]::IsPathRooted(@($dotnetTestProjects)[0])) { - Write-Information " Relative `$DotNetTestProjects specified" - Get-ChildItem -Path $BuildRoot -Include *.*proj -Recurse | - Where-Object { $dotnetTestProjects -contains $_.BaseName } - } else { - $dotnetTestProjects - } - ) | Convert-Path - Write-Information " DotNetTestProjects: $($script:dotnetTestProjects -join ", ")" - - # In order to publish nuget packages, you need to set these before running the build - $script:NuGetPublishKey ??= $Env:NUGET_API_KEY - $script:NuGetPublishUri ??= $Env:NUGET_API_URI ?? "https://api.nuget.org/v3/index.json" - Write-Information " NuGetPublishUri: $NuGetPublishUri" - - $script:dotnetOptions ??= @{} -} -#endregion - -#region PowerShell Module task variables. Find the PowerShell module once. -$script:PSModuleName ??= $Env:MODULE_NAME -if ($PSModuleName) { - Write-Information "Initializing PSModule build variables" - # We're looking for either a build.psd1 or the module manifest: - # ./src/ModuleName.psd1 - # ./source/ModuleName.psd1 - # ./ModuleName/ModuleName.psd1 - if ($PSModuleName -eq "*" -or !$PSModuleSourceRoot -or !$PSModuleName -or !(Test-Path $PSModuleSourceRoot -PathType Container)) { - Write-Information " Looking for PSModule source" - # look for a build.psd1 for ModuleBuilder. It should be in the root, but it might be in a subfolder - if (($BuildModule = Get-ChildItem -Recurse -Filter build.psd1 -ErrorAction Ignore | Select-Object -First 1)) { - Write-Information " Found build.psd1: $($BuildModule.FullName)" - - $script:PSModuleSourcePath = $BuildModule.FullName - # Import it, and figure out the path to the actual module - $Data = Import-PowerShellDataFile -LiteralPath $BuildModule.FullName - $SourcePath = ($Data.ModuleManifest ?? $Data.Path ?? $Data.SourcePath) - - # Find the actual source. Either a folder or a manifest - Push-Location $BuildModule.Root.FullName - $script:PSModuleSourceRoot = Resolve-Path $SourcePath - Pop-Location - if (Test-Path $PSModuleSourceRoot -PathType Container) { - Write-Information " Found PSModule source folder: $PSModuleSourceRoot" - # If it's a folder, look for a manifest - $script:PSModuleSourceRoot = Get-ChildItem $PSModuleSourceRoot -Filter "$PSModuleName.psd1" -File | - Where-Object Name -ne "build.psd1" | - Select-Object -First 1 | - Convert-Path - } - if (Test-Path $PSModuleSourceRoot -PathType Leaf) { - Write-Information " Found PSModule source manifest: $PSModuleSourceRoot" - $script:PSModuleName = [IO.Path]::GetFileNameWithoutExtension($PSModuleSourceRoot) - $script:PSModuleSourceRoot = Split-Path $PSModuleSourceRoot - } - } else { - Write-Information " No build manifest, searching for module source" - # Look for a module manifest - $ModuleManifest = Get-ChildItem "src", "source", $PSModuleName, "." -Filter "$PSModuleName.psd1" -File -ErrorAction Ignore | - Where-Object Name -ne "build.psd1" | - Select-Object -First 1 | - Convert-Path - if (Test-Path $ModuleManifest -PathType Leaf) { - Write-Information " Found PSModule source manifest: $ModuleManifest" - $script:PSModuleName = [IO.Path]::GetFileNameWithoutExtension($ModuleManifest) - $script:PSModuleSourceRoot = Split-Path $ModuleManifest - $script:PSModuleSourcePath = $ModuleManifest - } - } - - # As part of giving up, set ModuleName empty - if ($script:PSModuleName.Length -le 1) { - Write-Information " Could not find PSModule $PSModuleName" - $script:PSModuleName = "" - } - } - - Write-Information " PSModuleName: $PSModuleName" - if (!$script:PSModuleName) { - throw "Could not identify module to build. Please set `$PSModuleSourceRoot to point at the manifest, or add a build.psd1 in the root" - } - - Write-Information " PSModuleSourceRoot: $PSModuleSourceRoot" - if (!(Test-Path $PSModuleSourceRoot -PathType Container -ErrorAction Ignore)) { - throw "Can't perform module build for '$PSModuleName', can't find source folder '$PSModuleSourceRoot'" - } - - # THESE variables can be overridden in a devops pipeline or $module.build.ps1 - $script:PSModuleOutputPath ??= $Env:PSMODULE_OUTPUT_PATH ?? (Join-Path $OutputRoot $PSModuleName) - Write-Information " PSModuleOutputPath: $PSModuleOutputPath" - - $script:PSRepository ??= $Env:PSREPOSITORY ?? "PSGallery" - Write-Information " PSRepository: $PSRepository" - - # In order to publish modules, you may need to set these before running the build - $script:PSGalleryUri ??= $Env:PSGALLERY_URI ?? "https://www.powershellgallery.com/api/v2" - $script:PSGalleryKey ??= $Env:PSGALLERY_API_KEY - Write-Information " PSGalleryUri: $PSGalleryUri" -} -#endregion - -# PackageNames allows you to build and tag multiple packages from the same repository -$script:PackageNames = $script:PackageNames ?? @( - if ($dotnetProjects) { - Split-Path $dotnetProjects -LeafBase - } -) + @( - if ($PSModuleName) { - @($PSModuleName) - } -) + @( - if (!$dotnetProjects -and !$PSModuleName) { - "module" - } -) | Select-Object -Unique - -## The first task defined is the default task. Default to build and test. -if ($PSModuleName -and $dotnetProjects -or $DotNetPublishRoot) { - Add-BuildTask Build @( - if ($Clean) { "Clean" } - "DotNetRestore", "PSModuleRestore", "GitVersion", "DotNetBuild", "DotNetPublish", "PSModuleBuild" #, PSModuleBuildHelp - ) - Add-BuildTask Test Build, DotNetTest, PSModuleAnalyze, PSModuleTest - Add-BuildTask Pack Test, TagSource, DotNetPack - Add-BuildTask Push Pack, DotNetPush, PSModulePush -} elseif ($PSModuleName) { - Add-BuildTask Build @( - if ($Clean) { "Clean" } - "PSModuleRestore", "GitVersion", "PSModuleBuild" #, PSModuleBuildHelp - ) - Add-BuildTask Test Build, PSModuleAnalyze, PSModuleTest - Add-BuildTask Pack Test, TagSource - Add-BuildTask Push Pack, PSModulePush -} elseif ($dotnetProjects) { - Add-BuildTask Build @( - if ($Clean) { "Clean" } - "DotNetRestore", "GitVersion", "DotNetBuild", "DotNetPublish" - ) - Add-BuildTask Test Build, DotNetTest - Add-BuildTask Pack Test, TagSource - Add-BuildTask Push Pack, DotNetPack, DotNetPush -} - -# Finally, import all the Task.ps1 files in this folder -if (!$NoTasks) { - Write-Debug "Import Shared Tasks" - foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { - if (!$DotNet -and $taskfile.Name -match "DotNet") { continue } - if (!$PSModuleName -and $taskfile.Name -match "PSModule") { continue } - Write-Debug " $($taskfile.FullName)" - . $taskfile.FullName - } -} diff --git a/debug.log b/debug.log deleted file mode 100644 index 2093b1f..0000000 --- a/debug.log +++ /dev/null @@ -1,3 +0,0 @@ -[0512/164832.803:ERROR:registration_protocol_win.cc(108)] CreateFile: The system cannot find the file specified. (0x2) -[0512/164833.572:ERROR:registration_protocol_win.cc(108)] CreateFile: The system cannot find the file specified. (0x2) -[0512/164834.982:ERROR:registration_protocol_win.cc(108)] CreateFile: The system cannot find the file specified. (0x2) diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..0dbfa77 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "gitversion.tool": { + "version": "6.5.1", + "commands": [ + "dotnet-gitversion" + ], + "rollForward": false + }, + "dotnet-sonarscanner": { + "version": "11.0.0", + "commands": [ + "dotnet-sonarscanner" + ], + "rollForward": false + }, + "dotnet-coverage": { + "version": "18.3.2", + "commands": [ + "dotnet-coverage" + ], + "rollForward": false + }, + "dotnet-reportgenerator-globaltool": { + "version": "5.5.1", + "commands": [ + "reportgenerator" + ], + "rollForward": false + }, + "trx2junit": { + "version": "2.1.0", + "commands": [ + "trx2junit" + ], + "rollForward": false + }, + "pgutil": { + "version": "2.2.5", + "commands": [ + "pgutil" + ] + } + } +} \ No newline at end of file diff --git a/scripts/Install-RequiredModule.ps1 b/scripts/Install-RequiredModule.ps1 new file mode 100644 index 0000000..db2e235 --- /dev/null +++ b/scripts/Install-RequiredModule.ps1 @@ -0,0 +1,81 @@ +# .NOTES +# THIS SCRIPT IS SYNCED to both InvokeBuildTasks and SharedPipelines -- PLEASE KEEP IN SYNC +# .SYNOPSIS +# Installs Install-RequiredModule and then calls it ... +[CmdletBinding()] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand', 'InputObject', Justification = 'For Invoke-Build Compabitility')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', "InputObject", Justification = 'For Invoke-Build Compabitility')] +param( + [string]$RequiredModulesFile = "$pwd/RequiredModules.psd1", + + [hashtable]$RequiredModules = @{ + 'InvokeBuild' = '[5.11.1, 6.0)' + }, + # This command ignores pipeline input + [Parameter(ValueFromPipeline, ValueFromRemainingArguments)] + [PSObject[]]$InputObject, + + # This allows passing a different url for modulefastparam source. Used for Harness which must use APIM url to reach proget + [string]$ModuleFastSourceUrl = "https://nuget.loandepot.com/nuget/PowerShell/v3/index.json", + + # ProGet API token for authenticated access (required for Harness/APIM endpoint, not needed for ADO private link) + [string]$ProGetToken +) + +# Construct credential if token is provided (for Harness APIM authentication) +$Credential = if ($ProGetToken) { + $secureToken = ConvertTo-SecureString $ProGetToken -AsPlainText -Force + [System.Management.Automation.PSCredential]::new('api', $secureToken) +} else { + $null +} + +# We have not yet migrated our PowerShell modules to LocalApplicationData on Windows +$ModuleFastParam = @{ + Source = $ModuleFastSourceUrl + Destination = if ($IsWindows) { + # On Windows, the modules folder is not pre-created? + mkdir (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell/Modules') -Force | Convert-Path + } else { + Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'powershell/Modules' + } +} +if (-not (Test-Path $RequiredModulesFile)) { + $ModuleFastParam['Specification'] = $RequiredModules.GetEnumerator().ForEach{ $_.Key + ":" + $_.Value } + # Update this for the environment variable + $RequiredModulesFile = $RequiredModules.Keys -join ";" +} else { + $ModuleFastParam['Path'] = $RequiredModulesFile +} + +if (!(Get-Module ModuleFast -ListAvailable -ErrorAction SilentlyContinue)) { + # $PSModulePaths = @("PSModulePaths:") + $env:PSModulePath.Split([IO.Path]::PathSeparator, [StringSplitOptions]::RemoveEmptyEntries) + # Write-Verbose $($PSModulePaths -join "`n $($PSStyle.Formatting.Verbose)") -Verbose + + Write-Verbose "ModuleFast not found. Installing to $($ModuleFastParam.Destination)" -Verbose + # When we get redirected beyond our limit, IWR throws + [string]$Location = try { + # Github redirects releases/latest, but throttles their API + Invoke-WebRequest https://github.com/JustinGrote/ModuleFast/releases/latest -UseBasicParsing -MaximumRedirection 0 + "https://github.com/JustinGrote/ModuleFast/releases/tag/v0.6.0" + } catch { + $_.Exception.Response.Headers.location + } + $tag = Split-Path $Location -Leaf + $version = $tag.Trim("v") + $file = "ModuleFast.$version.zip" + $url = "https://github.com/JustinGrote/ModuleFast/releases/download/$tag/$file" + Write-Verbose "Installing $file from $url" -Verbose + Invoke-WebRequest $url -OutFile $file + Expand-Archive $file -DestinationPath $ModuleFastParam.Destination + Remove-Item $file +} + +# Install modules from ProGet (with auth for Harness/APIM, without auth for ADO private link) +if ($Credential) { + Install-ModuleFast @ModuleFastParam -Credential $Credential -Verbose +} else { + Install-ModuleFast @ModuleFastParam -Verbose +} + +Write-Host "##vso[task.setvariable variable=RequiredModules]$RequiredModulesFile" diff --git a/scripts/PSFormatting.ps1 b/scripts/PSFormatting.ps1 new file mode 100644 index 0000000..8325355 --- /dev/null +++ b/scripts/PSFormatting.ps1 @@ -0,0 +1,20 @@ +# TODO: This script can be removed, see ll1-15 Initialize.ps1 +$ErrorView = "DetailedView" +$InformationPreference = "Continue" +$ErrorActionPreference = "Stop" +$PSStyle.OutputRendering = "ANSI" + +# Force different colors for Verbose and Debug +if ($PSStyle.Formatting.Verbose -eq $PSStyle.Formatting.Warning) { + $PSStyle.Formatting.Verbose = $PSStyle.Foreground.BrightCyan +} +if ($PSStyle.Formatting.Debug -eq $PSStyle.Formatting.Warning) { + $PSStyle.Formatting.Debug = $PSStyle.Foreground.BrightGreen +} + +# turn on debug and verbose if the pipeline is run in debug mode +if ($ENV:AGENT_DIAGNOSTIC -eq 'True' -or $ENV:SYSTEM_DEBUG -eq 'True') { + $script:VerbosePreference = "Continue" + $script:DebugPreference = "Continue" + $PSStyle +} diff --git a/tasks/Clean.Task.ps1 b/tasks/Clean.Task.ps1 new file mode 100644 index 0000000..58f1592 --- /dev/null +++ b/tasks/Clean.Task.ps1 @@ -0,0 +1,4 @@ +Add-BuildTask Clean { + Remove-BuildItem $OutputPath + New-Item $OutputPath -ItemType Directory -Force | Out-Null +} diff --git a/DockerBuild.Task.ps1 b/tasks/DockerBuild.Task.ps1 similarity index 100% rename from DockerBuild.Task.ps1 rename to tasks/DockerBuild.Task.ps1 diff --git a/tasks/DotNetBuild.Task.ps1 b/tasks/DotNetBuild.Task.ps1 new file mode 100644 index 0000000..484f492 --- /dev/null +++ b/tasks/DotNetBuild.Task.ps1 @@ -0,0 +1,49 @@ +Add-BuildTask DotNetBuild @{ + If = $dotnetSolution + Inputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + $Projects + + # Also include source files from each project directory + foreach ($Proj in $Projects) { + $ProjectDir = Split-Path $Proj -Parent + Get-ChildItem $ProjectDir -Recurse -File -Include *.cs,*.csproj,*.resx,*.json -ErrorAction SilentlyContinue | + Where-Object FullName -NotMatch "[\\/]obj[\\/]|[\\/]bin[\\/]" + } + } + Outputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + + # Return corresponding DLL files in OutputPath bin directory + foreach ($Proj in $Projects) { + $ProjectName = [IO.Path]::GetFileNameWithoutExtension($Proj) + $DllPath = Join-Path $script:OutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + $DllPath + } + } + Jobs = "DotNetRestore", "GetVersion", "SonarQubeStart", { + $Name = (Split-Path $dotnetSolution -LeafBase).ToLower() + + $local:options = @{ + '-configuration' = $script:configuration + } + $script:dotnetOptions + + if (${script:Version}.$Name) { + $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" + } else { + $options["p"] = "Version=$(${script:Version}.InformationalVersion)" + } + + # Pass SolutionName so Directory.Build.props can calculate correct paths (I think?) + $SolutionName = if ($dotnetSolution -match '\.sln') { + Split-Path $dotnetSolution -LeafBase + } else { + "shared" + } + + Write-Build Gray "dotnet build $dotnetSolution --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$SolutionName" + # Invoke-BuildExec [-Command] ScriptBlock [[-ExitCode] Int32[]] [[-ErrorMessage] String] [-Echo] [-StdErr] + + dotnet build $dotnetSolution --no-restore @options "-p:SolutionName=$SolutionName" + } +} diff --git a/tasks/DotNetClean.Task.ps1 b/tasks/DotNetClean.Task.ps1 new file mode 100644 index 0000000..842cd13 --- /dev/null +++ b/tasks/DotNetClean.Task.ps1 @@ -0,0 +1,8 @@ +Add-BuildTask DotNetClean @{ + # This task should be skipped if there are no C# projects to build + If = $dotnetSolution + Jobs = { + Write-Build Gray "dotnet clean $Name" + dotnet clean $dotnetSolution + } +} diff --git a/tasks/DotNetPack.Task.ps1 b/tasks/DotNetPack.Task.ps1 new file mode 100644 index 0000000..63cffd1 --- /dev/null +++ b/tasks/DotNetPack.Task.ps1 @@ -0,0 +1,62 @@ +#! If this is trying to pack a test project, you must add true to the project file. +Add-BuildTask DotNetPack @{ + If = $dotnetSolution + Inputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + $Projects + + foreach ($Proj in $Projects) { + $ProjectName = Split-Path $Proj -LeafBase + + # Check if project is packable by reading the .csproj file + # Directory.Build.props sets IsPackable=false by default, so only projects + # that explicitly set IsPackable=true should be packed + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue + if ($Content -match '\s*(true|True|TRUE)\s*') { + $DllPath = Join-Path $script:OutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + if (Test-Path $DllPath) { + $DllPath + } + } + } + } + Outputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + + foreach ($Proj in $Projects) { + $ProjectName = Split-Path $Proj -LeafBase + + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue + if ($Content -match '\s*(true|True|TRUE)\s*') { + $NupkgPattern = Join-Path $script:DotNetPackRoot "$ProjectName.*.nupkg" + $ExistingPkg = Get-Item $NupkgPattern -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName + + if ($ExistingPkg) { + $ExistingPkg + } else { + # Return a placeholder path so Outputs is not empty (file doesn't exist yet, so task will run) + Join-Path $script:DotNetPackRoot "$ProjectName.nupkg" + } + } + } + } + Jobs = "DotNetBuild", { + $script:DotNetPackRoot = New-Item $script:DotNetPackRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path + + $local:options = @{ + "-output" = $script:DotNetPackRoot + } + + $Name = Split-Path $dotnetSolution -LeafBase + if (${script:Version}.$Name) { + $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" + } else { + $options["p"] = "Version=$(${script:Version}.InformationalVersion)" + } + + Write-Host "Packing $Name" + + Write-Build Gray "dotnet pack $dotnetSolution --no-build --include-symbols $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$dotnetSolutionName" + dotnet pack $dotnetSolution --no-build --include-symbols @options "-p:SolutionName=$dotnetSolutionName" + } +} diff --git a/tasks/DotNetPublish.Task.ps1 b/tasks/DotNetPublish.Task.ps1 new file mode 100644 index 0000000..2c93b4b --- /dev/null +++ b/tasks/DotNetPublish.Task.ps1 @@ -0,0 +1,72 @@ +Add-BuildTask DotNetPublish @{ + If = $dotnetSolution + Inputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + + foreach ($Proj in $Projects) { + $ProjectName = Split-Path $Proj -LeafBase + + # Check if project is publishable by reading the .csproj file + # Directory.Build.props sets IsPublishable=false by default, so only projects + # that explicitly set IsPublishable=true should be published + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue + if ($Content -imatch '\s*true\s*') { + $DllPath = Join-Path $script:OutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + if (Test-Path $DllPath) { $DllPath } + } + } + } + Outputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + foreach ($Proj in $Projects) { + $ProjectName = Split-Path $Proj -LeafBase + + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue + if ($Content -imatch '\s*true\s*') { + # Look for published dll in the publish directory + $PublishedDll = Join-Path $script:DotNetPublishRoot "$ProjectName/$ProjectName.dll" + $ExistingPublish = Get-Item $PublishedDll -ErrorAction SilentlyContinue + + if ($ExistingPublish) { + $ExistingPublish.FullName + } else { + # Return a placeholder path so Outputs is not empty (file doesn't exist yet, so task will run) + $PublishedDll + } + } + } + } + Jobs = "DotNetBuild", { + #! This handles a known issue with dotnet: any csproj identifying itself as Microsoft.NET.Sdk.Web will ALWAYS be published, even if IsPublishable is set to false + $script:ProjectsToIgnore = @() + foreach ($Proj in $dotnetProjects) { + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue + if (($Content -imatch '') -and ($Proj -in $script:dotnetTestProjects)) { + $script:ProjectsToIgnore += $Proj + } + } + }, { + if ($script:ProjectsToIgnore.Count -gt 0) { + Write-Host "Skipping publish for projects: $($script:ProjectsToIgnore -join ', ')" + dotnet sln $dotnetSolution remove $script:ProjectsToIgnore + } + $script:DotNetPublishRoot = New-Item $script:DotNetPublishRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path + $Name = Split-Path $dotnetSolution -LeafBase + + $local:options = @{} + $script:dotnetOptions + + if (${script:Version}.$Name) { + $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" + } else { + $options["p"] = "Version=$(${script:Version}.InformationalVersion)" + } + + Set-Location (Split-Path $dotnetSolution) + Write-Build Gray "dotnet publish $dotnetSolution --no-build --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$dotNetSolutionName" + dotnet publish $dotnetSolution --no-build --no-restore @options "-p:SolutionName=$dotNetSolutionName" + },{ + if ($script:ProjectsToIgnore.Count -gt 0) { + dotnet sln $dotnetSolution add $script:ProjectsToIgnore + } + } +} diff --git a/tasks/DotNetPush.Task.ps1 b/tasks/DotNetPush.Task.ps1 new file mode 100644 index 0000000..cf93bae --- /dev/null +++ b/tasks/DotNetPush.Task.ps1 @@ -0,0 +1,21 @@ +Add-BuildTask DotNetPush @{ + # This task should be skipped if there are no C# projects to build + If = $dotnetSolution + Jobs = "DotNetPack", { + $Package = Get-ChildItem $script:DotNetPackRoot -Recurse -Filter "*.nupkg" + + if ($BuildSystem -ne 'None' -and + $BranchName -in "master", "main" -or $BranchName -match "\brelease\b" -and + -not [string]::IsNullOrWhiteSpace($NuGetPublishKey)) { + foreach ($nupkg in $Package) { + Write-Build Gray "dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri" + dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri + } + } else { + Write-Warning ("Skipping push: To push $Package ensure that...`n" + + "`t* You are in a known build system (Current: $BuildSystem)`n" + + "`t* You are committing to the main branch (Current: $BranchName) `n" + + "`t* The repository APIKey is defined in `$NuGetPublishKey (Current: $(![string]::IsNullOrWhiteSpace($NuGetPublishKey)))") + } + } +} diff --git a/tasks/DotNetRestore.Task.ps1 b/tasks/DotNetRestore.Task.ps1 new file mode 100644 index 0000000..b959947 --- /dev/null +++ b/tasks/DotNetRestore.Task.ps1 @@ -0,0 +1,34 @@ +Add-BuildTask DotNetRestore @{ + If = $dotnetSolution + Inputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + $Projects + if (Test-Path "$BuildRoot/NuGet.config") { + "$BuildRoot/NuGet.config" + } + } + Outputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + # Return corresponding project.assets.json files + foreach ($Proj in $Projects) { + $ProjectName = [IO.Path]::GetFileNameWithoutExtension($Proj) + Join-Path $script:OutputPath "obj/$ProjectName/project.assets.json" + } + } + Jobs = "DotNetToolRestore", { + $local:options = @{} + $script:dotnetOptions + if (Test-Path "$BuildRoot/NuGet.config") { + $options["-configfile"] = "$BuildRoot/NuGet.config" + } + + # Pass SolutionName so Directory.Build.props can calculate correct paths (I think?) + $SolutionName = if ($dotnetSolution -match '\.sln') { + Split-Path $dotnetSolution -LeafBase + } else { + "shared" + } + + Write-Build Gray "dotnet restore $dotnetSolution $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$SolutionName" + dotnet restore $dotnetSolution @options "-p:SolutionName=$SolutionName" + } +} \ No newline at end of file diff --git a/tasks/DotNetTest.Task.ps1 b/tasks/DotNetTest.Task.ps1 new file mode 100644 index 0000000..67751ec --- /dev/null +++ b/tasks/DotNetTest.Task.ps1 @@ -0,0 +1,51 @@ +Add-BuildTask DotNetTest @{ + # This task should be skipped if there are no C# projects to build + If = $dotnetSolution + Inputs = { + $Projects = $dotnetTestProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + $Projects + + # Also include source files from each project directory + foreach ($Proj in $Projects) { + $ProjectDir = Split-Path $Proj -Parent + Get-ChildItem $ProjectDir -Recurse -File -Include *.cs,*.csproj,*.resx,*.json -ErrorAction SilentlyContinue | + Where-Object FullName -NotMatch "[\\/]obj[\\/]|[\\/]bin[\\/]" + } + } + Outputs = { + # Return any .trx files in the test results directory + # dotnet test generates .trx files with machine/user-based names, not project names + $TrxFiles = Get-ChildItem $TestResultsRoot -Filter "*.trx" -ErrorAction SilentlyContinue + + if ($TrxFiles) { + $TrxFiles | Select-Object -ExpandProperty FullName + } else { + # Return a placeholder path so Outputs is not empty (file doesn't exist yet, so task will run) + Join-Path $TestResultsRoot "test-results.trx" + } + } + Jobs = "DotNetBuild", { + + $local:options = @{ + "-logger" = "trx" + "-results-directory" = $TestResultsRoot + "-configuration" = $configuration + } + $script:dotnetOptions + + if ($Script:CollectCoverage) { + # Because we wrapt it in dotnet coverage, we need to build this as a string + $Command = "dotnet test $dotnetSolution --no-build" + $options.GetEnumerator() | ForEach-Object { + $Command += " -$($_.Key) $($_.Value)" + } + $Command += " -p:SolutionName=$dotnetSolutionName" + $Name = (Split-Path $dotnetSolution -LeafBase).ToLower() + Write-Build Gray "dotnet coverage collect '$Command' --output '$TestResultsRoot/coverage/$Name.xml' --output-format xml" + dotnet coverage collect $Command --output "$TestResultsRoot/coverage/$Name.xml" --output-format xml + } else { + Write-Build Gray "dotnet test $dotnetSolution --no-build $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$dotnetSolutionName" + dotnet test $dotnetSolution --no-build @options "-p:SolutionName=$dotnetSolutionName" + } + + }, "SonarQubeEnd", "DotNetTrx2JUnit" +} diff --git a/tasks/DotNetToolRestore.Task.ps1 b/tasks/DotNetToolRestore.Task.ps1 new file mode 100644 index 0000000..ea84e8b --- /dev/null +++ b/tasks/DotNetToolRestore.Task.ps1 @@ -0,0 +1,12 @@ +Add-BuildTask DotNetToolRestore @{ + Jobs = { + $DotNetToolManifest = @( + Join-Path $BuildRoot .config/dotnet-tools.json + Join-Path $BuildRoot dotnet-tools.json + ) | Resolve-Path -ErrorAction Ignore | Select-Object -First 1 + if (-not $DotNetToolManifest) { + Copy-Item "$PSScriptRoot/../dotnet-tools.json" "$BuildRoot/.config/dotnet-tools.json" -Force + } + dotnet tool restore + } +} diff --git a/tasks/DotNetTrx2JUnit.Task.ps1 b/tasks/DotNetTrx2JUnit.Task.ps1 new file mode 100644 index 0000000..2881633 --- /dev/null +++ b/tasks/DotNetTrx2JUnit.Task.ps1 @@ -0,0 +1,17 @@ +Add-BuildTask DotNetTrx2JUnit @{ + If = (dotnet tool list trx2junit --format json | ConvertFrom-Json).data + Partial = $true + Input = { + Get-ChildItem $TestResultsRoot/*.trx + } + Output = { + process { + [System.IO.Path]::ChangeExtension($_, 'xml') + } + } + Jobs = { + process { + dotnet trx2junit $_ + } + } +} \ No newline at end of file diff --git a/tasks/GetVersion.Task.ps1 b/tasks/GetVersion.Task.ps1 new file mode 100644 index 0000000..b14feed --- /dev/null +++ b/tasks/GetVersion.Task.ps1 @@ -0,0 +1,57 @@ +$script:VersionCacheFile = "$Script:OutputPath/version.json" +$script:Version = @{} + +<# NOTE: this version does not include support for multiple versions per-repo #> +Add-BuildTask GetVersion @{ + If = { + $head = git rev-parse HEAD + # If there's an existing GitVersion in output, load it + if (${script:Version}.Sha -ne $head) { + ${script:Version} = if (Test-Path $Script:OutputPath/version.json) { + Get-Content $Script:OutputPath/version.json | ConvertFrom-Json + } + } + # Skip if ${script:Version} is set correctly for this commit... + # we can skip (return $false) + return (${script:Version}.Sha -ne $head) + } + Jobs = "InstallGitVersion", "GitInit", { + # Support a config file in the repo (BuildRoot) to override the one in here (PSScriptRoot) + [string]$VersionConfig = Resolve-Path "$BuildRoot/GitVersion.y*ml", "$PSScriptRoot/GitVersion.y*ml" -ErrorAction Ignore + | Select-Object -First 1 + + # agent temp SHOULD be cleaned after each pipeline job + $VersionCacheFile = "$TempDirectory/version.json" + + # Delete the VersionCache so that importing it will fail if gitversion fails + if (Test-Path $VersionCacheFile) { + Remove-Item $VersionCacheFile + } + + Write-Host dotnet gitversion -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile + dotnet gitversion -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile | Out-Host + + try { + $local:GitVersion = Get-Content $VersionCacheFile | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-Warning "dotnet gitversion -config $VersionConfig -showconfig" + dotnet gitversion -config $VersionConfig -showconfig | Out-Host + Write-Warning "VersionTagPrefix: $($VersionTagPrefix)" + Write-Warning "VersionMessagePrefix: $($VersionMessagePrefix)" + Write-Warning 'git log --graph --format="%h %cr %d" --decorate --date=relative --all --remotes=* -n 100' + git log --graph --format="%h %cr %d" --decorate --date=relative --all --remotes=* -n 100 | Out-Host + Write-Host $VersionCacheFile + throw $_ + } + + $local:GitVersion | Add-Member -MemberType NoteProperty -Name Tag -Value (($VersionTagPrefix -replace "\[Vv]\?", "v") + $local:GitVersion.SemVer) + + # The things we know we want on our version output object + $script:Version = $local:GitVersion | + Select-Object -Property InformationalVersion, MajorMinorPatch, SemVer, Sha, Tag + + + # Cache the final object in output so we can skip rerunning + ${script:Version} | ConvertTo-Json -Compress | Out-File $Script:OutputPath/version.json + } +} diff --git a/tasks/GitInit.Task.ps1 b/tasks/GitInit.Task.ps1 new file mode 100644 index 0000000..0abed92 --- /dev/null +++ b/tasks/GitInit.Task.ps1 @@ -0,0 +1,12 @@ +Add-BuildTask GitInit @{ + If = { + -not (git config user.name) -or -not (git config user.email) -or ($script:GitUser -and $script:GitUser -ne (git config user.name)) + } + Jobs = { + # If the user is already set, don't change it + $Script:GitUser ??= (git config user.name) ?? 'Autobot' + # If we're running in the CI/CD pipeline we need to set the author so we can pass the commit email policy + git config user.name $Script:GitUser + git config user.email 'DevOps@loandepot.com' + } +} diff --git a/tasks/GitVersion.yml b/tasks/GitVersion.yml new file mode 100644 index 0000000..7993e83 --- /dev/null +++ b/tasks/GitVersion.yml @@ -0,0 +1,74 @@ +# Each merged branch against main will increment the version unless otherwise specified in a commit message +# TrunkBased is the only workflow where each commit to a feature changes the pre-release tag +workflow: TrunkBased/preview1 +# mode: ContinuousDeployment +# mode: ContinuousDelivery +# mode: ManualDeployment +# No dashes in date +commit-date-format: "yyyyMMddTHHmmss" +# Use BuildId from Azure DevOps (with fallback) +assembly-versioning-format: '{Major}.{Minor}.{Patch}.{env:BUILD_COUNT ?? 0}' +assembly-informational-format: '{Major}.{Minor}.{Patch}{PreReleaseTagWithDash}+Build.{env:BUILD_COUNT ?? 0}.Date.{CommitDate}.Branch.{env:SafeBranchName ?? unknown}.Sha.{Sha}' +# Format version bump messages as git trailers +major-version-bump-message: 'semver:\s?(breaking|major)' +minor-version-bump-message: 'semver:\s?(feature|minor)' +patch-version-bump-message: 'semver:\s?(fix|patch)' +no-bump-message: 'semver:\s?(none|skip)' +commit-message-incrementing: Enabled +# semantic-version-format: Loose +strategies: +- TaggedCommit +- Mainline +- TrackReleaseBranches +- VersionInBranchName +- MergeMessage + +branches: + main: + increment: Minor + prevent-increment: + # If false, rebuilds of the same code will increment the version! + when-current-commit-tagged: true + release: + mode: ManualDeployment + label: rc + increment: None + prevent-increment: + of-merged-branch: true + when-current-commit-tagged: false + track-merge-target: false + is-release-branch: true + # A hotfix is just a release with bad habits + regex: ^(?:releases?)/(?\d+\.\d+(\.\d+)?)$ + hotfix: + mode: ManualDeployment + label: hotfix + regex: ^(?:hotfix(?:es)?)/(?\d+\.\d+(\.\d+)?)$ + increment: None + prevent-increment: + of-merged-branch: true + when-current-commit-tagged: false + track-merge-target: false + is-release-branch: true + + feature: + # any branch name that starts with feature + # (with any number of / separated segments) + # we use the last segment as the BranchName label... + regex: ^features?[/-](.+[/-])*(?[^/-]+)$ + # label: alpha.{BranchName}. + # Since we *know* it's a feature, then we can increment the minor version + increment: Minor + source-branches: [ "main", "feature", "release" ] + pull-request: + label: pr{BranchName} + regex: ^pull/(?[^/-]+)/merge$ + unknown: + # we usually don't distinguish feature from fix in our branch names + # So EVERYTHING just increments the minor version + regex: ^.*[-/](?[^/-]+)$ + increment: Minor + # label: alpha.{BranchName}. + source-branches: [ "main", "release", "feature" ] + track-merge-target: true + tracks-release-branches: true diff --git a/tasks/InstallBuildDependencies.Task.ps1 b/tasks/InstallBuildDependencies.Task.ps1 new file mode 100644 index 0000000..e413715 --- /dev/null +++ b/tasks/InstallBuildDependencies.Task.ps1 @@ -0,0 +1 @@ +Add-BuildTask InstallBuildDependencies InstallRequiredModules diff --git a/tasks/InstallGitHubTools.Task.ps1 b/tasks/InstallGitHubTools.Task.ps1 new file mode 100644 index 0000000..c214a4f --- /dev/null +++ b/tasks/InstallGitHubTools.Task.ps1 @@ -0,0 +1,7 @@ +# TODO: in pipeline environments, we should trigger the "cache" task for these to speed up using them +Add-BuildTask InstallGitHubTools @{ + If = $script:GHTools.Count -gt 0 + Jobs = { + &(Join-Path (Split-Path $PSScriptRoot) "scripts/Install-GitHubRelease.ps1") $script:GHTools + } +} diff --git a/tasks/InstallGitVersion.Task.ps1 b/tasks/InstallGitVersion.Task.ps1 new file mode 100644 index 0000000..a529e4d --- /dev/null +++ b/tasks/InstallGitVersion.Task.ps1 @@ -0,0 +1,10 @@ +# This exist for repos that don't have a dotnettools.json file +Add-BuildTask InstallGitVersion @{ + Jobs = "DotNetToolRestore", { + # If there's no local gitversion tool, always update|install the global one + if (!((dotnet tool list GitVersion.Tool --format json | ConvertFrom-Json).data)) { + $ENV:PATH += ([IO.Path]::PathSeparator) + (Convert-Path ~/.dotnet/tools) + dotnet tool update GitVersion.Tool --global --version 6.* --verbosity diagnostic + } + } +} \ No newline at end of file diff --git a/tasks/InstallRequiredModules.Task.ps1 b/tasks/InstallRequiredModules.Task.ps1 new file mode 100644 index 0000000..1b27b02 --- /dev/null +++ b/tasks/InstallRequiredModules.Task.ps1 @@ -0,0 +1,7 @@ +Add-BuildTask InstallRequiredModules @{ + If = Test-Path $BuildRoot/RequiredModules.psd1 + Inputs = "$BuildRoot/RequiredModules.psd1" + Outputs = "$Output/RequiredModules.psd1" + Jobs = (Get-Command "$PSScriptRoot/../scripts/Install-RequiredModule.ps1").ScriptBlock, + { Copy-Item "$BuildRoot/RequiredModules.psd1" -Destination "$Output/RequiredModules.psd1" } +} diff --git a/PSModuleAnalyze.Task.ps1 b/tasks/PSModuleAnalyze.Task.ps1 similarity index 100% rename from PSModuleAnalyze.Task.ps1 rename to tasks/PSModuleAnalyze.Task.ps1 diff --git a/PSModuleBuild.Task.ps1 b/tasks/PSModuleBuild.Task.ps1 similarity index 100% rename from PSModuleBuild.Task.ps1 rename to tasks/PSModuleBuild.Task.ps1 diff --git a/PSModuleImport.Task.ps1 b/tasks/PSModuleImport.Task.ps1 similarity index 100% rename from PSModuleImport.Task.ps1 rename to tasks/PSModuleImport.Task.ps1 diff --git a/PSModulePush.Task.ps1 b/tasks/PSModulePush.Task.ps1 similarity index 100% rename from PSModulePush.Task.ps1 rename to tasks/PSModulePush.Task.ps1 diff --git a/PSModuleRestore.Task.ps1 b/tasks/PSModuleRestore.Task.ps1 similarity index 100% rename from PSModuleRestore.Task.ps1 rename to tasks/PSModuleRestore.Task.ps1 diff --git a/PSModuleTest.Task.ps1 b/tasks/PSModuleTest.Task.ps1 similarity index 100% rename from PSModuleTest.Task.ps1 rename to tasks/PSModuleTest.Task.ps1 diff --git a/tasks/ReportGenerator.Task.ps1 b/tasks/ReportGenerator.Task.ps1 new file mode 100644 index 0000000..13e1876 --- /dev/null +++ b/tasks/ReportGenerator.Task.ps1 @@ -0,0 +1,29 @@ +Add-BuildTask ReportGenerator @{ + If = $Script:CollectCoverage + Jobs = { + Set-Location $TestResultsRoot + # ------------------------------ + dotnet reportgenerator -reports:'./coverage/*.xml' ` + -targetdir:'./coverage' ` + -reporttypes:'Html;MarkdownSummaryGithub;TextSummary' ` + -filefilters:'+*;-/_*' ` + -title:"$script:ProductName" ` + -tag:"$(${script:Version}.SemVer)_${script:PipelineId}_${script:PipelineExecutionId}" + + switch ($script:BuildSystem) { + "AzureDevops" { + Write-Build Gray "##vso[task.uploadsummary]$TestResultsRoot/coverage/SummaryGithub.md" + } + "Harness" { + # https://developer.harness.io/docs/continuous-integration/use-ci/annotate-builds/ + hcli annotate --context test-summary --summary-file "$TestResultsRoot/coverage/SummaryGithub.md" + } + "GitHubActions" { + Get-Content ./coverage/SummaryGithub.md -Raw + } + default { + Get-Content ./coverage/Summary.txt -TotalCount 17 + } + } + } +} \ No newline at end of file diff --git a/tasks/SonarQubeEnd.Task.ps1 b/tasks/SonarQubeEnd.Task.ps1 new file mode 100644 index 0000000..e0ce093 --- /dev/null +++ b/tasks/SonarQubeEnd.Task.ps1 @@ -0,0 +1,6 @@ +Add-BuildTask SonarQubeEnd @{ + If = $script:SonarProjectKey -and $script:SonarToken + Jobs = { + dotnet sonarscanner end -d:"sonar.token=${script:SonarToken}" + } +} \ No newline at end of file diff --git a/tasks/SonarQubeStart.Task.ps1 b/tasks/SonarQubeStart.Task.ps1 new file mode 100644 index 0000000..c8bd2c3 --- /dev/null +++ b/tasks/SonarQubeStart.Task.ps1 @@ -0,0 +1,12 @@ +Add-BuildTask SonarQubeStart @{ + If = $script:SonarProjectKey -and $script:SonarToken + Jobs = "GetVersion", { + dotnet sonarscanner begin ` + -key:"$($Script:SonarProjectKey)" ` + -version:"$(${script:Version}.SemVer)" ` + -d:"sonar.token=${script:SonarToken}" ` + -d:"sonar.host.url=${script:SonarHostURL}" ` + -d:"sonar.cs.vscoveragexml.reportsPaths=$TestResultsRoot/coverage/*.xml" ` + -d:sonar.exclusions=*.xsd + } +} \ No newline at end of file diff --git a/tasks/TagSource.Task.ps1 b/tasks/TagSource.Task.ps1 new file mode 100644 index 0000000..f00f02b --- /dev/null +++ b/tasks/TagSource.Task.ps1 @@ -0,0 +1,9 @@ +Add-BuildTask TagSource @{ + If = { $script:BranchName -in "main", "master", "release" } + Jobs = "GitVersion", { + foreach ($Name in $PackageNames) { + git tag $Version.$Name.Tag -m "Release $($Version.$Name.InformationalVersion)" + git push origin --tags + } + } +} diff --git a/tasks/UniversalPackagePack.Task.ps1 b/tasks/UniversalPackagePack.Task.ps1 new file mode 100644 index 0000000..632b843 --- /dev/null +++ b/tasks/UniversalPackagePack.Task.ps1 @@ -0,0 +1,41 @@ + +Add-BuildTask UniversalPackagePack @{ + If = $dotnetSolution + Inputs = { + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + + foreach ($Proj in $Projects) { + $ProjectName = Split-Path $Proj -LeafBase + + # Check if project is publishable by reading the .csproj file + # Directory.Build.props sets IsPublishable=false by default, so only projects + # that explicitly set IsPublishable=true should be published + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue + if ($Content -imatch '\s*true\s*') { + $DllPath = Join-Path $script:OutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + if (Test-Path $DllPath) { $DllPath } + } + } + } + outputs = { Get-ChildItem $script:UniversalPacakgeRoot/*.upack -ErrorAction Ignore || Join-Path $script:UniversalPacakgeRoot "dummy.upack"} + # Requires dotnetpublish but future state this task should be able to publish any library (python, npm, whatever). These tasks were just initially written for dotnet projects + jobs = 'DotNetPublish', { + $VersionInfo = Get-Content (Join-Path $script:OutputPath version.json) | ConvertFrom-Json + $script:UniversalPacakgeRoot = New-Item $script:UniversalPacakgeRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path + Get-ChildItem $script:DotNetPublishRoot -Directory | ForEach-Object { + $Solution = $_ + $local:options = @{ + "-source-directory" = $Solution.FullName + "-name" = $Solution.Name + "-version" = $VersionInfo.InformationalVersion + "-target-directory" = $script:UniversalPacakgeRoot + } + Write-Build Gray "dotnet pgutil upack create $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" + dotnet pgutil upack create @options + } + # pgutil packages upload --feed=build-output --input-file=..\DevOpsScripts-Upack-Demo-0.0.0-rc.1+sha.df5b663.260206.upack --source=https://nuget.loandepot.com --api-key=04f1ab532b9397408b349e83420c762ac42eb98d + } +} + +# Project URL -> Repo url +# Are the audit properties being set by the pgutil tool (e.g. CreatedDate, CreatedBy) \ No newline at end of file diff --git a/tasks/_BootStrap.ps1 b/tasks/_BootStrap.ps1 new file mode 100644 index 0000000..cbeb6f0 --- /dev/null +++ b/tasks/_BootStrap.ps1 @@ -0,0 +1,25 @@ +<# + .SYNOPSIS + Ensures Install-RequiredModule and Invoke-Build are available + .DESCRIPTION + Installs Install-RequiredModule and runs it against your RequiredModules.psd1 + .EXAMPLE + # In azure-pipelines.yaml: + - pwsh: $(Build.SourcesDirectory)/InvokeBuildTasks/BootStrap.ps1 + displayName: 'BootStrap Invoke-Build' + workingDirectory: $(Build.SourcesDirectory)/$(Build.Repository.Name) +#> +[CmdletBinding()] +param( + # Path to a RequiredModules.psd1 (if missing will only install InvokeBuild) + $RequiredModulesPath = (Join-Path $pwd "RequiredModules.psd1"), + + # Scope for installation (of scripts and modules). Defaults to CurrentUser + [ValidateSet("AllUsers", "CurrentUser")] + $Scope = "CurrentUser" +) +Push-Location -StackName BootStrap + +& "$PSScriptRoot/../scripts/Install-RequiredModule.ps1" $RequiredModulesPath + +Pop-Location -StackName BootStrap diff --git a/tasks/_Initialize.ps1 b/tasks/_Initialize.ps1 new file mode 100644 index 0000000..62a2975 --- /dev/null +++ b/tasks/_Initialize.ps1 @@ -0,0 +1,252 @@ +#Requires -PSEdition Core +# TODO: Figure out what the Harness equiv is too all the github/ado build env vars +Write-Verbose "Initializing build variables" -Verbose +$script:ErrorView = "DetailedView" +$script:InformationPreference = "Continue" +$script:ErrorActionPreference = "Stop" +$PSStyle.OutputRendering = "ANSI" +# We're going to treat "DIAGNOSTIC" as "VERBOSE" + "DEBUG" and hope it rarely happens! ;) +if ($ENV:AGENT_DIAGNOSTIC -eq 'True' -or $ENV:SYSTEM_DEBUG -eq 'True') { # TODO: What is the harness equivalent for the env var + $script:VerbosePreference = "Continue" + $script:DebugPreference = "Continue" + + Get-ChildItem Env:* | ForEach-Object { + Write-Verbose " Env:$($_.Name) = $($_.Value)" -Verbose + } +} + +# Force different colors for Verbose and Debug +if ($PSStyle.Formatting.Verbose -eq $PSStyle.Formatting.Warning) { + $PSStyle.Formatting.Verbose = $PSStyle.Foreground.BrightCyan +} +if ($PSStyle.Formatting.Debug -eq $PSStyle.Formatting.Warning) { + $PSStyle.Formatting.Debug = $PSStyle.Foreground.BrightGreen +} + +# Our goal is 90% code coverage, but this can be overriden by defining it lower in the .build.ps1 file +$Script:RequiredCodeCoverage ??= 0.9 # TODO: Only used by invoke-pester wrapper, not needed + +# Our default build configuration is Release (probably only applies to DotNet) +$script:Configuration ??= "Release" +Write-Verbose " Configuration: $script:Configuration" -Verbose + +# NOTE: this variable is currently also used for Pester formatting ... +# We should use either "Harness", "AzureDevOps", "GithubActions", or "None" +$script:BuildSystem = if (Test-Path Env:HARNESS_STAGE_ID) { + "Harness" +} elseif (Test-Path Env:GITHUB_ACTIONS) { + "GithubActions" +} elseif (Test-Path Env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { + "AzureDevops" +} elseif (Test-Path Env:EARTHLY_BUILD_SHA) { + "Earthly" +} else { + "None" +} + +Write-Verbose " BuildSystem [$BuildSystem]" -Verbose +Write-Verbose " Information [$InformationPreference]" -Verbose +Write-Verbose " Verbose [$VerbosePreference]" -Verbose +Write-Verbose " Debug [$DebugPreference]" -Verbose + + +# In CI builds you have a BranchName +$script:BranchName = if ($Env:BUILD_SOURCEBRANCHNAME) { + $Env:BUILD_SOURCEBRANCHNAME +} elseif (Get-Command git -CommandType Application -ErrorAction SilentlyContinue) { + git branch --show-current +} + +# In PR Builds you have a SourceBranch and a TargetBranch +[bool]$script:IsPullRequest = $script:IsPullRequest ?? $Env:BUILD_REASON -eq "PullRequest" +[long]$script:PullRequestId = $script:PullRequestId ?? $Env:SYSTEM_PULLREQUEST_PULLREQUESTID +[string]$script:SourceBranch = $script:SourceBranch ?? $Env:SYSTEM_PULLREQUEST_SOURCEBRANCH ?? $Env:BUILD_SOURCEBRANCH ?? $script:BranchName +[string]$script:TargetBranch = $script:TargetBranch ?? $ENV:SYSTEM_PULLREQUEST_TARGETBRANCH ?? $script:MainBranch +# These SonarQube variables are settable from script or environment +[string]$script:SonarProjectKey = $script:SonarProjectKey ?? $Env:SONARQUBE_PROJECTKEY ?? $Env:SONAR_PROJECTKEY +[string]$script:SonarToken = $script:SonarToken ?? $Env:SONARQUBE_PAT ?? $Env:SONAR_TOKEN +[string]$script:SonarHostURL = $script:SonarHostURL ?? $Env:SONARQUBE_URL ?? $Env:SONAR_URL +# If the SonarProjectKey is set, we have to collect coverage +[switch]$script:CollectCoverage = $script:CollectCoverage -or $script:SonarProjectKey + +[string]$script:ProductName = $script:ProductName ?? $Env:PRODUCT_NAME ?? $Env:PIPELINE_NAME ?? $script:SonarProjectKey +[string]$script:PipelineId = $script:PipelineId ?? $Env:PIPELINE_ID ?? "local build" +[string]$script:PipelineExecutionId = $script:PipelineExecutionId ?? $Env:PIPELINE_EXECUTION_ID ?? $Env:BUILD_ID ?? "0" + +# A little extra BuildEnvironment magic +Set-BuildHeader { Write-Build 11 "Start Task: $($args[0])" } +Set-BuildFooter { Write-Build 11 "Finish Task: $($args[0]) $($Task.Elapsed) [Total: $([DateTime]::Now - ${*}.Started)]" } + + +# Cross-platform separator character +${script:/} = [IO.Path]::DirectorySeparatorChar + +# BuildRoot is provided by Invoke-Build +Write-Verbose " BuildRoot [$BuildRoot]" -Verbose + +### Note about Azure Pipeline environment variables: +# $Env:PIPELINE_WORKSPACE - Defaults to work/job +### These other three are defined relative to $Env:PIPELINE_WORKSPACE +# $Env:BUILD_SOURCESDIRECTORY - Cleaned BEFORE checkout IF: Workspace.Clean = All or Resources, or if Checkout.Clean = $True +# Importantly, defaults to work/job/s BUT when there are multiple sources, can be work/job/s/sourcename +# $Env:LDBUILD_BINARIESDIRECTORY - Cleaned BEFORE build IF: Workspace.Clean = Outputs +# $Env:BUILD_STAGINGDIRECTORY - Cleaned after each Build + +### Additionally, these two are cleaned after each Job: +# $Env:AGENT_TEMPDIRECTORY +# $Env:COMMON_TESTRESULTSDIRECTORY + +# TODO: Should we recreate something similar to the ADO directories described above? e.g. /s, /a, etc + +# There are a few different environment/variables it could be, and then our fallback +# Include solution name in output path to organize artifacts by solution +$SolutionFolder = if ($dotnetSolution -match '\.sln$') { + Split-Path $dotnetSolution -LeafBase +} elseif ($Solution -and $Solution -ne "*") { + $Solution +} else { + "All" +} + +$Script:OutputPath = if ($Env:BUILD_BINARIESDIRECTORY) { + $Env:BUILD_BINARIESDIRECTORY +} else { + Join-Path $BuildRoot 'Output' $SolutionFolder +} +$Env:LDLDBUILD_BINARIESDIRECTORY = $script:OutputPath + +Write-Verbose " Output [$OutputPath]" -Verbose +New-Item -Type Directory -Path $OutputPath -Force | Out-Null + +$Script:TestResultsRoot = $script:TestResultsRoot ?? + $Env:TEST_ROOT ?? # I set this for earthly + $Env:COMMON_TESTRESULTSDIRECTORY ?? # Azure + $Env:TEST_RESULTS_DIRECTORY ?? + $OutputPath # Because this is what we _have_ been using + +New-Item -Type Directory -Path $TestResultsRoot -Force | Out-Null +Write-Verbose " TestResultsRoot: $TestResultsRoot" -Verbose + +$Script:TempDirectory = @(Get-Content Env:AGENT_TEMPDIRECTORY, Env:COMMON_TESTRESULTSDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | + Where-Object { Test-Path $_ } | + Select-Object -First 1 + +# If you need to install additional tools, we use Install-GitHubRelease +# Set the Tools hashtable to @{ exe = "org", "project" } +# For example: +# $Script:Tools = @{ +# yq = "mikefarah", "yq" +# flux = "fluxcd", "flux2" +# } +[hashtable]$Script:GHTools = @{} + ($Script:GHTools ?? @{}) + +$script:UniversalPacakgeRoot ??= Join-Path $script:OutputPath universal + +#region DotNet task variables. +# When we have DotNet projects, we just need to set one of these variables: +if ($dotnetSolution -or $DotNetPublishRoot) { + Write-Information "Initializing DotNet build variables (dotnetSolution: $dotnetSolution, DotNetPublishRoot: $DotNetPublishRoot)" + # The DotNetPublishRoot is the "publish" folder within the Output (used for dotnet publish output) + $script:DotNetPublishRoot ??= Join-Path $script:OutputPath publish + $script:DotNetPackRoot ??= Join-Path $script:OutputPath nuget + $script:UniversalPacakgeRoot ??= Join-Path $Script:OutputPath universal + $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) + $script:TargetFramework ??= $Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".") + $script:TargetRuntime ??= $ENV:DOTNET_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64") + $ENV:LDBUILD_TARGET_RUNTIME = $script:TargetRuntime + + Write-Verbose " DotNetPublishRoot: $DotNetPublishRoot" -Verbose + + # Our projects are either: + # - Just the name + # - The full path to a csproj file + # We're going to normalize to the full csproj path + $script:dotnetSolution = $dotnetSolution + $script:dotnetSolutionName = Split-Path $dotnetSolution -LeafBase + Write-Verbose " Solution project: $dotnetSolution" -Verbose + $script:dotnetProjects = @(dotnet sln $dotnetSolution list | Where-Object { $_ -like "*.*proj" }) + Write-Verbose " DotNetProjects: $(($script:dotnetProjects).Count)" -Verbose + $script:dotnetTestProjects = @($script:dotnetProjects | Where-Object {$_ -like "*Test*.*proj"}) + Write-Verbose " DotNetTestProjects: $(($script:dotnetTestProjects).Count)" -Verbose + $script:dotnetOptions ??= @{} + + # TODO: Add variable for universal package feed "built-output" + $script:NuGetPublishKey ??= $Env:NUGET_API_KEY + $script:NuGetPublishUri ??= $Env:NUGET_API_URI ?? "https://nuget.loandepot.com/nuget/LDTS/v3/index.json" + Write-Verbose " NuGetPublishUri: $NuGetPublishUri" -Verbose + $script:UPackPublishKey ??= $Env:UPACK_API_KEY + $script:UPackPublishUri ??= $Env:UPACK_PUBLISH_URI ?? "https://nuget.loandepot.com" + $script:UPackFeed ??= $Env:UPACK_FEED_NAME ?? "build-output" + Write-Verbose " UPackPublishUri: $UPackPublishUri" -Verbose + Write-Verbose " UPackFeed: $UPackFeed" -Verbose + + # If the only (or last) task is "Clean" then add on DotNetClean + if (@($BuildTask)[-1] -eq "Clean") { + $BuildTask = @("Clean", "DotNetClean") + } +} +#endregion + + + + +## The first task defined is the default task. Put the right values for your project type here... +Add-BuildTask CI @( + # In CI pipelines (or if you specify $Clean) + # Run the Clean task before the rest of the build tasks + if ($BuildSystem -ne "None" -or $Script:Clean) { + "Clean" + } + "DotNetRestore" + "DotNetToolRestore" + "GetVersion" + "DotNetBuild" + "DotNetTest" + "DotNetTrx2JUnit" + "ReportGenerator" + "DotNetPack" + "DotNetPush" + "DotNetPublish" +) + +Add-BuildTask Restore @( + # Dependencies include restore and version + "DotNetRestore" +) + +Add-BuildTask Version @( + # Dependencies include tool restore + "GetVersion" +) + +Add-BuildTask Build @( + # Dependencies include restore and version + "DotNetBuild" +) + +Add-BuildTask Test @( + # Depends on Build + "DotNetTest" + ) + + +Add-BuildTask Pack @( + # Dependencies include build, restore and version -- but not test + "DotNetPack" +) + +# "DotNetPublish" is for websites, but needs testing +# "DotNetPush" is only valid in CI + +# Allow a -Clean switch to add the "Clean" task on the front +if ($Clean -and -not ($BuildTask -eq "Clean")) { + $BuildTask = @("Clean") + $BuildTask +} + +Write-Verbose " Import Shared Tasks" -Verbose +foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { + Write-Verbose " $($taskfile.FullName)" + . $taskfile.FullName +} + + From e70bc87370c67142968a52828bcd5fdf4b65b2a7 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 18 Apr 2026 23:29:19 -0400 Subject: [PATCH 03/43] Reworked DotNet and Docker Tasks, add UniversalPackagePack Centralize tool install using FromGitHub and Dotnet Tool Install --- BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD | 81 ++++++++---- Directory.Build.props | 8 +- RequiredModules.psd1 | 14 ++- build.example.ps1 | 113 +++++++++++++++++ scripts/Install-GithubRelease.ps1 | 174 ++++++++++++++++++++++++++ tasks/ConnectAzACR.Task.ps1 | 29 +++++ tasks/ConnectAzAccount.Task.ps1 | 22 ++++ tasks/DockerBuild.Task.ps1 | 91 +++++++++++--- tasks/DotNetBuild.Task.ps1 | 26 ++-- tasks/DotNetClean.Task.ps1 | 2 +- tasks/DotNetPack.Task.ps1 | 11 +- tasks/DotNetPublish.Task.ps1 | 12 +- tasks/DotNetPush.Task.ps1 | 4 +- tasks/DotNetRestore.Task.ps1 | 20 ++- tasks/DotNetTest.Task.ps1 | 14 +-- tasks/DotNetToolRestore.Task.ps1 | 3 +- tasks/DotNetTrx2JUnit.Task.ps1 | 12 +- tasks/GetVersion.Task.ps1 | 2 +- tasks/HelmInstall.Task.ps1 | 27 ++++ tasks/HelmPackChart.Task.ps1 | 22 ++++ tasks/HelmPushChart.Task.ps1 | 22 ++++ tasks/HelmTestChart.Task.ps1 | 40 ++++++ tasks/HelmUpdateValuesSchema.Task.ps1 | 16 +++ tasks/InstallGitHubTools.Task.ps1 | 9 +- tasks/InstallGitVersion.Task.ps1 | 10 -- tasks/InstallRequiredModules.Task.ps1 | 4 +- tasks/UniversalPackagePack.Task.ps1 | 27 ++-- tasks/_Initialize.ps1 | 106 +++++++++------- 28 files changed, 748 insertions(+), 173 deletions(-) create mode 100644 build.example.ps1 create mode 100644 scripts/Install-GithubRelease.ps1 create mode 100644 tasks/ConnectAzACR.Task.ps1 create mode 100644 tasks/ConnectAzAccount.Task.ps1 create mode 100644 tasks/HelmInstall.Task.ps1 create mode 100644 tasks/HelmPackChart.Task.ps1 create mode 100644 tasks/HelmPushChart.Task.ps1 create mode 100644 tasks/HelmTestChart.Task.ps1 create mode 100644 tasks/HelmUpdateValuesSchema.Task.ps1 delete mode 100644 tasks/InstallGitVersion.Task.ps1 diff --git a/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD b/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD index 671eed0..38eae38 100644 --- a/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD +++ b/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD @@ -1,5 +1,44 @@ # Monorepo Build System Implementation Guide +## Table of Contents + +- [Context and Purpose](#context-and-purpose) +- [Prerequisites](#prerequisites) +- [Implementation Guidance](#implementation-guidance) +- [Implementation Steps](#implementation-steps) + - [Step 0: Create or Merge Root Directory.Build.props](#step-0-create-or-merge-root-directorybuildprops) + - [Step 0a: Update .gitignore to Exclude Output Directory](#step-0a-update-gitignore-to-exclude-output-directory) + - [Step 0b: Create Root build.build.ps1 (If Not Present)](#step-0b-create-root-buildbuildps1-if-not-present) + - [Step 1: Analyze Repository Structure](#step-1-analyze-repository-structure) + - [Step 2: Create Root-Level Solution Files](#step-2-create-root-level-solution-files) + - [Step 3: Delete Original Subdirectory Solution Files](#step-3-delete-original-subdirectory-solution-files) + - [Step 4: Update Subdirectory Directory.Build.props Files](#step-4-update-subdirectory-directorybuildprops-files) + - [Step 5: Mark Projects as Publishable](#step-5-mark-projects-as-publishable) + - [Step 6: Simplify Dockerfiles for Publishable Projects](#step-6-simplify-dockerfiles-for-publishable-projects) + - [Step 7: Test the Build System](#step-7-test-the-build-system) +- [Key Concepts and Rationale](#key-concepts-and-rationale) + - [Why Move Solutions to Root?](#why-move-solutions-to-root) + - [Output Directory Structure](#output-directory-structure) + - [GitVersion Integration](#gitversion-integration) + - [IsPackable vs IsPublishable](#ispackable-vs-ispublishable) + - [Invoke-Build Task Dependencies](#invoke-build-task-dependencies) +- [Troubleshooting: Edge Cases and Complex Scenarios](#troubleshooting-edge-cases-and-complex-scenarios) + - [Edge Case 1: SpecRun Path Handling with Centralized Output](#edge-case-1-specrun-path-handling-with-centralized-output) + - [Edge Case 2: Package Downgrade Errors from Legacy Dependencies](#edge-case-2-package-downgrade-errors-from-legacy-dependencies) + - [Edge Case 3: Understanding NuGet Dependency Resolution](#edge-case-3-understanding-nuget-dependency-resolution) + - [Edge Case 4: Package Source Mapping Required with Central Package Management](#edge-case-4-package-source-mapping-required-with-central-package-management) + - [Edge Case 5: System* Package Compatibility](#edge-case-5-system-package-compatibility) + - [Recommendations for Preventing Edge Cases](#recommendations-for-preventing-edge-cases) +- [Common Issues and Solutions](#common-issues-and-solutions) + - [Issue: Build cannot find projects](#issue-build-cannot-find-projects) + - [Issue: Output directory conflicts](#issue-output-directory-conflicts) + - [Issue: GitVersion fails](#issue-gitversion-fails) + - [Issue: Projects not inheriting root Directory.Build.props](#issue-projects-not-inheriting-root-directorybuildprops) + - [Issue: Test projects being packed or published](#issue-test-projects-being-packed-or-published) + - [Issue: NU1507 errors with Central Package Management](#issue-nu1507-errors-with-central-package-management) +- [Validation Checklist](#validation-checklist) +- [Summary](#summary) + ## Context and Purpose This document provides comprehensive instructions for implementing a standardized Invoke-Build monorepo build system in .NET repositories. The build system consolidates multiple solution files from subdirectories into root-level solution files, implements centralized output management, and provides automated versioning with GitVersion. @@ -416,13 +455,25 @@ After: ``` -### Step 5: Mark Projects as Publishable or Packable +### Step 5: Mark Projects as Publishable -**Action:** Update .csproj files to explicitly declare their packaging/publishing intent. +**Action:** Update .csproj files to explicitly declare their publishing intent. **Instructions:** -1. **For projects that should be published** (web applications, services, executables): +**IMPORTANT:** +- **Do NOT add `true` to any .csproj files.** There is no deterministic way to identify which projects should be packable. Developers must manually add `true` to library projects that need to be packed as NuGet packages **before** running this build system implementation. +- Only add `True` to projects that need to be published as deployment artifacts. +- Add `true` to all test projects. + +1. **For test projects:** + - Add `true` to the main `` + - These are typically projects with: + - Names containing "Test", "Tests", or "Spec" (e.g., `Tests.LD.EPS.Common`, `LD.EPS.Core.Api.Tests`) + - References to test frameworks (xUnit, NUnit, MSTest, SpecFlow, etc.) + - Package references like `Microsoft.NET.Test.Sdk`, `xunit`, `NUnit`, `MSTest.TestFramework` + +2. **For projects that should be published** (web applications, services, executables): - Add `True` to the main `` - These are typically projects with: - `` @@ -430,15 +481,13 @@ After: - Entry points (Program.cs with Main method) - Service hosts -2. **For projects that should be packed as NuGet packages** (libraries, shared code): - - Add `True` to the main `` - - These are typically projects with: - - `` - - Reusable library code - - No entry point +3. **For projects that should not be published or tested** (libraries, internal utilities): + - No changes needed - the root `Directory.Build.props` sets both `IsPublishable` and `IsTestProject` to `False` by default -3. **For projects that should not be published or packed** (test projects, internal utilities): - - No changes needed - the root `Directory.Build.props` sets both to `False` by default +**Note on IsPackable:** +- The root `Directory.Build.props` sets `False` by default for all projects +- Developers must manually identify and mark library projects with `true` if they need to be packed as NuGet packages +- This step does NOT handle `IsPackable` configuration **Example Changes:** @@ -454,16 +503,6 @@ For a web service project (`LD.JV.BuilderAsync.Host.Messaging.csproj`): ``` -For a library project (`LD.JV.PlatformEvent.Internal.csproj`): -```xml - - net8.0;net9.0 - enable - enable - True - -``` - ### Step 6: Simplify Dockerfiles for Publishable Projects **Action:** Update Dockerfiles to use pre-built artifacts instead of multi-stage builds. diff --git a/Directory.Build.props b/Directory.Build.props index ed3631c..7c83523 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,10 +8,14 @@ $(RootOutputPath)bin/$(MSBuildProjectName) $(RootOutputPath)obj/$(MSBuildProjectName) $(RootOutputPath)publish/$(MSBuildProjectName) - - + $(LDBUILD_TARGET_RUNTIME) + + false + False False + + $(MSBuildThisFileDirectory)\.runsettings \ No newline at end of file diff --git a/RequiredModules.psd1 b/RequiredModules.psd1 index e8a84d6..d5c6ac6 100644 --- a/RequiredModules.psd1 +++ b/RequiredModules.psd1 @@ -1,7 +1,11 @@ @{ - InvokeBuild = "5.*" - Configuration = "[1.5.0,2.0)" - Metadata = '[1.5.0,6.0)' - BicepFlex = '[5.2.0,6.0)' - LDEnvironments = '[1.0.0,2.0)' + InvokeBuild = "5.*" + Configuration = "[1.5.0,2.0)" + Metadata = '[1.5.0,6.0)' + BicepFlex = '[5.2.0,6.0)' + LDEnvironments = '[1.0.0,2.0)' + yayaml = "0.*" + "Az.ContainerRegistry" = "5.*" + "Az.Accounts" = "5.*" + "LDNative" = "[1.0.6,2.0)" } diff --git a/build.example.ps1 b/build.example.ps1 new file mode 100644 index 0000000..6c947c2 --- /dev/null +++ b/build.example.ps1 @@ -0,0 +1,113 @@ +<# +.SYNOPSIS + ./build.build.ps1 +.EXAMPLE + Invoke-Build +.NOTES + 0.5.0 - Parameterize + Add parameters to this script to control the build +#> +[CmdletBinding()] +param( + # dotnet build configuration parameter (Debug or Release) + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + # Add the clean task before the default build + [switch]$Clean, + + # Collect code coverage when tests are run + [switch]$CollectCoverage, + + # Which solution to build + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + + Get-ChildItem -Path $PSScriptRoot -Filter *.sln | + Split-Path -LeafBase | + Where-Object { $_ -like "*$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } + })] + [Alias("Project")] + [Parameter(Position = 0)] + [string]$Solution = "*", + + # Which projects to build + [Alias("Projects")] + $dotnetSolution = @( + # By default build the EPS solution file in the root + if (Get-ChildItem -Filter "${Solution}.sln" -ErrorAction Ignore -OutVariable sln) { + if ($sln.Count -gt 1) { + Write-Warning "Multiple solution files found: `n- $($sln.FullName -join '`n- ')`nBuilding only the first one: $($sln[0].FullName)" + } + $sln[0] | Convert-Path + } + )[0], + + # Further options to pass to dotnet + [Alias("Options")] + $dotnetOptions = @{ + "-verbosity" = "minimal" + }, + + # Sets framework for solution, included in build output path + $TargetFramework = "net8.0", + + # Sets runtime for solution, included in build output path + [ValidateSet('linux-x64','win-x64')] + $TargetRuntime, + + # The Key to use for reporting to SonarQube + [string]$SonarProjectKey = $($Solution -ne "*" ? $Solution : ""), + + # Helm charts + [string]$HelmChartRoot = @( + if (Get-ChildItem -Path "$PSScriptRoot/charts" -File -Recurse -Filter "Chart.yaml" -ErrorAction Ignore) { + Resolve-Path "$PSScriptRoot/charts" | Convert-Path + } + )[0] +) + +$ScriptsFolder = "../LD.Platform.BuildTasks/scripts", "../BuildTasks/scripts", "../tasks/scripts" | Convert-Path -ErrorAction Ignore +. $ScriptsFolder/PSFormatting.ps1 +# The name of the solution +$script:SolutionName = $Solution +# Use Env because Earthly can override it +$Env:OUTPUT_ROOT ??= Join-Path $PSScriptRoot output + +$Tasks = "../LD.Platform.BuildTasks/tasks", "../BuildTasks/tasks", "../tasks/tasks", "tasks" | Convert-Path -ErrorAction Ignore +Write-Information "$($PSStyle.Foreground.BrightCyan)Found shared tasks in $Tasks" -Tag "InvokeBuild" + +## Self-contained build script - can be invoked directly or via Invoke-Build +if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { + foreach ($taskDir in $Tasks) { + $bootstrap = Join-Path $taskDir "_BootStrap.ps1" + Write-Information "Check for $bootstrap" -Tag "InvokeBuild" + if (Test-Path $bootstrap) { + Write-Information "Dotsource $bootstrap" -Tag "InvokeBuild" + . $bootstrap + } + } + + Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result + + if ($Result.Error) { + $Error[-1].ScriptStackTrace | Out-Host + exit 1 + } + exit 0 +} + +## Initialize the build variables, and import shared tasks, including DotNet tasks +foreach($taskDir in $Tasks) { + $initialize = Join-Path $taskDir "_Initialize.ps1" + if (Test-Path $initialize) { + Write-Information ". $initialize" + . $initialize + } +} + +Add-BuildTask HelmBuild InstallRequiredModules, GetVersion, HelmUpdateValuesSchema +Add-BuildTask HelmTest HelmBuild,HelmTestChart +Add-BuildTask HelmPack HelmTest,HelmPackChart +Add-BuildTask HelmPush HelmPack,HelmPushChart diff --git a/scripts/Install-GithubRelease.ps1 b/scripts/Install-GithubRelease.ps1 new file mode 100644 index 0000000..8c336f9 --- /dev/null +++ b/scripts/Install-GithubRelease.ps1 @@ -0,0 +1,174 @@ +<#PSScriptInfo + +.VERSION 1.5.2 + +.GUID 23addf96-d1d7-4f51-b97f-c4f0189263b6 + +.AUTHOR Joel 'Jaykul' Bennett + +.COMPANYNAME HuddledMasses.org + +.COPYRIGHT (c) Joel Bennett. All rights reserved. + +.TAGS Installer GitHub Releases Binaries Linux Windows MacOS + +.LICENSEURI https://github.com/Jaykul/FromGitHub/blob/main/LICENSE + +.PROJECTURI https://github.com/Jaykul/FromGitHub + +.ICONURI + +.EXTERNALMODULEDEPENDENCIES + +.REQUIREDSCRIPTS + +.EXTERNALSCRIPTDEPENDENCIES + +.RELEASENOTES + + FromGitHub v1.5.2+Build.local.Sha.d528184585be78a00fe7f69c808af9b12bc2a398.Date.20250711T045707 + 1.5.2 - Fix metadata problems in published module and script + 1.5.1 - Fix a bug in SelectAssetByPlatform not using the order of OS and Architecture to select the best match. + 1.5.0 - Convert to a module with a build that exports the script + + +.PRIVATEDATA + +#> + + + +<# +.SYNOPSIS +Install a binary from a github release. + +.DESCRIPTION +An installer for single-binary tools released on GitHub. +This cross-platform script will download, check the file hash, +unpack and and make sure the binary is on your PATH. + +It uses the github API to get the details of the release and find the +list of downloadable assets, and relies on the common naming convention +to detect the right binary for your OS (and architecture). + +.NOTES +All these examples are (only) tested on Windows and WSL Ubuntu + + +.EXAMPLE +Install-GithubRelease FluxCD Flux2 + +Install `Flux` from the https://github.com/FluxCD/Flux2 repository + +.EXAMPLE +Install-GithubRelease earthly earthly + +Install `earthly` from the https://github.com/earthly/earthly repository + +.EXAMPLE +Install-GithubRelease junegunn fzf + +Install `fzf` from the https://github.com/junegunn/fzf repository + +.EXAMPLE +Install-GithubRelease BurntSushi ripgrep + +Install `rg` from the https://github.com/BurntSushi/ripgrep repository + +.EXAMPLE +Install-GithubRelease opentofu opentofu + +Install `opentofu` from the https://github.com/opentofu/opentofu repository + +.EXAMPLE +Install-GithubRelease twpayne chezmoi + +Install `chezmoi` from the https://github.com/twpayne/chezmoi repository + +.EXAMPLE +Install-GitHubRelease https://github.com/mikefarah/yq/releases/tag/v4.44.6 + +Install `yq` version v4.44.6 from it's release on github.com + +.EXAMPLE +Install-GithubRelease sharkdp/bat +Install-GithubRelease sharkdp/fd + +Install `bat` and `fd` from their repositories + +#> + +[Alias("Install-GitHubRelease")] +[CmdletBinding(SupportsShouldProcess)] +param( + # The user or organization that owns the repository + # Also supports pasting the org and repo as a single string: fluxcd/flux2 + # Or passing the full URL to the project: https://github.com/fluxcd/flux2 + # Or a specific release: https://github.com/fluxcd/flux2/releases/tag/v2.5.0 + [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias("User")] + [string]$Org, + + # The name of the repository or project to download from + [Parameter(Position = 1, ValueFromPipelineByPropertyName)] + [string]$Repo, + + # The tag of the release to download. Defaults to 'latest' + [Parameter(Position = 2, ValueFromPipelineByPropertyName)] + [Alias("Version")] + [string]$Tag = 'latest', + + # Skip prompting to create the "BinDir" tool directory (on Windows) + [switch]$Force, + + # A regex pattern to override selecting the right option from the assets on the release + # The operating system is automatically detected, you do not need to pass this parameter + [string]$OS, + + # A regex pattern to override selecting the right option from the assets on the release + # The architecture is automatically detected, you do not need to pass this parameter + [string]$Architecture, + + # The location to install to. + # Defaults to $Env:LocalAppData\Programs\Tools on Windows, /usr/local/bin on Linux/MacOS + # There's normally no reason to pass this parameter + [string]$BinDir, + + # Optionally, the file name for the executable (it will be renamed to this) + [string]$ExecutableName + ) + + +@( +'3Dxpcxs3lt9Tlf/w3GIVmzaP2HE8E81oHVmWY83aokqU40zJGhvsBknE3UAHQIuiI//3rYejLzYpyfHs1izLLlLdOB7ehXcBO6d0zgSH7vBdJtkl0fTdz1T/zPTLfHpKE0oUHWbqYRcGD7/59ptZziONzZtt4I9vvwEA2IHnYzgen8GbySF8OD9I44TqZ4zHjM/D3sUHEBLOT4gkKdVUhr0L3+0tBZUlRINeUMh8AwUzKVI44kqTJBnUpyQ8hiUFTmkMWgCbcyGp6U+vtCTKDm3GCu1v/JwrLRmfX3TGct7/9puWF6c0E+1vzsgc9qCbEE2V7toGPd/yrWSaDp7TaT6HYCznu2CmABxuF+yocEbw+RmZB77bDrwkPE4s4GM5LxcPU8r4HAgIOR9JmonRJZUKsS+kaT3LkwTenL7C1RPIpPiNRhpfSoshOwGbQYiQwCAlOlpAMGd6kU+HkUiDnqdb2wKAKSBQtoZcJnZRQdlpbXQ3txppMh8N79enwI/FisOHQyk+G6osYToMRkFvuFxQScM/arDCgP4OnffwuQ/B5CPL3nDNkqB3/rAPj/oweOhYqQbYyeSZyHlcMJwaHgiuCePqv+kq7CIM3d4ahCUy3hLJkQYBtkR8WCaLYbmgHDKilKVQnRIOBYiBCqbw8/nuMJ6R+e1AREx+VQg/A01Kyf4/wavn6TenrxpwelDufTn+1ngewOgUFDVQGY3YjCFITC9Erp3oIsAtoODH8zI2WW/RIP3njULhxWFixKE76t5dHCqi4OaxtFwT1tFtNMAIFEWlpGkMVhO2a4EvY4q7McQ6LDATEvVmgyh/kje+Fl9s5omqoJU/68rxF6fv1ziigXnfboBgDrjQcDti3KFDO6rc8n4qQOidf9fCfPUt8hcqp0JRCA4WNPqIJLT7ulnkyMoAErWLo3eLjbJzSlWeaNiza8aZUQT8blyD7Yhfio90cEqVfk31QsQQLLTO1O5oRDI2LMXI7KtqVM48KvYvs0vD4CUlMRoiP/2xH0U0w/m7JMsSFhG0hEa/KcG78Bn8snY7M5L4vXddhX4NyDSZfyXwNhBmhpzgNwnohA71Q03m7zlJac/QpwTNMX5HWgpdw34cD17TdEolDI6FpidSZFTqlX2G0JYYQT3jOLx86BVhKTefYXBClDpbyPzbbz5/+83OIY9va7s+efztNzutjceTfRktmKaRzuUmS7feyFNzzbjdZG8umY4WF50TojWVvGE17sCJWFI5WdAkgQMhHWU6tRkty4cdIqMF7EHQCc9Pc65ZSodHXFMpsgmVlyyiauieH/GZkKlhgYvd3foCekFdkM2w5Z878IrOSbSCt4zHYqkqADb3kvNDfsmk4Cnl+mJ390g9efyM6XFGJdGMzycrpWlamyv49cnj4G+bhCP49a9PirceP+MMV0GSZNUHnUsOeoH7A3eGzJxeQWZRC3pBNOQqx8awFPKj8wHs9mQbtWx4FWTVEV82tESEsPZ6TR0G+zIN4A8IiEzDp/eePO4FzY0emzx57Bvhr2YDxAG+vvrrk+vv8fdaA98/jZ88vr7C/3998r4xVG3fr1F7fYGftwtUm4w8+nGzRJ0kRCNCN0qTb/C1JMnqHgZ7cG5ZbngH6XDdMw/TzYOUCyi8VzYzADDFuxpiOmOcxn300Th6qGmuNEwpyJwbI4dxK1PKCP0Pw4d9WC5YtADBPd+C4F7+hg7C8cTpAbtfSwYDVMKSoeCVMIXFUnZ33Qh1cQ+W9mmwZhluG+oV4/lVY6AEn91tmPHk18YgMZFLxu82ygtJ6bPJ88ZIM0npVMW1oaoN9EKKJQQ5V3mWCYkmpB/S97m7uhhPWpTEeLKuGjzWUXLd7+vw6d/vxUT2EAFrYm6xi83Nr+ucs6v1Vg592Mz+vBaqpZnHDbYrft9KXfgF3qgk6mL//aNWBXHEmWYkYZ/oM8afM9mmI5ptNqiJiaWimixEnsQnUkRUqc26w4Vw7JD9Fq3yQsiINnZn60XYPvU904G2B53QsMuRMhJiZfJIvSaRYQLojnIlR4mISDKaMjTF2p3Hgu8P+eXuK2y+n2XPiSY4SLD29N2JFHNJUvXuTIhEBduGNQO8HL8+HA21a9xr2H9mneEZVXpwQvTCL68uYDvwgkmlAZUi5IqCmPmGDX/EoNKi4mRiqTa0ZEKfgvGchsGBpETTApHoiJDYBPIQgqdBHwL/LhZUASo9esWUblgv+Dmmy8GRpikMzlYZhedM0kgLuYLaauAaxrkeHOfekKnBjBg+2T97CQMTh4Lzo/EQe1/s7uLXxLqdQhp3KXKuUYGoNh+yHPLB3ubhHpQ4XB9iB45mwHRXwX0u9H2/JfSLJS0Rqbi31NkMg4BKpFQvjNucSEriFe47K5FLQJg2RVMKPkaC3Ktwcjt/7cCzXGNXptFrL+CrxGVT8tEGNw0qogXhcwoZlSnhlOsNoRTTdg8aBubPVFce/EIkI9OEhgG2Dvq11v7lGZFzin3fKCp722a7JZHaBmiAOdkOppnuy6D9fMdgndvyDghH8WE2jO7CTFr4RQVtHrtBjF/0Rs3frs+//6FV+b8Wl/Twika5xrW2qf56izKpMBEpBU1JqiCkw/kQKJF6kaxG7rvXB/RLDZuRSOckgSnjRK5MlMbmFLyBhw39uG8pxMKoliXhGlGiUYG4Ud8vGX/yeEivKNBLKldG8/VBCZDUTifM0FP0kvHBcDj8j0uDvJAitTti5eV+wogKA8uOhToNPJy1Ac5Es/sO7DcdMwGKJhjJRbgkmy80EKWoNoEE49BVDakyRzP58wOTVoeumKLq2zQn40CvMJTC0KukcoCGY4KxP0N9OwOi2TNsy/AlNx+TtDnB2cLyDe6kBn4M+TBEdR8Ihl5nJEmmJPrYMleFj7dnr3bgBXamCl1yyHKZCUVVH8QllZLFFMYTiCligAneYhcdqROh2BWaOm1GTt0L20+SFyyhCvbQ1RscLFgSm53Z8xkM8D0MTmmUy1qKyncdHoicaxPb+67F+i4ixceigg0FNmbFeDFTRatJioGDhs3TObzSlGPQEqH9yZhx9/xq1za8HRgXHlnfOmpOCeDONwNYIMF4lUDUj18f6aewc3j8izEMDn898+ZG92/dHjyA7vDw10OXWTTQFqboTEhKjHdhEIgL9RirW6bHdImshvTCt0P8o8oNb9Er1ZhRsOH9lHAbMClD2w0uM9s5GnwYX2mYenX+bkHba9z+FUaxWvjXYK2U3A0YszYlrqUg2QaDqwYM1Db0A2N4FAM0Ie9Dc4aNW61d19GsRKJhB8EpzFhC+zYAwDSt8Qi9pNwaSl0FOWe/5xWqbBOCh+1rrZC5vpLNJkKVC47RhTUxtSWtsbNJLBsOM0adJ9B4ghvNSEioRSaZYSVLyxB3IqYqNG7mKQyGnxFlyeOSUBjYMAql/W1tOmOVhhXJrSqLyihFAxigoJTt132HHTiTK/gNQzSSpuISdUu54PpqzS7s17tOvbBClLXFSpolJKIQnA/eD+Hifvh0F+ME7q+gvUEt2uib9no3coT7OTyTLA0D7Begdmkgp83fGCNTL5mifbP5gNl9nJ1jEmDrS68EbW7QBndi3FaDtjkC7nZ3WmW7PBjg/bADTiuq8+aEJScp/rJ6Ga1qN1AjHWjf74HpQK2/aqd5kSeJndmDUMJS5jwqYFcF2ZjGM7p05jHjUZLHVoxjEanCAGYSMhJ9JHPqTGiRUa7FLO/VTZ4YjpwxvDBKX+KSPnKxxAfK6gYFS9R85JKwBKnXh2mujRmdikszd3obyQ9OD/efvz68fnV0cHg8Obw+eLl//PPhq/HPwUZRDoZpjKGBoVTafOsr+01UZL5jEQWwgWZFfgvz5RnSLBaRnaZBq8iFKVqZpTHYa6szSvIbg7hIW1q8jjlwwQc+paJMhNn6yanZ+oz5rvLY+GWRyFaABolRxiKJqUTFusSJsSYCNQKNm9rVG2pWSaL5ZXnMwAMDE5TpDfe1lmyaa6qM1gxOKYnHPFn1K2Z+E30GrvQSBrMmx9rBW1pHi1TE8OAKAttk1AkrMhXcpsqkEo5qGaJVuZyiBvey1dKpsDsdOrbu8RX1BHvGNW0X2ufGtHbx4CqyYXAopZD71redaJG1yXOTfq0LK9BZAaoA4XY7/jjXWW7FGpEUA9M07cOURgRtOxMuYilVgCGiIDcFF/jQO8Ou+Mw5xZ9YFkBKlUKFwhRKzMz0KacsODD8h2C8Ssmaeq0h6ch4rzdHnNtCCT9+3xp1mBj/cB+dwmerbbmp1ob/OYWNHQO5+v/hide8SqM+jedpFKYykWTIJJ1Ricm1pLCrix2pYGsExmpb5NJMiimZJivbC+M2sXMGj9RrfOSKS5yjWxPF86kQyUVYd2tt2JVNR8avxaLN7n0c+/5QifvdirpZZ3K4dhw3GE9NkdvAhtcf9jbmYtpKN3bgJZsv7AYxpUiLIgY1EdJSo0SLoQhaCSAk7ipi5vBIeWR2r2ghcFcj3FLrkpo4Af7toofUaXvvAZVOG0Jgw4yW3AlT2hAsFpgWNTbEAu0IAQtb8so0hMgdXCwLUp+Nn493wSV3DO+Y9Jc3XhSEMZ32QWZpH0j2sQ9UR1s6W5T4xSgI//Kp2qVTQr8HgdFqxqqYf7LfRA7dT3rljQR00qysoQXqMHpdlY0jK7tT4WKPpQ2Dom1i4ZIJyTR6jc7j4iQBYRnBB3sSMWcRTGkilrXRu0kCXpWX8Ave98Pzwls3JCVJlGOVlFVBbubaiLpMJMyIQoSFaNHl82TVwyFwOAKo3hNa51pr+kUizQiW62kBsVjyRBBUjAaenH+y1hYmxAlKKZNYWnRJqtH/uijc78NPf4Az8oMCfcHf4PAqw6zbH9B5PzReZ+E3df91/q/hxYNOtw/dbsWdGt5/GoZPd98hMXtP3w1tq17Qh27nIZZGfa4SbxscnmgVMDalUd5X7VbEgvFyudDO8i2ywRszK04PoVba2KZoZzHhxkb9s27DNT8Pv9v8frPvVf38+OOWEdpf3Tzwly5nKzC3Ws5WhGxYTkuGZutcHcZjiqHUcyIlWWHNFD4Yz8JSEfWRtTeFof63uMMC+mdR6pb74Itw+29jldtBdYcFfhW+2WzEV9XTWww5eu1klGCxkwzmFL5rKDMhC1Xm2/VtNxMeekX5XC+whNy6EbZnw799YQLrnbDc9mzQreeMiSGgo+YrsTvjyahmyX3gtb7XmI1IiR6cVcLL0/u5TFx6fmKsQfQQ3W77GmlL43LHLeEwdfGVraASUDQ6t/G8Fsv73LpYN8uWldTAaV1NIbvbF2bYtzaaD2bO0QisV15gpshZ2mhH476fmSAA7rhTtCXLungThm018MoRd9EZLVMKwuQTEG8uG/Huul5QjZ+yObHph1pkdktHu4RjE0IiGpa0K9G4Eab2ux1SYzdGhNvArEJozUmApTGVYpO20NWqVf9Zx2qdZYwAhes8I9pZhrhoiiu6u+223rMBrFpzRFmh5Hvm3IYx+IM+PLzpgEoZuTLmCTXyWOedSl22uO6QDWdUppISjOdmd925GnAcY10HLnnbhFt0WsOJuYMsuJIGq5XSPNEsS6oejjc//61C/IG/4aYx2rvoz6eMo89pkgi28BxOrDNvs2orGIwnGCMY1KRGYzxd4oKYNpAP1wshW9Gylhx1aDkWhdON+LmNRnYM/5vA8OoHHvjgXIv/aYfea+jk4shHUbJtDnaYyZ2cDlxuMYIIX6k8NXFkT/wQwo5akLWRvbQ2pDLwg1yrBXn0wxOVpwp/BqVM9YwEVjWoAcZ5MDUgLJxJ4razsk8BE/nTwLTksRv7ax2kjpl7OJViqah873n6fS4TJ+OhhcqKfUX2dvDshSuEl4nP3FjCWfdyjZ43HddAF8hQVOXpG4kxko3ANeuHzARbwnhbYnMPv2sc2sinCYve+YgZJvlt1Ky9hLTZyhPg7zv2ezj55/H4ZHJUiXW5TkB87ZAJ0fkTsIVgu/7PDycHp0cnZ0fj43KI/UqYxNDJMtzADWjKL/1AMfr+bgnlCGcYOYmkUGpQVCypSLJMw5IlSaHe+pZjynTtgqhFpaw15xgwsWWVqChrWXgHjslclyWBw2qA78iUvNjcr0PA/skR8tOc2shDTDVhiSprV8rgpdmi9aJib2AsCFt68CvBKBu1kDRhGHu24ZBIpKngqIrRRIgEv6S8Xp7iFG8t3OjJJqRd1Hhic9LVuGPP0+/w1/3XJ68O18iPsdhFGYt9keRXB8/N16MafhyzfMA3H8rMsD/MVTnIZYcwX48qJT53g8NF4f13Kyju3XZoGpVzXwzQbzmn85xzmH2atUIz+zTbDokfYTT7NPtiMJ7lkutJrhYMJMvmkmatwMj5dljKYUZumC+GyOdVix+tAPmX28HyrYofXwyVXmZkxc3W9ykVrBUm9247SG6gkWt8N4AqSY6WoVP2kc6IJIvR6vfaacfR5ePh48fDJ61Qr37/AP5iBNfOLsBkBLxaErxyk8HdUKcWRH6Ms9G0Wvy0veksbgV1SvQHo+0+zOISy0yWSGTUGSDD4/HZYWVz2k8Sl4CnVyTNMAePbmCIpTs9U91nNxSfYcZZ3k5ewZtpzrXLNu78l/325Z2thCmKPP/McQvrsJrqAYH/5oSzTzZDakwTseSu7qvBPbbzfqIEuFM6Ck9/m/ACthfSBpVNKYopkXQmnT+YPkvyqyge4dej6pBjWZwib16dYaPi5qKB3Ta23DIk8UVzkWe0G0doMPaj4Q/DShiqkkLEZLDB2B5814fXhMdYir7qwy8kySlaNicsownjtOXRs5W34UyStVq364iPFebtBb3Ni1G21akief0lDZXYv+Htm1b18G5wbylwRfg0mTdNkQpAQ3hOZyRPtMKn9RtcNkP46Isw647Cb6iWrl0h01gH1qUgOtPMMryAyJ6SwVUFrljfWJEQF2dcwlLqe5uONd2cuy2qgW0S18uJNayEOYJbbgsu5+WMtdptMyVBhD8GXMm/klwLPDkXmYJTa8DhScmVyH0hvs96o7TaPGKRNP/CtPTXX1rVoPy3LGtLUTrOj+d8rDLFwJ3dXLSoeBE7NXa/6fRYZd/orx0kcpn3UaXYuwBE0i6mfG0lKXA8F0GUBet26yxO49X0avXAt3dvNlTcQ8icY4Tna01lnUEzzty7sRzfNnDtMrul1eOup9QszSzE1R2YiOQU86GxxTFcoo6widD6+WZTcWA83aIewRglS8pk3Afx8Wk5l80bthy1sgd/6wMPakegi5Zrd3icB+NJcGFuV5hsLHy8t/1Qe/MqgvULEW4PTbWbhav9uH31RhSvzc3MdRvyp7UpqrR7k8Ved9oTT6EngitnZ7pxysLGUMkME+AZkfbOGTnP8YiWrVmoFTgV1/X40IC9raIlYNZeWjRwyqbo7/5uW1Z1YaaknoAkPBaprw/ELL31+VmFEB08TX7Ez2iawR6UdVhhpTL+Z6rxPf4V9nrNV6dmFqzXQokJexWpuunYZWXutqOXJ7laDFAt2cq5snV9tc+9TWGufEC/vjKuXXxlvUazUaVOyqqWPQgmLKFcJyt//DRYu5DlLZ2e0t9zjOAO3kjmSNce5BqMc22qPSsxuPULVtZ0pav0NKqjJQzqxdENWom4rYlk5bguwvGSqAUM7GGtOkx+EGgZtDXl4A8M+n4pU5U4/6agY0Nk2zMJ68d3mmHYnMd9UL42t3h7SRIWWybZCkWjMNqfnC1LmfqYNTPZMQ0h4wptglZ+qpVEv6VYb/OR0qxajtvYhsyNJKhNOie2TslIAGVYTQ9Lshq20riZTFp7yqmrPFrXyq6avCzzLZFSb3mTlBolVkka+88BRt2kq8BFlveHVKG6xJaOE6oLsW4eam7Evg+xntGaZGX9dGX4lku91pGEVcymcquVo18zjKaKmR6W99gMza5zSd8dGn6wOZlLX/W/oax2Y3rMX8GFS6qoHQNXoXA2Jvg0kTC4+nTZLK2+a4LOjXOLYTbKrTd57C071jHeomubtK6r8O1Q18Rkb0PPll3Ph7CN5UnwUK8tW/TlcC4E7Mz3+kH38s6GtRsm/Ctbt73r74K4qea/rgRC1bN+RG1xrcesG+ecTZ7CwFHtOXC10g627VbBichaha5WEV8hZv0AZmup9faEy8Mf/tKWn6ltSm2pmfqu1cjKbMjM4Af72Zgk9hMztNtQY5A5XsSgzfE7ISHFytraxqLMZQjG/q+Yh+35G/wc1DtjlI0olTvHYkGwJJZTrEo11xiU7km/PGxuYAwRHJ+gxid4UtNxpuk9E0kilkVIyo9Ty8Pg58iXSGCNtattdW4fdl79DqG9P8icQW1sqwamJhjoWDEFBVFsPa5qIs70JRz2T0/3/4k4N1tx0cqm2E1NSOmjTVegqNlw3bpMpZtNsvKVTQfjK4RkbaUm+2VvUZpj5MM6VbiDRxGLKdcksZ4lOt1JKhQiIhI8ouzSHkTCfdRn5D0ElmMMBhk6xHjAJrVX5NEYwjnDo5nFGU7jnBVBLXP3iHX/BMyI9PmjIpZqj1Xg1hraCvEN8dPtodLMqYqCEUwptk/wOZZvDSeh0dcSIsAuoWE4WdALFZQ5XWSKdfFniCU2U1oTnlwmqm8ZoI8PzeROftZd6vOLi46XmJo73cHqesMRe3B+ijGZi93dQxURRFTNwyh8C7cadDEckn0p990509SVx2xmdmNzjHUBJJljCdwi9esoIDcHwMu1uUqnsGNJgJU7vmXL+dGjWakzzInqN6ev+jCjaDabZGYF/UWc6c3pqxbbxhZO+MoCE0l+ujtq3NLaekfpGgS7DuaWupz1Oyht0zaDp3EQy7S7KzRIsi3gmBMVgmukUysga3GBXlGfVTCZqz8L3qkH13tBpSaraFIUFO7bC0L27Bm50oF6xTSVJHHnlKw3Ndj3PAOTl/uPfnjSG2LjkkGHw+GdVB5mpG+rzbyucSdPSm41V//aZThOLrmn+X5L5Unp5ZmiG+X7bCwAsv3NUZYWH/HeB/4LSqEntcrTD9yOuNscerO90WJAPP4L4vt/AAAA//8=' +)|.{ + + [CmdletBinding(DefaultParameterSetName = "ByteArray")] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [string]$Base64Content + ) + process { + $Out = [System.IO.MemoryStream]::new() + $In = [System.IO.MemoryStream][System.Convert]::FromBase64String($Base64Content) + $zip = [System.IO.Compression.DeflateStream]::new($In, [System.IO.Compression.CompressionMode]::Decompress) + $zip.CopyTo($Out) + trap [System.IO.InvalidDataException] { + Write-Debug "Base64Content not Compressed. Skipping Deflate." + $In.CopyTo($Out) + continue + } + $null = $Out.Seek(0, "Begin") + $null = [System.Reflection.Assembly]::Load($Out.ToArray()) + trap [BadImageFormatException] { + Write-Debug "Base64Content not an Assembly. Trying New-Module and ScriptBlock.Create." + $null = $Out.Seek(0, "Begin") + # Use StreamReader to handle possible BOM + $Source = [System.IO.StreamReader]::new($Out, $true).ReadToEnd() + $null = New-Module ([ScriptBlock]::Create($Source)) -Verbose:$false | Import-Module -Scope Global -Verbose:$false + continue + } + } + +} +Install-FromGitHub @PSBoundParameters \ No newline at end of file diff --git a/tasks/ConnectAzACR.Task.ps1 b/tasks/ConnectAzACR.Task.ps1 new file mode 100644 index 0000000..6aaa4df --- /dev/null +++ b/tasks/ConnectAzACR.Task.ps1 @@ -0,0 +1,29 @@ +Add-BuildTask ConnectAzACR @{ + Jobs = { + $RegistryName = "$($script:ACRName).azurecr.io" + + if ($env:AZURE_ACCESS_TOKEN) { + # Pipeline path: use the OIDC plugin token directly + Write-Build Gray "Using AZURE_ACCESS_TOKEN from OIDC plugin" + $TenantId = $env:AZURE_TENANT_ID ?? (Get-AzContext).Tenant.Id + + Write-Build Gray "Exchanging access token for ACR refresh token..." + $RefreshToken = (Invoke-RestMethod -Uri "https://$RegistryName/oauth2/exchange" -Method Post -Body @{ + grant_type = "access_token" + service = $RegistryName + access_token = $env:AZURE_ACCESS_TOKEN + tenant = $TenantId + }).refresh_token + + Write-Build Yellow "helm registry login $RegistryName" + $RefreshToken | helm registry login $RegistryName --username "00000000-0000-0000-0000-000000000000" --password-stdin + } else { + # Local path: use Az context via Connect-AzContainerRegistry + if ($null -eq (Get-AzContext -ErrorAction SilentlyContinue)) { + throw "No AZURE_ACCESS_TOKEN and no Az context. Run ConnectAzAccount first or provide AZURE_ACCESS_TOKEN." + } + Write-Build Yellow "Connect-AzContainerRegistry -Name $($script:ACRName)" + Connect-AzContainerRegistry -Name $script:ACRName + } + } +} diff --git a/tasks/ConnectAzAccount.Task.ps1 b/tasks/ConnectAzAccount.Task.ps1 new file mode 100644 index 0000000..ac4d3eb --- /dev/null +++ b/tasks/ConnectAzAccount.Task.ps1 @@ -0,0 +1,22 @@ +Add-BuildTask ConnectAzAccount @{ + If = ($null -eq (Get-AzContext -ErrorAction SilentlyContinue) ) + Jobs = { + Write-Build Gray "No Azure context found. Connecting to Azure..." + + if ($env:AZURE_CLIENT_ID -and $env:AZURE_TENANT_ID) { + Write-Build Yellow "Connect-AzAccount -Identity -AccountId $env:AZURE_CLIENT_ID" + Connect-AzAccount -Identity -AccountId $env:AZURE_CLIENT_ID | Out-Null + } elseif ($env:AZURE_CLIENT_ID -and $env:AZURE_CLIENT_SECRET -and $env:AZURE_TENANT_ID) { + Write-Build Yellow "Connect-AzAccount -ServicePrincipal -Credential $env:AZURE_CLIENT_ID -Tenant $env:AZURE_TENANT_ID" + $SecurePassword = ConvertTo-SecureString $env:AZURE_CLIENT_SECRET -AsPlainText -Force + $Credential = New-Object System.Management.Automation.PSCredential($env:AZURE_CLIENT_ID, $SecurePassword) + Connect-AzAccount -ServicePrincipal -Credential $Credential -Tenant $env:AZURE_TENANT_ID | Out-Null + } else { + Write-Build Yellow "Connect-AzAccount" + Connect-AzAccount | Out-Null + } + + $AzContext = Get-AzContext + Write-Build Green "Connected to Azure as $($AzContext.Account.Id) in subscription $($AzContext.Subscription.Name)" + } +} diff --git a/tasks/DockerBuild.Task.ps1 b/tasks/DockerBuild.Task.ps1 index 2904ae3..a0b22b0 100644 --- a/tasks/DockerBuild.Task.ps1 +++ b/tasks/DockerBuild.Task.ps1 @@ -1,24 +1,85 @@ +# TODO: Where is the repository name coming from? Add-BuildTask DockerBuild @{ - # This task can only be skipped if the images are newer than the source files - If = $dotnetProjects + If = $dotnetSolution Inputs = { - $dotnetProjects.Where{ Get-ChildItem (Split-Path $_) -File -Filter Dockerfile } | - Get-ChildItem -File + $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue + $PublishedDockerfiles.ForEach({ + Get-ChildItem $_.Directory -File + }) } Outputs = { - # We use the iidfile as a standing for date of the image - # Projects that have an adjacent Dockerfile - $dotnetProjects - | Where-Object { Get-ChildItem (Split-Path $_) -File -Filter Dockerfile } - | Join-Path -Path $OutputRoot -ChildPath { (Split-Path $_ -LeafBase).ToLower() } + $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue + if ($PublishedDockerFiles) { + $PublishedDockerfiles.ForEach({ + $Project = $_.DirectoryName + Join-Path $script:OutputPath "docker/$Project-metadata.json" + }) + } else { + $BuildRoot + } } - Jobs = { - foreach ($project in $dotnetProjects.Where{ Get-ChildItem (Split-Path $_) -File -Filter Dockerfile }) { - Set-Location (Split-Path $project) - $name = (Split-Path $project -LeafBase).ToLower() + Jobs = "GetVersion", "DotNetPublish", { + $script:DockerMetadataRoot = New-Item (Join-Path $script:OutputPath "docker") -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path + $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue + + foreach ($Dockerfile in $PublishedDockerfiles) { + $ProjectName = $_.DirectoryName + $Context = Join-Path $script:DotNetPublishRoot $ProjectName + $Version = $script:Version.SemVer + $Registry = "crazusw2dvosl1" + $FullImageName = "$Registry/$Repository`:$Version" + + $MetadataFile = Join-Path $script:DockerMetadataRoot "$ProjectName-metadata.json" + + $BuildArgs = @( + "buildx", "build" + "--rm=true" + "-f", $Dockerfile + "-t", $FullImageName + "--metadata-file", $MetadataFile + ) + + + if ($Version.Sha) { + $BuildArgs += "--label", "org.opencontainers.image.revision=$($Version.Sha)" + } + + if ($Version.CommitDate) { + $BuildArgs += "--label", "org.opencontainers.image.created=$($Version.CommitDate)" + } + + $RepoUrl = if ($Env:BUILD_REPOSITORY_URI) { + $Env:BUILD_REPOSITORY_URI + } elseif ($Env:GITHUB_REPOSITORY) { + "https://github.com/$($Env:GITHUB_REPOSITORY).git" + } else { + git config --get remote.origin.url + } + + if ($RepoUrl) { + $BuildArgs += "--label", "org.opencontainers.image.source=$RepoUrl" + $RepoUrlWithoutGit = $RepoUrl -replace '\.git$', '' + $BuildArgs += "--label", "org.opencontainers.image.url=$RepoUrlWithoutGit" + } + + if ($dockerProject.Labels) { + foreach ($label in $dockerProject.Labels.GetEnumerator()) { + $BuildArgs += "--label", "$($label.Key)=$($label.Value)" + } + } + + if ($dockerProject.BuildArgs) { + foreach ($arg in $dockerProject.BuildArgs.GetEnumerator()) { + $BuildArgs += "--build-arg", "$($arg.Key)=$($arg.Value)" + } + } + + $BuildArgs += $Context + + Write-Build Cyan "Building Docker image: $FullImageName" + Write-Build Yellow "docker $($BuildArgs -join ' ')" - Write-Build Gray "docker build . --tag $name --iidfile $(Join-Path $OutputRoot $name)" - docker build . --tag $name --iidfile (Join-Path $OutputRoot $name) + & docker @BuildArgs } } } diff --git a/tasks/DotNetBuild.Task.ps1 b/tasks/DotNetBuild.Task.ps1 index 484f492..608a6b8 100644 --- a/tasks/DotNetBuild.Task.ps1 +++ b/tasks/DotNetBuild.Task.ps1 @@ -11,17 +11,26 @@ Add-BuildTask DotNetBuild @{ Where-Object FullName -NotMatch "[\\/]obj[\\/]|[\\/]bin[\\/]" } } - Outputs = { + Outputs = { + # TODO: In harness this is rerunning every time. Need to figure out why. $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } # Return corresponding DLL files in OutputPath bin directory foreach ($Proj in $Projects) { $ProjectName = [IO.Path]::GetFileNameWithoutExtension($Proj) - $DllPath = Join-Path $script:OutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + + # linux edge case where csproj name does not match the dll name (case sensitivity) + $AssemblyName = $ProjectName + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue + if ($Content -match '([^<]+)') { + $AssemblyName = $Matches[1] + } + + $DllPath = Join-Path $script:dotnetOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$AssemblyName.dll" $DllPath } } - Jobs = "DotNetRestore", "GetVersion", "SonarQubeStart", { + Jobs = "DotNetRestore", "GetVersion", { $Name = (Split-Path $dotnetSolution -LeafBase).ToLower() $local:options = @{ @@ -34,16 +43,9 @@ Add-BuildTask DotNetBuild @{ $options["p"] = "Version=$(${script:Version}.InformationalVersion)" } - # Pass SolutionName so Directory.Build.props can calculate correct paths (I think?) - $SolutionName = if ($dotnetSolution -match '\.sln') { - Split-Path $dotnetSolution -LeafBase - } else { - "shared" - } - - Write-Build Gray "dotnet build $dotnetSolution --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$SolutionName" + Write-Build Yellow "dotnet build $dotnetSolution --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" # Invoke-BuildExec [-Command] ScriptBlock [[-ExitCode] Int32[]] [[-ErrorMessage] String] [-Echo] [-StdErr] - dotnet build $dotnetSolution --no-restore @options "-p:SolutionName=$SolutionName" + dotnet build $dotnetSolution --no-restore @options } } diff --git a/tasks/DotNetClean.Task.ps1 b/tasks/DotNetClean.Task.ps1 index 842cd13..20d83cf 100644 --- a/tasks/DotNetClean.Task.ps1 +++ b/tasks/DotNetClean.Task.ps1 @@ -2,7 +2,7 @@ Add-BuildTask DotNetClean @{ # This task should be skipped if there are no C# projects to build If = $dotnetSolution Jobs = { - Write-Build Gray "dotnet clean $Name" + Write-Build Yellow "dotnet clean $Name" dotnet clean $dotnetSolution } } diff --git a/tasks/DotNetPack.Task.ps1 b/tasks/DotNetPack.Task.ps1 index 63cffd1..09ea5d0 100644 --- a/tasks/DotNetPack.Task.ps1 +++ b/tasks/DotNetPack.Task.ps1 @@ -13,7 +13,7 @@ Add-BuildTask DotNetPack @{ # that explicitly set IsPackable=true should be packed $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue if ($Content -match '\s*(true|True|TRUE)\s*') { - $DllPath = Join-Path $script:OutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + $DllPath = Join-Path $script:dotnetOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" if (Test-Path $DllPath) { $DllPath } @@ -33,10 +33,7 @@ Add-BuildTask DotNetPack @{ if ($ExistingPkg) { $ExistingPkg - } else { - # Return a placeholder path so Outputs is not empty (file doesn't exist yet, so task will run) - Join-Path $script:DotNetPackRoot "$ProjectName.nupkg" - } + } else { $BuildRoot } } } } @@ -56,7 +53,7 @@ Add-BuildTask DotNetPack @{ Write-Host "Packing $Name" - Write-Build Gray "dotnet pack $dotnetSolution --no-build --include-symbols $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$dotnetSolutionName" - dotnet pack $dotnetSolution --no-build --include-symbols @options "-p:SolutionName=$dotnetSolutionName" + Write-Build Yellow "dotnet pack $dotnetSolution --no-build --include-symbols $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" + dotnet pack $dotnetSolution --no-build --include-symbols @options } } diff --git a/tasks/DotNetPublish.Task.ps1 b/tasks/DotNetPublish.Task.ps1 index 2c93b4b..2119442 100644 --- a/tasks/DotNetPublish.Task.ps1 +++ b/tasks/DotNetPublish.Task.ps1 @@ -11,12 +11,13 @@ Add-BuildTask DotNetPublish @{ # that explicitly set IsPublishable=true should be published $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue if ($Content -imatch '\s*true\s*') { - $DllPath = Join-Path $script:OutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + $DllPath = Join-Path $script:dotnetOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" if (Test-Path $DllPath) { $DllPath } } } } Outputs = { + # TODO: This is rerunning every time. Need to figure out why. $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } foreach ($Proj in $Projects) { $ProjectName = Split-Path $Proj -LeafBase @@ -29,10 +30,7 @@ Add-BuildTask DotNetPublish @{ if ($ExistingPublish) { $ExistingPublish.FullName - } else { - # Return a placeholder path so Outputs is not empty (file doesn't exist yet, so task will run) - $PublishedDll - } + } else { $BuildRoot } } } } @@ -62,8 +60,8 @@ Add-BuildTask DotNetPublish @{ } Set-Location (Split-Path $dotnetSolution) - Write-Build Gray "dotnet publish $dotnetSolution --no-build --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$dotNetSolutionName" - dotnet publish $dotnetSolution --no-build --no-restore @options "-p:SolutionName=$dotNetSolutionName" + Write-Build Yellow "dotnet publish $dotnetSolution --no-build --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" + dotnet publish $dotnetSolution --no-build --no-restore @options },{ if ($script:ProjectsToIgnore.Count -gt 0) { dotnet sln $dotnetSolution add $script:ProjectsToIgnore diff --git a/tasks/DotNetPush.Task.ps1 b/tasks/DotNetPush.Task.ps1 index cf93bae..b5033db 100644 --- a/tasks/DotNetPush.Task.ps1 +++ b/tasks/DotNetPush.Task.ps1 @@ -5,10 +5,10 @@ Add-BuildTask DotNetPush @{ $Package = Get-ChildItem $script:DotNetPackRoot -Recurse -Filter "*.nupkg" if ($BuildSystem -ne 'None' -and - $BranchName -in "master", "main" -or $BranchName -match "\brelease\b" -and + $BranchName -in "master", "main" -or $BranchName -like "release*" -or $BranchName -like "hotfix*" -and -not [string]::IsNullOrWhiteSpace($NuGetPublishKey)) { foreach ($nupkg in $Package) { - Write-Build Gray "dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri" + Write-Build Yellow "dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri" dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri } } else { diff --git a/tasks/DotNetRestore.Task.ps1 b/tasks/DotNetRestore.Task.ps1 index b959947..fb1e80e 100644 --- a/tasks/DotNetRestore.Task.ps1 +++ b/tasks/DotNetRestore.Task.ps1 @@ -12,23 +12,17 @@ Add-BuildTask DotNetRestore @{ # Return corresponding project.assets.json files foreach ($Proj in $Projects) { $ProjectName = [IO.Path]::GetFileNameWithoutExtension($Proj) - Join-Path $script:OutputPath "obj/$ProjectName/project.assets.json" + Join-Path $script:dotnetOutputPath "obj/$ProjectName/project.assets.json" } } Jobs = "DotNetToolRestore", { $local:options = @{} + $script:dotnetOptions - if (Test-Path "$BuildRoot/NuGet.config") { - $options["-configfile"] = "$BuildRoot/NuGet.config" - } - - # Pass SolutionName so Directory.Build.props can calculate correct paths (I think?) - $SolutionName = if ($dotnetSolution -match '\.sln') { - Split-Path $dotnetSolution -LeafBase - } else { - "shared" + $NugetConfig = Get-ChildItem $BuildRoot -File | Where-Object { $_.Name -ieq "NuGet.config" } + if ($NugetConfig) { + $options["-configfile"] = "$BuildRoot/$($NugetConfig.Name)" } - Write-Build Gray "dotnet restore $dotnetSolution $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$SolutionName" - dotnet restore $dotnetSolution @options "-p:SolutionName=$SolutionName" + Write-Build Yellow "dotnet restore $dotnetSolution $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" + dotnet restore $dotnetSolution @options } -} \ No newline at end of file +} diff --git a/tasks/DotNetTest.Task.ps1 b/tasks/DotNetTest.Task.ps1 index 67751ec..06cce4b 100644 --- a/tasks/DotNetTest.Task.ps1 +++ b/tasks/DotNetTest.Task.ps1 @@ -19,10 +19,7 @@ Add-BuildTask DotNetTest @{ if ($TrxFiles) { $TrxFiles | Select-Object -ExpandProperty FullName - } else { - # Return a placeholder path so Outputs is not empty (file doesn't exist yet, so task will run) - Join-Path $TestResultsRoot "test-results.trx" - } + } else { $BuildRoot } } Jobs = "DotNetBuild", { @@ -40,12 +37,11 @@ Add-BuildTask DotNetTest @{ } $Command += " -p:SolutionName=$dotnetSolutionName" $Name = (Split-Path $dotnetSolution -LeafBase).ToLower() - Write-Build Gray "dotnet coverage collect '$Command' --output '$TestResultsRoot/coverage/$Name.xml' --output-format xml" + Write-Build Yellow "dotnet coverage collect '$Command' --output '$TestResultsRoot/coverage/$Name.xml' --output-format xml" dotnet coverage collect $Command --output "$TestResultsRoot/coverage/$Name.xml" --output-format xml } else { - Write-Build Gray "dotnet test $dotnetSolution --no-build $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ') -p:SolutionName=$dotnetSolutionName" - dotnet test $dotnetSolution --no-build @options "-p:SolutionName=$dotnetSolutionName" + Write-Build Yellow "dotnet test $dotnetSolution --no-build $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" + dotnet test $dotnetSolution --no-build @options } - - }, "SonarQubeEnd", "DotNetTrx2JUnit" + }, "DotNetTrx2JUnit" } diff --git a/tasks/DotNetToolRestore.Task.ps1 b/tasks/DotNetToolRestore.Task.ps1 index ea84e8b..a1e88bb 100644 --- a/tasks/DotNetToolRestore.Task.ps1 +++ b/tasks/DotNetToolRestore.Task.ps1 @@ -5,7 +5,8 @@ Add-BuildTask DotNetToolRestore @{ Join-Path $BuildRoot dotnet-tools.json ) | Resolve-Path -ErrorAction Ignore | Select-Object -First 1 if (-not $DotNetToolManifest) { - Copy-Item "$PSScriptRoot/../dotnet-tools.json" "$BuildRoot/.config/dotnet-tools.json" -Force + New-Item -ItemType Directory -Path "$BuildRoot/.config" -Force -ErrorAction Ignore + Copy-Item "$PSScriptRoot/../dotnet-tools.json" "$BuildRoot/.config/dotnet-tools.json" -Force } dotnet tool restore } diff --git a/tasks/DotNetTrx2JUnit.Task.ps1 b/tasks/DotNetTrx2JUnit.Task.ps1 index 2881633..cc93a2f 100644 --- a/tasks/DotNetTrx2JUnit.Task.ps1 +++ b/tasks/DotNetTrx2JUnit.Task.ps1 @@ -1,5 +1,9 @@ Add-BuildTask DotNetTrx2JUnit @{ - If = (dotnet tool list trx2junit --format json | ConvertFrom-Json).data + If = if ($script:TargetFramework -eq "net8.0") { + dotnet tool list trx2junit | Select-Object -Skip 2 + } else { + (dotnet tool list trx2junit --format json | ConvertFrom-Json).data + } Partial = $true Input = { Get-ChildItem $TestResultsRoot/*.trx @@ -10,8 +14,8 @@ Add-BuildTask DotNetTrx2JUnit @{ } } Jobs = { - process { - dotnet trx2junit $_ - } + Get-ChildItem $TestResultsRoot/*.trx | ForEach-Object -ThrottleLimit ([Environment]::ProcessorCount - 1) -Parallel { + dotnet trx2junit $_ | Select-String -Pattern "Converting\s'" + } } } \ No newline at end of file diff --git a/tasks/GetVersion.Task.ps1 b/tasks/GetVersion.Task.ps1 index b14feed..0b482e3 100644 --- a/tasks/GetVersion.Task.ps1 +++ b/tasks/GetVersion.Task.ps1 @@ -15,7 +15,7 @@ Add-BuildTask GetVersion @{ # we can skip (return $false) return (${script:Version}.Sha -ne $head) } - Jobs = "InstallGitVersion", "GitInit", { + Jobs = "GitInit", "DotNetToolRestore", { # Support a config file in the repo (BuildRoot) to override the one in here (PSScriptRoot) [string]$VersionConfig = Resolve-Path "$BuildRoot/GitVersion.y*ml", "$PSScriptRoot/GitVersion.y*ml" -ErrorAction Ignore | Select-Object -First 1 diff --git a/tasks/HelmInstall.Task.ps1 b/tasks/HelmInstall.Task.ps1 new file mode 100644 index 0000000..980be09 --- /dev/null +++ b/tasks/HelmInstall.Task.ps1 @@ -0,0 +1,27 @@ +Add-BuildTask HelmInstall @{ + If = ($script:ChartName -and $script:BuildSystem -ne "None") + Jobs = { + # Install Helm binary if not available (pipeline/Linux) + if (-not (Get-Command helm -ErrorAction SilentlyContinue)) { + if ($IsLinux) { + $HelmVersion = "v4.1.0" + Write-Build Yellow "Invoke-WebRequest -Uri https://get.helm.sh/helm-${HelmVersion}-linux-amd64.tar.gz -OutFile $TarFile" + $TarFile = Join-Path $script:TempDirectory "helm.tar.gz" + Invoke-WebRequest -Uri "https://get.helm.sh/helm-${HelmVersion}-linux-amd64.tar.gz" -OutFile $TarFile + tar -zxvf $TarFile -C $script:TempDirectory + Move-Item (Join-Path $script:TempDirectory "linux-amd64/helm") "/usr/local/bin/helm" -Force + Remove-Item $TarFile -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $script:TempDirectory "linux-amd64") -Recurse -Force -ErrorAction SilentlyContinue + } else { + throw "Helm is not installed. Please install Helm: https://helm.sh/docs/intro/install/" + } + } + Write-Build Gray "Helm version: $(helm version --short)" + + # Install helm-schema plugin if not already installed + if ('schema' -notin (helm plugin list | ForEach-Object { ($_ -split '\t')[0] })) { + Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --verify=false" + helm plugin install https://github.com/dadav/helm-schema --verify=false + } + } +} diff --git a/tasks/HelmPackChart.Task.ps1 b/tasks/HelmPackChart.Task.ps1 new file mode 100644 index 0000000..8d4e7b4 --- /dev/null +++ b/tasks/HelmPackChart.Task.ps1 @@ -0,0 +1,22 @@ +Add-BuildTask HelmPackChart @{ + If = ($script:ChartName) + Inputs = { Get-ChildItem $script:HelmCharts -File -Recurse } + Outputs = { + foreach ($Chart in $script:HelmCharts) { + Join-Path $Chart.FullName "$($Chart.Name)-$($script:Version.SemVer).tgz" + } + } + Jobs = "GetVersion", { + foreach ($Chart in $script:HelmCharts) { + $Destination = Join-Path $script:helmOutputPath $Chart.Name + New-Item $Destination -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + $options = @( + "--destination", $Destination, + "--version", $script:Version.SemVer, + "--app-version", $script:Version.SemVer + ) + Write-Build Yellow "helm package $($Chart.FullName) $($options -join ' ')" + helm package $Chart.FullName @options + } + } +} diff --git a/tasks/HelmPushChart.Task.ps1 b/tasks/HelmPushChart.Task.ps1 new file mode 100644 index 0000000..b21781c --- /dev/null +++ b/tasks/HelmPushChart.Task.ps1 @@ -0,0 +1,22 @@ +Add-BuildTask HelmPushChart @{ + If = ($script:HelmCharts) + Jobs = "HelmPackChart", "ConnectAzACR", { + if ($BuildSystem -ne 'None' -and + $BranchName -in "master", "main" -or + $BranchName -like "release*" -or + $BranchName -like "hotfix*") { + + foreach ($Chart in $script:HelmCharts) { + # If this sort turns out to not be enough, we need to split the name and cast to [semver] to sort + $ChartToPush = Get-ChildItem (Join-Path $script:helmOutputPath $Chart.Name) -Filter *.tgz | Sort-Object LastWriteTime | Select-Object -Last 1 + Write-Build Yellow "helm push $($ChartToPush.FullName) oci://$($script:ACRName).azurecr.io/helm" + Invoke-Native { helm push $ChartToPush.FullName "oci://$($script:ACRName).azurecr.io/helm" } + } + + } else { + Write-Warning ("Skipping push: To push charts ensure that...`n" + + "`t* You are in a known build system (Current: $BuildSystem)`n" + + "`t* You are committing to the main or release or hotfix branch (Current: $BranchName) `n") + } + } +} diff --git a/tasks/HelmTestChart.Task.ps1 b/tasks/HelmTestChart.Task.ps1 new file mode 100644 index 0000000..9621f01 --- /dev/null +++ b/tasks/HelmTestChart.Task.ps1 @@ -0,0 +1,40 @@ +Add-BuildTask HelmTestChart @{ + If = ($script:HelmCharts) + Inputs = { Get-ChildItem $script:HelmCharts -File -Recurse } + Outputs = { + foreach ($chart in $script:HelmCharts) { + Join-Path $script:helmOutputPath "$($Chart.Name)-compiled.yaml" + } + } + Jobs = { + # helm lint requires the chart directory, not the chart.yaml file + Set-Location $script:HelmChartRoot + New-Item $script:helmOutputPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + # each $chart is a directory object + foreach ($chart in $script:HelmCharts) { + $TestValues = Join-Path $chart values.yaml + $CompiledOutput = Join-Path $script:helmOutputPath "$($Chart.Name)-compiled.yaml" + + Write-Build Yellow "helm lint $($Chart.FullName) --values $TestValues" + Invoke-Native { helm lint $chart.FullName --values $TestValues } + if ($LASTEXITCODE -ne 0) { + throw "Linting failed for $($chart)" + } + + Write-Build Yellow "helm template $($chart.FullName) --values $TestValues --generate-name" + $global:ErrorView = "ConciseView" + Invoke-Native { helm template $chart.FullName--values $TestValues --generate-name + } > $CompiledOutput + + # Shouldn't this be taken care of elsewhere as a pre-requisite? + if (-not (Get-Command kubeconform -ErrorAction SilentlyContinue)) { + Write-Build Yellow "kubeconform not found, attempting installation..." + &(Join-Path $script:BuildTaskScriptsDirectory "Install-GithubRelease.ps1") -Org "yannh" -Repo "kubeconform" -Verbose -ErrorAction SilentlyContinue + } + Write-Build Yellow "kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput" + Invoke-Native { + kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput + } + } + } +} diff --git a/tasks/HelmUpdateValuesSchema.Task.ps1 b/tasks/HelmUpdateValuesSchema.Task.ps1 new file mode 100644 index 0000000..d85816f --- /dev/null +++ b/tasks/HelmUpdateValuesSchema.Task.ps1 @@ -0,0 +1,16 @@ +Add-BuildTask HelmUpdateValuesSchema @{ + if = ($script:HelmCharts) + # TODO: This desparately needs working inputs/outputs + jobs = "HelmInstall", "InstallGithubTools", "ConnectAzACR", { + foreach ($Chart in $script:HelmCharts) { + Set-Location $Chart + Write-Build Yellow "helm dependency update $Chart" + # If the developers have not already done so + # This may create a `charts` subdirectory with the dependencies i.e. devops-library + # In general, we don't care if they commit those, but we need them for the schemas to be complete + Invoke-Native { helm dependency update . --skip-refresh } + Write-Build Yellow "helm schema" + Invoke-Native { helm schema } + } + } +} diff --git a/tasks/InstallGitHubTools.Task.ps1 b/tasks/InstallGitHubTools.Task.ps1 index c214a4f..b6269bf 100644 --- a/tasks/InstallGitHubTools.Task.ps1 +++ b/tasks/InstallGitHubTools.Task.ps1 @@ -1,7 +1,12 @@ # TODO: in pipeline environments, we should trigger the "cache" task for these to speed up using them Add-BuildTask InstallGitHubTools @{ - If = $script:GHTools.Count -gt 0 + If = $script:GHTools.keys.Count -gt 0 Jobs = { - &(Join-Path (Split-Path $PSScriptRoot) "scripts/Install-GitHubRelease.ps1") $script:GHTools + foreach ($tool in $script:GHTools.keys) { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { + Write-Build Gray "Installing $tool..." + $script:GHTools[$tool] | &(Join-Path (Split-Path $PSScriptRoot) "scripts/Install-GithubRelease.ps1") -ErrorAction SilentlyContinue + } + } } } diff --git a/tasks/InstallGitVersion.Task.ps1 b/tasks/InstallGitVersion.Task.ps1 deleted file mode 100644 index a529e4d..0000000 --- a/tasks/InstallGitVersion.Task.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -# This exist for repos that don't have a dotnettools.json file -Add-BuildTask InstallGitVersion @{ - Jobs = "DotNetToolRestore", { - # If there's no local gitversion tool, always update|install the global one - if (!((dotnet tool list GitVersion.Tool --format json | ConvertFrom-Json).data)) { - $ENV:PATH += ([IO.Path]::PathSeparator) + (Convert-Path ~/.dotnet/tools) - dotnet tool update GitVersion.Tool --global --version 6.* --verbosity diagnostic - } - } -} \ No newline at end of file diff --git a/tasks/InstallRequiredModules.Task.ps1 b/tasks/InstallRequiredModules.Task.ps1 index 1b27b02..875d9d8 100644 --- a/tasks/InstallRequiredModules.Task.ps1 +++ b/tasks/InstallRequiredModules.Task.ps1 @@ -1,7 +1,7 @@ Add-BuildTask InstallRequiredModules @{ If = Test-Path $BuildRoot/RequiredModules.psd1 Inputs = "$BuildRoot/RequiredModules.psd1" - Outputs = "$Output/RequiredModules.psd1" + Outputs = "$OutputPath/RequiredModules.psd1" Jobs = (Get-Command "$PSScriptRoot/../scripts/Install-RequiredModule.ps1").ScriptBlock, - { Copy-Item "$BuildRoot/RequiredModules.psd1" -Destination "$Output/RequiredModules.psd1" } + { Copy-Item "$BuildRoot/RequiredModules.psd1" -Destination "$OutputPath/RequiredModules.psd1" } } diff --git a/tasks/UniversalPackagePack.Task.ps1 b/tasks/UniversalPackagePack.Task.ps1 index 632b843..4c8bfd7 100644 --- a/tasks/UniversalPackagePack.Task.ps1 +++ b/tasks/UniversalPackagePack.Task.ps1 @@ -17,25 +17,28 @@ Add-BuildTask UniversalPackagePack @{ } } } - outputs = { Get-ChildItem $script:UniversalPacakgeRoot/*.upack -ErrorAction Ignore || Join-Path $script:UniversalPacakgeRoot "dummy.upack"} + outputs = { + if (($ExistingPack = Get-ChildItem $script:UniversalPackageRoot/*.upack -ErrorAction Ignore) -ne $null) { + $ExistingPack + } else { + $BuildRoot + } + } # Requires dotnetpublish but future state this task should be able to publish any library (python, npm, whatever). These tasks were just initially written for dotnet projects jobs = 'DotNetPublish', { $VersionInfo = Get-Content (Join-Path $script:OutputPath version.json) | ConvertFrom-Json - $script:UniversalPacakgeRoot = New-Item $script:UniversalPacakgeRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path + $script:UniversalPackageRoot = New-Item $script:UniversalPackageRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path Get-ChildItem $script:DotNetPublishRoot -Directory | ForEach-Object { $Solution = $_ - $local:options = @{ - "-source-directory" = $Solution.FullName - "-name" = $Solution.Name - "-version" = $VersionInfo.InformationalVersion - "-target-directory" = $script:UniversalPacakgeRoot - } - Write-Build Gray "dotnet pgutil upack create $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" + $local:options = @( + "--source-directory=$($Solution.FullName)" + "--name=$($Solution.Name)" + "--version=$($VersionInfo.Semver)" + "--target-directory=$($script:UniversalPackageRoot)" + ) + Write-Build Yellow "dotnet pgutil upack create $($Options -join ' ')" dotnet pgutil upack create @options } # pgutil packages upload --feed=build-output --input-file=..\DevOpsScripts-Upack-Demo-0.0.0-rc.1+sha.df5b663.260206.upack --source=https://nuget.loandepot.com --api-key=04f1ab532b9397408b349e83420c762ac42eb98d } } - -# Project URL -> Repo url -# Are the audit properties being set by the pgutil tool (e.g. CreatedDate, CreatedBy) \ No newline at end of file diff --git a/tasks/_Initialize.ps1 b/tasks/_Initialize.ps1 index 62a2975..c0524f2 100644 --- a/tasks/_Initialize.ps1 +++ b/tasks/_Initialize.ps1 @@ -6,7 +6,8 @@ $script:InformationPreference = "Continue" $script:ErrorActionPreference = "Stop" $PSStyle.OutputRendering = "ANSI" # We're going to treat "DIAGNOSTIC" as "VERBOSE" + "DEBUG" and hope it rarely happens! ;) -if ($ENV:AGENT_DIAGNOSTIC -eq 'True' -or $ENV:SYSTEM_DEBUG -eq 'True') { # TODO: What is the harness equivalent for the env var +if ($ENV:AGENT_DIAGNOSTIC -eq 'True' -or $ENV:SYSTEM_DEBUG -eq 'True') { + # TODO: What is the harness equivalent for the env var $script:VerbosePreference = "Continue" $script:DebugPreference = "Continue" @@ -30,6 +31,8 @@ $Script:RequiredCodeCoverage ??= 0.9 # TODO: Only used by invoke-pester wrapper, $script:Configuration ??= "Release" Write-Verbose " Configuration: $script:Configuration" -Verbose +$script:BuildTasksDirectory = $PSScriptRoot +$script:BuildTaskScriptsDirectory = Join-Path (Split-Path $PSScriptRoot) "scripts" # NOTE: this variable is currently also used for Pester formatting ... # We should use either "Harness", "AzureDevOps", "GithubActions", or "None" $script:BuildSystem = if (Test-Path Env:HARNESS_STAGE_ID) { @@ -58,10 +61,10 @@ $script:BranchName = if ($Env:BUILD_SOURCEBRANCHNAME) { } # In PR Builds you have a SourceBranch and a TargetBranch -[bool]$script:IsPullRequest = $script:IsPullRequest ?? $Env:BUILD_REASON -eq "PullRequest" -[long]$script:PullRequestId = $script:PullRequestId ?? $Env:SYSTEM_PULLREQUEST_PULLREQUESTID -[string]$script:SourceBranch = $script:SourceBranch ?? $Env:SYSTEM_PULLREQUEST_SOURCEBRANCH ?? $Env:BUILD_SOURCEBRANCH ?? $script:BranchName -[string]$script:TargetBranch = $script:TargetBranch ?? $ENV:SYSTEM_PULLREQUEST_TARGETBRANCH ?? $script:MainBranch +[bool]$script:IsPullRequest = $script:IsPullRequest ?? ($Env:BUILD_REASON -eq "PullRequest" -or $Env:DRONE_BUILD_EVENT -eq "pull_request") +[long]$script:PullRequestId = $script:PullRequestId ?? $Env:SYSTEM_PULLREQUEST_PULLREQUESTID ?? $Env:DRONE_PULL_REQUEST +[string]$script:SourceBranch = $script:SourceBranch ?? $Env:SYSTEM_PULLREQUEST_SOURCEBRANCH ?? $Env:BUILD_SOURCEBRANCH ?? $Env:DRONE_SOURCE_BRANCH ?? $Env:CI_COMMIT_BRANCH ?? $script:BranchName +[string]$script:TargetBranch = $script:TargetBranch ?? $ENV:SYSTEM_PULLREQUEST_TARGETBRANCH ?? $Env:DRONE_TARGET_BRANCH ?? $script:MainBranch # These SonarQube variables are settable from script or environment [string]$script:SonarProjectKey = $script:SonarProjectKey ?? $Env:SONARQUBE_PROJECTKEY ?? $Env:SONAR_PROJECTKEY [string]$script:SonarToken = $script:SonarToken ?? $Env:SONARQUBE_PAT ?? $Env:SONAR_TOKEN @@ -69,9 +72,9 @@ $script:BranchName = if ($Env:BUILD_SOURCEBRANCHNAME) { # If the SonarProjectKey is set, we have to collect coverage [switch]$script:CollectCoverage = $script:CollectCoverage -or $script:SonarProjectKey -[string]$script:ProductName = $script:ProductName ?? $Env:PRODUCT_NAME ?? $Env:PIPELINE_NAME ?? $script:SonarProjectKey -[string]$script:PipelineId = $script:PipelineId ?? $Env:PIPELINE_ID ?? "local build" -[string]$script:PipelineExecutionId = $script:PipelineExecutionId ?? $Env:PIPELINE_EXECUTION_ID ?? $Env:BUILD_ID ?? "0" +[string]$script:ProductName = $script:ProductName ?? $Env:PRODUCT_NAME ?? $Env:PIPELINE_NAME ?? $Env:DRONE_REPO_NAME ?? $Env:CI_REPO ?? $script:SonarProjectKey +[string]$script:PipelineId = $script:PipelineId ?? $Env:PIPELINE_ID ?? $Env:HARNESS_PIPELINE_ID ?? $Env:PLUGIN_PIPELINE ?? "local build" +[string]$script:PipelineExecutionId = $script:PipelineExecutionId ?? $Env:PIPELINE_EXECUTION_ID ?? $Env:BUILD_ID ?? $Env:HARNESS_EXECUTION_ID ?? $Env:HARNESS_BUILD_ID ?? $Env:DRONE_BUILD_NUMBER ?? "0" # A little extra BuildEnvironment magic Set-BuildHeader { Write-Build 11 "Start Task: $($args[0])" } @@ -89,7 +92,7 @@ Write-Verbose " BuildRoot [$BuildRoot]" -Verbose ### These other three are defined relative to $Env:PIPELINE_WORKSPACE # $Env:BUILD_SOURCESDIRECTORY - Cleaned BEFORE checkout IF: Workspace.Clean = All or Resources, or if Checkout.Clean = $True # Importantly, defaults to work/job/s BUT when there are multiple sources, can be work/job/s/sourcename -# $Env:LDBUILD_BINARIESDIRECTORY - Cleaned BEFORE build IF: Workspace.Clean = Outputs +# $Env:BUILD_BINARIESDIRECTORY - Cleaned BEFORE build IF: Workspace.Clean = Outputs # $Env:BUILD_STAGINGDIRECTORY - Cleaned after each Build ### Additionally, these two are cleaned after each Job: @@ -99,37 +102,28 @@ Write-Verbose " BuildRoot [$BuildRoot]" -Verbose # TODO: Should we recreate something similar to the ADO directories described above? e.g. /s, /a, etc # There are a few different environment/variables it could be, and then our fallback -# Include solution name in output path to organize artifacts by solution -$SolutionFolder = if ($dotnetSolution -match '\.sln$') { - Split-Path $dotnetSolution -LeafBase -} elseif ($Solution -and $Solution -ne "*") { - $Solution -} else { - "All" -} - $Script:OutputPath = if ($Env:BUILD_BINARIESDIRECTORY) { $Env:BUILD_BINARIESDIRECTORY } else { - Join-Path $BuildRoot 'Output' $SolutionFolder + Join-Path $BuildRoot 'Output' } -$Env:LDLDBUILD_BINARIESDIRECTORY = $script:OutputPath Write-Verbose " Output [$OutputPath]" -Verbose New-Item -Type Directory -Path $OutputPath -Force | Out-Null $Script:TestResultsRoot = $script:TestResultsRoot ?? - $Env:TEST_ROOT ?? # I set this for earthly - $Env:COMMON_TESTRESULTSDIRECTORY ?? # Azure - $Env:TEST_RESULTS_DIRECTORY ?? - $OutputPath # Because this is what we _have_ been using +$Env:TEST_ROOT ?? # I set this for earthly +$Env:COMMON_TESTRESULTSDIRECTORY ?? # Azure +$Env:TEST_RESULTS_DIRECTORY ?? +(Join-Path $OutputPath testresults) New-Item -Type Directory -Path $TestResultsRoot -Force | Out-Null Write-Verbose " TestResultsRoot: $TestResultsRoot" -Verbose $Script:TempDirectory = @(Get-Content Env:AGENT_TEMPDIRECTORY, Env:COMMON_TESTRESULTSDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | - Where-Object { Test-Path $_ } | - Select-Object -First 1 + Where-Object { Test-Path $_ } | + Select-Object -First 1 +if (-not $Script:TempDirectory) { $Script:TempDirectory = if ($IsLinux) { "/tmp" } else { [System.IO.Path]::GetTempPath() } } # If you need to install additional tools, we use Install-GitHubRelease # Set the Tools hashtable to @{ exe = "org", "project" } @@ -140,39 +134,42 @@ $Script:TempDirectory = @(Get-Content Env:AGENT_TEMPDIRECTORY, Env:COMMON_TESTRE # } [hashtable]$Script:GHTools = @{} + ($Script:GHTools ?? @{}) -$script:UniversalPacakgeRoot ??= Join-Path $script:OutputPath universal +$script:UniversalPackageRoot ??= Join-Path $script:OutputPath universal #region DotNet task variables. # When we have DotNet projects, we just need to set one of these variables: if ($dotnetSolution -or $DotNetPublishRoot) { Write-Information "Initializing DotNet build variables (dotnetSolution: $dotnetSolution, DotNetPublishRoot: $DotNetPublishRoot)" + + $script:dotnetSolution = $dotnetSolution + $script:dotnetSolutionName = Split-Path $dotnetSolution -LeafBase + Write-Verbose " Solution project: $dotnetSolution" -Verbose + $script:dotnetOutputPath = Join-Path $script:OutputPath $script:dotnetSolutionName + # This is used in Directory.build.props to configure the default output directory for dotnet restore and build (and publish?) + $Env:LDBUILD_BINARIESDIRECTORY = $script:OutputPath + Write-Verbose " dotnetOutputPath: $script:dotnetOutputPath" -Verbose + # The DotNetPublishRoot is the "publish" folder within the Output (used for dotnet publish output) - $script:DotNetPublishRoot ??= Join-Path $script:OutputPath publish - $script:DotNetPackRoot ??= Join-Path $script:OutputPath nuget - $script:UniversalPacakgeRoot ??= Join-Path $Script:OutputPath universal + $script:DotNetPublishRoot ??= Join-Path $script:dotnetOutputPath publish + $script:DotNetPackRoot ??= Join-Path $script:dotnetOutputPath nuget + $script:UniversalPackageRoot ??= Join-Path $script:dotnetOutputPath universal $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) $script:TargetFramework ??= $Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".") $script:TargetRuntime ??= $ENV:DOTNET_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64") $ENV:LDBUILD_TARGET_RUNTIME = $script:TargetRuntime Write-Verbose " DotNetPublishRoot: $DotNetPublishRoot" -Verbose + Write-Verbose " DotNetPackRoot: $DotNetPackRoot" -Verbose + Write-Verbose " UniversalPackageRoot: $UniversalPackageRoot" -Verbose - # Our projects are either: - # - Just the name - # - The full path to a csproj file - # We're going to normalize to the full csproj path - $script:dotnetSolution = $dotnetSolution - $script:dotnetSolutionName = Split-Path $dotnetSolution -LeafBase - Write-Verbose " Solution project: $dotnetSolution" -Verbose $script:dotnetProjects = @(dotnet sln $dotnetSolution list | Where-Object { $_ -like "*.*proj" }) Write-Verbose " DotNetProjects: $(($script:dotnetProjects).Count)" -Verbose - $script:dotnetTestProjects = @($script:dotnetProjects | Where-Object {$_ -like "*Test*.*proj"}) + $script:dotnetTestProjects = @($script:dotnetProjects | Where-Object { $_ -like "*Test*.*proj" }) Write-Verbose " DotNetTestProjects: $(($script:dotnetTestProjects).Count)" -Verbose $script:dotnetOptions ??= @{} - - # TODO: Add variable for universal package feed "built-output" + $script:NuGetPublishKey ??= $Env:NUGET_API_KEY - $script:NuGetPublishUri ??= $Env:NUGET_API_URI ?? "https://nuget.loandepot.com/nuget/LDTS/v3/index.json" + $script:NuGetPublishUri ??= $Env:NUGET_API_URI ?? "https://nuget.loandepot.com/nuget/LDTS/v3/index.json" Write-Verbose " NuGetPublishUri: $NuGetPublishUri" -Verbose $script:UPackPublishKey ??= $Env:UPACK_API_KEY $script:UPackPublishUri ??= $Env:UPACK_PUBLISH_URI ?? "https://nuget.loandepot.com" @@ -187,8 +184,21 @@ if ($dotnetSolution -or $DotNetPublishRoot) { } #endregion - - +#region Helm task variables +$HelmChartRoot ??= Join-Path $BuildRoot "charts" +if (Test-Path $HelmChartRoot) { + $script:HelmChartRoot = $HelmChartRoot + $script:ACRName = $script:ACRName ?? $ENV:ACR_URI ?? "crazusw2dvosl1" + $script:helmOutputPath = Join-Path $Script:OutputPath "charts" + Write-Verbose " HelmChartRoot: $script:HelmChartRoot" -Verbose + Write-Verbose " ACRName: $script:ACRName" -Verbose + Write-Verbose " helmOutputPath: $script:helmOutputPath" -Verbose + $script:ChartName ??= Get-ChildItem -Path $script:HelmChartRoot -File -Filter Chart.yaml -Recurse -Depth 1 | ForEach-Object { $_.Directory.Name } + $script:HelmCharts = $script:ChartName | Join-Path -Path $HelmChartRoot -ChildPath { $_ } | Get-Item + Write-Verbose " HelmCharts: $(($script:HelmCharts).Count)" -Verbose + $script:GHTools.add("kubeconform", "https://github.com/yannh/kubeconform/releases/tag/v0.7.0") +} +#endregion ## The first task defined is the default task. Put the right values for your project type here... Add-BuildTask CI @( @@ -227,8 +237,8 @@ Add-BuildTask Build @( Add-BuildTask Test @( # Depends on Build "DotNetTest" - ) - +) + Add-BuildTask Pack @( # Dependencies include build, restore and version -- but not test @@ -236,6 +246,10 @@ Add-BuildTask Pack @( ) # "DotNetPublish" is for websites, but needs testing +Add-BuildTask Publish @( + # Depends on Build + "DotNetPublish" +) # "DotNetPush" is only valid in CI # Allow a -Clean switch to add the "Clean" task on the front @@ -248,5 +262,3 @@ foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { Write-Verbose " $($taskfile.FullName)" . $taskfile.FullName } - - From bf4256e177128fe11f33ce60930d5729424adf17 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 18 Apr 2026 23:34:15 -0400 Subject: [PATCH 04/43] Add Extends documentation and ADO build pipeline --- docs/Extends.md | 176 +++++++++++++++++++++++++++++++++++++++++++++ pipeline/build.yml | 17 +++++ 2 files changed, 193 insertions(+) create mode 100644 docs/Extends.md create mode 100644 pipeline/build.yml diff --git a/docs/Extends.md b/docs/Extends.md new file mode 100644 index 0000000..9a41844 --- /dev/null +++ b/docs/Extends.md @@ -0,0 +1,176 @@ +# Build Script Inheritance with Invoke-Build `Extends` + +Invoke-Build (v5.11+) supports a special `$Extends` parameter that enables **build script inheritance**. Instead of copying `build.example.ps1` into every project, a derived script can extend the base and inherit its parameters, initialization, and task definitions. + +## How It Works + +### The `$Extends` Parameter + +A derived build script declares a `param` block with a single special parameter: + +```powershell +param( + [ValidateScript({"..\LD.Platform.BuildTasks\build.example.ps1"})] + $Extends +) +``` + +When Invoke-Build processes this script: + +1. It finds the `$Extends` parameter with a `ValidateScript` attribute +2. It **evaluates the script block** to get the path(s) to base script(s) +3. It resolves the path relative to the derived script's directory (`$BuildRoot`) +4. It **processes the base script first** — parameters, body, and task definitions +5. Then it processes the derived script's body + +The `ValidateScript` block is not used for validation here — Invoke-Build hijacks it to extract the base script path. The block should return one or more path strings. + +### What Gets Inherited + +| Inherited from base | How | +|---|---| +| **Parameters** | The base script's `param()` block becomes shared. The derived script gets `-Configuration`, `-Clean`, `-Solution`, etc. without redeclaring them. | +| **Task definitions** | Aggregate tasks like `CI`, `Build`, `Test`, `Pack`, `Publish` carry over automatically. | +| **Script body code** | Initialization, variable setup, and task imports all execute during base processing. | + +### What the Derived Script Controls + +- **Override tasks** — `Add-BuildTask` with the same name as a base task **replaces** it (the old definition is moved to a "Redefined" list). +- **Add new tasks** — Define project-specific tasks not in the base. +- **Re-initialize variables** — Fix path-dependent variables that were set with the base's `$BuildRoot` (see below). + +## The `$BuildRoot` Problem + +This is the most important caveat when using Extends with `build.example.ps1`. + +When Invoke-Build processes the base script via Extends, **`$BuildRoot` is set to the base script's directory**, not the derived project's directory. This means all initialization code in `_Initialize.ps1` runs with wrong paths: + +``` +# During Extends (base script processing): +$BuildRoot = C:\XDL\LD.Platform.BuildTasks # <- base script's dir + +# During derived script body: +$BuildRoot = C:\XDL\LD.Shared.MyProject # <- correct +``` + +Variables set during base initialization that depend on `$BuildRoot` will have stale values: +- `$OutputPath` — uses direct assignment, gets overwritten on re-init +- `$HelmChartRoot` — uses `??=`, does NOT get overwritten +- `$TestResultsRoot` — uses `??` chain, does NOT get overwritten +- `$UniversalPacakgeRoot` — uses `??=`, does NOT get overwritten + +### The Fix: Reset and Re-initialize + +The derived script must: + +1. **Reset variables** that use `??=` (null-coalescing assignment) so `_Initialize.ps1` can set them correctly +2. **Re-run `_Initialize.ps1`** with the correct `$BuildRoot` + +```powershell +# Reset variables that won't self-correct due to ??= operators +Remove-Variable HelmChartRoot -ErrorAction Ignore +$script:TestResultsRoot = $null +$script:UniversalPacakgeRoot = $null + +foreach($taskDir in $Tasks) { + $initialize = Join-Path $taskDir "_Initialize.ps1" + if (Test-Path $initialize) { + . $initialize + } +} +``` + +> **Why `Remove-Variable` for `$HelmChartRoot`?** +> The base script's param block declares `[string]$HelmChartRoot`. The `[string]` type constraint converts `$null` to `""` (empty string). Since `""` is not `$null`, the `??=` operator in `_Initialize.ps1` won't overwrite it. `Remove-Variable` fully removes the typed variable so `??=` works on the next run. + +Re-running `_Initialize.ps1` also **re-imports all `.Task.ps1` files**. Since `Add-BuildTask` replaces existing tasks with the same name, the re-imported tasks get the correct `$BuildRoot` context (stored in the task's `B1` property). + +## Complete Example + +### Base script: `build.example.ps1` + +Located in `LD.Platform.BuildTasks`. Contains shared parameters, initialization, bootstrapping, and aggregate task definitions (CI, Build, Test, etc.). + +### Derived script: `build.build.ps1` + +Located in the consuming project (e.g., `LD.Shared.EnterprisePlatformServices.API`): + +```powershell +<# +.SYNOPSIS + ./build.build.ps1 +.EXAMPLE + Invoke-Build +#> +[CmdletBinding()] +param( + [ValidateScript({"..\LD.Platform.BuildTasks\build.example.ps1"})] + $Extends +) + +$Tasks = "../LD.Platform.BuildTasks/tasks", "../BuildTasks/tasks", "../tasks/tasks", "tasks" | + Convert-Path -ErrorAction Ignore + +## Self-contained: can be invoked directly or via Invoke-Build +if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { + foreach ($taskDir in $Tasks) { + $bootstrap = Join-Path $taskDir "_BootStrap.ps1" + if (Test-Path $bootstrap) { . $bootstrap } + } + Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result + if ($Result.Error) { + $Error[-1].ScriptStackTrace | Out-Host + exit 1 + } + exit 0 +} + +## Re-initialize with this project's $BuildRoot +Remove-Variable HelmChartRoot -ErrorAction Ignore +$script:TestResultsRoot = $null +$script:UniversalPacakgeRoot = $null +foreach($taskDir in $Tasks) { + $initialize = Join-Path $taskDir "_Initialize.ps1" + if (Test-Path $initialize) { . $initialize } +} + +## Project-specific aggregate tasks +Add-BuildTask HelmBuild InstallRequiredModules, GetVersion, HelmUpdateValuesSchema +Add-BuildTask HelmTest HelmBuild, HelmTestChart +Add-BuildTask HelmPack HelmTest, HelmPackChart +Add-BuildTask HelmPush HelmPack, HelmPushChart +``` + +## Multiple Inheritance and Prefixing + +Invoke-Build also supports extending multiple scripts and renaming inherited tasks with a prefix using `::` syntax: + +```powershell +param( + [ValidateScript({ + "MyPrefix::..\Base1\build.ps1" + "..\Base2\build.ps1" + })] + $Extends +) +``` + +Tasks from `Base1` would be prefixed (e.g., `MyPrefix::Build`), while tasks from `Base2` keep their original names. Tasks named `.` (the default task) are never renamed. + +## Quick Reference + +| Concept | Detail | +|---|---| +| **Minimum Invoke-Build version** | 5.11.0 | +| **Parameter name** | Must be `$Extends` | +| **Path resolution** | Relative to the derived script's directory | +| **Task redefinition** | `Add-BuildTask` with same name replaces the base's version | +| **`$BuildRoot` during Extends** | Set to the **base** script's directory | +| **`$BuildRoot` in derived body** | Set to the **derived** script's directory | +| **Variables using `??=`** | Must be reset before re-initialization | +| **`[string]` typed params** | Use `Remove-Variable` instead of `= $null` to reset | + +## Further Reading + +- [Invoke-Build Extends documentation](https://github.com/nightroman/Invoke-Build/tree/main/Tasks/Extends) +- [Invoke-Build wiki](https://github.com/nightroman/Invoke-Build/wiki) diff --git a/pipeline/build.yml b/pipeline/build.yml new file mode 100644 index 0000000..be20728 --- /dev/null +++ b/pipeline/build.yml @@ -0,0 +1,17 @@ +# Docs: https://aka.ms/yaml +name: $(GitVersion.SemVer) +pool: LDLinux + +trigger: +- main +pr: +- main + +jobs: + - job: build + displayName: Build starters + steps: + - checkout: self + persistCredentials: "true" # required for pushing tags + fetchDepth: "0" # required for gitversion + From e99bd04d1f5afc5d20b3554fbeb4a249c39f6a03 Mon Sep 17 00:00:00 2001 From: Alex Curley Date: Mon, 23 Mar 2026 17:44:44 -0600 Subject: [PATCH 05/43] Use invoke-native to make helm fail when it has errors Make PushEnabled a settable option (#7) Setting can be added to build.build.ps1 to allow "pushing" (containers and helm charts, etc.) from feature branches. semver:feature Add condition for helm version ne v4* (#9) --- tasks/DotNetPush.Task.ps1 | 6 ++---- tasks/HelmInstall.Task.ps1 | 14 +++++++++++--- tasks/HelmPackChart.Task.ps1 | 2 +- tasks/HelmPushChart.Task.ps1 | 9 ++------- tasks/HelmTestChart.Task.ps1 | 12 ++++-------- tasks/HelmUpdateValuesSchema.Task.ps1 | 4 ++-- tasks/_Initialize.ps1 | 6 +++--- 7 files changed, 25 insertions(+), 28 deletions(-) diff --git a/tasks/DotNetPush.Task.ps1 b/tasks/DotNetPush.Task.ps1 index b5033db..46d624d 100644 --- a/tasks/DotNetPush.Task.ps1 +++ b/tasks/DotNetPush.Task.ps1 @@ -4,9 +4,7 @@ Add-BuildTask DotNetPush @{ Jobs = "DotNetPack", { $Package = Get-ChildItem $script:DotNetPackRoot -Recurse -Filter "*.nupkg" - if ($BuildSystem -ne 'None' -and - $BranchName -in "master", "main" -or $BranchName -like "release*" -or $BranchName -like "hotfix*" -and - -not [string]::IsNullOrWhiteSpace($NuGetPublishKey)) { + if ($script:PushEnabled -and "$NuGetPublishKey") { foreach ($nupkg in $Package) { Write-Build Yellow "dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri" dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri @@ -15,7 +13,7 @@ Add-BuildTask DotNetPush @{ Write-Warning ("Skipping push: To push $Package ensure that...`n" + "`t* You are in a known build system (Current: $BuildSystem)`n" + "`t* You are committing to the main branch (Current: $BranchName) `n" + - "`t* The repository APIKey is defined in `$NuGetPublishKey (Current: $(![string]::IsNullOrWhiteSpace($NuGetPublishKey)))") + "`t* The repository APIKey is defined in `$NuGetPublishKey (Current: $(!!"$NuGetPublishKey"))") } } } diff --git a/tasks/HelmInstall.Task.ps1 b/tasks/HelmInstall.Task.ps1 index 980be09..6788b0c 100644 --- a/tasks/HelmInstall.Task.ps1 +++ b/tasks/HelmInstall.Task.ps1 @@ -1,3 +1,4 @@ +# TODO: This task needs to install helm on linux and windows, in CI or in local Add-BuildTask HelmInstall @{ If = ($script:ChartName -and $script:BuildSystem -ne "None") Jobs = { @@ -16,12 +17,19 @@ Add-BuildTask HelmInstall @{ throw "Helm is not installed. Please install Helm: https://helm.sh/docs/intro/install/" } } - Write-Build Gray "Helm version: $(helm version --short)" + $HelmVersionShort = helm version --short + Write-Build Gray "Helm version: $HelmVersionShort" # Install helm-schema plugin if not already installed + # TODO: This will be a PITA for windows if ('schema' -notin (helm plugin list | ForEach-Object { ($_ -split '\t')[0] })) { - Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --verify=false" - helm plugin install https://github.com/dadav/helm-schema --verify=false + if ($HelmVersionShort -ilike "v4*") { + Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --verify=false" + helm plugin install https://github.com/dadav/helm-schema --verify=false + } else { + Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema" + helm plugin install https://github.com/dadav/helm-schema + } } } } diff --git a/tasks/HelmPackChart.Task.ps1 b/tasks/HelmPackChart.Task.ps1 index 8d4e7b4..e9acb20 100644 --- a/tasks/HelmPackChart.Task.ps1 +++ b/tasks/HelmPackChart.Task.ps1 @@ -16,7 +16,7 @@ Add-BuildTask HelmPackChart @{ "--app-version", $script:Version.SemVer ) Write-Build Yellow "helm package $($Chart.FullName) $($options -join ' ')" - helm package $Chart.FullName @options + Invoke-Native { helm package $Chart.FullName @options } -ExceptionalExit } } } diff --git a/tasks/HelmPushChart.Task.ps1 b/tasks/HelmPushChart.Task.ps1 index b21781c..bf97fa5 100644 --- a/tasks/HelmPushChart.Task.ps1 +++ b/tasks/HelmPushChart.Task.ps1 @@ -1,18 +1,13 @@ Add-BuildTask HelmPushChart @{ If = ($script:HelmCharts) Jobs = "HelmPackChart", "ConnectAzACR", { - if ($BuildSystem -ne 'None' -and - $BranchName -in "master", "main" -or - $BranchName -like "release*" -or - $BranchName -like "hotfix*") { - + if ($script:PushEnabled) { foreach ($Chart in $script:HelmCharts) { # If this sort turns out to not be enough, we need to split the name and cast to [semver] to sort $ChartToPush = Get-ChildItem (Join-Path $script:helmOutputPath $Chart.Name) -Filter *.tgz | Sort-Object LastWriteTime | Select-Object -Last 1 Write-Build Yellow "helm push $($ChartToPush.FullName) oci://$($script:ACRName).azurecr.io/helm" - Invoke-Native { helm push $ChartToPush.FullName "oci://$($script:ACRName).azurecr.io/helm" } + Invoke-Native { helm push $ChartToPush.FullName "oci://$($script:ACRName).azurecr.io/helm" } -ExceptionalExit } - } else { Write-Warning ("Skipping push: To push charts ensure that...`n" + "`t* You are in a known build system (Current: $BuildSystem)`n" + diff --git a/tasks/HelmTestChart.Task.ps1 b/tasks/HelmTestChart.Task.ps1 index 9621f01..30c3d03 100644 --- a/tasks/HelmTestChart.Task.ps1 +++ b/tasks/HelmTestChart.Task.ps1 @@ -16,25 +16,21 @@ Add-BuildTask HelmTestChart @{ $CompiledOutput = Join-Path $script:helmOutputPath "$($Chart.Name)-compiled.yaml" Write-Build Yellow "helm lint $($Chart.FullName) --values $TestValues" - Invoke-Native { helm lint $chart.FullName --values $TestValues } - if ($LASTEXITCODE -ne 0) { - throw "Linting failed for $($chart)" - } + Invoke-Native { helm lint $chart.FullName --values $TestValues } -ExceptionalExit Write-Build Yellow "helm template $($chart.FullName) --values $TestValues --generate-name" - $global:ErrorView = "ConciseView" - Invoke-Native { helm template $chart.FullName--values $TestValues --generate-name - } > $CompiledOutput + Invoke-Native { helm template $chart.FullName --values $TestValues --generate-name } -ExceptionalExit > $CompiledOutput # Shouldn't this be taken care of elsewhere as a pre-requisite? if (-not (Get-Command kubeconform -ErrorAction SilentlyContinue)) { Write-Build Yellow "kubeconform not found, attempting installation..." &(Join-Path $script:BuildTaskScriptsDirectory "Install-GithubRelease.ps1") -Org "yannh" -Repo "kubeconform" -Verbose -ErrorAction SilentlyContinue } + # TODO: Why is this here? This should be handled by InstallGithubTools Write-Build Yellow "kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput" Invoke-Native { kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput - } + } -ExceptionalExit } } } diff --git a/tasks/HelmUpdateValuesSchema.Task.ps1 b/tasks/HelmUpdateValuesSchema.Task.ps1 index d85816f..45a74b5 100644 --- a/tasks/HelmUpdateValuesSchema.Task.ps1 +++ b/tasks/HelmUpdateValuesSchema.Task.ps1 @@ -8,9 +8,9 @@ Add-BuildTask HelmUpdateValuesSchema @{ # If the developers have not already done so # This may create a `charts` subdirectory with the dependencies i.e. devops-library # In general, we don't care if they commit those, but we need them for the schemas to be complete - Invoke-Native { helm dependency update . --skip-refresh } + Invoke-Native { helm dependency update . --skip-refresh } -ExceptionalExit Write-Build Yellow "helm schema" - Invoke-Native { helm schema } + Invoke-Native { helm schema } -ExceptionalExit } } } diff --git a/tasks/_Initialize.ps1 b/tasks/_Initialize.ps1 index c0524f2..c17e8b0 100644 --- a/tasks/_Initialize.ps1 +++ b/tasks/_Initialize.ps1 @@ -60,6 +60,8 @@ $script:BranchName = if ($Env:BUILD_SOURCEBRANCHNAME) { git branch --show-current } +$script:PushEnabled ??= $BuildSystem -ne 'None' -and ($BranchName -match "^main|^release/|^hotfix/") + # In PR Builds you have a SourceBranch and a TargetBranch [bool]$script:IsPullRequest = $script:IsPullRequest ?? ($Env:BUILD_REASON -eq "PullRequest" -or $Env:DRONE_BUILD_EVENT -eq "pull_request") [long]$script:PullRequestId = $script:PullRequestId ?? $Env:SYSTEM_PULLREQUEST_PULLREQUESTID ?? $Env:DRONE_PULL_REQUEST @@ -99,8 +101,6 @@ Write-Verbose " BuildRoot [$BuildRoot]" -Verbose # $Env:AGENT_TEMPDIRECTORY # $Env:COMMON_TESTRESULTSDIRECTORY -# TODO: Should we recreate something similar to the ADO directories described above? e.g. /s, /a, etc - # There are a few different environment/variables it could be, and then our fallback $Script:OutputPath = if ($Env:BUILD_BINARIESDIRECTORY) { $Env:BUILD_BINARIESDIRECTORY @@ -167,7 +167,7 @@ if ($dotnetSolution -or $DotNetPublishRoot) { $script:dotnetTestProjects = @($script:dotnetProjects | Where-Object { $_ -like "*Test*.*proj" }) Write-Verbose " DotNetTestProjects: $(($script:dotnetTestProjects).Count)" -Verbose $script:dotnetOptions ??= @{} - + $script:NuGetPublishKey ??= $Env:NUGET_API_KEY $script:NuGetPublishUri ??= $Env:NUGET_API_URI ?? "https://nuget.loandepot.com/nuget/LDTS/v3/index.json" Write-Verbose " NuGetPublishUri: $NuGetPublishUri" -Verbose From d2a6e21b7b87d6975a45b25e7b4308583a91d5a9 Mon Sep 17 00:00:00 2001 From: Shawn Melton Date: Wed, 25 Mar 2026 11:24:28 -0500 Subject: [PATCH 06/43] Switch to $Extends syntax Make GetVersion set the BuildName for Azure DevOps GetVersion, TagSource (#12) Make ACR tasks work Improve failure logic on errors --- .gitignore | 1 + .vscode/settings.json | 5 + BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD | 4 - Build.build.ps1 | 74 --- Directory.Build.props | 8 +- README.md | 118 +++- RequiredModules.psd1 | 2 + .../DotNetDockerBuild.Task.ps1 | 18 +- {tasks => archive}/SonarQubeEnd.Task.ps1 | 2 +- {tasks => archive}/SonarQubeStart.Task.ps1 | 4 +- build.build.ps1 | 42 ++ build.example.ps1 | 113 ---- .../Clean-Output.Task.ps1 | 2 +- .../Connect-AzACR.Task.ps1 | 14 +- common/Connect-AzAccount.Task.ps1 | 7 + .../Get-Version.Task.ps1 | 25 +- {tasks => common}/GitVersion.yml | 0 .../Initialize-Git.Task.ps1 | 2 +- common/Install-BuildDependencies.Task.ps1 | 1 + .../Install-GitHubTools.Task.ps1 | 4 +- .../Install-RequiredModules.Task.ps1 | 8 +- .../Pack-UniversalPackage.Task.ps1 | 5 +- common/Push-Docker.Task.ps1 | 60 ++ .../Restore-DotNetTools.Task.ps1 | 10 +- common/Tag-Source.Task.ps1 | 22 + common/base.ps1 | 206 ++++++ docs/Extends.md | 613 +++++++++++++++--- dotnet-tools.json | 7 - .../Build-DotNet.Task.ps1 | 26 +- dotnet/Clean-DotNet.Task.ps1 | 6 + .../Convert-Coverage.Task.ps1 | 14 +- dotnet/Convert-Trx2JUnit.Task.ps1 | 21 + .../Pack-DotNet.Task.ps1 | 25 +- .../Publish-DotNet.Task.ps1 | 23 +- .../Push-DotNet.Task.ps1 | 6 +- .../Restore-DotNet.Task.ps1 | 19 +- .../Test-DotNet.Task.ps1 | 26 +- dotnet/base.ps1 | 150 +++++ helm/Build-Helm.Task.ps1 | 10 + .../Install-Helm.Task.ps1 | 13 +- .../Pack-Helm.Task.ps1 | 12 +- .../Push-Helm.Task.ps1 | 5 +- helm/Restore-Helm.Task.ps1 | 12 + .../Test-Helm.Task.ps1 | 11 +- helm/base.ps1 | 59 ++ pipeline/build.yml | 40 +- .../PSModuleAnalyze.Task.ps1 | 0 {tasks => powershell}/PSModuleBuild.Task.ps1 | 0 {tasks => powershell}/PSModuleImport.Task.ps1 | 0 {tasks => powershell}/PSModulePush.Task.ps1 | 0 .../PSModuleRestore.Task.ps1 | 0 {tasks => powershell}/PSModuleTest.Task.ps1 | 0 tasks/_BootStrap.ps1 => scripts/Bootstrap.ps1 | 2 +- scripts/PSFormatting.ps1 | 20 - tasks/ConnectAzAccount.Task.ps1 | 22 - tasks/DotNetClean.Task.ps1 | 8 - tasks/DotNetTrx2JUnit.Task.ps1 | 21 - tasks/HelmUpdateValuesSchema.Task.ps1 | 16 - tasks/InstallBuildDependencies.Task.ps1 | 1 - tasks/TagSource.Task.ps1 | 9 - tasks/_Initialize.ps1 | 264 -------- 61 files changed, 1417 insertions(+), 801 deletions(-) create mode 100644 .gitignore create mode 100644 .vscode/settings.json delete mode 100644 Build.build.ps1 rename tasks/DockerBuild.Task.ps1 => archive/DotNetDockerBuild.Task.ps1 (79%) rename {tasks => archive}/SonarQubeEnd.Task.ps1 (65%) rename {tasks => archive}/SonarQubeStart.Task.ps1 (81%) create mode 100644 build.build.ps1 delete mode 100644 build.example.ps1 rename tasks/Clean.Task.ps1 => common/Clean-Output.Task.ps1 (77%) rename tasks/ConnectAzACR.Task.ps1 => common/Connect-AzACR.Task.ps1 (64%) create mode 100644 common/Connect-AzAccount.Task.ps1 rename tasks/GetVersion.Task.ps1 => common/Get-Version.Task.ps1 (70%) rename {tasks => common}/GitVersion.yml (100%) rename tasks/GitInit.Task.ps1 => common/Initialize-Git.Task.ps1 (94%) create mode 100644 common/Install-BuildDependencies.Task.ps1 rename tasks/InstallGitHubTools.Task.ps1 => common/Install-GitHubTools.Task.ps1 (85%) rename tasks/InstallRequiredModules.Task.ps1 => common/Install-RequiredModules.Task.ps1 (50%) rename tasks/UniversalPackagePack.Task.ps1 => common/Pack-UniversalPackage.Task.ps1 (95%) create mode 100644 common/Push-Docker.Task.ps1 rename tasks/DotNetToolRestore.Task.ps1 => common/Restore-DotNetTools.Task.ps1 (57%) create mode 100644 common/Tag-Source.Task.ps1 create mode 100644 common/base.ps1 rename tasks/DotNetBuild.Task.ps1 => dotnet/Build-DotNet.Task.ps1 (80%) create mode 100644 dotnet/Clean-DotNet.Task.ps1 rename tasks/ReportGenerator.Task.ps1 => dotnet/Convert-Coverage.Task.ps1 (67%) create mode 100644 dotnet/Convert-Trx2JUnit.Task.ps1 rename tasks/DotNetPack.Task.ps1 => dotnet/Pack-DotNet.Task.ps1 (83%) rename tasks/DotNetPublish.Task.ps1 => dotnet/Publish-DotNet.Task.ps1 (85%) rename tasks/DotNetPush.Task.ps1 => dotnet/Push-DotNet.Task.ps1 (83%) rename tasks/DotNetRestore.Task.ps1 => dotnet/Restore-DotNet.Task.ps1 (71%) rename tasks/DotNetTest.Task.ps1 => dotnet/Test-DotNet.Task.ps1 (71%) create mode 100644 dotnet/base.ps1 create mode 100644 helm/Build-Helm.Task.ps1 rename tasks/HelmInstall.Task.ps1 => helm/Install-Helm.Task.ps1 (78%) rename tasks/HelmPackChart.Task.ps1 => helm/Pack-Helm.Task.ps1 (75%) rename tasks/HelmPushChart.Task.ps1 => helm/Push-Helm.Task.ps1 (89%) create mode 100644 helm/Restore-Helm.Task.ps1 rename tasks/HelmTestChart.Task.ps1 => helm/Test-Helm.Task.ps1 (86%) create mode 100644 helm/base.ps1 rename {tasks => powershell}/PSModuleAnalyze.Task.ps1 (100%) rename {tasks => powershell}/PSModuleBuild.Task.ps1 (100%) rename {tasks => powershell}/PSModuleImport.Task.ps1 (100%) rename {tasks => powershell}/PSModulePush.Task.ps1 (100%) rename {tasks => powershell}/PSModuleRestore.Task.ps1 (100%) rename {tasks => powershell}/PSModuleTest.Task.ps1 (100%) rename tasks/_BootStrap.ps1 => scripts/Bootstrap.ps1 (92%) delete mode 100644 scripts/PSFormatting.ps1 delete mode 100644 tasks/ConnectAzAccount.Task.ps1 delete mode 100644 tasks/DotNetClean.Task.ps1 delete mode 100644 tasks/DotNetTrx2JUnit.Task.ps1 delete mode 100644 tasks/HelmUpdateValuesSchema.Task.ps1 delete mode 100644 tasks/InstallBuildDependencies.Task.ps1 delete mode 100644 tasks/TagSource.Task.ps1 delete mode 100644 tasks/_Initialize.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d351402 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Output/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..72fb572 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "nofetch" + ] +} \ No newline at end of file diff --git a/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD b/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD index 38eae38..8820304 100644 --- a/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD +++ b/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD @@ -229,9 +229,6 @@ param( [Parameter(Position = 0)] [string]$Solution = "*", - # The Key to use for reporting to SonarQube - [string]$SonarProjectKey = $($Solution -ne "*" ? $Solution : ""), - # [string[]]$MonoRepoVersionNames = @(""), # Which projects to build @@ -732,7 +729,6 @@ CI Task: └─ DotNetBuild └─ DotNetRestore └─ GetVersion - └─ SonarQubeStart (if configured) └─ DotNetTest └─ DotNetTrx2JUnit └─ ReportGenerator diff --git a/Build.build.ps1 b/Build.build.ps1 deleted file mode 100644 index c91f72b..0000000 --- a/Build.build.ps1 +++ /dev/null @@ -1,74 +0,0 @@ -<# -.SYNOPSIS - ./project.build.ps1 -.EXAMPLE - Invoke-Build -.NOTES - 0.5.0 - Parameterize - Add parameters to this script to control the build -#> -[CmdletBinding()] -param( - # Add the clean task before the default build - [switch]$Clean, - - # dotnet build configuration parameter (Debug or Release) - [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Release', - - # Collect code coverage when tests are run - [switch]$CollectCoverage, - - # Which projects to build - [Alias("Projects")] - $dotnetProjects = @( - <# Add C# Project basenames to build by default #> - ), - - # Which projects are test projects - [Alias("TestProjects")] - $dotnetTestProjects = @( - <# Add C# Project basenames to run as tests by default #> - ), - - # Further options to pass to dotnet - [Alias("Options")] - $dotnetOptions = @{ - "-verbosity" = "minimal" - # "-runtime" = "linux-x64" - } -) -$InformationPreference = "Continue" - -$Tasks = "Tasks", "../Tasks", "../../Tasks" | Convert-Path -ErrorAction Ignore | Select-Object -First 1 - -## Self-contained build script - can be invoked directly or via Invoke-Build -if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { - & "$Tasks/_Bootstrap.ps1" - - Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result - - if ($Result.Error) { - $Error[-1].ScriptStackTrace | Out-String - exit 1 - } - exit 0 -} - -## The first task defined is the default task. Put the right values for your project type here... -if ($dotnetProjects) { - if ($Clean) { - Add-BuildTask . Clean, GitVersion, DotNetRestore, DotNetBuild, DotNetTest, DotNetPublish - } else { - Add-BuildTask . GitVersion, DotNetRestore, DotNetBuild, DotNetTest, DotNetPublish - } -} else { - if ($Clean) { - Add-BuildTask . Clean, GitVersion, PSModuleRestore, PSModuleBuild, PSModuleTest, PSModulePush - } else { - Add-BuildTask . GitVersion, PSModuleRestore, PSModuleBuild, PSModuleTest, PSModulePush - } -} - -## Initialize the build variables, and import shared tasks, including DotNet tasks -. "$Tasks/_Initialize.ps1" -DotNet \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 7c83523..ac463b2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,8 +2,10 @@ shared - $(MSBuildThisFileDirectory)Output/$(SolutionName)/ - $(LDBUILD_BINARIESDIRECTORY)/$(SolutionName)/ + + $(MSBuildThisFileDirectory)Output/$(SolutionName)/ + + $(LDBUILD_OUTPUT_ROOT)/$(SolutionName)/ $(RootOutputPath)bin/$(MSBuildProjectName) $(RootOutputPath)obj/$(MSBuildProjectName) @@ -15,7 +17,7 @@ False False - + $(MSBuildThisFileDirectory)\.runsettings \ No newline at end of file diff --git a/README.md b/README.md index e0a64e7..25e3e8a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,111 @@ # LD.Platform.BuildTasks -## TODO - -- [ ] Yeah, I think one other thing we need to do before we try to turn this into a "process" is we need to clean up our tasks to use exec (or our Invoke-Native command) so that when the native tools fail, the build fails. (See Invoke-Build Basics and Guidelines) -- [ x ] CSC : error CS5001: Program does not contain a static 'Main' method suitable for an entry point [C:\XDL\LD.Shared.EnterprisePlatformServices.API\EPS\ThirdParty\LD.EPS.ThirdParty.Calyx\LD.EPS.ThirdParty.Calyx.csproj] -- [ x ] what are we going to do about dotnet-tools.json? - - [ x ] Copy the file to the build root -- [ x ] Use a solution filter to filter out test projects that trying to publish because they're using microsoft.net.sdk.web - - [ ] ACTUALLY use dotnet sln remove to exclude them from the build and then add them back after the task completes -- [ ] How do I handle tasks for publishing to ACR vs universal package proget +Centralized, reusable Invoke-Build task system for .NET monorepo projects at LoanDepot. + +## Overview + +BuildTasks provides reusable tasks scripts for Invoke-Build to standardize CI tasks across our various project types. + +For each project type we aim to provide a common set of tasks used in CI, along with a "CI" task that composes them in a typical workflow, and common tasks for automating chores like updating dependencies and lock files, as well as deployment scripts. + +- Get-Version: calculate the next version +- Initialize: restore dependencies +- Build: compile code or generate outputs +- Publish: create deployment artifacts +- Test: run tests and generate reports +- Push: push artifacts to build and artifact registries +- Checkpoint: tag the repository with the version + +## Quick Starts + + +1. Clone this repository **as a sibling** to your project folder: + ```powershell + git clone https://github.com/loandepot/LD.Platform.BuildTasks BuildTasks + ``` +2. Follow the new-build instructions for your project type: + - [Creating a new dotnet build](./skills/new-build-dotnet/SKILL.md) + +## Common Commands + +```powershell +# Full default build +Invoke-Build + +# Build only +Invoke-Build Build + +# Run tests +Invoke-Build Test + +# Full CI build (with package publishing, etc) +Invoke-Build CI + +# Investigate available tasks: +Invoke-Build ? + +# Verify what will be executed: +Invoke-Build -whatif +``` + +## Documentation + +Skills are being developed in the `skills/` directory, and both humans and AI agents are recommended to use the `new-build-/SKILL.md` files as the most up-to-date documentation for getting started. + +Additional documentation is available in the `docs/` directory. + +## Repository Structure + +``` +LD.Platform.BuildTasks/ +├── common/ # Shared tasks and base build script +│ ├── base.ps1 # Base build script (shared initialization) +│ ├── Get-Version.Task.ps1 # Semantic versioning via GitVersion +│ ├── Clean-Output.Task.ps1 # Output directory cleanup +~ +├── dotnet/ # .NET build tasks +│ ├── base.ps1 # DotNet build script (extends common/base.ps1) +│ ├── Build-DotNet.Task.ps1 # .NET build task +│ ├── Test-DotNet.Task.ps1 # .NET test task +~ +├── helm/ # Helm chart tasks +│ ├── base.ps1 # Helm build script (extends common/base.ps1) +│ ├── Build-Helm.Task.ps1 # Helm build task +~ +├── powershell/ # PowerShell tasks (future) +├── node/ # Node.js tasks (future) +├── scripts/ # Utility scripts +├── docs/ # Additional Documentation +├── skills/ # AI Agent skills including the templates for new builds +├── GitVersion.yml # Default versioning configuration +~ +└── README.md # This file! +``` + +## Key Features + +### Centralized Output Management + +All build outputs go to a centralized `Output/` directory. + +For some project types (.NET, in particular) we put intermediate output (which might be duplicate) +in per-solution subfolders so we can parallelize this work. Final outputs should go directly in the +Output folder categorized by how it's being published (nuget, containers, charts, etc). + +``` +Output/ +├── / +│ ├── bin/ # Compiled assemblies +│ ├── obj/ # Intermediate files +│ └── version.json # Version output +├── nuget/ # NuGet packages +├── publish/ # Deployment artifacts +├── containers/ # Container image tarballs +└── testresults/ # Test results +``` + +### Semantic Versioning + +GitVersion is integrated, following our new git-flow workflow: +- Automatic version increments after each release is tagged and merged +- Version information embedded in output libraries and packages diff --git a/RequiredModules.psd1 b/RequiredModules.psd1 index d5c6ac6..db424fd 100644 --- a/RequiredModules.psd1 +++ b/RequiredModules.psd1 @@ -8,4 +8,6 @@ "Az.ContainerRegistry" = "5.*" "Az.Accounts" = "5.*" "LDNative" = "[1.0.6,2.0)" + "LDGit" = "[2.2.2,3.0)" + "LDAzOps" = "[0.3.0,1.0)" } diff --git a/tasks/DockerBuild.Task.ps1 b/archive/DotNetDockerBuild.Task.ps1 similarity index 79% rename from tasks/DockerBuild.Task.ps1 rename to archive/DotNetDockerBuild.Task.ps1 index a0b22b0..2f72412 100644 --- a/tasks/DockerBuild.Task.ps1 +++ b/archive/DotNetDockerBuild.Task.ps1 @@ -1,6 +1,5 @@ # TODO: Where is the repository name coming from? -Add-BuildTask DockerBuild @{ - If = $dotnetSolution +Add-BuildTask DotNetDockerBuild @{ Inputs = { $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue $PublishedDockerfiles.ForEach({ @@ -18,16 +17,25 @@ Add-BuildTask DockerBuild @{ $BuildRoot } } +<<<<<<<< HEAD:common/DockerBuild.Task.ps1 Jobs = "GetVersion", "DotNetPublish", { +|||||||| parent of c4aeb6a (Move Tasks and Update Documentation (#29)):tasks/DotNetDockerBuild.Task.ps1 + Jobs = "GetVersion", "DotNetPublish", "ConnectAzACR", { +======== + Jobs = "Get-Version", "Publish-DotNet", "Connect-AzACR", { +>>>>>>>> c4aeb6a (Move Tasks and Update Documentation (#29)):archive/DotNetDockerBuild.Task.ps1 $script:DockerMetadataRoot = New-Item (Join-Path $script:OutputPath "docker") -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue foreach ($Dockerfile in $PublishedDockerfiles) { - $ProjectName = $_.DirectoryName + $ProjectName = $DockerFile.Directory.Name + $Parts = $ProjectName.Split('.') + $PathPrefix = ($Parts[0..($Parts.Length - 3)] -join '/').ToLower() + $ImageName = ($Parts[($Parts.Length - 2)..($Parts.Length - 1)] -join '-').ToLower() + $Repository = "$PathPrefix/$ImageName" $Context = Join-Path $script:DotNetPublishRoot $ProjectName $Version = $script:Version.SemVer - $Registry = "crazusw2dvosl1" - $FullImageName = "$Registry/$Repository`:$Version" + $FullImageName = "$script:ACRUri/$Repository`:$Version" $MetadataFile = Join-Path $script:DockerMetadataRoot "$ProjectName-metadata.json" diff --git a/tasks/SonarQubeEnd.Task.ps1 b/archive/SonarQubeEnd.Task.ps1 similarity index 65% rename from tasks/SonarQubeEnd.Task.ps1 rename to archive/SonarQubeEnd.Task.ps1 index e0ce093..f6ee641 100644 --- a/tasks/SonarQubeEnd.Task.ps1 +++ b/archive/SonarQubeEnd.Task.ps1 @@ -1,5 +1,5 @@ Add-BuildTask SonarQubeEnd @{ - If = $script:SonarProjectKey -and $script:SonarToken + If = { $script:SonarProjectKey -and $script:SonarToken } Jobs = { dotnet sonarscanner end -d:"sonar.token=${script:SonarToken}" } diff --git a/tasks/SonarQubeStart.Task.ps1 b/archive/SonarQubeStart.Task.ps1 similarity index 81% rename from tasks/SonarQubeStart.Task.ps1 rename to archive/SonarQubeStart.Task.ps1 index c8bd2c3..e93eb0a 100644 --- a/tasks/SonarQubeStart.Task.ps1 +++ b/archive/SonarQubeStart.Task.ps1 @@ -1,6 +1,6 @@ Add-BuildTask SonarQubeStart @{ - If = $script:SonarProjectKey -and $script:SonarToken - Jobs = "GetVersion", { + If = { $script:SonarProjectKey -and $script:SonarToken } + Jobs = "Get-Version", { dotnet sonarscanner begin ` -key:"$($Script:SonarProjectKey)" ` -version:"$(${script:Version}.SemVer)" ` diff --git a/build.build.ps1 b/build.build.ps1 new file mode 100644 index 0000000..1dd71cc --- /dev/null +++ b/build.build.ps1 @@ -0,0 +1,42 @@ +<# +.SYNOPSIS + ./build.build.ps1 +.EXAMPLE + Invoke-Build +#> +[CmdletBinding()] +param( + [ValidateScript( + { + @( + "../*BuildTasks/common/base.ps1" + ) | Convert-Path + } + )] + $Extends +) + +## Self-contained build script - can be invoked directly or via Invoke-Build +if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { + Write-Information "Bootstrap Build Dependencies" -Tag "InvokeBuild" + . (Convert-Path ../*BuildTasks/scripts/Bootstrap.ps1) + + Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result + + if ($Result.Error) { + $Error[-1].ScriptStackTrace | Out-Host + exit 1 + } + exit 0 +} +# Define your preferred default build for local dev: +Add-BuildTask . Get-Version, Tag-Source + +# Each build is responsible to define the five core tasks for CI +# But each base adds opinionated tasks to these variables +# So it's usually safe to just use these: +Add-BuildTask Initialize $script:InitializeTasks +Add-BuildTask Build $script:BuildTasks +Add-BuildTask Test $script:TestTasks +Add-BuildTask Publish $script:PublishTasks +Add-BuildTask Push $script:PushTasks \ No newline at end of file diff --git a/build.example.ps1 b/build.example.ps1 deleted file mode 100644 index 6c947c2..0000000 --- a/build.example.ps1 +++ /dev/null @@ -1,113 +0,0 @@ -<# -.SYNOPSIS - ./build.build.ps1 -.EXAMPLE - Invoke-Build -.NOTES - 0.5.0 - Parameterize - Add parameters to this script to control the build -#> -[CmdletBinding()] -param( - # dotnet build configuration parameter (Debug or Release) - [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Release', - - # Add the clean task before the default build - [switch]$Clean, - - # Collect code coverage when tests are run - [switch]$CollectCoverage, - - # Which solution to build - [ArgumentCompleter({ - param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) - - Get-ChildItem -Path $PSScriptRoot -Filter *.sln | - Split-Path -LeafBase | - Where-Object { $_ -like "*$wordToComplete*" } | - ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } - })] - [Alias("Project")] - [Parameter(Position = 0)] - [string]$Solution = "*", - - # Which projects to build - [Alias("Projects")] - $dotnetSolution = @( - # By default build the EPS solution file in the root - if (Get-ChildItem -Filter "${Solution}.sln" -ErrorAction Ignore -OutVariable sln) { - if ($sln.Count -gt 1) { - Write-Warning "Multiple solution files found: `n- $($sln.FullName -join '`n- ')`nBuilding only the first one: $($sln[0].FullName)" - } - $sln[0] | Convert-Path - } - )[0], - - # Further options to pass to dotnet - [Alias("Options")] - $dotnetOptions = @{ - "-verbosity" = "minimal" - }, - - # Sets framework for solution, included in build output path - $TargetFramework = "net8.0", - - # Sets runtime for solution, included in build output path - [ValidateSet('linux-x64','win-x64')] - $TargetRuntime, - - # The Key to use for reporting to SonarQube - [string]$SonarProjectKey = $($Solution -ne "*" ? $Solution : ""), - - # Helm charts - [string]$HelmChartRoot = @( - if (Get-ChildItem -Path "$PSScriptRoot/charts" -File -Recurse -Filter "Chart.yaml" -ErrorAction Ignore) { - Resolve-Path "$PSScriptRoot/charts" | Convert-Path - } - )[0] -) - -$ScriptsFolder = "../LD.Platform.BuildTasks/scripts", "../BuildTasks/scripts", "../tasks/scripts" | Convert-Path -ErrorAction Ignore -. $ScriptsFolder/PSFormatting.ps1 -# The name of the solution -$script:SolutionName = $Solution -# Use Env because Earthly can override it -$Env:OUTPUT_ROOT ??= Join-Path $PSScriptRoot output - -$Tasks = "../LD.Platform.BuildTasks/tasks", "../BuildTasks/tasks", "../tasks/tasks", "tasks" | Convert-Path -ErrorAction Ignore -Write-Information "$($PSStyle.Foreground.BrightCyan)Found shared tasks in $Tasks" -Tag "InvokeBuild" - -## Self-contained build script - can be invoked directly or via Invoke-Build -if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { - foreach ($taskDir in $Tasks) { - $bootstrap = Join-Path $taskDir "_BootStrap.ps1" - Write-Information "Check for $bootstrap" -Tag "InvokeBuild" - if (Test-Path $bootstrap) { - Write-Information "Dotsource $bootstrap" -Tag "InvokeBuild" - . $bootstrap - } - } - - Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result - - if ($Result.Error) { - $Error[-1].ScriptStackTrace | Out-Host - exit 1 - } - exit 0 -} - -## Initialize the build variables, and import shared tasks, including DotNet tasks -foreach($taskDir in $Tasks) { - $initialize = Join-Path $taskDir "_Initialize.ps1" - if (Test-Path $initialize) { - Write-Information ". $initialize" - . $initialize - } -} - -Add-BuildTask HelmBuild InstallRequiredModules, GetVersion, HelmUpdateValuesSchema -Add-BuildTask HelmTest HelmBuild,HelmTestChart -Add-BuildTask HelmPack HelmTest,HelmPackChart -Add-BuildTask HelmPush HelmPack,HelmPushChart diff --git a/tasks/Clean.Task.ps1 b/common/Clean-Output.Task.ps1 similarity index 77% rename from tasks/Clean.Task.ps1 rename to common/Clean-Output.Task.ps1 index 58f1592..16fdf57 100644 --- a/tasks/Clean.Task.ps1 +++ b/common/Clean-Output.Task.ps1 @@ -1,4 +1,4 @@ -Add-BuildTask Clean { +Add-BuildTask Clean-Output { Remove-BuildItem $OutputPath New-Item $OutputPath -ItemType Directory -Force | Out-Null } diff --git a/tasks/ConnectAzACR.Task.ps1 b/common/Connect-AzACR.Task.ps1 similarity index 64% rename from tasks/ConnectAzACR.Task.ps1 rename to common/Connect-AzACR.Task.ps1 index 6aaa4df..155a290 100644 --- a/tasks/ConnectAzACR.Task.ps1 +++ b/common/Connect-AzACR.Task.ps1 @@ -1,26 +1,24 @@ -Add-BuildTask ConnectAzACR @{ +Add-BuildTask Connect-AzACR @{ Jobs = { - $RegistryName = "$($script:ACRName).azurecr.io" - if ($env:AZURE_ACCESS_TOKEN) { # Pipeline path: use the OIDC plugin token directly Write-Build Gray "Using AZURE_ACCESS_TOKEN from OIDC plugin" $TenantId = $env:AZURE_TENANT_ID ?? (Get-AzContext).Tenant.Id Write-Build Gray "Exchanging access token for ACR refresh token..." - $RefreshToken = (Invoke-RestMethod -Uri "https://$RegistryName/oauth2/exchange" -Method Post -Body @{ + $RefreshToken = (Invoke-RestMethod -Uri "https://$script:ACRUri/oauth2/exchange" -Method Post -Body @{ grant_type = "access_token" - service = $RegistryName + service = $script:ACRUri access_token = $env:AZURE_ACCESS_TOKEN tenant = $TenantId }).refresh_token - Write-Build Yellow "helm registry login $RegistryName" - $RefreshToken | helm registry login $RegistryName --username "00000000-0000-0000-0000-000000000000" --password-stdin + Write-Build Yellow "helm registry login $script:ACRUri" + $RefreshToken | helm registry login $script:ACRUri --username "00000000-0000-0000-0000-000000000000" --password-stdin } else { # Local path: use Az context via Connect-AzContainerRegistry if ($null -eq (Get-AzContext -ErrorAction SilentlyContinue)) { - throw "No AZURE_ACCESS_TOKEN and no Az context. Run ConnectAzAccount first or provide AZURE_ACCESS_TOKEN." + throw "No AZURE_ACCESS_TOKEN and no Az context. Run Connect-AzAccount first or provide AZURE_ACCESS_TOKEN." } Write-Build Yellow "Connect-AzContainerRegistry -Name $($script:ACRName)" Connect-AzContainerRegistry -Name $script:ACRName diff --git a/common/Connect-AzAccount.Task.ps1 b/common/Connect-AzAccount.Task.ps1 new file mode 100644 index 0000000..55bc0e4 --- /dev/null +++ b/common/Connect-AzAccount.Task.ps1 @@ -0,0 +1,7 @@ +Add-BuildTask Connect-AzAccount @{ + If = { $null -eq (Get-AzContext -ErrorAction SilentlyContinue) } + Jobs = { + Write-Build Yellow "Connect-AzContext -Passthru" + Connect-AzContext -Passthru + } +} diff --git a/tasks/GetVersion.Task.ps1 b/common/Get-Version.Task.ps1 similarity index 70% rename from tasks/GetVersion.Task.ps1 rename to common/Get-Version.Task.ps1 index 0b482e3..fca3244 100644 --- a/tasks/GetVersion.Task.ps1 +++ b/common/Get-Version.Task.ps1 @@ -2,7 +2,7 @@ $script:VersionCacheFile = "$Script:OutputPath/version.json" $script:Version = @{} <# NOTE: this version does not include support for multiple versions per-repo #> -Add-BuildTask GetVersion @{ +Add-BuildTask Get-Version @{ If = { $head = git rev-parse HEAD # If there's an existing GitVersion in output, load it @@ -15,7 +15,7 @@ Add-BuildTask GetVersion @{ # we can skip (return $false) return (${script:Version}.Sha -ne $head) } - Jobs = "GitInit", "DotNetToolRestore", { + Jobs = "Initialize-Git", "Restore-DotNetTools", { # Support a config file in the repo (BuildRoot) to override the one in here (PSScriptRoot) [string]$VersionConfig = Resolve-Path "$BuildRoot/GitVersion.y*ml", "$PSScriptRoot/GitVersion.y*ml" -ErrorAction Ignore | Select-Object -First 1 @@ -28,12 +28,13 @@ Add-BuildTask GetVersion @{ Remove-Item $VersionCacheFile } - Write-Host dotnet gitversion -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile + Write-Build Yellow "dotnet gitversion -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile" dotnet gitversion -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile | Out-Host try { $local:GitVersion = Get-Content $VersionCacheFile | ConvertFrom-Json -ErrorAction Stop - } catch { + } + catch { Write-Warning "dotnet gitversion -config $VersionConfig -showconfig" dotnet gitversion -config $VersionConfig -showconfig | Out-Host Write-Warning "VersionTagPrefix: $($VersionTagPrefix)" @@ -49,9 +50,23 @@ Add-BuildTask GetVersion @{ # The things we know we want on our version output object $script:Version = $local:GitVersion | Select-Object -Property InformationalVersion, MajorMinorPatch, SemVer, Sha, Tag - + Write-Build Gray "Version Tag: $(($script:Version).Tag)" # Cache the final object in output so we can skip rerunning ${script:Version} | ConvertTo-Json -Compress | Out-File $Script:OutputPath/version.json + + if ($Script:BuildSystem -ieq "AzureDevOps") { + # Replace "$(Gitversion.*)" tokens in the BuildNumber (AKA name) + $buildNumber = $ENV:BUILD_BUILDNUMBER + # The default buildNumber is just 'yyyymmdd.r' so replace that with a reference to Semver + if ($buildNumber -match "^[\d\.]+$") { + $buildNumber = '$(GitVersion.Semver)' + } + # https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comparison_operators#replacement-with-a-script-block + $buildNumber = $buildNumber -replace "\$\(GitVersion.([^)]+)\)", { + $Version.($_.Groups[1].Value) + } + Write-Information "##vso[build.updatebuildnumber]$buildNumber" -InformationAction Continue + } } } diff --git a/tasks/GitVersion.yml b/common/GitVersion.yml similarity index 100% rename from tasks/GitVersion.yml rename to common/GitVersion.yml diff --git a/tasks/GitInit.Task.ps1 b/common/Initialize-Git.Task.ps1 similarity index 94% rename from tasks/GitInit.Task.ps1 rename to common/Initialize-Git.Task.ps1 index 0abed92..cf38789 100644 --- a/tasks/GitInit.Task.ps1 +++ b/common/Initialize-Git.Task.ps1 @@ -1,4 +1,4 @@ -Add-BuildTask GitInit @{ +Add-BuildTask Initialize-Git @{ If = { -not (git config user.name) -or -not (git config user.email) -or ($script:GitUser -and $script:GitUser -ne (git config user.name)) } diff --git a/common/Install-BuildDependencies.Task.ps1 b/common/Install-BuildDependencies.Task.ps1 new file mode 100644 index 0000000..99c27b7 --- /dev/null +++ b/common/Install-BuildDependencies.Task.ps1 @@ -0,0 +1 @@ +Add-BuildTask Install-BuildDependencies Install-RequiredModules diff --git a/tasks/InstallGitHubTools.Task.ps1 b/common/Install-GitHubTools.Task.ps1 similarity index 85% rename from tasks/InstallGitHubTools.Task.ps1 rename to common/Install-GitHubTools.Task.ps1 index b6269bf..dec6015 100644 --- a/tasks/InstallGitHubTools.Task.ps1 +++ b/common/Install-GitHubTools.Task.ps1 @@ -1,6 +1,6 @@ # TODO: in pipeline environments, we should trigger the "cache" task for these to speed up using them -Add-BuildTask InstallGitHubTools @{ - If = $script:GHTools.keys.Count -gt 0 +Add-BuildTask Install-GitHubTools @{ + If = { $script:GHTools.keys.Count -gt 0 } Jobs = { foreach ($tool in $script:GHTools.keys) { if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { diff --git a/tasks/InstallRequiredModules.Task.ps1 b/common/Install-RequiredModules.Task.ps1 similarity index 50% rename from tasks/InstallRequiredModules.Task.ps1 rename to common/Install-RequiredModules.Task.ps1 index 875d9d8..fc9d5dc 100644 --- a/tasks/InstallRequiredModules.Task.ps1 +++ b/common/Install-RequiredModules.Task.ps1 @@ -1,7 +1,7 @@ -Add-BuildTask InstallRequiredModules @{ - If = Test-Path $BuildRoot/RequiredModules.psd1 - Inputs = "$BuildRoot/RequiredModules.psd1" - Outputs = "$OutputPath/RequiredModules.psd1" +Add-BuildTask Install-RequiredModules @{ + If = { Test-Path $BuildRoot/RequiredModules.psd1 } + Inputs = { "$BuildRoot/RequiredModules.psd1" } + Outputs = { "$OutputPath/RequiredModules.psd1" } Jobs = (Get-Command "$PSScriptRoot/../scripts/Install-RequiredModule.ps1").ScriptBlock, { Copy-Item "$BuildRoot/RequiredModules.psd1" -Destination "$OutputPath/RequiredModules.psd1" } } diff --git a/tasks/UniversalPackagePack.Task.ps1 b/common/Pack-UniversalPackage.Task.ps1 similarity index 95% rename from tasks/UniversalPackagePack.Task.ps1 rename to common/Pack-UniversalPackage.Task.ps1 index 4c8bfd7..567ed59 100644 --- a/tasks/UniversalPackagePack.Task.ps1 +++ b/common/Pack-UniversalPackage.Task.ps1 @@ -1,6 +1,5 @@ -Add-BuildTask UniversalPackagePack @{ - If = $dotnetSolution +Add-BuildTask Pack-UniversalPackage @{ Inputs = { $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } @@ -25,7 +24,7 @@ Add-BuildTask UniversalPackagePack @{ } } # Requires dotnetpublish but future state this task should be able to publish any library (python, npm, whatever). These tasks were just initially written for dotnet projects - jobs = 'DotNetPublish', { + jobs = 'Publish-DotNet', { $VersionInfo = Get-Content (Join-Path $script:OutputPath version.json) | ConvertFrom-Json $script:UniversalPackageRoot = New-Item $script:UniversalPackageRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path Get-ChildItem $script:DotNetPublishRoot -Directory | ForEach-Object { diff --git a/common/Push-Docker.Task.ps1 b/common/Push-Docker.Task.ps1 new file mode 100644 index 0000000..8b782c6 --- /dev/null +++ b/common/Push-Docker.Task.ps1 @@ -0,0 +1,60 @@ +Add-BuildTask Push-Docker @{ + Inputs = { + # Docker metadata files created by DockerBuild task + $MetadataFiles = Get-ChildItem (Join-Path $script:OutputPath "docker") -Filter "*-metadata.json" -ErrorAction SilentlyContinue + if ($MetadataFiles) { + $MetadataFiles.FullName + } + else { + $BuildRoot + } + } + Outputs = { + # Create a marker file for each pushed image + $MetadataFiles = Get-ChildItem (Join-Path $script:OutputPath "docker") -Filter "*-metadata.json" -ErrorAction SilentlyContinue + if ($MetadataFiles) { + $MetadataFiles.ForEach({ + $ProjectName = $_.BaseName -replace '-metadata$', '' + Join-Path $script:OutputPath "docker/$ProjectName-pushed.txt" + }) + } + else { + $BuildRoot + } + } + Jobs = "Connect-AzACR", { + if ($script:PushEnabled) { + $script:DockerMetadataRoot = Join-Path $script:OutputPath "docker" + + $MetadataFiles = Get-ChildItem $script:DockerMetadataRoot -Filter "*-metadata.json" -ErrorAction SilentlyContinue + + foreach ($MetadataFile in $MetadataFiles) { + $ProjectName = $MetadataFile.BaseName -replace '-metadata$', '' + $Parts = $ProjectName.Split('.') + $PathPrefix = ($Parts[0..($Parts.Length - 3)] -join '/').ToLower() + $ImageName = ($Parts[($Parts.Length - 2)..($Parts.Length - 1)] -join '-').ToLower() + $Repository = "$PathPrefix/$ImageName" + $Version = $script:Version.SemVer + $FullImageName = "$script:ACRUri/$Repository`:$Version" + + Write-Build Yellow "docker push $FullImageName" + Invoke-Native { docker push $FullImageName } -ExceptionalExit + + # Create marker file to indicate successful push + $PushedMarker = Join-Path $script:DockerMetadataRoot "$ProjectName-pushed.txt" + @" +Image: $FullImageName +Pushed: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ss') +Registry: $script:ACRUri +Repository: $Repository +Version: $Version +"@ | Set-Content $PushedMarker + } + } + else { + Write-Warning ("Skipping push: To push images ensure that...`n" + + "`t* You are in a known build system (Current: $BuildSystem)`n" + + "`t* You are committing to the main or release or hotfix branch (Current: $BranchName) `n") + } + } +} diff --git a/tasks/DotNetToolRestore.Task.ps1 b/common/Restore-DotNetTools.Task.ps1 similarity index 57% rename from tasks/DotNetToolRestore.Task.ps1 rename to common/Restore-DotNetTools.Task.ps1 index a1e88bb..52d6c00 100644 --- a/tasks/DotNetToolRestore.Task.ps1 +++ b/common/Restore-DotNetTools.Task.ps1 @@ -1,4 +1,12 @@ -Add-BuildTask DotNetToolRestore @{ +<# +.SYNOPSIS + Restore dotnet tools specified in the manifest file. +.DESCRIPTION + We use a few core dotnet tools as part of every build. Therefore, if there is no dotnet-tools.json manifest file in your repository, one will be copied from the BuildTasks repo. + + Then `dotnet tool restore` will be run to ensure the tools are installed. +#> +Add-BuildTask Restore-DotNetTools @{ Jobs = { $DotNetToolManifest = @( Join-Path $BuildRoot .config/dotnet-tools.json diff --git a/common/Tag-Source.Task.ps1 b/common/Tag-Source.Task.ps1 new file mode 100644 index 0000000..f51e87e --- /dev/null +++ b/common/Tag-Source.Task.ps1 @@ -0,0 +1,22 @@ +Add-BuildTask Tag-Source @{ + If = { $script:BuildSystem -ne 'None' -and $script:BranchName -match "^main" } + Jobs = "Get-Version", { + $tag = $script:Version.Tag + $sha = $script:Version.Sha + + if (-not $tag -or -not $sha) { + throw "Version.Tag ('$tag') or Version.Sha ('$sha') is missing. Cannot tag." + } + + Write-Build Gray "Tag: $tag" + Write-Build Gray "Sha: $sha" + + # Ensure git user is configured for the annotated tag + if (-not (git config get user.email)) { + git config user.name 'GitVersion' + git config user.email 'DevOps@loandepot.com' + } + Write-Build Yellow "New-GitTag -TagName $tag -Sha $sha" + New-GitTag -TagName $tag -Sha $sha + } +} diff --git a/common/base.ps1 b/common/base.ps1 new file mode 100644 index 0000000..4cf67cf --- /dev/null +++ b/common/base.ps1 @@ -0,0 +1,206 @@ +#Requires -PSEdition Core + +<# +.SYNOPSIS + Base build script -- core initialization shared by all build types. +.DESCRIPTION + Provides shared parameters, bootstrapping, environment detection, + output path setup, and .Task.ps1 imports. Not intended to be invoked + directly -- use build.dotnet.ps1 or build.helm.ps1 (or both via Extends). +.NOTES + 0.6.0 - Split from build.example.ps1 +#> +[CmdletBinding()] +param( + # Add the clean task before the default build + [switch]$Clean, + + # Collect code coverage when tests are run + [switch]$CollectCoverage +) + +## Guard against double-initialization in diamond inheritance +## (e.g. a project extends both dotnet.ps1 and helm.ps1) +if ($script:_BuildBaseInitialized) { return } +$script:_BuildBaseInitialized = $true + +## When used via Extends, redirect $BuildRoot to the derived (root) script's directory. +## This ensures all initialization below uses the project's paths, not the base script's. +## See: https://github.com/nightroman/Invoke-Build/blob/main/Tasks/Extends/README.md#build-roots +if ($BuildRoots.Count -gt 1) { + $BuildRoot = $BuildRoots[-1] +} +$script:BuildTasksRoot = "$PSScriptRoot/.." | Convert-Path + +Write-Information "$($PSStyle.Foreground.BrightBlue)Initializing task variables$($PSStyle.Reset)" + +# Common PowerShell Formatting Options +$script:ErrorView = "DetailedView" +$script:InformationPreference = "Continue" +$script:ErrorActionPreference = "Stop" +$PSStyle.OutputRendering = "ANSI" +# We're going to treat "DIAGNOSTIC" as "VERBOSE" + "DEBUG" and hope it rarely happens! ;) +if ($ENV:AGENT_DIAGNOSTIC -eq 'True' -or $ENV:SYSTEM_DEBUG -eq 'True') { + $script:VerbosePreference = "Continue" + $script:DebugPreference = "Continue" + + Get-ChildItem Env:* | ForEach-Object { + Write-Information "$($PSStyle.Foreground.BrightBlue) Env:$($_.Name) = $($_.Value)$($PSStyle.Reset)" + } +} + +# Force distinct colors for Verbose and Debug +if ($PSStyle.Formatting.Verbose -eq $PSStyle.Formatting.Warning) { + $PSStyle.Formatting.Verbose = $PSStyle.Foreground.BrightCyan +} +if ($PSStyle.Formatting.Debug -eq $PSStyle.Formatting.Warning) { + $PSStyle.Formatting.Debug = $PSStyle.Foreground.BrightGreen +} + +# NOTE: this variable is currently also used for Pester formatting ... +# We should use either "Harness", "AzureDevOps", "GithubActions", or "None" +$script:BuildSystem = if (Test-Path Env:HARNESS_STAGE_ID) { + "Harness" +} elseif (Test-Path Env:GITHUB_ACTIONS) { + "GithubActions" +} elseif (Test-Path Env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { + "AzureDevops" +} elseif (Test-Path Env:EARTHLY_BUILD_SHA) { + "Earthly" +} else { + "None" +} + +Write-Information "$($PSStyle.Foreground.BrightBlue) BuildSystem [$BuildSystem]$($PSStyle.Reset)" +Write-Information "$($PSStyle.Foreground.BrightBlue) Information [$InformationPreference]$($PSStyle.Reset)" +Write-Information "$($PSStyle.Foreground.BrightBlue) Verbose [$VerbosePreference]$($PSStyle.Reset)" +Write-Information "$($PSStyle.Foreground.BrightBlue) Debug [$DebugPreference]$($PSStyle.Reset)" + +# A little extra BuildEnvironment magic +Set-BuildHeader { Write-Build 11 "Start Task: $($args[0])" } +Set-BuildFooter { Write-Build 11 "Finish Task: $($args[0]) $($Task.Elapsed) [Total: $([DateTime]::Now - ${*}.Started)]" } + + +# Cross-platform separator character +${script:/} = [IO.Path]::DirectorySeparatorChar + +# BuildRoot is provided by Invoke-Build +Write-Information "$($PSStyle.Foreground.BrightBlue) BuildRoot [$BuildRoot]$($PSStyle.Reset)" + +# Enter-Build runs only when actually building (not during ??, ?, or WhatIf). +# Each script in the Extends tree gets its own Enter-Build invoked with its $BuildRoot. +Enter-Build { + # In CI builds you have a BranchName + $script:BranchName = if ($Env:BUILD_SOURCEBRANCHNAME) { + $Env:BUILD_SOURCEBRANCHNAME + } elseif (Get-Command git -CommandType Application -ErrorAction SilentlyContinue) { + git branch --show-current + } + + <# ? None of this information is being used except to print it out here... + [bool]$script:IsPullRequest = $script:IsPullRequest ?? ($Env:BUILD_REASON -eq "PullRequest" -or $Env:DRONE_BUILD_EVENT -eq "pull_request") + [long]$script:PullRequestId = $script:PullRequestId ?? $Env:SYSTEM_PULLREQUEST_PULLREQUESTID ?? $Env:DRONE_PULL_REQUEST + [string]$script:SourceBranch = $script:SourceBranch ?? $Env:SYSTEM_PULLREQUEST_SOURCEBRANCH ?? $Env:BUILD_SOURCEBRANCH ?? $Env:DRONE_SOURCE_BRANCH ?? $Env:CI_COMMIT_BRANCH ?? $script:BranchName + [string]$script:TargetBranch = $script:TargetBranch ?? $ENV:SYSTEM_PULLREQUEST_TARGETBRANCH ?? $Env:DRONE_TARGET_BRANCH ?? $script:MainBranch + [string]$script:ProductName = $script:ProductName ?? $Env:PRODUCT_NAME ?? $Env:PIPELINE_NAME ?? $Env:DRONE_REPO_NAME ?? $Env:CI_REPO + [string]$script:PipelineId = $script:PipelineId ?? $Env:PIPELINE_ID ?? $Env:HARNESS_PIPELINE_ID ?? $Env:PLUGIN_PIPELINE ?? "local build" + [string]$script:PipelineExecutionId = $script:PipelineExecutionId ?? $Env:PIPELINE_EXECUTION_ID ?? $Env:BUILD_ID ?? $Env:HARNESS_EXECUTION_ID ?? $Env:HARNESS_BUILD_ID ?? $Env:DRONE_BUILD_NUMBER ?? "0" + + Write-Information "$($PSStyle.Foreground.BrightBlue) BranchName [$BranchName]$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) IsPullRequest [$IsPullRequest]$($PSStyle.Reset)" + if ($IsPullRequest) { + Write-Information "$($PSStyle.Foreground.BrightBlue) PullRequestId [$PullRequestId]$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) SourceBranch [$SourceBranch]$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) TargetBranch [$TargetBranch]$($PSStyle.Reset)" + } + if ($BuildSystem -ne "None") { + Write-Information "$($PSStyle.Foreground.BrightBlue) ProductName [$ProductName]$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) PipelineId [$PipelineId]$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) PipelineExecutionId [$PipelineExecutionId]$($PSStyle.Reset)" + } + #> + + #?# Note about Azure Pipeline environment variables: + # $Env:PIPELINE_WORKSPACE - Defaults to work/job + ### These other three are defined relative to $Env:PIPELINE_WORKSPACE + # $Env:BUILD_SOURCESDIRECTORY - Cleaned BEFORE checkout IF: Workspace.Clean = All or Resources, or if Checkout.Clean = $True + # Importantly, defaults to work/job/s BUT when there are multiple sources, can be work/job/s/sourcename + # $Env:BUILD_BINARIESDIRECTORY - Cleaned BEFORE build IF: Workspace.Clean = Outputs + # $Env:BUILD_STAGINGDIRECTORY - Cleaned after each Build + ### Additionally, these two are cleaned after each Job: + # $Env:AGENT_TEMPDIRECTORY + # $Env:COMMON_TESTRESULTSDIRECTORY + + # Build-system information. There are a few different sources for the information + # But each variable should have a default here: + $Script:OutputPath = if ($Env:BUILD_BINARIESDIRECTORY) { + $Env:BUILD_BINARIESDIRECTORY + } else { + Join-Path $BuildRoot 'Output' + } + New-Item -Type Directory -Path $OutputPath -Force | Out-Null + + $Script:TestResultsRoot = $script:TestResultsRoot ?? # An override for build script parameters + $Env:LDBUILD_TEST_ROOT ?? # An override for machine-level settings + $Env:COMMON_TESTRESULTSDIRECTORY ?? # Azure + $Env:TEST_RESULTS_DIRECTORY ?? + (Join-Path $OutputPath testresults) + + $Script:TempDirectory = @(Get-Content Env:LDBUILD_TEMP_DIRECTORY, Env:AGENT_TEMPDIRECTORY, Env:COMMON_TESTRESULTSDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | + Where-Object { Test-Path $_ } | + Select-Object -First 1 + if (-not $Script:TempDirectory) { $Script:TempDirectory = if ($IsLinux) { "/tmp" } else { [System.IO.Path]::GetTempPath() } } + + # If you need to install additional tools, we use Install-GitHubRelease + # Set the Tools hashtable to @{ exe = "org", "project" } + # For example: + # $Script:Tools = @{ + # yq = "mikefarah", "yq" + # flux = "fluxcd", "flux2" + # } + [hashtable]$Script:GHTools = @{} + ($Script:GHTools ?? @{}) + + $script:UniversalPackageRoot ??= Join-Path $script:OutputPath universal + + # Allow a -Clean switch to add the "Clean-Output" task on the front + if ($Clean -and -not ($BuildTask -eq "Clean-Output")) { + $BuildTask = @("Clean-Output") + $BuildTask + } + + Write-Build Cyan " Output [$OutputPath]" + Write-Build Cyan " TestResultsRoot: $TestResultsRoot" + Write-Build Cyan " TempDirectory: $TempDirectory" + Write-Build Cyan " UniversalPackageRoot: $UniversalPackageRoot" + + # The default goal is 90% code coverage + $Script:RequiredCodeCoverage ??= 0.9 +} + +# Our common task definitions +$script:InitializeTasks = "Install-RequiredModules", "Install-GitHubTools", "Restore-DotNetTools", "Get-Version" +$script:BuildTasks = @() +$script:PublishTasks = @() +$script:TestTasks = @() +$script:PushTasks = @("Push-Docker") +$script:CheckpointTasks = @("Tag-Source") + +# Initially define the CI task as Get-Version...Tag-Source using virtual task names +Add-BuildTask CI @( + # In CI pipelines (or if you specify $Clean) + if ($BuildSystem -ne "None" -or $Script:Clean) { + # Run the Clean-Output task before the rest of the build tasks + "Clean-Output" + } + "Get-Version" + "Initialize" + "Build" + "Test" + "Publish" + "Push" + "Tag-Source" +) + +foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { + # Write-Information "$($PSStyle.Foreground.BrightBlue) $($taskfile.FullName)$($PSStyle.Reset)" + . $taskfile.FullName +} diff --git a/docs/Extends.md b/docs/Extends.md index 9a41844..a6aa7e9 100644 --- a/docs/Extends.md +++ b/docs/Extends.md @@ -1,161 +1,571 @@ # Build Script Inheritance with Invoke-Build `Extends` -Invoke-Build (v5.11+) supports a special `$Extends` parameter that enables **build script inheritance**. Instead of copying `build.example.ps1` into every project, a derived script can extend the base and inherit its parameters, initialization, and task definitions. +Invoke-Build (v5.11+) supports a special `$Extends` parameter that enables **build script inheritance**. A project's build script can extend the base scripts and inherit their parameters, initialization, and task definitions. We've organized our tasks into framework folders, and each framework has a `base.ps1` script in it. To create a build, you'll want to extend one or more of those! ## How It Works ### The `$Extends` Parameter -A derived build script declares a `param` block with a single special parameter: +A build script declares a `param` block with a `ValidateScript` attribute on a parameter named `$Extends` that returns the paths to the base script(s) you want to inherit. ```powershell param( - [ValidateScript({"..\LD.Platform.BuildTasks\build.example.ps1"})] - $Extends + [ValidateScript({ + @( + "../*BuildTasks/dotnet/base.ps1" + "../*BuildTasks/helm/base.ps1" + ) | Resolve-Path + })] + $Extends, + $TargetFramework = "net8.0" ) ``` When Invoke-Build processes this script: 1. It finds the `$Extends` parameter with a `ValidateScript` attribute -2. It **evaluates the script block** to get the path(s) to base script(s) -3. It resolves the path relative to the derived script's directory (`$BuildRoot`) -4. It **processes the base script first** — parameters, body, and task definitions -5. Then it processes the derived script's body +2. It **evaluates the ValidateScript** to get the path(s) to base script(s) +3. It resolves those paths relative to the script's directory (`$BuildRoot`) +4. It **processes the base scripts first** +5. Then it processes the build script's body -The `ValidateScript` block is not used for validation here — Invoke-Build hijacks it to extract the base script path. The block should return one or more path strings. +Note that the `ValidateScript` block is not used for validation. +In fact, no value is ever passed to that parameter. +Invoke-Build uses it solely to determine the base scripts. ### What Gets Inherited | Inherited from base | How | |---|---| -| **Parameters** | The base script's `param()` block becomes shared. The derived script gets `-Configuration`, `-Clean`, `-Solution`, etc. without redeclaring them. | -| **Task definitions** | Aggregate tasks like `CI`, `Build`, `Test`, `Pack`, `Publish` carry over automatically. | -| **Script body code** | Initialization, variable setup, and task imports all execute during base processing. | +| **Parameters** | The variables in each base script's `param()` block become script variables and parameters to Invoke-Build. The derived script gets `-Clean`, `-TargetFramework`, `-ChartName`, etc. without redeclaring them. | +| **Task definitions** | Tasks defined in base scripts (and their imported `.Task.ps1` files) carry over automatically. | +| **Script body code** | Lightweight initialization and task imports execute during base processing. | +| **Enter-Build blocks** | Each base script's `Enter-Build` block runs (in order) before the first task. | ### What the Derived Script Controls -- **Override tasks** — `Add-BuildTask` with the same name as a base task **replaces** it (the old definition is moved to a "Redefined" list). -- **Add new tasks** — Define project-specific tasks not in the base. -- **Re-initialize variables** — Fix path-dependent variables that were set with the base's `$BuildRoot` (see below). - -## The `$BuildRoot` Problem +- **Override tasks** -- Calling `Add-BuildTask` with the same name as any existing task **replaces** it (the old definition is moved to a "Redefined" list). +- **Add new tasks** -- Define project-specific tasks not in the base. +- **Redirect `$BuildRoot`** -- Base scripts use `$BuildRoot = $BuildRoots[-1]` to point at the consuming project's directory (see [`$BuildRoot` Flow](#buildroot-flow-with-the-modern-buildscripts-architecture) below). -This is the most important caveat when using Extends with `build.example.ps1`. +## Multiple Inheritance and Prefixing -When Invoke-Build processes the base script via Extends, **`$BuildRoot` is set to the base script's directory**, not the derived project's directory. This means all initialization code in `_Initialize.ps1` runs with wrong paths: +Invoke-Build supports specifying a extending multiple scripts and renaming inherited tasks with a prefix using `::` syntax: +```powershell +param( + [ValidateScript({ + "dotnet::../dotnet/base.ps1" + "../common/base.ps1" + })] + $Extends +) ``` -# During Extends (base script processing): -$BuildRoot = C:\XDL\LD.Platform.BuildTasks # <- base script's dir -# During derived script body: -$BuildRoot = C:\XDL\LD.Shared.MyProject # <- correct +Tasks from `dotnet/base` would be prefixed (e.g., `dotnet::Build`), while tasks from `common` keep their original names. Tasks named `.` (the default task) are never renamed. + +## Parameter Inheritance + +### How Parameters Are Merged + +Invoke-Build discovers parameters by walking the Extends chain **depth-first**. Each script's `param()` block is read, and its parameters are added to a global dictionary. Since it's a dictionary, **the last script to declare a parameter wins** (its type, attributes, and default value become the "official" definition for that parameter). + +For our architecture, the Extends chain and discovery order looks like this: + ``` +extend.build.ps1 ← discovery starts here + ├── Extends: helm/base.ps1 ← listed first + │ └── Extends: common/base.ps1 ← helm's base + └── Extends: dotnet/base.ps1 ← listed second + └── Extends: common/base.ps1 ← dotnet's base (diamond) +``` + +Parameter discovery order (depth-first recursion): -Variables set during base initialization that depend on `$BuildRoot` will have stale values: -- `$OutputPath` — uses direct assignment, gets overwritten on re-init -- `$HelmChartRoot` — uses `??=`, does NOT get overwritten -- `$TestResultsRoot` — uses `??` chain, does NOT get overwritten -- `$UniversalPacakgeRoot` — uses `??=`, does NOT get overwritten +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ PARAMETER DISCOVERY (depth-first) │ +│ │ +│ 1. common/base.ps1 → $Clean, $CollectCoverage │ +│ 2. helm/base.ps1 → $HelmChartRoot, $ChartName │ +│ 3. common/base.ps1 → $Clean, $CollectCoverage (same, no-op) │ +│ 4. dotnet/base.ps1 → $Configuration, $Solution, | +| $dotnetSolution, $dotnetOptions, │ +│ $TargetFramework = "net10.0", ← registered │ +│ $TargetRuntime │ +│ 5. extend.build.ps1 → $TargetFramework = "net8.0" ← OVERWRITES! │ +│ │ +│ Final param dictionary for $TargetFramework: │ +│ default = "net8.0" (from extend.build.ps1, the last writer) │ +└─────────────────────────────────────────────────────────────────────┘ +``` -### The Fix: Reset and Re-initialize +### User-Provided Values vs Defaults -The derived script must: +There are two cases when a parameter is defined in multiple scripts: -1. **Reset variables** that use `??=` (null-coalescing assignment) so `_Initialize.ps1` can set them correctly -2. **Re-run `_Initialize.ps1`** with the correct `$BuildRoot` +**Case 1: User provides a value on the command line** ```powershell -# Reset variables that won't self-correct due to ??= operators -Remove-Variable HelmChartRoot -ErrorAction Ignore -$script:TestResultsRoot = $null -$script:UniversalPacakgeRoot = $null - -foreach($taskDir in $Tasks) { - $initialize = Join-Path $taskDir "_Initialize.ps1" - if (Test-Path $initialize) { - . $initialize - } -} +Invoke-Build Build -TargetFramework net9.0 +``` + +The user-provided value is **splatted to every script that declares that parameter**. All scripts see `$TargetFramework = "net9.0"`. No conflict. + +**Case 2: No user-provided value (defaults apply)** + +Each script's `param()` block evaluates its own default expression when that script is dot-sourced. Since all scripts share the same scope, **the last script body to run overwrites the variable**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ LOADING PHASE -- $TargetFramework default resolution │ +│ │ +│ BB[0] common/base.ps1 body runs │ +│ │ (does not declare $TargetFramework) │ +│ ▼ │ +│ BB[1] helm/base.ps1 body runs │ +│ │ (does not declare $TargetFramework) │ +│ ▼ │ +│ BB[2] common/base.ps1 body runs (diamond -- guard returns early) │ +│ ▼ │ +│ BB[3] dotnet/base.ps1 body runs │ +│ │ param($TargetFramework = "net10.0") │ +│ │ $TargetFramework is now "net10.0" ← dotnet's default │ +│ ▼ │ +│ BB[4] extend.build.ps1 body runs │ +│ │ param($TargetFramework = "net8.0") │ +│ │ $TargetFramework is now "net8.0" ← OVERWRITES! derived wins │ +│ ▼ │ +│ Final: $TargetFramework = "net8.0" │ +└─────────────────────────────────────────────────────────────────────┘ ``` -> **Why `Remove-Variable` for `$HelmChartRoot`?** -> The base script's param block declares `[string]$HelmChartRoot`. The `[string]` type constraint converts `$null` to `""` (empty string). Since `""` is not `$null`, the `??=` operator in `_Initialize.ps1` won't overwrite it. `Remove-Variable` fully removes the typed variable so `??=` works on the next run. +**The derived (root) script's default always wins**, because it runs last. -Re-running `_Initialize.ps1` also **re-imports all `.Task.ps1` files**. Since `Add-BuildTask` replaces existing tasks with the same name, the re-imported tasks get the correct `$BuildRoot` context (stored in the task's `B1` property). +### Parameters in Enter-Build -## Complete Example +All `Enter-Build` blocks run **after all script bodies have completed**. They execute in the shared script scope, so they see the **final parameter values** -- not the intermediate values their script's body saw: -### Base script: `build.example.ps1` +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ PARAMETER VALUES OVER TIME │ +│ │ +│ dotnet/base.ps1 body: │ +│ │ $TargetFramework = "net10.0" ← dotnet's default at this point│ +│ │ Write-Verbose "TARGETFRAME: $TargetFramework" │ +│ │ # prints: "net10.0" │ +│ ▼ │ +│ extend.build.ps1 body: │ +│ │ $TargetFramework = "net8.0" ← overwrites to derived default │ +│ ▼ │ +│ ─── all bodies done, Enter-Build phase begins ─── │ +│ │ +│ common/base.ps1 Enter-Build: │ +│ │ $Clean is available (parameter from common/base.ps1) ✓ │ +│ ▼ │ +│ helm/base.ps1 Enter-Build: │ +│ │ $HelmChartRoot is available (parameter from helm/base.ps1) ✓ │ +│ ▼ │ +│ dotnet/base.ps1 Enter-Build: │ +│ │ $TargetFramework = "net8.0" ← sees the FINAL value ✓ │ +│ │ Write-Verbose "Enter-Build: TARGETFRAME: $TargetFramework" │ +│ │ # prints: "net8.0" -- NOT "net10.0"! │ +│ ▼ │ +│ extend.build.ps1: (no Enter-Build) │ +└─────────────────────────────────────────────────────────────────────┘ +``` -Located in `LD.Platform.BuildTasks`. Contains shared parameters, initialization, bootstrapping, and aggregate task definitions (CI, Build, Test, etc.). +**Key rule**: Enter-Build blocks can reference any parameter from any script in the chain -- they all share the same scope. But when a parameter is declared in multiple scripts, Enter-Build always sees the derived script's default (or the user-provided value). -### Derived script: `build.build.ps1` +### Parameter Default Expressions and `$PSScriptRoot` -Located in the consuming project (e.g., `LD.Shared.EnterprisePlatformServices.API`): +Parameter default expressions evaluate in the context of **their declaring script**. This matters when defaults use `$PSScriptRoot`: ```powershell -<# -.SYNOPSIS - ./build.build.ps1 -.EXAMPLE - Invoke-Build -#> -[CmdletBinding()] +# In helm/base.ps1 -- $PSScriptRoot = C:\...\LD.Platform.BuildTasks\helm param( - [ValidateScript({"..\LD.Platform.BuildTasks\build.example.ps1"})] - $Extends + [string]$HelmChartRoot = @( + if (Get-ChildItem -Path "$PSScriptRoot/charts" ...) { # ← helm.ps1's dir + Resolve-Path "$PSScriptRoot/charts" + } + )[0] ) +``` -$Tasks = "../LD.Platform.BuildTasks/tasks", "../BuildTasks/tasks", "../tasks/tasks", "tasks" | - Convert-Path -ErrorAction Ignore - -## Self-contained: can be invoked directly or via Invoke-Build -if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { - foreach ($taskDir in $Tasks) { - $bootstrap = Join-Path $taskDir "_BootStrap.ps1" - if (Test-Path $bootstrap) { . $bootstrap } - } - Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result - if ($Result.Error) { - $Error[-1].ScriptStackTrace | Out-Host - exit 1 - } - exit 0 -} +When used via Extends, `$PSScriptRoot` resolves to `helm/`, not the project directory. This is why scripts re-evaluate path-dependent params in their body after redirecting `$BuildRoot`: -## Re-initialize with this project's $BuildRoot -Remove-Variable HelmChartRoot -ErrorAction Ignore -$script:TestResultsRoot = $null -$script:UniversalPacakgeRoot = $null -foreach($taskDir in $Tasks) { - $initialize = Join-Path $taskDir "_Initialize.ps1" - if (Test-Path $initialize) { . $initialize } +```powershell +# In helm/base.ps1 body -- after $BuildRoot = $BuildRoots[-1] +if ($BuildRoots.Count -gt 1 -and -not $HelmChartRoot) { + $HelmChartRoot = @( + if (Get-ChildItem -Path "$BuildRoot/charts" ...) { # ← project dir + Resolve-Path "$BuildRoot/charts" + } + )[0] } - -## Project-specific aggregate tasks -Add-BuildTask HelmBuild InstallRequiredModules, GetVersion, HelmUpdateValuesSchema -Add-BuildTask HelmTest HelmBuild, HelmTestChart -Add-BuildTask HelmPack HelmTest, HelmPackChart -Add-BuildTask HelmPush HelmPack, HelmPushChart ``` -## Multiple Inheritance and Prefixing +### Diamond Inheritance and the Guard Pattern -Invoke-Build also supports extending multiple scripts and renaming inherited tasks with a prefix using `::` syntax: +When `extend.build.ps1` extends both `helm/base.ps1` and `dotnet/base.ps1`, and both extend `common/base.ps1`, the base script gets loaded twice. The guard pattern prevents double-initialization: ```powershell -param( - [ValidateScript({ - "MyPrefix::..\Base1\build.ps1" - "..\Base2\build.ps1" - })] - $Extends -) +# In common/base.ps1 +if ($script:_BuildBaseInitialized) { return } +$script:_BuildBaseInitialized = $true +``` + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ DIAMOND INHERITANCE │ +│ │ +│ BB[0] common/base.ps1 (from helm/base.ps1's chain) │ +│ │ Guard: $false → runs fully, registers Enter-Build │ +│ ▼ │ +│ BB[1] helm/base.ps1 │ +│ ▼ │ +│ BB[2] common/base.ps1 (from dotnet/base.ps1's chain) │ +│ │ Guard: $true → return (body skipped, NO Enter-Build registered)│ +│ ▼ │ +│ BB[3] dotnet/base.ps1 │ +│ ▼ │ +│ BB[4] extend.build.ps1 │ +│ │ +│ Enter-Build runs for: BB[0], BB[1], BB[3], BB[4] │ +│ (BB[2] has no Enter-Build because its body returned early) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Parameter Inheritance Summary + +``` +┌──────────────────────────────┬──────────────────────────────────────┐ +│ Scenario │ Behavior │ +├──────────────────────────────┼──────────────────────────────────────┤ +│ User passes -Param value │ All scripts see "value" │ +│ Only one script defines it │ That script's default is used │ +│ Multiple scripts define it │ Last body to run wins (= derived) │ +│ $PSScriptRoot in defaults │ Resolves to declaring script's dir │ +│ Enter-Build reads a param │ Sees the final value after all bodies│ +│ Diamond inheritance │ Guard prevents double-init │ +│ Param from any base │ Available everywhere (shared scope) │ +└──────────────────────────────┴──────────────────────────────────────┘ ``` -Tasks from `Base1` would be prefixed (e.g., `MyPrefix::Build`), while tasks from `Base2` keep their original names. Tasks named `.` (the default task) are never renamed. +## `$BuildRoot` Flow with the Framework Folder Architecture + +The modern approach uses composable scripts in framework folders (`common/base.ps1`, `dotnet/base.ps1`, `helm/base.ps1`) with `Enter-Build` for heavy initialization. Here's how `$BuildRoot` flows through the entire lifecycle. + +### The Extends Chain + +``` +extend.build.ps1 (C:\XDL\LD.Shared.EnterprisePlatformServices.API\) + ├── Extends: helm/base.ps1 (C:\XDL\LD.Platform.BuildTasks\helm\) + │ └── Extends: common/base.ps1 + └── Extends: dotnet/base.ps1 (C:\XDL\LD.Platform.BuildTasks\dotnet\) + └── Extends: common/base.ps1 (diamond -- guarded) +``` + +### Phase 1: Loading (script bodies run) + +Invoke-Build processes scripts **base-first → derived-last** (depth-first). Each script gets its own build block (`B1`) with its own `$BuildRoot` set to that script's directory: + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ LOADING PHASE (runs for ??, ?, and real builds) │ +│ │ +│ ┌──── BB[0]: common/base.ps1 (from helm/base.ps1's chain) ─────┐ │ +│ │ $BuildRoot = C:\...\LD.Platform.BuildTasks\buildscripts\ │ │ +│ │ $BuildRoots = @( │ │ +│ │ "C:\...\buildscripts\", ← [0] │ │ +│ │ "C:\...\buildscripts\", ← [1] │ │ +│ │ "C:\...\EnterprisePlatformServices.API\" ← [-1] │ │ +│ │ ) │ │ +│ │ $BuildRoot = $BuildRoots[-1] ← REDIRECTS to project dir! │ │ +│ │ # Sets preferences, $BuildSystem, $BranchName, etc. │ │ +│ │ # Imports .Task.ps1 files │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──── BB[1]: helm/base.ps1 ────────────────────────────────────┐ │ +│ │ $BuildRoot = $BuildRoots[-1] ← REDIRECTS to project dir! │ │ +│ │ # Re-evaluates $HelmChartRoot from project dir │ │ +│ │ # Sets $script:HelmChartRoot for task If conditions │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──── BB[2]: common/base.ps1 (from dotnet/base.ps1 ) ──────────┐ │ +│ │ Guard: $_BuildBaseInitialized = $true → return │ │ +│ │ (body skipped, no Enter-Build registered) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──── BB[3]: dotnet/base.ps1 ──────────────────────────────────┐ │ +│ │ $BuildRoot = $BuildRoots[-1] ← REDIRECTS to project dir! │ │ +│ │ # Re-evaluates $dotnetSolution from project dir │ │ +│ │ # Sets $script:dotnetSolution for task If conditions │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──── BB[4]: extend.build.ps1 ─────────────────────────────────┐ │ +│ │ $BuildRoot = C:\...\EnterprisePlatformServices.API\ │ │ +│ │ (no redirect needed -- already the root script) │ │ +│ │ # Defines aggregate tasks: Restore, Build, Test, Pack, Push │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ After loading, $BuildRoot is RESOLVED and LOCKED (made constant) │ +│ per build block. Each BB remembers its final $BuildRoot. │ +└────────────────────────────────────────────────────────────────────┘ +``` + +### Phase 2: Enter-Build (only real builds) + +`Enter-Build` blocks run **in inheritance order** (base first → derived last), each with its BB's stored `$BuildRoot`. This phase is **skipped entirely** for `??`, `?`, and `WhatIf` queries -- making task listing fast. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ENTER-BUILD PHASE (skipped for ?? and ? queries) │ +│ │ +│ BB[0] common/base.ps1 Enter-Build: │ +│ │ $BuildRoot = ...EnterprisePlatformServices.API\ (redirected) │ +│ │ Creates Output/, testresults/ dirs │ +│ │ Sets $OutputPath, $TestResultsRoot, $GHTools, etc. │ +│ ▼ │ +│ BB[1] helm/base.ps1 Enter-Build: │ +│ │ $BuildRoot = ...EnterprisePlatformServices.API\ (redirected) │ +│ │ Sets $helmOutputPath, $ACRName, enumerates charts │ +│ ▼ │ +│ BB[2] common/base.ps1: (diamond -- no Enter-Build) │ +│ ▼ │ +│ BB[3] dotnet/base.ps1 Enter-Build: │ +│ │ $BuildRoot = ...EnterprisePlatformServices.API\ (redirected) │ +│ │ Sets $SolutionOutputPath, runs dotnet sln list, etc. │ +│ ▼ │ +│ BB[4] extend.build.ps1: (no Enter-Build defined) │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +`Exit-Build` blocks run in **reverse order** (derived first → base last), including on failures. + +### Phase 3: Task Execution + +Each **task** remembers which BB defined it (stored in `$Task.B1`). When a task runs, `$BuildRoot` is set from that task's BB -- and that BB's `Enter-BuildTask` / `Exit-BuildTask` blocks are invoked: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ TASK EXECUTION │ +│ │ +│ Task "Build" (defined in extend.build.ps1 → BB[4]) │ +│ │ $BuildRoot = ...EnterprisePlatformServices.API\ │ +│ │ │ +│ ├─► Task "Build-DotNet" (imported by common/base.ps1 → BB[0]) │ +│ │ $BuildRoot = ...EnterprisePlatformServices.API\ (redirected) │ +│ │ Enter-BuildTask from BB[0] runs (if defined) │ +│ │ Exit-BuildTask from BB[0] runs (if defined) │ +│ ... │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### The Key Insight: `$BuildRoots[-1]` + +Without the `$BuildRoot = $BuildRoots[-1]` redirect in `common/base.ps1`, `helm/base.ps1`, and `dotnet/base.ps1`, `$BuildRoot` would point to the **base script's** directory. That's wrong because paths like `Join-Path $BuildRoot "charts"` or `Join-Path $BuildRoot "Output"` need to resolve relative to the **consuming project**, not the shared build framework. + +The `$BuildRoots` array is available during loading and always has `[-1]` pointing to the root (derived) script's directory. So `$BuildRoot = $BuildRoots[-1]` is the standard pattern to redirect `$BuildRoot` upward to the consuming project. + +### Why `Enter-Build` Matters + +| What | Body (loading) | `Enter-Build` | +|---|---|---| +| **When it runs** | Always (including `??`, `?`, WhatIf) | Only for real builds | +| **Use for** | Lightweight setup, task `If` variables | Directory creation, expensive commands | +| **`$BuildRoot`** | Set per-BB, can be redirected | Set per-BB, already locked | +| **Task `If` scriptblocks** | Can reference body variables | Can reference Enter-Build variables | + +Task `If` conditions that reference variables set in `Enter-Build` **must** be wrapped in `{}` scriptblocks so they evaluate at runtime (after Enter-Build) rather than at definition time (during loading). + +## `Enter-Build` Architecture + +### The Problem: Loading ≠ Building + +Every time Invoke-Build touches your script -- even just to list tasks (`??`), show help (`?`), or run with `-WhatIf` -- it executes the **entire script body**. Before `Enter-Build`, that meant every query paid the full cost of initialization: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ BEFORE: Everything in the script body │ +│ │ +│ User runs: Invoke-Build ?? │ +│ │ +│ Script body executes: │ +│ ├── $BuildRoot redirect (lightweight) ✓ │ +│ ├── Set preferences, $BuildSystem (lightweight) ✓ │ +│ ├── New-Item Output/, testresults/ dirs (side effect) ✗ │ +│ ├── dotnet sln list, dotnet --version (expensive) ✗ │ +│ ├── Get-ChildItem for Helm charts (expensive) ✗ │ +│ ├── Initialize $GHTools, $TempDirectory (not needed) ✗ │ +│ └── Define tasks (required) ✓ │ +│ │ +│ Result: Slow ?? queries, directories created unnecessarily, │ +│ external commands run for no reason │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### The Solution: Split Body vs Enter-Build + +`Enter-Build` is a special block that Invoke-Build calls **only when actually building** -- after loading, after task resolution, right before the first task runs. Each script in the Extends chain gets its own independent `Enter-Build`. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ AFTER: Split between body and Enter-Build │ +│ │ +│ ┌── SCRIPT BODY (runs ALWAYS, even for ??) ─────────────────────┐ │ +│ │ │ │ +│ │ # Lightweight, no side effects │ │ +│ │ $BuildRoot = $BuildRoots[-1] ← redirect │ │ +│ │ $script:BuildSystem = ... ← env detection │ │ +│ │ $script:BranchName = ... ← git branch │ │ +│ │ $script:dotnetSolution = ... ← for task If │ │ +│ │ $script:HelmChartRoot = ... ← for task If │ │ +│ │ Set-BuildHeader { ... } ← cosmetic │ │ +│ │ │ │ +│ │ # Task definitions │ │ +│ │ Add-BuildTask Build-DotNet @{ If = { $script:dotnetSolution } │ │ +│ │ Add-BuildTask Pack-Helm @{ If = { $script:ChartName } } │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌── ENTER-BUILD (runs ONLY for real builds) ────────────────────┐ │ +│ │ │ │ +│ │ # Heavy init, side effects OK │ │ +│ │ New-Item $OutputPath -Type Directory ← creates dirs │ │ +│ │ New-Item $TestResultsRoot -Type Directory │ │ +│ │ dotnet sln list ← expensive call │ │ +│ │ dotnet --version ← expensive call │ │ +│ │ Get-ChildItem for $HelmCharts ← chart enumeration │ │ +│ │ $script:GHTools = ... ← tool registration │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### How Enter-Build Executes Across the Extends Chain + +Each script registers its own `Enter-Build` block. They run **in inheritance order**, each with the correct `$BuildRoot`, and each in the **script scope** (as if the code were written directly in the script body): + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Invoke-Build Build │ +│ │ +│ 1. LOAD all scripts (bodies run) │ +│ BB[0] always.ps1 body → registers Enter-Build { ... } │ +│ BB[1] helm.ps1 body → registers Enter-Build { ... } │ +│ BB[2] always.ps1 body → guard returns early (diamond) │ +│ BB[3] dotnet.ps1 body → registers Enter-Build { ... } │ +│ BB[4] extend.build.ps1 → (no Enter-Build) │ +│ │ +│ 2. RESOLVE tasks, check for missing references │ +│ │ +│ 3. RUN Enter-Build blocks (in order): │ +│ │ +│ BB[0] always.ps1 Enter-Build: │ +│ │ $BuildRoot = ...EnterprisePlatformServices.API\ │ +│ │ ┌─────────────────────────────────────────────┐ │ +│ │ │ $Script:OutputPath = Join-Path $BuildRoot │ │ +│ │ │ 'Output' │ │ +│ │ │ New-Item $OutputPath -Force │ │ +│ │ │ $Script:TestResultsRoot = ... │ │ +│ │ │ New-Item $TestResultsRoot -Force │ │ +│ │ │ $Script:GHTools = @{} │ │ +│ │ │ $Script:UniversalPackageRoot = ... │ │ +│ │ └─────────────────────────────────────────────┘ │ +│ ▼ │ +│ BB[1] helm.ps1 Enter-Build: │ +│ │ $BuildRoot = ...EnterprisePlatformServices.API\ │ +│ │ ┌─────────────────────────────────────────────┐ │ +│ │ │ # Can see $OutputPath from BB[0]'s │ │ +│ │ │ # Enter-Build (shared script scope) │ │ +│ │ │ $script:helmOutputPath = Join-Path │ │ +│ │ │ $OutputPath "charts" │ │ +│ │ │ $script:HelmCharts = Get-ChildItem ... │ │ +│ │ │ $script:GHTools.add("kubeconform", ...) │ │ +│ │ └─────────────────────────────────────────────┘ │ +│ ▼ │ +│ BB[2] always.ps1: (diamond -- no Enter-Build registered) │ +│ ▼ │ +│ BB[3] dotnet.ps1 Enter-Build: │ +│ │ $BuildRoot = ...EnterprisePlatformServices.API\ │ +│ │ ┌─────────────────────────────────────────────┐ │ +│ │ │ # $TargetFramework = "net8.0" (final value) │ │ +│ │ │ $script:SolutionOutputPath = Join-Path │ │ +│ │ │ $OutputPath $SolutionName │ │ +│ │ │ dotnet sln list → $dotnetProjects │ │ +│ │ │ dotnet --version → $DotNetVersion │ │ +│ │ └─────────────────────────────────────────────┘ │ +│ ▼ │ +│ BB[4] extend.build.ps1: (no Enter-Build -- nothing to do) │ +│ │ +│ 4. RUN tasks: Build → Build-DotNet → ... │ +│ │ +│ 5. RUN Exit-Build blocks (REVERSE order): │ +│ BB[4] extend.build.ps1 → (none) │ +│ BB[3] dotnet.ps1 → (none currently) │ +│ BB[2] always.ps1 → (none, diamond) │ +│ BB[1] helm.ps1 → (none currently) │ +│ BB[0] always.ps1 → (none currently) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### The `If` Scriptblock Rule + +Because task `If` conditions are evaluated **at task definition time** (during loading), but `Enter-Build` variables don't exist yet at that point, any `If` that references an `Enter-Build` variable must be wrapped in `{}`: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ TIMELINE │ +│ │ +│ Loading Enter-Build Task If eval Task runs │ +│ ────┬──────────────┬───────────────────┬──────────────┬────── │ +│ │ │ │ │ │ +│ │ If = $var │ │ │ │ +│ │ ↑ evaluates │ │ │ │ +│ │ NOW → $null! │ │ │ │ +│ │ │ │ │ │ +│ │ If = { $var }│ │ ← evaluates │ │ +│ │ ↑ stores the │ │ HERE with │ │ +│ │ scriptblock │ $var = "value" │ "value" ✓ │ │ +│ │ │ ↑ set here │ │ │ +│ ────┴──────────────┴───────────────────┴──────────────┴────── │ +└─────────────────────────────────────────────────────────────────────┘ + +# WRONG -- evaluates to $null during loading, task always skips: +Add-BuildTask Install-GitHubTools @{ If = $script:GHTools.Count -gt 0 } + +# RIGHT -- deferred to runtime, evaluates after Enter-Build sets $GHTools: +Add-BuildTask Install-GitHubTools @{ If = { $script:GHTools.Count -gt 0 } } +``` + +### What Goes Where -- Decision Guide + +``` +┌──────────────────────────────────┬────────────┬──────────────┐ +│ Code │ Body │ Enter-Build │ +├──────────────────────────────────┼────────────┼──────────────┤ +│ $BuildRoot = $BuildRoots[-1] │ ✓ │ │ +│ $script:BuildSystem = ... │ ✓ │ │ +│ $script:BranchName = ... │ ✓ │ │ +│ $script:dotnetSolution = ... │ ✓ │ │ +│ $script:HelmChartRoot = ... │ ✓ │ │ +│ Set-BuildHeader / Set-BuildFooter│ ✓ │ │ +│ Add-BuildTask ... │ ✓ │ │ +│ . $taskfile.FullName (imports) │ ✓ │ │ +├──────────────────────────────────┼────────────┼──────────────┤ +│ New-Item (create directories) │ │ ✓ │ +│ dotnet sln list │ │ ✓ │ +│ dotnet --version │ │ ✓ │ +│ Get-ChildItem (chart enum) │ │ ✓ │ +│ $script:OutputPath = ... │ │ ✓ │ +│ $script:TestResultsRoot = ... │ │ ✓ │ +│ $script:GHTools = @{} │ │ ✓ │ +│ $script:dotnetProjects = ... │ │ ✓ │ +│ $Env:LDBUILD_* = ... │ │ ✓ │ +└──────────────────────────────────┴────────────┴──────────────┘ +``` ## Quick Reference @@ -167,8 +577,9 @@ Tasks from `Base1` would be prefixed (e.g., `MyPrefix::Build`), while tasks from | **Task redefinition** | `Add-BuildTask` with same name replaces the base's version | | **`$BuildRoot` during Extends** | Set to the **base** script's directory | | **`$BuildRoot` in derived body** | Set to the **derived** script's directory | -| **Variables using `??=`** | Must be reset before re-initialization | -| **`[string]` typed params** | Use `Remove-Variable` instead of `= $null` to reset | +| **`$BuildRoots[-1]`** | Standard redirect to the consuming project's directory | +| **`Enter-Build`** | Heavy init deferred to build time; skipped for `??` / `?` | +| **Task `If` scriptblocks** | Wrap in `{}` if referencing `Enter-Build` variables | ## Further Reading diff --git a/dotnet-tools.json b/dotnet-tools.json index 0dbfa77..6bb903f 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -9,13 +9,6 @@ ], "rollForward": false }, - "dotnet-sonarscanner": { - "version": "11.0.0", - "commands": [ - "dotnet-sonarscanner" - ], - "rollForward": false - }, "dotnet-coverage": { "version": "18.3.2", "commands": [ diff --git a/tasks/DotNetBuild.Task.ps1 b/dotnet/Build-DotNet.Task.ps1 similarity index 80% rename from tasks/DotNetBuild.Task.ps1 rename to dotnet/Build-DotNet.Task.ps1 index 608a6b8..6e56f3f 100644 --- a/tasks/DotNetBuild.Task.ps1 +++ b/dotnet/Build-DotNet.Task.ps1 @@ -1,9 +1,8 @@ -Add-BuildTask DotNetBuild @{ - If = $dotnetSolution +Add-BuildTask Build-DotNet @{ Inputs = { $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } $Projects - + # Also include source files from each project directory foreach ($Proj in $Projects) { $ProjectDir = Split-Path $Proj -Parent @@ -11,41 +10,40 @@ Add-BuildTask DotNetBuild @{ Where-Object FullName -NotMatch "[\\/]obj[\\/]|[\\/]bin[\\/]" } } - Outputs = { - # TODO: In harness this is rerunning every time. Need to figure out why. + Outputs = { $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - + # Return corresponding DLL files in OutputPath bin directory foreach ($Proj in $Projects) { $ProjectName = [IO.Path]::GetFileNameWithoutExtension($Proj) - + # linux edge case where csproj name does not match the dll name (case sensitivity) $AssemblyName = $ProjectName $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue if ($Content -match '([^<]+)') { $AssemblyName = $Matches[1] } - - $DllPath = Join-Path $script:dotnetOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$AssemblyName.dll" + + $DllPath = Join-Path $script:SolutionOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$AssemblyName.dll" $DllPath } } - Jobs = "DotNetRestore", "GetVersion", { + Jobs = "Restore-DotNet", "Get-Version", { $Name = (Split-Path $dotnetSolution -LeafBase).ToLower() - + $local:options = @{ '-configuration' = $script:configuration } + $script:dotnetOptions - + if (${script:Version}.$Name) { $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" } else { $options["p"] = "Version=$(${script:Version}.InformationalVersion)" } - + Write-Build Yellow "dotnet build $dotnetSolution --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" # Invoke-BuildExec [-Command] ScriptBlock [[-ExitCode] Int32[]] [[-ErrorMessage] String] [-Echo] [-StdErr] - + dotnet build $dotnetSolution --no-restore @options } } diff --git a/dotnet/Clean-DotNet.Task.ps1 b/dotnet/Clean-DotNet.Task.ps1 new file mode 100644 index 0000000..dfbe2ec --- /dev/null +++ b/dotnet/Clean-DotNet.Task.ps1 @@ -0,0 +1,6 @@ +Add-BuildTask Clean-DotNet @{ + Jobs = { + Write-Build Yellow "dotnet clean $Name" + dotnet clean $dotnetSolution + } +} diff --git a/tasks/ReportGenerator.Task.ps1 b/dotnet/Convert-Coverage.Task.ps1 similarity index 67% rename from tasks/ReportGenerator.Task.ps1 rename to dotnet/Convert-Coverage.Task.ps1 index 13e1876..5777ec0 100644 --- a/tasks/ReportGenerator.Task.ps1 +++ b/dotnet/Convert-Coverage.Task.ps1 @@ -1,22 +1,22 @@ -Add-BuildTask ReportGenerator @{ - If = $Script:CollectCoverage +Add-BuildTask Convert-Coverage @{ + If = { $Script:CollectCoverage } Jobs = { - Set-Location $TestResultsRoot + Set-Location $SolutionTestResultsRoot # ------------------------------ dotnet reportgenerator -reports:'./coverage/*.xml' ` -targetdir:'./coverage' ` -reporttypes:'Html;MarkdownSummaryGithub;TextSummary' ` -filefilters:'+*;-/_*' ` -title:"$script:ProductName" ` - -tag:"$(${script:Version}.SemVer)_${script:PipelineId}_${script:PipelineExecutionId}" + -tag:"$(${script:Version}.InformationalVersion)" switch ($script:BuildSystem) { - "AzureDevops" { - Write-Build Gray "##vso[task.uploadsummary]$TestResultsRoot/coverage/SummaryGithub.md" + "AzureDevOps" { + Write-Build Gray "##vso[task.uploadsummary]$SolutionTestResultsRoot/coverage/SummaryGithub.md" } "Harness" { # https://developer.harness.io/docs/continuous-integration/use-ci/annotate-builds/ - hcli annotate --context test-summary --summary-file "$TestResultsRoot/coverage/SummaryGithub.md" + hcli annotate --context test-summary --summary-file "$SolutionTestResultsRoot/coverage/SummaryGithub.md" } "GitHubActions" { Get-Content ./coverage/SummaryGithub.md -Raw diff --git a/dotnet/Convert-Trx2JUnit.Task.ps1 b/dotnet/Convert-Trx2JUnit.Task.ps1 new file mode 100644 index 0000000..24ca42f --- /dev/null +++ b/dotnet/Convert-Trx2JUnit.Task.ps1 @@ -0,0 +1,21 @@ +Add-BuildTask Convert-Trx2JUnit @{ + If = { if ($script:TargetFramework -eq "net8.0") { + dotnet tool list trx2junit | Select-Object -Skip 2 + } else { + (dotnet tool list trx2junit --format json | ConvertFrom-Json).data + } } + Partial = $true + Input = { + Get-ChildItem $SolutionTestResultsRoot/*.trx + } + Output = { + process { + [System.IO.Path]::ChangeExtension($_, 'xml') + } + } + Jobs = { + Get-ChildItem $SolutionTestResultsRoot/*.trx | ForEach-Object -ThrottleLimit ([Environment]::ProcessorCount - 1) -Parallel { + dotnet trx2junit $_ | Select-String -Pattern "Converting\s'" + } + } +} \ No newline at end of file diff --git a/tasks/DotNetPack.Task.ps1 b/dotnet/Pack-DotNet.Task.ps1 similarity index 83% rename from tasks/DotNetPack.Task.ps1 rename to dotnet/Pack-DotNet.Task.ps1 index 09ea5d0..00dbe29 100644 --- a/tasks/DotNetPack.Task.ps1 +++ b/dotnet/Pack-DotNet.Task.ps1 @@ -1,19 +1,18 @@ -#! If this is trying to pack a test project, you must add true to the project file. -Add-BuildTask DotNetPack @{ - If = $dotnetSolution +#! If this is trying to pack a test project, you must add true to the project file. +Add-BuildTask Pack-DotNet @{ Inputs = { $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } $Projects - + foreach ($Proj in $Projects) { $ProjectName = Split-Path $Proj -LeafBase - + # Check if project is packable by reading the .csproj file # Directory.Build.props sets IsPackable=false by default, so only projects # that explicitly set IsPackable=true should be packed $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue if ($Content -match '\s*(true|True|TRUE)\s*') { - $DllPath = Join-Path $script:dotnetOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + $DllPath = Join-Path $script:SolutionOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" if (Test-Path $DllPath) { $DllPath } @@ -22,28 +21,28 @@ Add-BuildTask DotNetPack @{ } Outputs = { $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - + foreach ($Proj in $Projects) { $ProjectName = Split-Path $Proj -LeafBase - + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue if ($Content -match '\s*(true|True|TRUE)\s*') { $NupkgPattern = Join-Path $script:DotNetPackRoot "$ProjectName.*.nupkg" $ExistingPkg = Get-Item $NupkgPattern -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName - + if ($ExistingPkg) { $ExistingPkg } else { $BuildRoot } } } } - Jobs = "DotNetBuild", { + Jobs = "Build-DotNet", { $script:DotNetPackRoot = New-Item $script:DotNetPackRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path $local:options = @{ - "-output" = $script:DotNetPackRoot - } - + "-output" = $script:DotNetPackRoot + } + $Name = Split-Path $dotnetSolution -LeafBase if (${script:Version}.$Name) { $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" diff --git a/tasks/DotNetPublish.Task.ps1 b/dotnet/Publish-DotNet.Task.ps1 similarity index 85% rename from tasks/DotNetPublish.Task.ps1 rename to dotnet/Publish-DotNet.Task.ps1 index 2119442..aa27636 100644 --- a/tasks/DotNetPublish.Task.ps1 +++ b/dotnet/Publish-DotNet.Task.ps1 @@ -1,17 +1,16 @@ -Add-BuildTask DotNetPublish @{ - If = $dotnetSolution +Add-BuildTask Publish-DotNet @{ Inputs = { $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - + foreach ($Proj in $Projects) { $ProjectName = Split-Path $Proj -LeafBase - + # Check if project is publishable by reading the .csproj file # Directory.Build.props sets IsPublishable=false by default, so only projects # that explicitly set IsPublishable=true should be published $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue if ($Content -imatch '\s*true\s*') { - $DllPath = Join-Path $script:dotnetOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + $DllPath = Join-Path $script:SolutionOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" if (Test-Path $DllPath) { $DllPath } } } @@ -21,25 +20,25 @@ Add-BuildTask DotNetPublish @{ $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } foreach ($Proj in $Projects) { $ProjectName = Split-Path $Proj -LeafBase - + $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue if ($Content -imatch '\s*true\s*') { # Look for published dll in the publish directory $PublishedDll = Join-Path $script:DotNetPublishRoot "$ProjectName/$ProjectName.dll" $ExistingPublish = Get-Item $PublishedDll -ErrorAction SilentlyContinue - + if ($ExistingPublish) { $ExistingPublish.FullName } else { $BuildRoot } } } } - Jobs = "DotNetBuild", { + Jobs = "Build-DotNet", "Pack-DotNet", { #! This handles a known issue with dotnet: any csproj identifying itself as Microsoft.NET.Sdk.Web will ALWAYS be published, even if IsPublishable is set to false $script:ProjectsToIgnore = @() - foreach ($Proj in $dotnetProjects) { + foreach ($Proj in $dotnetProjects) { $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue - if (($Content -imatch '') -and ($Proj -in $script:dotnetTestProjects)) { + if (($Content -imatch '') -and ($Proj -in $script:dotnetTestProjects)) { $script:ProjectsToIgnore += $Proj } } @@ -50,9 +49,9 @@ Add-BuildTask DotNetPublish @{ } $script:DotNetPublishRoot = New-Item $script:DotNetPublishRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path $Name = Split-Path $dotnetSolution -LeafBase - + $local:options = @{} + $script:dotnetOptions - + if (${script:Version}.$Name) { $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" } else { diff --git a/tasks/DotNetPush.Task.ps1 b/dotnet/Push-DotNet.Task.ps1 similarity index 83% rename from tasks/DotNetPush.Task.ps1 rename to dotnet/Push-DotNet.Task.ps1 index 46d624d..8d3c5b9 100644 --- a/tasks/DotNetPush.Task.ps1 +++ b/dotnet/Push-DotNet.Task.ps1 @@ -1,7 +1,5 @@ -Add-BuildTask DotNetPush @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetSolution - Jobs = "DotNetPack", { +Add-BuildTask Push-DotNet @{ + Jobs = "Pack-DotNet", { $Package = Get-ChildItem $script:DotNetPackRoot -Recurse -Filter "*.nupkg" if ($script:PushEnabled -and "$NuGetPublishKey") { diff --git a/tasks/DotNetRestore.Task.ps1 b/dotnet/Restore-DotNet.Task.ps1 similarity index 71% rename from tasks/DotNetRestore.Task.ps1 rename to dotnet/Restore-DotNet.Task.ps1 index fb1e80e..1591976 100644 --- a/tasks/DotNetRestore.Task.ps1 +++ b/dotnet/Restore-DotNet.Task.ps1 @@ -1,5 +1,12 @@ -Add-BuildTask DotNetRestore @{ - If = $dotnetSolution +<# +.SYNOPSIS + Runs dotnet restore +.DESCRIPTION + Checks to make sure the output assets.json is up to date with + the input *proj files, and if not, runs dotnet restore +#> + +Add-BuildTask Restore-DotNet @{ Inputs = { $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } $Projects @@ -8,20 +15,20 @@ Add-BuildTask DotNetRestore @{ } } Outputs = { - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } + $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } # Return corresponding project.assets.json files foreach ($Proj in $Projects) { $ProjectName = [IO.Path]::GetFileNameWithoutExtension($Proj) - Join-Path $script:dotnetOutputPath "obj/$ProjectName/project.assets.json" + Join-Path $script:SolutionOutputPath "obj/$ProjectName/project.assets.json" } } - Jobs = "DotNetToolRestore", { + Jobs = "Restore-DotNetTools", { $local:options = @{} + $script:dotnetOptions $NugetConfig = Get-ChildItem $BuildRoot -File | Where-Object { $_.Name -ieq "NuGet.config" } if ($NugetConfig) { $options["-configfile"] = "$BuildRoot/$($NugetConfig.Name)" } - + Write-Build Yellow "dotnet restore $dotnetSolution $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" dotnet restore $dotnetSolution @options } diff --git a/tasks/DotNetTest.Task.ps1 b/dotnet/Test-DotNet.Task.ps1 similarity index 71% rename from tasks/DotNetTest.Task.ps1 rename to dotnet/Test-DotNet.Task.ps1 index 06cce4b..0302c5d 100644 --- a/tasks/DotNetTest.Task.ps1 +++ b/dotnet/Test-DotNet.Task.ps1 @@ -1,10 +1,8 @@ -Add-BuildTask DotNetTest @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetSolution +Add-BuildTask Test-DotNet @{ Inputs = { $Projects = $dotnetTestProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } $Projects - + # Also include source files from each project directory foreach ($Proj in $Projects) { $ProjectDir = Split-Path $Proj -Parent @@ -15,17 +13,17 @@ Add-BuildTask DotNetTest @{ Outputs = { # Return any .trx files in the test results directory # dotnet test generates .trx files with machine/user-based names, not project names - $TrxFiles = Get-ChildItem $TestResultsRoot -Filter "*.trx" -ErrorAction SilentlyContinue - + $TrxFiles = Get-ChildItem $SolutionTestResultsRoot -Filter "*.trx" -ErrorAction SilentlyContinue + if ($TrxFiles) { $TrxFiles | Select-Object -ExpandProperty FullName } else { $BuildRoot } } - Jobs = "DotNetBuild", { - + Jobs = "Build-DotNet", { + $local:options = @{ - "-logger" = "trx" - "-results-directory" = $TestResultsRoot + "-logger" = "trx" + "-results-directory" = $SolutionTestResultsRoot "-configuration" = $configuration } + $script:dotnetOptions @@ -35,13 +33,13 @@ Add-BuildTask DotNetTest @{ $options.GetEnumerator() | ForEach-Object { $Command += " -$($_.Key) $($_.Value)" } - $Command += " -p:SolutionName=$dotnetSolutionName" + $Command += " -p:SolutionName=$SolutionName" $Name = (Split-Path $dotnetSolution -LeafBase).ToLower() - Write-Build Yellow "dotnet coverage collect '$Command' --output '$TestResultsRoot/coverage/$Name.xml' --output-format xml" - dotnet coverage collect $Command --output "$TestResultsRoot/coverage/$Name.xml" --output-format xml + Write-Build Yellow "dotnet coverage collect '$Command' --output '$SolutionTestResultsRoot/coverage/$Name.xml' --output-format xml" + dotnet coverage collect $Command --output "$SolutionTestResultsRoot/coverage/$Name.xml" --output-format xml } else { Write-Build Yellow "dotnet test $dotnetSolution --no-build $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" dotnet test $dotnetSolution --no-build @options } - }, "DotNetTrx2JUnit" + }, "Convert-Trx2JUnit" } diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 new file mode 100644 index 0000000..317aebe --- /dev/null +++ b/dotnet/base.ps1 @@ -0,0 +1,150 @@ +<# +.SYNOPSIS + DotNet build script -- extends always.ps1 with .NET build support. +.EXAMPLE + Invoke-Build +.NOTES + 0.6.0 - Split from build.example.ps1 +#> +[CmdletBinding()] +param( + [ValidateScript({ "../common/base.ps1" })] + $Extends, + # dotnet build configuration parameter (Debug or Release) + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + # Solution to build -- accepts a name, a glob pattern, or a path (relative or full) to a .sln file. + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + # TODO: See if we can use the argument completer described here: + # https://github.com/nightroman/Invoke-Build/blob/main/Docs/Argument-Completers.md + # Because this doesn't work with the extends pattern + Get-ChildItem -Path $PSScriptRoot -Filter *.sln | + Split-Path -LeafBase | + Where-Object { $_ -like "*$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } + })] + [Parameter(Position = 0)] + [ValidateScript({ + if ($_ -match '[\\/]') { + (Test-Path $_ -IsValid) -and ($_ -ilike '*.sln') + } else { + # Name or glob (e.g. "LD.EPS", "*", "*.sln") + $true + } + })] + [string]$Solution = "*", + + # Further options to pass to dotnet + [Alias("Options")] + [hashtable]$dotnetOptions = @{ + "-verbosity" = "minimal" + }, + + # Sets framework for solution, included in build output path + [ValidatePattern('^net\d+\.\d+$')] + $TargetFramework = "net10.0", + + # Sets runtime for solution, included in build output path + [ValidateSet('linux-x64', 'win-x64', 'any')] + $TargetRuntime +) + +# Redirect $BuildRoot to the root script's directory +if ($BuildRoots.Count -gt 1) { + $BuildRoot = $BuildRoots[-1] +} + +#region DotNet task variables -- initialized in Enter-Build (runs only when actually building) +Enter-Build { + $script:Configuration ??= "Release" + + # Resolve $Solution to a full path -- path separators indicate a direct path, otherwise search $BuildRoot + $script:dotnetSolution = if ($Solution -match '[\\/]') { + $solutionPath = if ([System.IO.Path]::IsPathRooted($Solution)) { + $Solution + } else { + Join-Path $BuildRoot $Solution + } + if (-not (Test-Path $solutionPath)) { + throw "Solution file not found: $solutionPath" + } + Convert-Path $solutionPath + } else { + $filter = if ($Solution -ilike '*.sln') { $Solution } else { "${Solution}.sln" } + $found = Get-ChildItem -Path $BuildRoot -Filter $filter -ErrorAction Ignore + if (-not $found) { + throw "No solution file matching '$filter' found in $BuildRoot" + } + if ($found.Count -gt 1) { + Write-Warning "Multiple solution files found:`n- $($found.FullName -join "`n- ")`nBuilding only the first one: $($found[0].FullName)" + } + $found[0] | Convert-Path + } + $script:SolutionName = Split-Path $script:dotnetSolution -LeafBase + $script:SolutionOutputPath = Join-Path $script:OutputPath $script:SolutionName + # This is used in Directory.build.props to configure the default output directory for dotnet restore and build (and publish?) + $Env:LDBUILD_OUTPUT_ROOT = $script:OutputPath + + # The DotNetPublishRoot is the "publish" folder within the Output (used for dotnet publish output) + $script:DotNetPublishRoot ??= Join-Path $script:OutputPath publish + $script:DotNetPackRoot ??= Join-Path $script:OutputPath nuget + + $script:SolutionTestResultsRoot = Join-Path $Script:TestResultsRoot $script:SolutionName + New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null + $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) + $script:TargetFramework ??= $Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".") + $script:TargetRuntime ??= $ENV:DOTNET_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64") + $ENV:LDBUILD_TARGET_RUNTIME = $script:TargetRuntime + + $script:dotnetProjects = @(dotnet sln $script:dotnetSolution list | Where-Object { $_ -like "*.*proj" }) + $script:dotnetTestProjects = @($script:dotnetProjects | Where-Object { $_ -like "*Test*.*proj" }) + $script:dotnetOptions ??= @{} + + $script:NuGetPublishKey ??= $Env:NUGET_API_KEY + $script:NuGetPublishUri ??= $Env:NUGET_API_URI ?? "https://nuget.loandepot.com/nuget/LDTS/v3/index.json" + $script:UPackPublishKey ??= $Env:UPACK_API_KEY + $script:UPackPublishUri ??= $Env:UPACK_PUBLISH_URI ?? "https://nuget.loandepot.com" + $script:UPackFeed ??= $Env:UPACK_FEED_NAME ?? "build-output" + + Write-Build Cyan "Initializing DotNet task variables (Solution: $script:dotnetSolution)" + Write-Build Cyan " Configuration: $script:Configuration" + Write-Build Cyan " dotnetSolution: $script:dotnetSolution" + Write-Build Cyan " SolutionOutputPath: $script:SolutionOutputPath" + Write-Build Cyan " DotNetPublishRoot: $DotNetPublishRoot" + Write-Build Cyan " DotNetPackRoot: $DotNetPackRoot" + Write-Build Cyan " SolutionTestResultsRoot: $SolutionTestResultsRoot" + Write-Build Cyan " DotNetProjects: $(($script:dotnetProjects).Count)" + Write-Build Cyan " DotNetTestProjects: $(($script:dotnetTestProjects).Count)" + Write-Build Cyan " NuGetPublishUri: $NuGetPublishUri" + Write-Build Cyan " UPackPublishUri: $UPackPublishUri" + Write-Build Cyan " UPackFeed: $UPackFeed" + + # If the only (or last) task is "Clean-Output" then add on Clean-DotNet + if (@($BuildTask)[-1] -eq "Clean-Output") { + $BuildTask = @("Clean-Output", "Clean-DotNet") + } + +} +#endregion + +# Add the dotnet tasks to the common tasks +$script:InitializeTasks = @( + # In CI pipelines (or if you specify $Clean) + if ($BuildSystem -ne "None" -or $Script:Clean) { + # Run the Clean-Output task before the rest of the build tasks + "Clean-Output" + } +) + $InitializeTasks + @("Restore-DotNet") + +$script:BuildTasks += @("Build-DotNet") +$script:PublishTasks += @("Pack-DotNet", "Publish-DotNet") +$script:TestTasks += $script:BuildSystem -eq "None" ? @("Test-DotNet") : @("Test-DotNet", "Convert-Trx2JUnit", "Convert-Coverage") +$script:PushTasks += @("Push-DotNet") +$script:CheckpointTasks += @() + +foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { + # Write-Information "$($PSStyle.Foreground.BrightBlue) $($taskfile.FullName)$($PSStyle.Reset)" + . $taskfile.FullName +} \ No newline at end of file diff --git a/helm/Build-Helm.Task.ps1 b/helm/Build-Helm.Task.ps1 new file mode 100644 index 0000000..53a7acd --- /dev/null +++ b/helm/Build-Helm.Task.ps1 @@ -0,0 +1,10 @@ +Add-BuildTask Build-Helm @{ + # TODO: This desparately needs working inputs/outputs + jobs = "Restore-Helm", { + foreach ($Chart in $script:HelmCharts) { + Set-Location $Chart + Write-Build Yellow "helm schema" + Invoke-Native { helm schema } -ExceptionalExit + } + } +} diff --git a/tasks/HelmInstall.Task.ps1 b/helm/Install-Helm.Task.ps1 similarity index 78% rename from tasks/HelmInstall.Task.ps1 rename to helm/Install-Helm.Task.ps1 index 6788b0c..3c61444 100644 --- a/tasks/HelmInstall.Task.ps1 +++ b/helm/Install-Helm.Task.ps1 @@ -1,6 +1,6 @@ # TODO: This task needs to install helm on linux and windows, in CI or in local -Add-BuildTask HelmInstall @{ - If = ($script:ChartName -and $script:BuildSystem -ne "None") +Add-BuildTask Install-Helm @{ + If = { $script:ChartName -and $script:BuildSystem -ne "None" } Jobs = { # Install Helm binary if not available (pipeline/Linux) if (-not (Get-Command helm -ErrorAction SilentlyContinue)) { @@ -23,12 +23,13 @@ Add-BuildTask HelmInstall @{ # Install helm-schema plugin if not already installed # TODO: This will be a PITA for windows if ('schema' -notin (helm plugin list | ForEach-Object { ($_ -split '\t')[0] })) { + # bug was introduced by owner of the helm-schema plugin in 0.23.0 and they "unreleased" it but didn't remove the latest tag on GitHub for it. So we have to pin to 0.22.0 for now until they fix it. if ($HelmVersionShort -ilike "v4*") { - Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --verify=false" - helm plugin install https://github.com/dadav/helm-schema --verify=false + Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 --verify=false" + helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 --verify=false } else { - Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema" - helm plugin install https://github.com/dadav/helm-schema + Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --version 0.22.0" + helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 } } } diff --git a/tasks/HelmPackChart.Task.ps1 b/helm/Pack-Helm.Task.ps1 similarity index 75% rename from tasks/HelmPackChart.Task.ps1 rename to helm/Pack-Helm.Task.ps1 index e9acb20..b845fa7 100644 --- a/tasks/HelmPackChart.Task.ps1 +++ b/helm/Pack-Helm.Task.ps1 @@ -1,12 +1,16 @@ -Add-BuildTask HelmPackChart @{ - If = ($script:ChartName) +# The actual helm command is helm package +# But we alias it as pack and publish for consistency with other frameworks +Add-BuildTask Pack-Helm Package-Helm +Add-BuildTask Publish-Helm Pack-Helm + +Add-BuildTask Package-Helm @{ Inputs = { Get-ChildItem $script:HelmCharts -File -Recurse } Outputs = { foreach ($Chart in $script:HelmCharts) { Join-Path $Chart.FullName "$($Chart.Name)-$($script:Version.SemVer).tgz" } } - Jobs = "GetVersion", { + Jobs = "Get-Version", "Test-Helm", { foreach ($Chart in $script:HelmCharts) { $Destination = Join-Path $script:helmOutputPath $Chart.Name New-Item $Destination -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null @@ -19,4 +23,4 @@ Add-BuildTask HelmPackChart @{ Invoke-Native { helm package $Chart.FullName @options } -ExceptionalExit } } -} +} \ No newline at end of file diff --git a/tasks/HelmPushChart.Task.ps1 b/helm/Push-Helm.Task.ps1 similarity index 89% rename from tasks/HelmPushChart.Task.ps1 rename to helm/Push-Helm.Task.ps1 index bf97fa5..c61ccc6 100644 --- a/tasks/HelmPushChart.Task.ps1 +++ b/helm/Push-Helm.Task.ps1 @@ -1,6 +1,5 @@ -Add-BuildTask HelmPushChart @{ - If = ($script:HelmCharts) - Jobs = "HelmPackChart", "ConnectAzACR", { +Add-BuildTask Push-Helm @{ + Jobs = "Pack-Helm", "Connect-AzACR", { if ($script:PushEnabled) { foreach ($Chart in $script:HelmCharts) { # If this sort turns out to not be enough, we need to split the name and cast to [semver] to sort diff --git a/helm/Restore-Helm.Task.ps1 b/helm/Restore-Helm.Task.ps1 new file mode 100644 index 0000000..bf87c61 --- /dev/null +++ b/helm/Restore-Helm.Task.ps1 @@ -0,0 +1,12 @@ +Add-BuildTask Restore-Helm @{ + jobs = "Install-Helm", "Connect-AzACR", { + foreach ($Chart in $script:HelmCharts) { + Set-Location $Chart + # If the developers have not already done so + # This may create a `charts` subdirectory with the dependencies i.e. devops-library + # In general, we don't care if they commit those, but we need them for the schemas to be complete + Write-Build Yellow "helm dependency build $Chart" + Invoke-Native { helm dependency build . } -ExceptionalExit + } + } +} diff --git a/tasks/HelmTestChart.Task.ps1 b/helm/Test-Helm.Task.ps1 similarity index 86% rename from tasks/HelmTestChart.Task.ps1 rename to helm/Test-Helm.Task.ps1 index 30c3d03..46979db 100644 --- a/tasks/HelmTestChart.Task.ps1 +++ b/helm/Test-Helm.Task.ps1 @@ -1,12 +1,11 @@ -Add-BuildTask HelmTestChart @{ - If = ($script:HelmCharts) +Add-BuildTask Test-Helm @{ Inputs = { Get-ChildItem $script:HelmCharts -File -Recurse } Outputs = { foreach ($chart in $script:HelmCharts) { Join-Path $script:helmOutputPath "$($Chart.Name)-compiled.yaml" } } - Jobs = { + Jobs = "Build-Helm", { # helm lint requires the chart directory, not the chart.yaml file Set-Location $script:HelmChartRoot New-Item $script:helmOutputPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null @@ -16,7 +15,7 @@ Add-BuildTask HelmTestChart @{ $CompiledOutput = Join-Path $script:helmOutputPath "$($Chart.Name)-compiled.yaml" Write-Build Yellow "helm lint $($Chart.FullName) --values $TestValues" - Invoke-Native { helm lint $chart.FullName --values $TestValues } -ExceptionalExit + Invoke-Native { helm lint $chart.FullName --values $TestValues } -ExceptionalExit Write-Build Yellow "helm template $($chart.FullName) --values $TestValues --generate-name" Invoke-Native { helm template $chart.FullName --values $TestValues --generate-name } -ExceptionalExit > $CompiledOutput @@ -24,9 +23,9 @@ Add-BuildTask HelmTestChart @{ # Shouldn't this be taken care of elsewhere as a pre-requisite? if (-not (Get-Command kubeconform -ErrorAction SilentlyContinue)) { Write-Build Yellow "kubeconform not found, attempting installation..." - &(Join-Path $script:BuildTaskScriptsDirectory "Install-GithubRelease.ps1") -Org "yannh" -Repo "kubeconform" -Verbose -ErrorAction SilentlyContinue + &(Join-Path $script:BuildTasksRoot "scripts" "Install-GithubRelease.ps1") -Org "yannh" -Repo "kubeconform" -Verbose -ErrorAction SilentlyContinue } - # TODO: Why is this here? This should be handled by InstallGithubTools + # TODO: Why is this here? This should be handled by Install-GitHubTools Write-Build Yellow "kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput" Invoke-Native { kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput diff --git a/helm/base.ps1 b/helm/base.ps1 new file mode 100644 index 0000000..876edfa --- /dev/null +++ b/helm/base.ps1 @@ -0,0 +1,59 @@ +<# +.SYNOPSIS + Helm build script -- extends always.ps1 with Helm chart support. +.EXAMPLE + Invoke-Build Build-Helm +.NOTES + 0.6.0 - Split from build.example.ps1 +#> +[CmdletBinding()] +param( + [ValidateScript({ "../common/base.ps1" })] + $Extends, + # Path to the Helm charts directory -- defaults to $BuildRoot/charts + [string]$HelmChartRoot, + + # Build specific charts by name, e.g. -ChartName "macpublicservice" + [string[]]$ChartName +) + +# Redirect $BuildRoot to the derived (root) script's directory +if ($BuildRoots.Count -gt 1) { + $BuildRoot = $BuildRoots[-1] +} + +#region Helm task variables -- initialized in Enter-Build (runs only when actually building) +Enter-Build { + # Resolve $HelmChartRoot -- default to $BuildRoot/charts if not specified + if (-not $HelmChartRoot) { $HelmChartRoot = Join-Path $BuildRoot "charts" } + $script:HelmChartRoot = if (Test-Path $HelmChartRoot) { Convert-Path $HelmChartRoot } + + if ($script:HelmChartRoot) { + $script:ACRName = $script:ACRName ?? $ENV:ACR_URI ?? "crazusw2dvosl1" + $script:helmOutputPath = Join-Path $Script:OutputPath "charts" + $script:ChartName ??= Get-ChildItem -Path $script:HelmChartRoot -File -Filter Chart.yaml -Recurse -Depth 1 | ForEach-Object { $_.Directory.Name } + $script:HelmCharts = $script:ChartName | Join-Path -Path $script:HelmChartRoot -ChildPath { $_ } | Get-Item + $script:GHTools.add("kubeconform", "https://github.com/yannh/kubeconform/releases/tag/v0.7.0") + + Write-Build Cyan "Initializing Helm task variables (HelmChartRoot: $script:HelmChartRoot)" + Write-Build Cyan " HelmChartRoot: $script:HelmChartRoot" + Write-Build Cyan " ACRName: $script:ACRName" + Write-Build Cyan " helmOutputPath: $script:helmOutputPath" + Write-Build Cyan " HelmCharts: $(($script:HelmCharts).Count)" + } +} +#endregion + + +# Add the helm tasks to the common tasks +$script:InitializeTasks += @("Install-Helm", "Restore-Helm") +$script:BuildTasks += @("Build-Helm") +$script:PublishTasks += @("Package-Helm") +$script:TestTasks += @("Test-Helm") +$script:PushTasks += @("Push-Helm") +$script:CheckpointTasks += @() + +foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { + # Write-Information "$($PSStyle.Foreground.BrightBlue) $($taskfile.FullName)$($PSStyle.Reset)" + . $taskfile.FullName +} \ No newline at end of file diff --git a/pipeline/build.yml b/pipeline/build.yml index be20728..71185ec 100644 --- a/pipeline/build.yml +++ b/pipeline/build.yml @@ -7,11 +7,39 @@ trigger: pr: - main +resources: + repositories: + - repository: BuildTasks + type: github + name: loandepot/LD.Platform.BuildTasks + endpoint: loandepot + ref: curley/LDDO-2556 + fetchDepth: 1 + jobs: - - job: build - displayName: Build starters - steps: - - checkout: self - persistCredentials: "true" # required for pushing tags - fetchDepth: "0" # required for gitversion +- job: build + displayName: Build starters + workspace: + clean: all + steps: + - checkout: self + persistCredentials: "true" # required for pushing tags + fetchDepth: "0" # required for gitversion + - checkout: BuildTasks + path: s/BuildTasks + - pwsh: | + . $(Pipeline.Workspace)/$(buildTasksPath)/scripts/Bootstrap.ps1 + displayName: 'Bootstrap' + + - pwsh: | + Invoke-Build Get-Version + workingDirectory: $(Pipeline.Workspace)/$(repoPath) + displayName: 'Get-Version' + - pwsh: | + Invoke-Build Tag-Source + workingDirectory: $(Pipeline.Workspace)/$(repoPath) + displayName: 'Tag-Source' + condition: eq(variables['Build.SourceBranch'], 'refs/heads/main') + env: + XDG_CONFIG_HOME: /home/AzDevOps/.config diff --git a/tasks/PSModuleAnalyze.Task.ps1 b/powershell/PSModuleAnalyze.Task.ps1 similarity index 100% rename from tasks/PSModuleAnalyze.Task.ps1 rename to powershell/PSModuleAnalyze.Task.ps1 diff --git a/tasks/PSModuleBuild.Task.ps1 b/powershell/PSModuleBuild.Task.ps1 similarity index 100% rename from tasks/PSModuleBuild.Task.ps1 rename to powershell/PSModuleBuild.Task.ps1 diff --git a/tasks/PSModuleImport.Task.ps1 b/powershell/PSModuleImport.Task.ps1 similarity index 100% rename from tasks/PSModuleImport.Task.ps1 rename to powershell/PSModuleImport.Task.ps1 diff --git a/tasks/PSModulePush.Task.ps1 b/powershell/PSModulePush.Task.ps1 similarity index 100% rename from tasks/PSModulePush.Task.ps1 rename to powershell/PSModulePush.Task.ps1 diff --git a/tasks/PSModuleRestore.Task.ps1 b/powershell/PSModuleRestore.Task.ps1 similarity index 100% rename from tasks/PSModuleRestore.Task.ps1 rename to powershell/PSModuleRestore.Task.ps1 diff --git a/tasks/PSModuleTest.Task.ps1 b/powershell/PSModuleTest.Task.ps1 similarity index 100% rename from tasks/PSModuleTest.Task.ps1 rename to powershell/PSModuleTest.Task.ps1 diff --git a/tasks/_BootStrap.ps1 b/scripts/Bootstrap.ps1 similarity index 92% rename from tasks/_BootStrap.ps1 rename to scripts/Bootstrap.ps1 index cbeb6f0..a803b6c 100644 --- a/tasks/_BootStrap.ps1 +++ b/scripts/Bootstrap.ps1 @@ -12,7 +12,7 @@ [CmdletBinding()] param( # Path to a RequiredModules.psd1 (if missing will only install InvokeBuild) - $RequiredModulesPath = (Join-Path $pwd "RequiredModules.psd1"), + $RequiredModulesPath = "$PSScriptRoot/../RequiredModules.psd1", # Scope for installation (of scripts and modules). Defaults to CurrentUser [ValidateSet("AllUsers", "CurrentUser")] diff --git a/scripts/PSFormatting.ps1 b/scripts/PSFormatting.ps1 deleted file mode 100644 index 8325355..0000000 --- a/scripts/PSFormatting.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -# TODO: This script can be removed, see ll1-15 Initialize.ps1 -$ErrorView = "DetailedView" -$InformationPreference = "Continue" -$ErrorActionPreference = "Stop" -$PSStyle.OutputRendering = "ANSI" - -# Force different colors for Verbose and Debug -if ($PSStyle.Formatting.Verbose -eq $PSStyle.Formatting.Warning) { - $PSStyle.Formatting.Verbose = $PSStyle.Foreground.BrightCyan -} -if ($PSStyle.Formatting.Debug -eq $PSStyle.Formatting.Warning) { - $PSStyle.Formatting.Debug = $PSStyle.Foreground.BrightGreen -} - -# turn on debug and verbose if the pipeline is run in debug mode -if ($ENV:AGENT_DIAGNOSTIC -eq 'True' -or $ENV:SYSTEM_DEBUG -eq 'True') { - $script:VerbosePreference = "Continue" - $script:DebugPreference = "Continue" - $PSStyle -} diff --git a/tasks/ConnectAzAccount.Task.ps1 b/tasks/ConnectAzAccount.Task.ps1 deleted file mode 100644 index ac4d3eb..0000000 --- a/tasks/ConnectAzAccount.Task.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -Add-BuildTask ConnectAzAccount @{ - If = ($null -eq (Get-AzContext -ErrorAction SilentlyContinue) ) - Jobs = { - Write-Build Gray "No Azure context found. Connecting to Azure..." - - if ($env:AZURE_CLIENT_ID -and $env:AZURE_TENANT_ID) { - Write-Build Yellow "Connect-AzAccount -Identity -AccountId $env:AZURE_CLIENT_ID" - Connect-AzAccount -Identity -AccountId $env:AZURE_CLIENT_ID | Out-Null - } elseif ($env:AZURE_CLIENT_ID -and $env:AZURE_CLIENT_SECRET -and $env:AZURE_TENANT_ID) { - Write-Build Yellow "Connect-AzAccount -ServicePrincipal -Credential $env:AZURE_CLIENT_ID -Tenant $env:AZURE_TENANT_ID" - $SecurePassword = ConvertTo-SecureString $env:AZURE_CLIENT_SECRET -AsPlainText -Force - $Credential = New-Object System.Management.Automation.PSCredential($env:AZURE_CLIENT_ID, $SecurePassword) - Connect-AzAccount -ServicePrincipal -Credential $Credential -Tenant $env:AZURE_TENANT_ID | Out-Null - } else { - Write-Build Yellow "Connect-AzAccount" - Connect-AzAccount | Out-Null - } - - $AzContext = Get-AzContext - Write-Build Green "Connected to Azure as $($AzContext.Account.Id) in subscription $($AzContext.Subscription.Name)" - } -} diff --git a/tasks/DotNetClean.Task.ps1 b/tasks/DotNetClean.Task.ps1 deleted file mode 100644 index 20d83cf..0000000 --- a/tasks/DotNetClean.Task.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -Add-BuildTask DotNetClean @{ - # This task should be skipped if there are no C# projects to build - If = $dotnetSolution - Jobs = { - Write-Build Yellow "dotnet clean $Name" - dotnet clean $dotnetSolution - } -} diff --git a/tasks/DotNetTrx2JUnit.Task.ps1 b/tasks/DotNetTrx2JUnit.Task.ps1 deleted file mode 100644 index cc93a2f..0000000 --- a/tasks/DotNetTrx2JUnit.Task.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -Add-BuildTask DotNetTrx2JUnit @{ - If = if ($script:TargetFramework -eq "net8.0") { - dotnet tool list trx2junit | Select-Object -Skip 2 - } else { - (dotnet tool list trx2junit --format json | ConvertFrom-Json).data - } - Partial = $true - Input = { - Get-ChildItem $TestResultsRoot/*.trx - } - Output = { - process { - [System.IO.Path]::ChangeExtension($_, 'xml') - } - } - Jobs = { - Get-ChildItem $TestResultsRoot/*.trx | ForEach-Object -ThrottleLimit ([Environment]::ProcessorCount - 1) -Parallel { - dotnet trx2junit $_ | Select-String -Pattern "Converting\s'" - } - } -} \ No newline at end of file diff --git a/tasks/HelmUpdateValuesSchema.Task.ps1 b/tasks/HelmUpdateValuesSchema.Task.ps1 deleted file mode 100644 index 45a74b5..0000000 --- a/tasks/HelmUpdateValuesSchema.Task.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -Add-BuildTask HelmUpdateValuesSchema @{ - if = ($script:HelmCharts) - # TODO: This desparately needs working inputs/outputs - jobs = "HelmInstall", "InstallGithubTools", "ConnectAzACR", { - foreach ($Chart in $script:HelmCharts) { - Set-Location $Chart - Write-Build Yellow "helm dependency update $Chart" - # If the developers have not already done so - # This may create a `charts` subdirectory with the dependencies i.e. devops-library - # In general, we don't care if they commit those, but we need them for the schemas to be complete - Invoke-Native { helm dependency update . --skip-refresh } -ExceptionalExit - Write-Build Yellow "helm schema" - Invoke-Native { helm schema } -ExceptionalExit - } - } -} diff --git a/tasks/InstallBuildDependencies.Task.ps1 b/tasks/InstallBuildDependencies.Task.ps1 deleted file mode 100644 index e413715..0000000 --- a/tasks/InstallBuildDependencies.Task.ps1 +++ /dev/null @@ -1 +0,0 @@ -Add-BuildTask InstallBuildDependencies InstallRequiredModules diff --git a/tasks/TagSource.Task.ps1 b/tasks/TagSource.Task.ps1 deleted file mode 100644 index f00f02b..0000000 --- a/tasks/TagSource.Task.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -Add-BuildTask TagSource @{ - If = { $script:BranchName -in "main", "master", "release" } - Jobs = "GitVersion", { - foreach ($Name in $PackageNames) { - git tag $Version.$Name.Tag -m "Release $($Version.$Name.InformationalVersion)" - git push origin --tags - } - } -} diff --git a/tasks/_Initialize.ps1 b/tasks/_Initialize.ps1 deleted file mode 100644 index c17e8b0..0000000 --- a/tasks/_Initialize.ps1 +++ /dev/null @@ -1,264 +0,0 @@ -#Requires -PSEdition Core -# TODO: Figure out what the Harness equiv is too all the github/ado build env vars -Write-Verbose "Initializing build variables" -Verbose -$script:ErrorView = "DetailedView" -$script:InformationPreference = "Continue" -$script:ErrorActionPreference = "Stop" -$PSStyle.OutputRendering = "ANSI" -# We're going to treat "DIAGNOSTIC" as "VERBOSE" + "DEBUG" and hope it rarely happens! ;) -if ($ENV:AGENT_DIAGNOSTIC -eq 'True' -or $ENV:SYSTEM_DEBUG -eq 'True') { - # TODO: What is the harness equivalent for the env var - $script:VerbosePreference = "Continue" - $script:DebugPreference = "Continue" - - Get-ChildItem Env:* | ForEach-Object { - Write-Verbose " Env:$($_.Name) = $($_.Value)" -Verbose - } -} - -# Force different colors for Verbose and Debug -if ($PSStyle.Formatting.Verbose -eq $PSStyle.Formatting.Warning) { - $PSStyle.Formatting.Verbose = $PSStyle.Foreground.BrightCyan -} -if ($PSStyle.Formatting.Debug -eq $PSStyle.Formatting.Warning) { - $PSStyle.Formatting.Debug = $PSStyle.Foreground.BrightGreen -} - -# Our goal is 90% code coverage, but this can be overriden by defining it lower in the .build.ps1 file -$Script:RequiredCodeCoverage ??= 0.9 # TODO: Only used by invoke-pester wrapper, not needed - -# Our default build configuration is Release (probably only applies to DotNet) -$script:Configuration ??= "Release" -Write-Verbose " Configuration: $script:Configuration" -Verbose - -$script:BuildTasksDirectory = $PSScriptRoot -$script:BuildTaskScriptsDirectory = Join-Path (Split-Path $PSScriptRoot) "scripts" -# NOTE: this variable is currently also used for Pester formatting ... -# We should use either "Harness", "AzureDevOps", "GithubActions", or "None" -$script:BuildSystem = if (Test-Path Env:HARNESS_STAGE_ID) { - "Harness" -} elseif (Test-Path Env:GITHUB_ACTIONS) { - "GithubActions" -} elseif (Test-Path Env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { - "AzureDevops" -} elseif (Test-Path Env:EARTHLY_BUILD_SHA) { - "Earthly" -} else { - "None" -} - -Write-Verbose " BuildSystem [$BuildSystem]" -Verbose -Write-Verbose " Information [$InformationPreference]" -Verbose -Write-Verbose " Verbose [$VerbosePreference]" -Verbose -Write-Verbose " Debug [$DebugPreference]" -Verbose - - -# In CI builds you have a BranchName -$script:BranchName = if ($Env:BUILD_SOURCEBRANCHNAME) { - $Env:BUILD_SOURCEBRANCHNAME -} elseif (Get-Command git -CommandType Application -ErrorAction SilentlyContinue) { - git branch --show-current -} - -$script:PushEnabled ??= $BuildSystem -ne 'None' -and ($BranchName -match "^main|^release/|^hotfix/") - -# In PR Builds you have a SourceBranch and a TargetBranch -[bool]$script:IsPullRequest = $script:IsPullRequest ?? ($Env:BUILD_REASON -eq "PullRequest" -or $Env:DRONE_BUILD_EVENT -eq "pull_request") -[long]$script:PullRequestId = $script:PullRequestId ?? $Env:SYSTEM_PULLREQUEST_PULLREQUESTID ?? $Env:DRONE_PULL_REQUEST -[string]$script:SourceBranch = $script:SourceBranch ?? $Env:SYSTEM_PULLREQUEST_SOURCEBRANCH ?? $Env:BUILD_SOURCEBRANCH ?? $Env:DRONE_SOURCE_BRANCH ?? $Env:CI_COMMIT_BRANCH ?? $script:BranchName -[string]$script:TargetBranch = $script:TargetBranch ?? $ENV:SYSTEM_PULLREQUEST_TARGETBRANCH ?? $Env:DRONE_TARGET_BRANCH ?? $script:MainBranch -# These SonarQube variables are settable from script or environment -[string]$script:SonarProjectKey = $script:SonarProjectKey ?? $Env:SONARQUBE_PROJECTKEY ?? $Env:SONAR_PROJECTKEY -[string]$script:SonarToken = $script:SonarToken ?? $Env:SONARQUBE_PAT ?? $Env:SONAR_TOKEN -[string]$script:SonarHostURL = $script:SonarHostURL ?? $Env:SONARQUBE_URL ?? $Env:SONAR_URL -# If the SonarProjectKey is set, we have to collect coverage -[switch]$script:CollectCoverage = $script:CollectCoverage -or $script:SonarProjectKey - -[string]$script:ProductName = $script:ProductName ?? $Env:PRODUCT_NAME ?? $Env:PIPELINE_NAME ?? $Env:DRONE_REPO_NAME ?? $Env:CI_REPO ?? $script:SonarProjectKey -[string]$script:PipelineId = $script:PipelineId ?? $Env:PIPELINE_ID ?? $Env:HARNESS_PIPELINE_ID ?? $Env:PLUGIN_PIPELINE ?? "local build" -[string]$script:PipelineExecutionId = $script:PipelineExecutionId ?? $Env:PIPELINE_EXECUTION_ID ?? $Env:BUILD_ID ?? $Env:HARNESS_EXECUTION_ID ?? $Env:HARNESS_BUILD_ID ?? $Env:DRONE_BUILD_NUMBER ?? "0" - -# A little extra BuildEnvironment magic -Set-BuildHeader { Write-Build 11 "Start Task: $($args[0])" } -Set-BuildFooter { Write-Build 11 "Finish Task: $($args[0]) $($Task.Elapsed) [Total: $([DateTime]::Now - ${*}.Started)]" } - - -# Cross-platform separator character -${script:/} = [IO.Path]::DirectorySeparatorChar - -# BuildRoot is provided by Invoke-Build -Write-Verbose " BuildRoot [$BuildRoot]" -Verbose - -### Note about Azure Pipeline environment variables: -# $Env:PIPELINE_WORKSPACE - Defaults to work/job -### These other three are defined relative to $Env:PIPELINE_WORKSPACE -# $Env:BUILD_SOURCESDIRECTORY - Cleaned BEFORE checkout IF: Workspace.Clean = All or Resources, or if Checkout.Clean = $True -# Importantly, defaults to work/job/s BUT when there are multiple sources, can be work/job/s/sourcename -# $Env:BUILD_BINARIESDIRECTORY - Cleaned BEFORE build IF: Workspace.Clean = Outputs -# $Env:BUILD_STAGINGDIRECTORY - Cleaned after each Build - -### Additionally, these two are cleaned after each Job: -# $Env:AGENT_TEMPDIRECTORY -# $Env:COMMON_TESTRESULTSDIRECTORY - -# There are a few different environment/variables it could be, and then our fallback -$Script:OutputPath = if ($Env:BUILD_BINARIESDIRECTORY) { - $Env:BUILD_BINARIESDIRECTORY -} else { - Join-Path $BuildRoot 'Output' -} - -Write-Verbose " Output [$OutputPath]" -Verbose -New-Item -Type Directory -Path $OutputPath -Force | Out-Null - -$Script:TestResultsRoot = $script:TestResultsRoot ?? -$Env:TEST_ROOT ?? # I set this for earthly -$Env:COMMON_TESTRESULTSDIRECTORY ?? # Azure -$Env:TEST_RESULTS_DIRECTORY ?? -(Join-Path $OutputPath testresults) - -New-Item -Type Directory -Path $TestResultsRoot -Force | Out-Null -Write-Verbose " TestResultsRoot: $TestResultsRoot" -Verbose - -$Script:TempDirectory = @(Get-Content Env:AGENT_TEMPDIRECTORY, Env:COMMON_TESTRESULTSDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | - Where-Object { Test-Path $_ } | - Select-Object -First 1 -if (-not $Script:TempDirectory) { $Script:TempDirectory = if ($IsLinux) { "/tmp" } else { [System.IO.Path]::GetTempPath() } } - -# If you need to install additional tools, we use Install-GitHubRelease -# Set the Tools hashtable to @{ exe = "org", "project" } -# For example: -# $Script:Tools = @{ -# yq = "mikefarah", "yq" -# flux = "fluxcd", "flux2" -# } -[hashtable]$Script:GHTools = @{} + ($Script:GHTools ?? @{}) - -$script:UniversalPackageRoot ??= Join-Path $script:OutputPath universal - -#region DotNet task variables. -# When we have DotNet projects, we just need to set one of these variables: -if ($dotnetSolution -or $DotNetPublishRoot) { - Write-Information "Initializing DotNet build variables (dotnetSolution: $dotnetSolution, DotNetPublishRoot: $DotNetPublishRoot)" - - $script:dotnetSolution = $dotnetSolution - $script:dotnetSolutionName = Split-Path $dotnetSolution -LeafBase - Write-Verbose " Solution project: $dotnetSolution" -Verbose - $script:dotnetOutputPath = Join-Path $script:OutputPath $script:dotnetSolutionName - # This is used in Directory.build.props to configure the default output directory for dotnet restore and build (and publish?) - $Env:LDBUILD_BINARIESDIRECTORY = $script:OutputPath - Write-Verbose " dotnetOutputPath: $script:dotnetOutputPath" -Verbose - - # The DotNetPublishRoot is the "publish" folder within the Output (used for dotnet publish output) - $script:DotNetPublishRoot ??= Join-Path $script:dotnetOutputPath publish - $script:DotNetPackRoot ??= Join-Path $script:dotnetOutputPath nuget - $script:UniversalPackageRoot ??= Join-Path $script:dotnetOutputPath universal - $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) - $script:TargetFramework ??= $Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".") - $script:TargetRuntime ??= $ENV:DOTNET_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64") - $ENV:LDBUILD_TARGET_RUNTIME = $script:TargetRuntime - - Write-Verbose " DotNetPublishRoot: $DotNetPublishRoot" -Verbose - Write-Verbose " DotNetPackRoot: $DotNetPackRoot" -Verbose - Write-Verbose " UniversalPackageRoot: $UniversalPackageRoot" -Verbose - - $script:dotnetProjects = @(dotnet sln $dotnetSolution list | Where-Object { $_ -like "*.*proj" }) - Write-Verbose " DotNetProjects: $(($script:dotnetProjects).Count)" -Verbose - $script:dotnetTestProjects = @($script:dotnetProjects | Where-Object { $_ -like "*Test*.*proj" }) - Write-Verbose " DotNetTestProjects: $(($script:dotnetTestProjects).Count)" -Verbose - $script:dotnetOptions ??= @{} - - $script:NuGetPublishKey ??= $Env:NUGET_API_KEY - $script:NuGetPublishUri ??= $Env:NUGET_API_URI ?? "https://nuget.loandepot.com/nuget/LDTS/v3/index.json" - Write-Verbose " NuGetPublishUri: $NuGetPublishUri" -Verbose - $script:UPackPublishKey ??= $Env:UPACK_API_KEY - $script:UPackPublishUri ??= $Env:UPACK_PUBLISH_URI ?? "https://nuget.loandepot.com" - $script:UPackFeed ??= $Env:UPACK_FEED_NAME ?? "build-output" - Write-Verbose " UPackPublishUri: $UPackPublishUri" -Verbose - Write-Verbose " UPackFeed: $UPackFeed" -Verbose - - # If the only (or last) task is "Clean" then add on DotNetClean - if (@($BuildTask)[-1] -eq "Clean") { - $BuildTask = @("Clean", "DotNetClean") - } -} -#endregion - -#region Helm task variables -$HelmChartRoot ??= Join-Path $BuildRoot "charts" -if (Test-Path $HelmChartRoot) { - $script:HelmChartRoot = $HelmChartRoot - $script:ACRName = $script:ACRName ?? $ENV:ACR_URI ?? "crazusw2dvosl1" - $script:helmOutputPath = Join-Path $Script:OutputPath "charts" - Write-Verbose " HelmChartRoot: $script:HelmChartRoot" -Verbose - Write-Verbose " ACRName: $script:ACRName" -Verbose - Write-Verbose " helmOutputPath: $script:helmOutputPath" -Verbose - $script:ChartName ??= Get-ChildItem -Path $script:HelmChartRoot -File -Filter Chart.yaml -Recurse -Depth 1 | ForEach-Object { $_.Directory.Name } - $script:HelmCharts = $script:ChartName | Join-Path -Path $HelmChartRoot -ChildPath { $_ } | Get-Item - Write-Verbose " HelmCharts: $(($script:HelmCharts).Count)" -Verbose - $script:GHTools.add("kubeconform", "https://github.com/yannh/kubeconform/releases/tag/v0.7.0") -} -#endregion - -## The first task defined is the default task. Put the right values for your project type here... -Add-BuildTask CI @( - # In CI pipelines (or if you specify $Clean) - # Run the Clean task before the rest of the build tasks - if ($BuildSystem -ne "None" -or $Script:Clean) { - "Clean" - } - "DotNetRestore" - "DotNetToolRestore" - "GetVersion" - "DotNetBuild" - "DotNetTest" - "DotNetTrx2JUnit" - "ReportGenerator" - "DotNetPack" - "DotNetPush" - "DotNetPublish" -) - -Add-BuildTask Restore @( - # Dependencies include restore and version - "DotNetRestore" -) - -Add-BuildTask Version @( - # Dependencies include tool restore - "GetVersion" -) - -Add-BuildTask Build @( - # Dependencies include restore and version - "DotNetBuild" -) - -Add-BuildTask Test @( - # Depends on Build - "DotNetTest" -) - - -Add-BuildTask Pack @( - # Dependencies include build, restore and version -- but not test - "DotNetPack" -) - -# "DotNetPublish" is for websites, but needs testing -Add-BuildTask Publish @( - # Depends on Build - "DotNetPublish" -) -# "DotNetPush" is only valid in CI - -# Allow a -Clean switch to add the "Clean" task on the front -if ($Clean -and -not ($BuildTask -eq "Clean")) { - $BuildTask = @("Clean") + $BuildTask -} - -Write-Verbose " Import Shared Tasks" -Verbose -foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { - Write-Verbose " $($taskfile.FullName)" - . $taskfile.FullName -} From a75f49255394eb340743f13bbb5f50380a0ac364 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Thu, 16 Apr 2026 17:06:54 -0400 Subject: [PATCH 07/43] Adding initial skills Fix spelling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Fix grammar Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .agent/skills/invoke-build/SKILL.md | 46 + .agent/skills/new-build-dotnet/SKILL.md | 34 + .../assets/Directory.Build.props | 45 + .../assets/Directory.Build.targets | 17 + .../new-build-dotnet/assets/build.build.ps1 | 52 + .../references/1_Move_Solution_Files.md | 89 ++ .../references/2_Copy_Build_Assets.md | 32 + .../references/3_Update_Projects.md | 25 + .../references/Concepts_and_Prerequisites.md | 93 ++ .../Troubleshooting_Build_Failures.md | 54 + .../references/Troubleshooting_Edge_Cases.md | 319 +++++ .../references/Validation_Checklist.md | 72 ++ BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD | 1149 ----------------- Directory.Build.props | 23 - 14 files changed, 878 insertions(+), 1172 deletions(-) create mode 100644 .agent/skills/invoke-build/SKILL.md create mode 100644 .agent/skills/new-build-dotnet/SKILL.md create mode 100644 .agent/skills/new-build-dotnet/assets/Directory.Build.props create mode 100644 .agent/skills/new-build-dotnet/assets/Directory.Build.targets create mode 100644 .agent/skills/new-build-dotnet/assets/build.build.ps1 create mode 100644 .agent/skills/new-build-dotnet/references/1_Move_Solution_Files.md create mode 100644 .agent/skills/new-build-dotnet/references/2_Copy_Build_Assets.md create mode 100644 .agent/skills/new-build-dotnet/references/3_Update_Projects.md create mode 100644 .agent/skills/new-build-dotnet/references/Concepts_and_Prerequisites.md create mode 100644 .agent/skills/new-build-dotnet/references/Troubleshooting_Build_Failures.md create mode 100644 .agent/skills/new-build-dotnet/references/Troubleshooting_Edge_Cases.md create mode 100644 .agent/skills/new-build-dotnet/references/Validation_Checklist.md delete mode 100644 BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD delete mode 100644 Directory.Build.props diff --git a/.agent/skills/invoke-build/SKILL.md b/.agent/skills/invoke-build/SKILL.md new file mode 100644 index 0000000..735bda8 --- /dev/null +++ b/.agent/skills/invoke-build/SKILL.md @@ -0,0 +1,46 @@ +--- +name: invoke-build +description: Building and testing this project +--- + +These instructions are for projects that use a PowerShell `*.build.ps1` script to build. +They depend on our shared [BuildTasks repository](https://github.com/loandepot/LD.Platform.BuildTasks), and use common build task names for build and testing. + +To get started, you must verify that the [BuildTasks repository](https://github.com/loandepot/LD.Platform.BuildTasks) is cloned (and up to date) in a sibling folder to the project you're working on. Obviously that doesn't apply if this **is** the BuildTasks repository! + +To clone the repository **as a sibling** to your project folder: + +```bash +cd .. +git clone https://github.com/loandepot/LD.Platform.BuildTasks BuildTasks +``` + +To initialize your system and install dependencies for building, run the `../BuildTasks/scripts/Bootstrap.ps1` script. + +## Invoke-Build + +If you have a `*.build.ps1` in your project root that extends our common build scripts, then all builds should be run _in PowerShell_. You can run `Invoke-Build ?` to determine the available tasks, and then call one, like `Invoke-Build Build`. To review which tasks would be executed instead of running the full build, you can add `-whatif`. + +### To restore dependencies: + +```powershell +Invoke-Build Initialize +``` + +### To build the whole repository: + +```powershell +Invoke-Build Build +``` + +### To run all tests: + +```powershell +Invoke-Build Test +``` + +### Full CI build (with package publishing, etc) + +```powershell +Invoke-Build CI +``` \ No newline at end of file diff --git a/.agent/skills/new-build-dotnet/SKILL.md b/.agent/skills/new-build-dotnet/SKILL.md new file mode 100644 index 0000000..a48abb8 --- /dev/null +++ b/.agent/skills/new-build-dotnet/SKILL.md @@ -0,0 +1,34 @@ +--- +name: new-build-dotnet +description: configuring dotnet projects for build and creating a new build script +--- + +## Requirements + +1. You must have the .NET 10 SDK installed. The projects may reference older SDKs like .NET 8, but you must have 10 available. +2. All projects must be using SDK-style projects + +## Reference + +There are NUMBERED documents in ./references with more detailed instructions for each of these steps, which you should refer to as you go through them. + +## Process Overview + +1. Move solution files to the root of the project +2. Copy the `*.Build.*` files from assets/ to your project root and customize them as needed +3. Update your projects: + - Ensure direct project references + - Add the `` property as appropriate + - Add the `` property as appropriate + - Add the `` property for container builds and remove Dockerfiles. + - Add the `` for all test projects. + +Once those steps are done, make sure that `.gitignore` includes `Output/` directory and run your `build.build.ps1` to verify that the build is working. You may need to further customize and troubleshoot, but the build should work locally. + +Finally, use the `references/Validation_Checklist.md` to ensure all steps have been completed correctly, and verify the build works by testing individual build tasks and verifying that they produce the correct output. + +There are conceptual documents and explanations in the ./references folder that you can read to understand the reasons behind these change, so you can customize and extend the build for your project safely. + +Additionally, there are TROUBLESHOOTING documents in ./references where we document known problems and their solutions. If you encounter any problems not covered in those docs, please update the documentation for our future benefit. + +You may want to copy the `invoke-build` skill into your project. \ No newline at end of file diff --git a/.agent/skills/new-build-dotnet/assets/Directory.Build.props b/.agent/skills/new-build-dotnet/assets/Directory.Build.props new file mode 100644 index 0000000..0d8f08e --- /dev/null +++ b/.agent/skills/new-build-dotnet/assets/Directory.Build.props @@ -0,0 +1,45 @@ + + + + + devops@loandepot.com + loanDepot + + + _ + + $(MSBuildThisFileDirectory)Output/$(SolutionName)/ + + + $([MSBuild]::EnsureTrailingSlash($(LDBUILD_OUTPUT_ROOT)))$(SolutionName)/ + $(RootOutputPath)bin/$(MSBuildProjectName) + $(RootOutputPath)obj/$(MSBuildProjectName) + $(RootOutputPath)publish/$(MSBuildProjectName) + $(RootOutputPath)containers/ + + + $(LDBUILD_TARGET_RUNTIME) + $(RuntimeIdentifier) + false + + + crazusw2dvosl1.azurecr.io/dotnet/aspnet:8.0 + crazusw2dvosl1.azurecr.io + /app/ + https://github.com/loandepot/LD.EnterprisePlatformServices.API + + $(MSBuildThisFileDirectory)\.runsettings + + + + False + False + + \ No newline at end of file diff --git a/.agent/skills/new-build-dotnet/assets/Directory.Build.targets b/.agent/skills/new-build-dotnet/assets/Directory.Build.targets new file mode 100644 index 0000000..379af30 --- /dev/null +++ b/.agent/skills/new-build-dotnet/assets/Directory.Build.targets @@ -0,0 +1,17 @@ + + + + + + $(ContainerArchiveDir)$(ContainerRepository)-$(Version).tgz + + + + + + + diff --git a/.agent/skills/new-build-dotnet/assets/build.build.ps1 b/.agent/skills/new-build-dotnet/assets/build.build.ps1 new file mode 100644 index 0000000..5fe3b0d --- /dev/null +++ b/.agent/skills/new-build-dotnet/assets/build.build.ps1 @@ -0,0 +1,52 @@ +<# +.SYNOPSIS + Builds the project +.DESCRIPTION + Controls which steps are used in the build of a project, including helm charts, etc. +.EXAMPLE + Invoke-Build + + Runs a build and test of the project +.EXAMPLE + Invoke-Build CI + + Runs the full CI build, which is what your pipeline runs. This includes all steps: calculating version, cleaning output, converting test results, and packaging (and publishing) artifacts, etc. +#> +[CmdletBinding()] +param( + [ValidateScript( + { + @( + "../*BuildTasks/dotnet/base.ps1" + "../*BuildTasks/helm/base.ps1" + ) | Convert-Path + } + )] + $Extends +) + +## Self-contained build script - can be invoked directly or via Invoke-Build +if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { + Write-Information "Bootstrap Build Dependencies" -Tag "InvokeBuild" + . (Convert-Path ../*BuildTasks/scripts/Bootstrap.ps1) + + Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result + + if ($Result.Error) { + $Error[-1].ScriptStackTrace | Out-Host + exit 1 + } + exit 0 +} + +# Define your preferred default build for local dev: +Add-BuildTask . Get-Version, Build, Test + +# Each build is responsible to define the five core tasks for CI +# But each base adds opinionated tasks to these variables +# So it's usually safe to just use these: +Add-BuildTask Initialize $script:InitializeTasks +Add-BuildTask Build $script:BuildTasks +Add-BuildTask Test $script:TestTasks +Add-BuildTask Publish $script:PublishTasks +Add-BuildTask Push $script:PushTasks \ No newline at end of file diff --git a/.agent/skills/new-build-dotnet/references/1_Move_Solution_Files.md b/.agent/skills/new-build-dotnet/references/1_Move_Solution_Files.md new file mode 100644 index 0000000..4ead428 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/1_Move_Solution_Files.md @@ -0,0 +1,89 @@ + +### Step 1: Analyze Repository Structure + +**Action:** Identify all solution files and their locations. + +**Instructions:** +1. Search for all `.sln` files in the repository +2. For each solution file found in a subdirectory (not in root), note: + - The subdirectory name (e.g., `LD.JV.Builder`) + - The solution file name (e.g., `LD.JV.Builder.sln`) + - The projects contained in that solution +3. Create a mapping of subdirectory → new root-level solution name: + - Pattern: If subdirectory is `LD.JV.Builder` and solution is `LD.JV.Builder.sln`, create root-level `LD.JV.Builder.sln` + - Pattern: If subdirectory is `LD.JV.BuilderAsync` and solution is `LD.JV.BuilderAsync.sln`, create root-level `LD.JV.BuilderAsync.sln` + - Pattern: If subdirectory is `LD.JV.PlatformEvent.Common` and solution is `LD.JV.PlatformEvent.Common.sln`, create root-level `LD.JV.PlatformEvent.Common.sln` + +**Expected Output Format:** + +After completing Step 1, provide a summary in this format: + +``` +## Step 1 Complete: Repository Structure Analysis + +### Solution Files Found + +**1. [Solution Name]** +- **Location:** [Full path to current solution file] +- **Subdirectory:** [Subdirectory name] +- **New root-level name:** [Name for root solution file] + +**Projects contained ([count] total):** +- **Main/API Projects:** + - [Project names that are web apps or APIs] + +- **Library Projects:** + - [Project names that are libraries] + +- **Test Projects:** + - [Project names that are test projects] + +[Repeat for each solution found] + +### Summary + +- **[N] solution files** found in subdirectories +- **[Solution1.sln]** will be moved from `[SubDir]/` to root with updated paths (prefix: `[SubDir]\`) +- **[Solution2.sln]** will be moved from `[SubDir]/` to root with updated paths (prefix: `[SubDir]\`) +``` + +### Step 2: Create Root-Level Solution Files + +**Action:** For each subdirectory solution one level deep from the root, move the solution file to the root and update the project paths. + +**Instructions:** +1. Read the original solution file from the subdirectory +2. Update all project paths in the solution file to be relative from the repository root: + - **Original path pattern:** `ProjectName\ProjectName.csproj` (relative to subdirectory) + - **New path pattern:** `SubdirectoryName\ProjectName\ProjectName.csproj` (relative to root) +4. Preserve all project GUIDs, configurations, and solution items +5. Update any solution items paths (like NuGet.config) to reference the subdirectory: + - **Original:** `NuGet.config = NuGet.config` + - **New:** `NuGet.config = SubdirectoryName\NuGet.config` + +**Example Transformation:** + +Original solution in `LD.JV.Builder/LD.JV.Builder.sln`: +``` +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LD.JV.Builder.Host.Web", "LD.JV.Builder.Host.Web\LD.JV.Builder.Host.Web.csproj", "{GUID}" +``` + +New solution in root `LD.JV.Builder.sln`: +``` +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LD.JV.Builder.Host.Web", "LD.JV.Builder\LD.JV.Builder.Host.Web\LD.JV.Builder.Host.Web.csproj", "{GUID}" +``` + +### Step 3: Delete Original Subdirectory Solution Files + +**Action:** Remove the old solution files from subdirectories. + +**Instructions:** +1. For each solution file that was in a subdirectory (e.g., `LD.JV.Builder/LD.JV.Builder.sln`), delete it using `git rm` +2. **Do not delete:** + - Project files (.csproj, .fsproj, etc.) + - Source code + - Tests + - Docker files + - Any other non-solution files + +**Why:** The root-level solution files replace the subdirectory solutions. Keeping both would cause confusion and maintenance issues. diff --git a/.agent/skills/new-build-dotnet/references/2_Copy_Build_Assets.md b/.agent/skills/new-build-dotnet/references/2_Copy_Build_Assets.md new file mode 100644 index 0000000..0e05388 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/2_Copy_Build_Assets.md @@ -0,0 +1,32 @@ +# Copy the `Build` files from assets/ + +These files go in your project root and they need to be customized a little bit. + +If there are existing `Directory.build.props` or `Directory.build.targets` files _in the root_ of the project, you will need to merge their contents with the ones from the assets folder, making sure to preserve any customizations or important properties and targets that are already defined. + +Additionally, if there are existing `Directory.build.props` in any **child** folders, those need to be updated to import the files in the root, by adding this line inside the `` tag: + +```xml + +``` + +Finally, if there is already a *.build.ps1 script, you should leave that file as-is, and assume it's already customized. You could copy over our `assets/build.build.ps1` as `new.build.ps1` but you'll need to compare them and resolve to a single `*.build.ps1` file in the root before running Invoke-Build! + +## Update the `` to your **team** email + +This email should be set to a team email distribution list for the maintainers of the project. + +## Update the `` to the repo URL + +This should be set to the web URL of the repository where this project lives, not the git URL. + +## Finally, update the base files in the build.build.ps1 + +By default the build.build.ps1 references these two base scripts: + +``` + "../*BuildTasks/dotnet/base.ps1" + "../*BuildTasks/helm/base.ps1" +``` + +If your project is not using Helm, you can remove the second reference. If you have other project types mixed into the repo, you'll want to update the list here to reference the appropriate base files. \ No newline at end of file diff --git a/.agent/skills/new-build-dotnet/references/3_Update_Projects.md b/.agent/skills/new-build-dotnet/references/3_Update_Projects.md new file mode 100644 index 0000000..8675012 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/3_Update_Projects.md @@ -0,0 +1,25 @@ +# Update Projects To Modern Standards + +Obviously we've already covered that all projects must be on a supported .NET Core SDK and using SDK-style projects, but the following steps must be taken to make sure you get the outputs you need from each project. + +## Update the Project References + +Start by collecting a full list of all the project assembly names. You can find these in the .csproj files in the AssemblyName property, like `LD.EPS.Common.Logging`. If there is no `` property, the assembly name defaults to the project file base name (without extension). + +Examine all PackageReference elements in each project file and ensure that any NuGet package references are to projects which are not part of this repository. + +If any projects have PackageReference elements that reference the name of a project in the same repository, you must convert those references to ProjectReference. Otherwise, you will end up needing to do multiple pull requests whenever you update a library -- first to merge changes to the library, and then to update the projects with dependencies on it. + +## Update Publishing Properties + +For projects to publish a NuGet package, they must have the `true` property, and ensure it has the necessary metadata (PackageId, Version, etc.). This change _must_ be reviewed by a human. There is no programmatic way of telling whether a project is supposed to be publishing a NuGet package. + +Specifically, in some cases, things that _were_ published as NuGet packages prior to conversion to the monorepo pattern no longer need to be published, because the only consumers are now co-located in this repository. + +For projects to publish a container, service, or web app, they must have the `true` property, and ensure it has the necessary metadata (Authors, etc.). + +If projects are currently targeting containers for deployment they will have a Dockerfile in the project folder. That Dockerfile should be removed, and they should instead set the image repository name in the project as a property, like `ld/eps/publicservice`. That will be enough to produce the container image. + +All test projects _must_ set the `true` attribute. These projects are not intended to be _shipped_ or packed into containers. They usually have "Test" or "Spec" in the name, and reference test frameworks like xUnit, NUnit, MSTest or SpecFlow. + +Note that for advanced teams, there may be an integration project with a reference to a test framework that needs to be published to a container image so we can run integration tests in Kubernetes. Those projects should have `IsPublishable` and `ContainerRepository` set. \ No newline at end of file diff --git a/.agent/skills/new-build-dotnet/references/Concepts_and_Prerequisites.md b/.agent/skills/new-build-dotnet/references/Concepts_and_Prerequisites.md new file mode 100644 index 0000000..774e629 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/Concepts_and_Prerequisites.md @@ -0,0 +1,93 @@ +# Key Concepts and Rationale + +## Prerequisites + +Before implementing this build system, ensure: + +1. The repository contains one or more .NET solutions in subdirectories +2. Each subdirectory has its own solution file (e.g., `SubFolder/ProjectName.sln`) +3. You have identified which projects need to be published (web apps, services) vs packed (libraries) + +## Implementation Guidance + +**Approach:** Implement steps sequentially, checking in after each step completion for review. + +**Solution Scope Determination:** Before implementing any changes, identify which solution file(s) in the repository root define the scope of work. Only apply build system changes to subdirectories and projects that are referenced by the root-level solution file(s). Subdirectories not referenced by any root solution file should be left unchanged, as they may be independent projects with their own build systems or may be legacy code not part of the current build scope. + +**Repository Structure:** Use Step 1 to discover all solution files and their locations in subdirectories. + +**Project Classification:** Use Step 5 instructions to identify: +- **Publishable projects** (web apps, services): Look for ``, Docker support, or entry points +- **Packable projects** (libraries): Look for `` with reusable code +- **Neither** (test projects): Projects with test frameworks or internal utilities + +**Testing:** Developers must perform manual build and testing after implementation is complete. + +## On Moving The Solution Files + +Multiple solutions in subdirectories make it difficult to determine which solutions exist, and which should be built in automation. They also make it difficult to manage the output directories consistently, and version components together. + +By moving the solution files in all repositories to the root, and using them to referencing the projects, we can share the same build process across all repositories while allowing you to structure your projects in sub-folders however you prefer. + +You can even maintain additional solution files (or filters) that are not intended for CI build, but are just for developer convenience, as long as you don't put them in the root of the repository. + +## Output Directory Structure + +The build system creates a structured output directory where all output is in the root "Output" folder, and intermediate output is grouped per-solution so that if the solutions are built in parallel, but reference some of the same library projects, we don't get two solution builds trying to write the same output files at the same time. + +``` +Output/ +├── Solution1/ +│ ├── bin/ +│ │ └── ProjectName/ +│ │ └── Release/ +│ │ └── net9.0/ +│ ├── obj/ +│ │ └── ProjectName/ +│ ├── publish/ +│ │ └── ProjectName/ +│ └── version.json +├── Solution2/ +~ (same structure) +├── Solution3/ +~ (same structure) +├── nuget/ +│ ├── ProjectName.1.0.0.nupkg +│ └── (all nuget packages from all solutions) +├── containers/ +│ ├── ProjectName.1.0.0.tar +│ └── (all container images from all solutions) +~ +└── version.json +``` + +## GitVersion Integration + +All builds currently use GitVersion for semantic versioning: + +**Configuration highlights:** +- **Workflow:** GitFlow +- **Main branch:** Increments `beta` pre-release version on merge +- **Feature branches:** Increments `alpha` pre-release version on merge +- **Release and hotfix branches:** Increments `rc` pre-release version on merge + +**Version format:** +- Assembly version: `{Major}.{Minor}.{Patch}.{BuildCount}` +- Informational version: `{Major}.{Minor}.{Patch}{PreReleaseTag}+Build.{BuildCount}.Date.{CommitDate}.Branch.{BranchName}.Sha.{Sha}` + +## IsPackable vs IsPublishable + +**IsPackable=True:** +- Creates NuGet packages with `dotnet pack` +- For libraries and shared code +- Output goes to `Output/nuget/project.nupkg` + +**IsPublishable=True:** +- Creates deployment artifacts with `dotnet publish` +- For applications, services, and executables +- Output goes to `Output//publish/project` + +**Default (both False):** +- Test projects +- Internal utilities +- Projects not meant for distribution diff --git a/.agent/skills/new-build-dotnet/references/Troubleshooting_Build_Failures.md b/.agent/skills/new-build-dotnet/references/Troubleshooting_Build_Failures.md new file mode 100644 index 0000000..884a590 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/Troubleshooting_Build_Failures.md @@ -0,0 +1,54 @@ +# Common Issues and Solutions + +## Issue: Build cannot find projects + +**Symptom:** Error like "Project file does not exist" + +**Solution:** Verify project paths in root solution files are correct and relative to repository root, not subdirectory. + +## Issue: Output directory conflicts + +**Symptom:** Build artifacts from different solutions overwrite each other + +**Solution:** Ensure each root solution file has a unique name, which becomes the `SolutionName` variable used in output paths. + +## Issue: GitVersion fails + +**Symptom:** "No commits found on the current branch" + +**Solution:** +- Ensure repository has at least one commit +- Run `git fetch --unshallow` if in a shallow clone +- Verify `GitVersion.yml` is in repository root + +## Issue: Projects not inheriting root Directory.Build.props + +**Symptom:** Output goes to default `bin/` and `obj/` directories in project folders + +**Solution:** Add the import statement to subdirectory `Directory.Build.props` files as described in Step 4. + +## Issue: Test projects being packed or published + +**Symptom:** NuGet packages or publish folders created for test projects + +**Solution:** Ensure test projects do NOT have `True` or `True`. The default from root `Directory.Build.props` is False for both. + +## Issue: NU1507 errors with Central Package Management + +**Symptom:** Build warnings or errors like "NU1507: There are 2 package sources defined in your configuration. When using central package management, please map your package sources with package source mapping..." + +**Solution:** Add `packageSourceMapping` section to the root `nuget.config` file. This is required when using Central Package Management (Directory.Packages.props). Example: + +```xml + + + + + + + + + +``` + +Place this section inside the `` element, typically after ``. Map each package source to the package patterns it should provide. diff --git a/.agent/skills/new-build-dotnet/references/Troubleshooting_Edge_Cases.md b/.agent/skills/new-build-dotnet/references/Troubleshooting_Edge_Cases.md new file mode 100644 index 0000000..6d6e8e5 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/Troubleshooting_Edge_Cases.md @@ -0,0 +1,319 @@ +# Troubleshooting: Edge Cases and Complex Scenarios + +This section is a history of real-world problems encountered during build system implementations and what the solution ended up being. The earlier the case, the less applicable, as this repository is under constant development. We do have a summary at the top here, with some recommendations for preventing problems. + +1. **Regular Dependency Audits** + ```powershell + # Check for outdated packages + dotnet list package --outdated + + # Check for vulnerable packages + dotnet list package --vulnerable + ``` + +2. **Central Package Management** + + Use `Directory.Packages.props` for version management: + + ```xml + + + true + + + + + + + ``` + +3. **Upgrade Legacy Dependencies** + - Prioritize upgrading internal packages to target modern .NET + - Update test frameworks to current versions when feasible + - Document upgrade blockers for future planning + +4. **Monitor Build Warnings** + - Review all NU1605 warnings even if build succeeds + - Investigate NU1608 (version override) warnings + - Track MSB3539 warnings about property modifications + +5. **Test Framework Compatibility Matrix** + | Framework | Last Version Supporting .NET Framework | First Version Supporting .NET 5+ | + |-----------|---------------------------------------|-----------------------------------| + | SpecFlow | 3.9.x | 3.10+ | + | NUnit | 3.13.x | 3.13+ (both) | + | xUnit | 2.4.x | 2.4+ (both) | + | MSTest | 2.2.x | 2.2+ (both) | + +--- + +## Edge Case 1: SpecRun Path Handling with Centralized Output + +**Repository:** FeeEngine (LD.FeeEngine.API) + +**Symptom:** +Build fails with path duplication error in SpecRun-based test projects: +``` +error: The filename, directory name, or volume label syntax is incorrect. : +'C:\XDL\LD.FeeEngine.API\FeeEngine\LD.EPS.FeeEngine.BDD.Tests\C:\XDL\LD.FeeEngine.API\Output\FeeEngine\obj\...' +``` + +**Root Cause:** +SpecRun.SpecFlow targets file (version 3.9.31) does not correctly handle absolute paths in `BaseIntermediateOutputPath`. When the centralized build system sets this to an absolute path, SpecRun's targets attempt to concatenate it with the project directory path, resulting in malformed paths. + +This is a known limitation of older SpecRun versions (pre-.NET 5) that assume `BaseIntermediateOutputPath` is always a relative path. + +**Solution:** +Override the intermediate output path properties in the affected project to use a local relative path: + +```xml + + + + obj\ + $(BaseIntermediateOutputPath)\$(Configuration)\$(TargetFramework)\ + +``` + +**Trade-offs:** +- This project won't benefit from centralized intermediate output cleanup +- Creates a non-fatal warning about `BaseIntermediateOutputPath` being modified after MSBuild uses it +- Build artifacts (bin) still go to centralized location; only intermediate files (obj) are kept local +- Acceptable compromise until SpecRun is upgraded to version 3.10+ or SpecFlow 4.x + +**When to Apply:** +- Projects using SpecRun.SpecFlow versions < 3.10 +- Projects using SpecFlow.Plus.Runner with older versions +- Any test framework with custom MSBuild targets that assume relative paths + +--- + +## Edge Case 2: Package Downgrade Errors from Legacy Dependencies + +**Repository:** FeeEngine (LD.FeeEngine.API) + +**Symptom:** +Build fails with NU1605 errors (package downgrade warnings treated as errors): +``` +error NU1605: Warning As Error: Detected package downgrade: System.Diagnostics.Debug from 4.3.0 to 4.0.11 +error NU1605: Warning As Error: Detected package downgrade: System.IO.FileSystem.Primitives from 4.3.0 to 4.0.1 +error NU1605: Warning As Error: Detected package downgrade: System.Runtime.InteropServices from 4.3.0 to 4.1.0 +error NU1605: Warning As Error: Detected package downgrade: System.Threading from 4.3.0 to 4.0.11 +``` + +**Root Cause:** +Legacy internal packages (e.g., `LD.Common.AspNetCore 2.0.0.49`) or old test frameworks (e.g., `AutoFixture.AutoMoq 4.8.0`, `Moq 4.7.0`) create conflicting transitive dependency chains: + +``` +Project → LD.Common.AspNetCore 2.0.0.49 + → Microsoft.AspNetCore.Mvc.Core 2.2.0 + → Microsoft.Extensions.DependencyModel 2.1.0 + → Microsoft.DotNet.PlatformAbstractions 2.1.0 + → System.IO.FileSystem 4.0.1 + → runtime.win.System.IO.FileSystem 4.3.0 + → System.Diagnostics.Debug (>= 4.3.0) ← Requires 4.3.0 + +But also: +Project → LD.Common.AspNetCore 2.0.0.49 + → Microsoft.Extensions.DependencyModel 2.1.0 + → System.Diagnostics.Debug (>= 4.0.11) ← Allows 4.0.11 +``` + +NuGet's dependency resolver chooses the lower version to satisfy both constraints, but this violates the "no downgrade" rule when `TreatWarningsAsErrors` is enabled. + +**Solution:** +Add explicit package references for the higher versions required by transitive dependencies: + +**For library projects with LD.Common.AspNetCore dependencies:** +```xml + + + + + + +``` + +**For test projects with AutoFixture.AutoMoq dependencies:** +```xml + + + + +``` + +**Why This Works:** +- Explicit package references take precedence over transitive dependencies in NuGet's resolution +- Forces NuGet to use version 4.3.0, satisfying all dependency constraints without downgrades +- System.* packages at version 4.3.0 (from .NET Core 1.x era) remain compatible with modern .NET +- At runtime, .NET 8.0+ uses built-in implementations; package references primarily satisfy NuGet's dependency graph + +**Alternative Solutions (Not Recommended):** +- **Upgrade legacy packages:** Requires coordination across teams, potential breaking changes +- **Disable TreatWarningsAsErrors:** Reduces build quality, allows security vulnerabilities +- **Add NoWarn for NU1605:** Masks the problem without fixing it + +**When to Apply:** +- Projects referencing legacy internal packages targeting .NET Framework or early .NET Core +- Test projects using older versions of Moq, AutoFixture, NSubstitute, or similar frameworks +- Any project with `TreatWarningsAsErrors=true` encountering NU1605 warnings + +**Affected Projects in FeeEngine Example:** +- `LD.EPS.FeeEngine.ThirdPartyFramework` - Added 3 System.* packages +- `LD.EPS.FeeEngine.ClosingCorp` - Added 3 System.* packages +- `LD.EPS.FeeEngine.ClosingCorp.Tests` - Added System.Threading +- `LD.EPS.FeeEngine.ThirdPartyFees.Tests` - Added System.Threading +- `LD.EPS.FeeEngine.InRule.Tests` - Added System.Threading + +--- + +## Edge Case 3: Understanding NuGet Dependency Resolution + +**Background:** +Understanding how NuGet resolves package versions helps diagnose and fix dependency conflicts. + +**NuGet Resolution Strategy:** +1. **Direct references win:** Explicit `` in the project file takes highest precedence +2. **Nearest wins:** Among transitive dependencies, the package "nearest" to the project (fewest hops) is chosen +3. **Lowest compatible version:** When multiple versions satisfy constraints, NuGet picks the lowest version that works +4. **Downgrade detection:** If resolution results in using a lower version than required by any dependency, NU1605 is issued + +**Example Dependency Graph:** +``` +MyProject.csproj +├─ PackageA 2.0 +│ └─ System.Text.Json >= 6.0.0 +└─ PackageB 1.0 + └─ System.Text.Json >= 4.7.0 + +Resolution: System.Text.Json 6.0.0 (satisfies both >= 6.0.0 and >= 4.7.0) +``` + +**Downgrade Example:** +``` +MyProject.csproj +├─ PackageA 2.0 +│ └─ System.Text.Json 4.7.0 (exact version) +└─ PackageB 1.0 + └─ System.Text.Json >= 6.0.0 + +Resolution: System.Text.Json 4.7.0 (nearest wins, but downgrades from 6.0.0) +Warning NU1605: Detected package downgrade +``` + +**Fix:** Add explicit reference to override: +```xml + +``` + +--- + +## Edge Case 4: Package Source Mapping Required with Central Package Management + +**Repository:** LD.Shared.EnterprisePlatformServices.API (EPS) + +**Symptom:** +Build fails with NU1507 errors when using Central Package Management with multiple NuGet sources: +``` +error NU1507: Warning As Error: There are 6 package sources defined in your configuration. +When using central package management, please map your package sources with package source mapping +(https://aka.ms/nuget-package-source-mapping) or specify a single package source. +``` + +**Root Cause:** +When `Directory.Packages.props` enables Central Package Management (`true`), NuGet requires explicit package source mapping if multiple package sources are defined. This is a security feature to prevent dependency confusion attacks and ensure packages come from expected sources. + +Without package source mapping, NuGet doesn't know which source to query for each package, leading to: +- Slower restore operations (queries all sources) +- Potential security risks (malicious packages from unexpected sources) +- Build failures when `TreatWarningsAsErrors` is enabled + +**Solution:** +Add `` section to `nuget.config` to map package patterns to specific sources: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Package Pattern Guidelines:** +- Use `*` wildcard to match all packages from a source (typically nuget.org for public packages) +- Use specific patterns like `LD.*` to match company-internal packages +- Use `CompanyName.*` patterns for vendor-specific packages +- More specific patterns take precedence over wildcards + +**Why This Works:** +- NuGet now knows to query nuget.org for all public packages (Microsoft.*, System.*, etc.) +- Internal LD.* packages are only queried from company feeds +- Eliminates ambiguity and improves restore performance +- Prevents accidental package substitution attacks + +**Alternative Solutions (Not Recommended):** +- **Disable Central Package Management:** Loses version consistency benefits +- **Use single package source:** Requires consolidating all packages into one feed +- **Disable TreatWarningsAsErrors:** Reduces build quality, allows security issues + +**When to Apply:** +- Any repository using Central Package Management (`Directory.Packages.props`) +- Repositories with multiple NuGet package sources +- Builds failing with NU1507 errors +- Organizations with internal package feeds alongside nuget.org + +**Related Documentation:** +- [NuGet Package Source Mapping](https://aka.ms/nuget-package-source-mapping) +- [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) + +--- + +## Edge Case 5: System.* Package Compatibility + +**Question:** Why are System.* packages from .NET Core 1.x (version 4.3.0) still compatible with .NET 8.0? + +**Answer:** +- System.* packages (System.Threading, System.Diagnostics.Debug, etc.) are part of .NET Standard 2.0 +- .NET Standard 2.0 is supported by all modern .NET versions (.NET Core 2.0+, .NET 5+, .NET Framework 4.6.1+) +- Modern .NET includes these types in the core framework (no separate package needed at runtime) +- Package references are primarily for NuGet's dependency graph resolution +- At runtime, .NET 8.0's built-in implementations are used (type forwarding) + +**Verification:** +```powershell +# Check if package is actually used at runtime +dotnet publish MyProject.csproj -c Release +# System.* packages won't appear in publish output - they're built into the runtime +``` + +--- \ No newline at end of file diff --git a/.agent/skills/new-build-dotnet/references/Validation_Checklist.md b/.agent/skills/new-build-dotnet/references/Validation_Checklist.md new file mode 100644 index 0000000..72512b1 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/Validation_Checklist.md @@ -0,0 +1,72 @@ +# Validation Checklist + +## After implementation, verify: + +- [ ] All original subdirectory solution files are deleted +- [ ] New root-level solution files exist for each releasable component +- [ ] All project paths in root solutions are correct and relative to root +- [ ] Subdirectory `Directory.Build.props` files import the root `Directory.Build.props` +- [ ] Publishable projects have `True` +- [ ] Packable projects have `True` +- [ ] Test projects have neither IsPublishable nor IsPackable set to True +- [ ] `.gitignore` includes `Output/` directory + + +## Test the Build + +**Action:** Verify the build system works correctly by testing individual build tasks sequentially with the developer. + +**Instructions:** + +**IMPORTANT:** Each command must be run in PowerShell. A human must review the `Output/` directory after each command to ensure the results are as expected. + +Agents should work with the developer to test each build task in sequence. + +1. **Test package restore:** + ```powershell + Invoke-Build Initialize + ``` + - Verifies NuGet packages can be restored + - Checks package source configuration + - Ensures all dependencies are available + +2. **Test build:** + ```powershell + Invoke-Build Build + ``` + - Compiles all projects in the solution + - Validates project references + - Confirms output paths are correct + +3. **Test unit tests:** + ```powershell + Invoke-Build Test + ``` + - Runs all test projects + - Generates test results + - Validates test discovery + +4. **Test publish (for publishable projects):** + ```powershell + Invoke-Build Publish + ``` + - Creates NuGet packages for projects marked `IsPackable=True` + - Creates deployment artifacts for projects marked `IsPublishable=True` + +5. **Verify outputs:** + - Check that `Output/` directory is created in the repository root + - Verify version.json is created with correct version information + - Verify subdirectories exist: `Output//` should have `bin/`, `obj/`, etc. + - Confirm build artifacts are in expected locations + - Published websites go to `Output/publish//` + - Packages go to `Output/nuget/` + - Container images go to `Output/containers/` + - All published output includes runtime dependencies + - All published output includes correct version information + +6. **Test full CI pipeline (after individual tasks succeed):** + ```powershell + Invoke-Build CI + ``` + - Runs all tasks in sequence + - Simulates continuous integration build diff --git a/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD b/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD deleted file mode 100644 index 8820304..0000000 --- a/BUILD_SYSTEM_IMPLEMENTATION_PROMPT.MD +++ /dev/null @@ -1,1149 +0,0 @@ -# Monorepo Build System Implementation Guide - -## Table of Contents - -- [Context and Purpose](#context-and-purpose) -- [Prerequisites](#prerequisites) -- [Implementation Guidance](#implementation-guidance) -- [Implementation Steps](#implementation-steps) - - [Step 0: Create or Merge Root Directory.Build.props](#step-0-create-or-merge-root-directorybuildprops) - - [Step 0a: Update .gitignore to Exclude Output Directory](#step-0a-update-gitignore-to-exclude-output-directory) - - [Step 0b: Create Root build.build.ps1 (If Not Present)](#step-0b-create-root-buildbuildps1-if-not-present) - - [Step 1: Analyze Repository Structure](#step-1-analyze-repository-structure) - - [Step 2: Create Root-Level Solution Files](#step-2-create-root-level-solution-files) - - [Step 3: Delete Original Subdirectory Solution Files](#step-3-delete-original-subdirectory-solution-files) - - [Step 4: Update Subdirectory Directory.Build.props Files](#step-4-update-subdirectory-directorybuildprops-files) - - [Step 5: Mark Projects as Publishable](#step-5-mark-projects-as-publishable) - - [Step 6: Simplify Dockerfiles for Publishable Projects](#step-6-simplify-dockerfiles-for-publishable-projects) - - [Step 7: Test the Build System](#step-7-test-the-build-system) -- [Key Concepts and Rationale](#key-concepts-and-rationale) - - [Why Move Solutions to Root?](#why-move-solutions-to-root) - - [Output Directory Structure](#output-directory-structure) - - [GitVersion Integration](#gitversion-integration) - - [IsPackable vs IsPublishable](#ispackable-vs-ispublishable) - - [Invoke-Build Task Dependencies](#invoke-build-task-dependencies) -- [Troubleshooting: Edge Cases and Complex Scenarios](#troubleshooting-edge-cases-and-complex-scenarios) - - [Edge Case 1: SpecRun Path Handling with Centralized Output](#edge-case-1-specrun-path-handling-with-centralized-output) - - [Edge Case 2: Package Downgrade Errors from Legacy Dependencies](#edge-case-2-package-downgrade-errors-from-legacy-dependencies) - - [Edge Case 3: Understanding NuGet Dependency Resolution](#edge-case-3-understanding-nuget-dependency-resolution) - - [Edge Case 4: Package Source Mapping Required with Central Package Management](#edge-case-4-package-source-mapping-required-with-central-package-management) - - [Edge Case 5: System* Package Compatibility](#edge-case-5-system-package-compatibility) - - [Recommendations for Preventing Edge Cases](#recommendations-for-preventing-edge-cases) -- [Common Issues and Solutions](#common-issues-and-solutions) - - [Issue: Build cannot find projects](#issue-build-cannot-find-projects) - - [Issue: Output directory conflicts](#issue-output-directory-conflicts) - - [Issue: GitVersion fails](#issue-gitversion-fails) - - [Issue: Projects not inheriting root Directory.Build.props](#issue-projects-not-inheriting-root-directorybuildprops) - - [Issue: Test projects being packed or published](#issue-test-projects-being-packed-or-published) - - [Issue: NU1507 errors with Central Package Management](#issue-nu1507-errors-with-central-package-management) -- [Validation Checklist](#validation-checklist) -- [Summary](#summary) - -## Context and Purpose - -This document provides comprehensive instructions for implementing a standardized Invoke-Build monorepo build system in .NET repositories. The build system consolidates multiple solution files from subdirectories into root-level solution files, implements centralized output management, and provides automated versioning with GitVersion. - -## Prerequisites - -Before implementing this build system, ensure: - -1. The repository contains one or more .NET solutions in subdirectories -2. Each subdirectory has its own solution file (e.g., `SubFolder/ProjectName.sln`) -3. You have identified which projects need to be published (web apps, services) vs packed (libraries) - -## Implementation Guidance - -**Approach:** Implement steps sequentially, checking in after each step completion for review. - -**Solution Scope Determination:** Before implementing any changes, identify which solution file(s) in the repository root define the scope of work. Only apply build system changes to subdirectories and projects that are referenced by the root-level solution file(s). Subdirectories not referenced by any root solution file should be left unchanged, as they may be independent projects with their own build systems or may be legacy code not part of the current build scope. - -**Repository Structure:** Use Step 1 to discover all solution files and their locations in subdirectories. - -**Project Classification:** Use Step 5 instructions to identify: -- **Publishable projects** (web apps, services): Look for ``, Docker support, or entry points -- **Packable projects** (libraries): Look for `` with reusable code -- **Neither** (test projects): Projects with test frameworks or internal utilities - -**Dockerfiles:** Follow Step 6 to simplify any existing Dockerfiles found in publishable project directories. - -**Testing:** Developer will handle manual testing after implementation is complete. - ---- - -## Implementation Steps - -### Step 0: Create or Merge Root Directory.Build.props - -**Action:** Ensure the repository has a properly configured root `Directory.Build.props` file for centralized output management. - -**Instructions:** - -1. **Check if `Directory.Build.props` exists in the repository root** - -2. **If NO `Directory.Build.props` exists:** - - Create a new `Directory.Build.props` file in the repository root with the following content: - -```xml - - - shared - - $(MSBuildThisFileDirectory)Output/$(SolutionName)/ - $(LDBUILD_BINARIESDIRECTORY)/$(SolutionName)/ - - $(RootOutputPath)bin/$(MSBuildProjectName) - $(RootOutputPath)obj/$(MSBuildProjectName) - $(RootOutputPath)publish/$(MSBuildProjectName) - - - $(LDBUILD_TARGET_RUNTIME) - False - False - - -``` - -3. **If `Directory.Build.props` ALREADY EXISTS:** - - Read the existing file and identify any custom properties or settings - - Attempt to merge the required build system properties with existing content: - - Add the `SolutionName` property if not present - - Add the `RootOutputPath` conditional properties for output management - - Add the `BaseOutputPath`, `BaseIntermediateOutputPath`, and `PublishDir` properties - - Add the `RuntimeIdentifier` conditional property - - Add the `IsPackable` and `IsPublishable` default properties if not present - - Preserve any existing custom properties (e.g., `enable`, `true`) - - **Present the merged version to the user for approval** before making changes - - Ask: "I've merged the build system properties with your existing Directory.Build.props. Please review the merged content below. Is this acceptable?" - -**Why This Matters:** -- The `Directory.Build.props` file is the foundation of centralized output management -- It ensures all projects build to a consistent `Output//` directory structure -- The `RuntimeIdentifier` property enables the build system to correctly locate test DLLs -- The `IsPackable` and `IsPublishable` defaults prevent accidental packaging of test projects - -### Step 0a: Update .gitignore to Exclude Output Directory - -**Action:** Add the `Output/` directory to `.gitignore` to prevent build artifacts from being committed. - -**Instructions:** - -1. **Open the `.gitignore` file in the repository root** - -2. **Add the following line to exclude the Output directory:** - ``` - Output/ - ``` - -3. **Placement:** Add this line in the appropriate section (typically near other build output exclusions like `bin/`, `obj/`, etc.) - -**Why This Matters:** -- The centralized build system creates all build artifacts in the `Output/` directory -- Build artifacts should never be committed to source control -- This prevents accidentally committing: - - Compiled binaries (`Output//bin/`) - - Intermediate build files (`Output//obj/`) - - Published applications (`Output//publish/`) - - NuGet packages (`Output//nuget/`) - - Test results (`Output//TestResults/`) - -**Example Merge Scenario:** - -Existing `Directory.Build.props`: -```xml - - - enable - true - latest - - -``` - -Merged `Directory.Build.props`: -```xml - - - shared - - $(MSBuildThisFileDirectory)Output/$(SolutionName)/ - $(LDBUILD_BINARIESDIRECTORY)/$(SolutionName)/ - - $(RootOutputPath)bin/$(MSBuildProjectName) - $(RootOutputPath)obj/$(MSBuildProjectName) - $(RootOutputPath)publish/$(MSBuildProjectName) - $(LDBUILD_TARGET_RUNTIME) - False - False - - - enable - true - latest - - -``` - -### Step 0b: Create Root build.build.ps1 (If Not Present) - -**Action:** Ensure the repository has a `build.build.ps1` file in the root for orchestrating builds. - -**Instructions:** - -1. **Check if `build.build.ps1` exists in the repository root** - -2. **If `build.build.ps1` DOES NOT exist:** - - Create a new `build.build.ps1` file in the repository root with the following content: - -```powershell -<# -.SYNOPSIS - ./project.build.ps1 -.EXAMPLE - Invoke-Build -.NOTES - 0.5.0 - Parameterize - Add parameters to this script to control the build -#> -[CmdletBinding()] -param( - # dotnet build configuration parameter (Debug or Release) - [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Release', - - # Add the clean task before the default build - [switch]$Clean, - - # Collect code coverage when tests are run - [switch]$CollectCoverage, - - # Which solution to build - [ArgumentCompleter({ - param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) - - Get-ChildItem -Path $PSScriptRoot/*/* -Filter *.sln | - Split-Path -LeafBase | - Where-Object { $_ -like "*$wordToComplete*" } | - ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } - })] - [Alias("Project")] - [Parameter(Position = 0)] - [string]$Solution = "*", - - # [string[]]$MonoRepoVersionNames = @(""), - - # Which projects to build - [Alias("Projects")] - $solutionProject = @( - # By default build the first solution file in the root - if (Get-ChildItem -Filter "${Solution}.sln" -ErrorAction Ignore -OutVariable sln) { - if ($sln.Count -gt 1) { - Write-Warning "Multiple solution files found: `n- $($sln.FullName -join '`n- ')`Building only the first one: $($sln[0].FullName)" - } - $sln[0] | Convert-Path - } - )[0], - - # This should always be calculated automagically if you have docker files. If not, you'll need to specify these. - $DotNetPublishProjects = @(), - - # Which projects are test projects - [Alias("TestProjects")] - $dotnetTestProjects = @( - $dotnetProjects | Where-Object { - $_ -match "\.slnx?$" -or - $_ -match "Test[^\\/]*\..*proj$" - } - ), - - # Further options to pass to dotnet - [Alias("Options")] - $dotnetOptions = @{ - "-verbosity" = "minimal" - # "-runtime" = "linux-x64" - }, - - $TargetFramework = "net8.0", - [ValidateSet('linux-x64','win-x64')] - $TargetRuntime -) - -. $PSScriptRoot/../LD.Platform.BuildTasks/scripts/PSFormatting.ps1 - -# The name of the module to publish -$script:PSModuleName = "TerminalBlocks" -# Use Env because Earthly can override it -$Env:OUTPUT_ROOT ??= Join-Path $PSScriptRoot output - -$Tasks = "../LD.Platform.BuildTasks/tasks", "tasks", "../tasks", "../../tasks" | Convert-Path -ErrorAction Ignore -Write-Information "$($PSStyle.Foreground.BrightCyan)Found shared tasks in $Tasks" -Tag "InvokeBuild" - -## Self-contained build script - can be invoked directly or via Invoke-Build -if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { - foreach ($taskDir in $Tasks) { - $bootstrap = Join-Path $taskDir "_BootStrap.ps1" - Write-Information "Check for $bootstrap" -Tag "InvokeBuild" - if (Test-Path $bootstrap) { - Write-Information "Dotsource $bootstrap" -Tag "InvokeBuild" - . $bootstrap - } - } - - Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result - - if ($Result.Error) { - $Error[-1].ScriptStackTrace | Out-Host - exit 1 - } - exit 0 -} - -## Initialize the build variables, and import shared tasks, including DotNet tasks -foreach($taskDir in $Tasks) { - $initialize = Join-Path $taskDir "_Initialize.ps1" - if (Test-Path $initialize) { - Write-Information ". $initialize" - . $initialize - } -} -``` - -3. **If `build.build.ps1` ALREADY EXISTS:** - - **Do nothing** - leave the existing file as-is - - The developer has already customized this file for their repository - -**Why This Matters:** -- The `build.build.ps1` file is the entry point for all build operations -- It defines parameters for controlling builds (Configuration, Solution, Clean, etc.) -- The `$TargetFramework` parameter must match the framework used by projects in the repository (commonly `net8.0` or `net9.0`) -- **Critical:** If `$TargetFramework` doesn't match the actual project target frameworks, tests will be skipped with "Skipping empty input" because the build system won't find the compiled test DLLs in the expected paths -- The `$dotnetTestProjects` parameter enables automatic test project discovery by pattern matching - -**Important Configuration Notes:** -- **TargetFramework:** Set to `net8.0` or `net9.0` based on your projects' target framework. This must match or tests won't run. -- **Solution Discovery:** The script auto-discovers solution files in the root directory -- **Test Project Discovery:** Automatically finds projects matching `Test[^\\/]*\..*proj$` pattern -- **Task Paths:** Looks for shared tasks in `../LD.Platform.BuildTasks/tasks` and local `tasks/` directories - -### Step 1: Analyze Repository Structure - -**Action:** Identify all solution files and their locations. - -**Instructions:** -1. Search for all `.sln` files in the repository -2. For each solution file found in a subdirectory (not in root), note: - - The subdirectory name (e.g., `LD.JV.Builder`) - - The solution file name (e.g., `LD.JV.Builder.sln`) - - The projects contained in that solution -3. Create a mapping of subdirectory → new root-level solution name: - - Pattern: If subdirectory is `LD.JV.Builder` and solution is `LD.JV.Builder.sln`, create root-level `LD.JV.Builder.sln` - - Pattern: If subdirectory is `LD.JV.BuilderAsync` and solution is `LD.JV.BuilderAsync.sln`, create root-level `LD.JV.BuilderAsync.sln` - - Pattern: If subdirectory is `LD.JV.PlatformEvent.Common` and solution is `LD.JV.PlatformEvent.Common.sln`, create root-level `LD.JV.PlatformEvent.Common.sln` - -**Expected Output Format:** - -After completing Step 1, provide a summary in this format: - -``` -## Step 1 Complete: Repository Structure Analysis - -### Solution Files Found - -**1. [Solution Name]** -- **Location:** [Full path to current solution file] -- **Subdirectory:** [Subdirectory name] -- **New root-level name:** [Name for root solution file] - -**Projects contained ([count] total):** -- **Main/API Projects:** - - [Project names that are web apps or APIs] - -- **Library Projects:** - - [Project names that are libraries] - -- **Test Projects:** - - [Project names that are test projects] - -[Repeat for each solution found] - -### Summary - -- **[N] solution files** found in subdirectories -- **[Solution1.sln]** will be moved from `[SubDir]/` to root with updated paths (prefix: `[SubDir]\`) -- **[Solution2.sln]** will be moved from `[SubDir]/` to root with updated paths (prefix: `[SubDir]\`) -``` - -### Step 2: Create Root-Level Solution Files - -**Action:** For each subdirectory solution one level deep from the root, move the solution file to the root and update the project paths. - -**Instructions:** -1. Read the original solution file from the subdirectory -2. Update all project paths in the solution file to be relative from the repository root: - - **Original path pattern:** `ProjectName\ProjectName.csproj` (relative to subdirectory) - - **New path pattern:** `SubdirectoryName\ProjectName\ProjectName.csproj` (relative to root) -4. Preserve all project GUIDs, configurations, and solution items -5. Update any solution items paths (like NuGet.config) to reference the subdirectory: - - **Original:** `NuGet.config = NuGet.config` - - **New:** `NuGet.config = SubdirectoryName\NuGet.config` - -**Example Transformation:** - -Original solution in `LD.JV.Builder/LD.JV.Builder.sln`: -``` -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LD.JV.Builder.Host.Web", "LD.JV.Builder.Host.Web\LD.JV.Builder.Host.Web.csproj", "{GUID}" -``` - -New solution in root `LD.JV.Builder.sln`: -``` -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LD.JV.Builder.Host.Web", "LD.JV.Builder\LD.JV.Builder.Host.Web\LD.JV.Builder.Host.Web.csproj", "{GUID}" -``` - -### Step 3: Delete Original Subdirectory Solution Files - -**Action:** Remove the old solution files from subdirectories. - -**Instructions:** -1. For each solution file that was in a subdirectory (e.g., `LD.JV.Builder/LD.JV.Builder.sln`), delete it using `git rm` -2. **Do not delete:** - - Project files (.csproj, .fsproj, etc.) - - Source code - - Tests - - Docker files - - Any other non-solution files - -**Why:** The root-level solution files replace the subdirectory solutions. Keeping both would cause confusion and maintenance issues. - -### Step 4: Update Subdirectory Directory.Build.props Files - -**Action:** Modify existing `Directory.Build.props` files in subdirectories to import the root-level `Directory.Build.props`. - -**Instructions:** -1. Search for existing `Directory.Build.props` files in subdirectories -2. For each file found, add the following import statement as the **first line** after the opening `` tag: - -```xml - - - -``` - -**Why:** This ensures that the centralized output path configuration from the root `Directory.Build.props` is inherited by all projects, while still allowing subdirectory-specific properties. - -**Example:** - -Before: -```xml - - - enable - $(WarningsAsErrors);CS8600 - - -``` - -After: -```xml - - - - enable - $(WarningsAsErrors);CS8600 - - -``` - -### Step 5: Mark Projects as Publishable - -**Action:** Update .csproj files to explicitly declare their publishing intent. - -**Instructions:** - -**IMPORTANT:** -- **Do NOT add `true` to any .csproj files.** There is no deterministic way to identify which projects should be packable. Developers must manually add `true` to library projects that need to be packed as NuGet packages **before** running this build system implementation. -- Only add `True` to projects that need to be published as deployment artifacts. -- Add `true` to all test projects. - -1. **For test projects:** - - Add `true` to the main `` - - These are typically projects with: - - Names containing "Test", "Tests", or "Spec" (e.g., `Tests.LD.EPS.Common`, `LD.EPS.Core.Api.Tests`) - - References to test frameworks (xUnit, NUnit, MSTest, SpecFlow, etc.) - - Package references like `Microsoft.NET.Test.Sdk`, `xunit`, `NUnit`, `MSTest.TestFramework` - -2. **For projects that should be published** (web applications, services, executables): - - Add `True` to the main `` - - These are typically projects with: - - `` - - Docker support - - Entry points (Program.cs with Main method) - - Service hosts - -3. **For projects that should not be published or tested** (libraries, internal utilities): - - No changes needed - the root `Directory.Build.props` sets both `IsPublishable` and `IsTestProject` to `False` by default - -**Note on IsPackable:** -- The root `Directory.Build.props` sets `False` by default for all projects -- Developers must manually identify and mark library projects with `true` if they need to be packed as NuGet packages -- This step does NOT handle `IsPackable` configuration - -**Example Changes:** - -For a web service project (`LD.JV.BuilderAsync.Host.Messaging.csproj`): -```xml - - net9.0 - enable - enable - 2318815b-a78c-420b-80db-571a264a2cf9 - True - Linux - -``` - -### Step 6: Simplify Dockerfiles for Publishable Projects - -**Action:** Update Dockerfiles to use pre-built artifacts instead of multi-stage builds. - -**Instructions:** - -For projects marked with `True` that have Dockerfiles, simplify them to expect pre-built publish artifacts: - -1. **Identify Dockerfiles** in publishable project directories -2. **Replace multi-stage build Dockerfiles** with simplified single-stage versions -3. **Pattern to follow:** - -**Before (multi-stage build):** -```dockerfile -FROM dotnet/sdk:9.0 AS build -WORKDIR /src -COPY ["NuGet.config", "."] -COPY ["Project/Project.csproj", "Project/"] -RUN dotnet restore "Project/Project.csproj" -COPY . . -WORKDIR "/src/Project" -RUN dotnet build "Project.csproj" -c Release -o /app/build -RUN dotnet publish "Project.csproj" -c Release -o /app/publish - -FROM dotnet/runtime:9.0 AS final -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "Project.dll"] -``` - -**After (simplified for pre-built artifacts):** -```dockerfile -FROM crazusw2dvosl1.azurecr.io/dotnet/runtime:9.0 -WORKDIR /app -EXPOSE 8080 -COPY . . -ENTRYPOINT ["dotnet", "ProjectName.dll"] -``` - -**Key Changes:** -- Remove all build stages (SDK image, restore, build, publish) -- Use only the runtime base image -- Simple `COPY . .` to copy pre-built artifacts -- Keep EXPOSE, ENTRYPOINT, and WORKDIR directives - -**Why:** The build system now handles `dotnet publish` via the build tasks, creating publish artifacts in `Output//publish//`. Dockerfiles should only package these pre-built artifacts, not rebuild the application. This: -- Separates build concerns from containerization -- Speeds up Docker image creation -- Ensures consistent builds across environments -- Allows the build system to control versioning and build parameters - -**Examples from this repository:** - -For ASP.NET web applications: -```dockerfile -FROM crazusw2dvosl1.azurecr.io/dotnet/runtime:9.0 -WORKDIR /app -EXPOSE 8080 -COPY . . -ENTRYPOINT ["dotnet", "LD.JV.Builder.Host.Web.dll"] -``` - -For console applications/services: -```dockerfile -FROM crazusw2dvosl1.azurecr.io/dotnet/runtime:9.0 -WORKDIR /app -EXPOSE 5000 -COPY . . -ENTRYPOINT ["./LD.JV.PlatformEvent.Consumer"] -``` - -**Note:** The Dockerfile should remain in the project directory alongside the .csproj file. The build system will copy it to the publish output directory when `True` is set. - -### Step 7: Test the Build System - -**Action:** Verify the build system works correctly by testing individual build tasks sequentially with the developer. - -**Instructions:** - -**IMPORTANT:** The developer must run each command in their terminal. Prompt them to execute each command and wait for them to share the output before proceeding to the next step. Do not attempt to run commands automatically. - -Work with the developer to test each build task in sequence. After each command succeeds, proceed to the next one: - -1. **Test package restore:** - ```powershell - Invoke-Build DotNetRestore - ``` - - Verifies NuGet packages can be restored - - Checks package source configuration - - Ensures all dependencies are available - -2. **Test build:** - ```powershell - Invoke-Build DotNetBuild - ``` - - Compiles all projects in the solution - - Validates project references - - Confirms output paths are correct - -3. **Test unit tests:** - ```powershell - Invoke-Build DotNetTest - ``` - - Runs all test projects - - Generates test results - - Validates test discovery - -4. **Test publish (for publishable projects):** - ```powershell - Invoke-Build DotNetPublish - ``` - - Creates deployment artifacts for projects marked `IsPublishable=True` - - Output goes to `Output//publish//` - - Includes runtime dependencies - -5. **Test pack (for packable projects):** - ```powershell - Invoke-Build DotNetPack - ``` - - Creates NuGet packages for projects marked `IsPackable=True` - - Output goes to `Output//nuget/` - - Includes version information from GitVersion - -6. **Verify outputs:** - - Check that `Output/` directory is created in the repository root - - Verify subdirectories exist: `Output//bin/`, `obj/`, `publish/`, `nuget/` - - Confirm build artifacts are in expected locations - - Verify version.json is created with GitVersion information - -7. **Test full CI pipeline (after individual tasks succeed):** - ```powershell - Invoke-Build CI - ``` - - Runs all tasks in sequence - - Simulates continuous integration build - ---- - -## Key Concepts and Rationale - -### Why Move Solutions to Root? - -**Problem:** Multiple solutions in subdirectories make it difficult to: -- Build all solutions with a single command -- Share build configuration and tasks -- Manage output directories consistently -- Version solutions independently or together - -**Solution:** Root-level solution files with subdirectory project references allow: -- Centralized build orchestration -- Shared build tasks and configuration -- Per-solution output directories -- Flexible versioning strategies - -### Output Directory Structure - -The build system creates a structured output directory: - -``` -Output/ -├── JVBuilder/ -│ ├── bin/ -│ │ └── ProjectName/ -│ │ └── Release/ -│ │ └── net9.0/ -│ ├── obj/ -│ │ └── ProjectName/ -│ ├── publish/ -│ │ └── ProjectName/ -│ ├── nuget/ -│ └── version.json -├── JVBuilderAsync/ -│ └── (same structure) -└── JVPlatformEventCommon/ - └── (same structure) -``` - -### GitVersion Integration - -The build system uses GitVersion for semantic versioning: - -**Configuration highlights:** -- **Workflow:** TrunkBased/preview1 - Each commit can increment version -- **Main branch:** Auto-increments minor version on merge -- **Feature branches:** Include branch name in pre-release tag -- **Release branches:** Use manual deployment mode with RC label -- **Commit messages:** Support `semver:` trailers for explicit version control - - `semver: major` or `semver: breaking` - Increment major version - - `semver: minor` or `semver: feature` - Increment minor version - - `semver: patch` or `semver: fix` - Increment patch version - - `semver: none` or `semver: skip` - No version increment - -**Version format:** -- Assembly version: `{Major}.{Minor}.{Patch}.{BuildCount}` -- Informational version: `{Major}.{Minor}.{Patch}{PreReleaseTag}+Build.{BuildCount}.Date.{CommitDate}.Branch.{BranchName}.Sha.{Sha}` - -### IsPackable vs IsPublishable - -**IsPackable=True:** -- Creates NuGet packages with `dotnet pack` -- For libraries and shared code -- Output goes to `Output//nuget/` - -**IsPublishable=True:** -- Creates deployment artifacts with `dotnet publish` -- For applications, services, and executables -- Output goes to `Output//publish/` -- Can include Dockerfiles and runtime dependencies - -**Default (both False):** -- Test projects -- Internal utilities -- Projects not meant for distribution - -### Invoke-Build Task Dependencies - -The build system defines task dependencies: - -``` -CI Task: - └─ Clean (if in CI or -Clean specified) - └─ DotNetRestore - └─ DotNetToolRestore - └─ GetVersion - └─ InstallGitVersion - └─ GitInit - └─ DotNetBuild - └─ DotNetRestore - └─ GetVersion - └─ DotNetTest - └─ DotNetTrx2JUnit - └─ ReportGenerator - └─ DotNetPack - └─ DotNetPush -``` - ---- - -## Troubleshooting: Edge Cases and Complex Scenarios - -This section documents real-world edge cases encountered during build system implementations and their solutions. - -### Edge Case 1: SpecRun Path Handling with Centralized Output - -**Repository:** FeeEngine (LD.FeeEngine.API) - -**Symptom:** -Build fails with path duplication error in SpecRun-based test projects: -``` -error: The filename, directory name, or volume label syntax is incorrect. : -'C:\XDL\LD.FeeEngine.API\FeeEngine\LD.EPS.FeeEngine.BDD.Tests\C:\XDL\LD.FeeEngine.API\Output\FeeEngine\obj\...' -``` - -**Root Cause:** -SpecRun.SpecFlow targets file (version 3.9.31) does not correctly handle absolute paths in `BaseIntermediateOutputPath`. When the centralized build system sets this to an absolute path, SpecRun's targets attempt to concatenate it with the project directory path, resulting in malformed paths. - -This is a known limitation of older SpecRun versions (pre-.NET 5) that assume `BaseIntermediateOutputPath` is always a relative path. - -**Solution:** -Override the intermediate output path properties in the affected project to use a local relative path: - -```xml - - - - obj\ - $(BaseIntermediateOutputPath)\$(Configuration)\$(TargetFramework)\ - -``` - -**Trade-offs:** -- This project won't benefit from centralized intermediate output cleanup -- Creates a non-fatal warning about `BaseIntermediateOutputPath` being modified after MSBuild uses it -- Build artifacts (bin) still go to centralized location; only intermediate files (obj) are kept local -- Acceptable compromise until SpecRun is upgraded to version 3.10+ or SpecFlow 4.x - -**When to Apply:** -- Projects using SpecRun.SpecFlow versions < 3.10 -- Projects using SpecFlow.Plus.Runner with older versions -- Any test framework with custom MSBuild targets that assume relative paths - ---- - -### Edge Case 2: Package Downgrade Errors from Legacy Dependencies - -**Repository:** FeeEngine (LD.FeeEngine.API) - -**Symptom:** -Build fails with NU1605 errors (package downgrade warnings treated as errors): -``` -error NU1605: Warning As Error: Detected package downgrade: System.Diagnostics.Debug from 4.3.0 to 4.0.11 -error NU1605: Warning As Error: Detected package downgrade: System.IO.FileSystem.Primitives from 4.3.0 to 4.0.1 -error NU1605: Warning As Error: Detected package downgrade: System.Runtime.InteropServices from 4.3.0 to 4.1.0 -error NU1605: Warning As Error: Detected package downgrade: System.Threading from 4.3.0 to 4.0.11 -``` - -**Root Cause:** -Legacy internal packages (e.g., `LD.Common.AspNetCore 2.0.0.49`) or old test frameworks (e.g., `AutoFixture.AutoMoq 4.8.0`, `Moq 4.7.0`) create conflicting transitive dependency chains: - -``` -Project → LD.Common.AspNetCore 2.0.0.49 - → Microsoft.AspNetCore.Mvc.Core 2.2.0 - → Microsoft.Extensions.DependencyModel 2.1.0 - → Microsoft.DotNet.PlatformAbstractions 2.1.0 - → System.IO.FileSystem 4.0.1 - → runtime.win.System.IO.FileSystem 4.3.0 - → System.Diagnostics.Debug (>= 4.3.0) ← Requires 4.3.0 - -But also: -Project → LD.Common.AspNetCore 2.0.0.49 - → Microsoft.Extensions.DependencyModel 2.1.0 - → System.Diagnostics.Debug (>= 4.0.11) ← Allows 4.0.11 -``` - -NuGet's dependency resolver chooses the lower version to satisfy both constraints, but this violates the "no downgrade" rule when `TreatWarningsAsErrors` is enabled. - -**Solution:** -Add explicit package references for the higher versions required by transitive dependencies: - -**For library projects with LD.Common.AspNetCore dependencies:** -```xml - - - - - - -``` - -**For test projects with AutoFixture.AutoMoq dependencies:** -```xml - - - - -``` - -**Why This Works:** -- Explicit package references take precedence over transitive dependencies in NuGet's resolution -- Forces NuGet to use version 4.3.0, satisfying all dependency constraints without downgrades -- System.* packages at version 4.3.0 (from .NET Core 1.x era) remain compatible with modern .NET -- At runtime, .NET 8.0+ uses built-in implementations; package references primarily satisfy NuGet's dependency graph - -**Alternative Solutions (Not Recommended):** -- **Upgrade legacy packages:** Requires coordination across teams, potential breaking changes -- **Disable TreatWarningsAsErrors:** Reduces build quality, allows security vulnerabilities -- **Add NoWarn for NU1605:** Masks the problem without fixing it - -**When to Apply:** -- Projects referencing legacy internal packages targeting .NET Framework or early .NET Core -- Test projects using older versions of Moq, AutoFixture, NSubstitute, or similar frameworks -- Any project with `TreatWarningsAsErrors=true` encountering NU1605 warnings - -**Affected Projects in FeeEngine Example:** -- `LD.EPS.FeeEngine.ThirdPartyFramework` - Added 3 System.* packages -- `LD.EPS.FeeEngine.ClosingCorp` - Added 3 System.* packages -- `LD.EPS.FeeEngine.ClosingCorp.Tests` - Added System.Threading -- `LD.EPS.FeeEngine.ThirdPartyFees.Tests` - Added System.Threading -- `LD.EPS.FeeEngine.InRule.Tests` - Added System.Threading - ---- - -### Edge Case 3: Understanding NuGet Dependency Resolution - -**Background:** -Understanding how NuGet resolves package versions helps diagnose and fix dependency conflicts. - -**NuGet Resolution Strategy:** -1. **Direct references win:** Explicit `` in the project file takes highest precedence -2. **Nearest wins:** Among transitive dependencies, the package "nearest" to the project (fewest hops) is chosen -3. **Lowest compatible version:** When multiple versions satisfy constraints, NuGet picks the lowest version that works -4. **Downgrade detection:** If resolution results in using a lower version than required by any dependency, NU1605 is issued - -**Example Dependency Graph:** -``` -MyProject.csproj -├─ PackageA 2.0 -│ └─ System.Text.Json >= 6.0.0 -└─ PackageB 1.0 - └─ System.Text.Json >= 4.7.0 - -Resolution: System.Text.Json 6.0.0 (satisfies both >= 6.0.0 and >= 4.7.0) -``` - -**Downgrade Example:** -``` -MyProject.csproj -├─ PackageA 2.0 -│ └─ System.Text.Json 4.7.0 (exact version) -└─ PackageB 1.0 - └─ System.Text.Json >= 6.0.0 - -Resolution: System.Text.Json 4.7.0 (nearest wins, but downgrades from 6.0.0) -Warning NU1605: Detected package downgrade -``` - -**Fix:** Add explicit reference to override: -```xml - -``` - ---- - -### Edge Case 4: Package Source Mapping Required with Central Package Management - -**Repository:** LD.Shared.EnterprisePlatformServices.API (EPS) - -**Symptom:** -Build fails with NU1507 errors when using Central Package Management with multiple NuGet sources: -``` -error NU1507: Warning As Error: There are 6 package sources defined in your configuration. -When using central package management, please map your package sources with package source mapping -(https://aka.ms/nuget-package-source-mapping) or specify a single package source. -``` - -**Root Cause:** -When `Directory.Packages.props` enables Central Package Management (`true`), NuGet requires explicit package source mapping if multiple package sources are defined. This is a security feature to prevent dependency confusion attacks and ensure packages come from expected sources. - -Without package source mapping, NuGet doesn't know which source to query for each package, leading to: -- Slower restore operations (queries all sources) -- Potential security risks (malicious packages from unexpected sources) -- Build failures when `TreatWarningsAsErrors` is enabled - -**Solution:** -Add `` section to `nuget.config` to map package patterns to specific sources: - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -**Package Pattern Guidelines:** -- Use `*` wildcard to match all packages from a source (typically nuget.org for public packages) -- Use specific patterns like `LD.*` to match company-internal packages -- Use `CompanyName.*` patterns for vendor-specific packages -- More specific patterns take precedence over wildcards - -**Why This Works:** -- NuGet now knows to query nuget.org for all public packages (Microsoft.*, System.*, etc.) -- Internal LD.* packages are only queried from company feeds -- Eliminates ambiguity and improves restore performance -- Prevents accidental package substitution attacks - -**Alternative Solutions (Not Recommended):** -- **Disable Central Package Management:** Loses version consistency benefits -- **Use single package source:** Requires consolidating all packages into one feed -- **Disable TreatWarningsAsErrors:** Reduces build quality, allows security issues - -**When to Apply:** -- Any repository using Central Package Management (`Directory.Packages.props`) -- Repositories with multiple NuGet package sources -- Builds failing with NU1507 errors -- Organizations with internal package feeds alongside nuget.org - -**Related Documentation:** -- [NuGet Package Source Mapping](https://aka.ms/nuget-package-source-mapping) -- [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) - ---- - -### Edge Case 5: System.* Package Compatibility - -**Question:** Why are System.* packages from .NET Core 1.x (version 4.3.0) still compatible with .NET 8.0? - -**Answer:** -- System.* packages (System.Threading, System.Diagnostics.Debug, etc.) are part of .NET Standard 2.0 -- .NET Standard 2.0 is supported by all modern .NET versions (.NET Core 2.0+, .NET 5+, .NET Framework 4.6.1+) -- Modern .NET includes these types in the core framework (no separate package needed at runtime) -- Package references are primarily for NuGet's dependency graph resolution -- At runtime, .NET 8.0's built-in implementations are used (type forwarding) - -**Verification:** -```powershell -# Check if package is actually used at runtime -dotnet publish MyProject.csproj -c Release -# System.* packages won't appear in publish output - they're built into the runtime -``` - ---- - -### Recommendations for Preventing Edge Cases - -**1. Regular Dependency Audits** -```powershell -# Check for outdated packages -dotnet list package --outdated - -# Check for vulnerable packages -dotnet list package --vulnerable -``` - -**2. Central Package Management** -Use `Directory.Packages.props` for version management: -```xml - - - true - - - - - - -``` - -**3. Upgrade Legacy Dependencies** -- Prioritize upgrading internal packages to target modern .NET -- Update test frameworks to current versions when feasible -- Document upgrade blockers for future planning - -**4. Monitor Build Warnings** -- Review all NU1605 warnings even if build succeeds -- Investigate NU1608 (version override) warnings -- Track MSB3539 warnings about property modifications - -**5. Test Framework Compatibility Matrix** -| Framework | Last Version Supporting .NET Framework | First Version Supporting .NET 5+ | -|-----------|---------------------------------------|-----------------------------------| -| SpecFlow | 3.9.x | 3.10+ | -| NUnit | 3.13.x | 3.13+ (both) | -| xUnit | 2.4.x | 2.4+ (both) | -| MSTest | 2.2.x | 2.2+ (both) | - ---- - -## Common Issues and Solutions - -### Issue: Build cannot find projects - -**Symptom:** Error like "Project file does not exist" - -**Solution:** Verify project paths in root solution files are correct and relative to repository root, not subdirectory. - -### Issue: Output directory conflicts - -**Symptom:** Build artifacts from different solutions overwrite each other - -**Solution:** Ensure each root solution file has a unique name, which becomes the `SolutionName` variable used in output paths. - -### Issue: GitVersion fails - -**Symptom:** "No commits found on the current branch" - -**Solution:** -- Ensure repository has at least one commit -- Run `git fetch --unshallow` if in a shallow clone -- Verify `GitVersion.yml` is in repository root - -### Issue: Projects not inheriting root Directory.Build.props - -**Symptom:** Output goes to default `bin/` and `obj/` directories in project folders - -**Solution:** Add the import statement to subdirectory `Directory.Build.props` files as described in Step 4. - -### Issue: Test projects being packed or published - -**Symptom:** NuGet packages or publish folders created for test projects - -**Solution:** Ensure test projects do NOT have `True` or `True`. The default from root `Directory.Build.props` is False for both. - -### Issue: NU1507 errors with Central Package Management - -**Symptom:** Build warnings or errors like "NU1507: There are 2 package sources defined in your configuration. When using central package management, please map your package sources with package source mapping..." - -**Solution:** Add `packageSourceMapping` section to the root `nuget.config` file. This is required when using Central Package Management (Directory.Packages.props). Example: - -```xml - - - - - - - - - -``` - -Place this section inside the `` element, typically after ``. Map each package source to the package patterns it should provide. - ---- - -## Validation Checklist - -After implementation, verify: - -- [ ] All original subdirectory solution files are deleted -- [ ] New root-level solution files exist for each subdirectory solution -- [ ] All project paths in root solutions are correct and relative to root -- [ ] Subdirectory `Directory.Build.props` files import the root `Directory.Build.props` -- [ ] Publishable projects have `True` -- [ ] Packable projects have `True` -- [ ] Test projects have neither IsPublishable nor IsPackable set to True -- [ ] `build.build.ps1` runs successfully for each solution -- [ ] Output directory structure is created correctly: `Output//` -- [ ] GitVersion generates version information successfully -- [ ] Tests run and produce results -- [ ] CI/CD pipeline is updated to use new build system -- [ ] `.gitignore` includes `Output/` directory - ---- - -## Summary - -This build system provides: -1. **Monorepo support** - Multiple solutions in one repository -2. **Centralized configuration** - Shared build tasks and properties -3. **Organized outputs** - Per-solution output directories -4. **Automated versioning** - GitVersion integration -5. **CI/CD ready** - Designed for pipeline automation -6. **Flexible** - Build all solutions or specific ones -7. **Extensible** - Add custom tasks as needed - -The implementation consolidates build logic, reduces duplication, and provides a consistent developer and CI/CD experience across all solutions in the repository. diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index ac463b2..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,23 +0,0 @@ - - - shared - - - $(MSBuildThisFileDirectory)Output/$(SolutionName)/ - - $(LDBUILD_OUTPUT_ROOT)/$(SolutionName)/ - - $(RootOutputPath)bin/$(MSBuildProjectName) - $(RootOutputPath)obj/$(MSBuildProjectName) - $(RootOutputPath)publish/$(MSBuildProjectName) - - $(LDBUILD_TARGET_RUNTIME) - - false - - False - False - - $(MSBuildThisFileDirectory)\.runsettings - - \ No newline at end of file From c5b47ce1db69303683e1f1e40d7e10c5e2e7ffab Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Tue, 21 Apr 2026 16:13:50 -0400 Subject: [PATCH 08/43] Add PowerShell module build support (#31) - Add PowerShell tasks and base script - Rename, Refactor and normalize to new patterns - Make Pester task simpler and not module-specific - Minor updates to existing names for consistency - Includes MonoRepoGitVersion Tasks Remove the old PowerShell tasks --- .../new-build-dotnet/assets/build.build.ps1 | 2 +- GitVersion.yml | 80 ++------ PSScriptAnalyzerSettings.psd1 | 33 ++++ RequiredModules.psd1 => build.requires.psd1 | 0 common/Get-Version.Task.ps1 | 2 +- common/Install-All.Task.ps1 | 1 + common/Install-BuildDependencies.Task.ps1 | 1 - ...s.Task.ps1 => Install-DotNetTool.Task.ps1} | 2 +- ...s.Task.ps1 => Install-FromGitHub.Task.ps1} | 2 +- common/Install-PowerShellModule.Task.ps1 | 7 + common/Install-RequiredModules.Task.ps1 | 7 - common/MonoRepoGitVersion.Task.ps1 | 163 +++++++++++++++++ common/MonoRepoTagSource.Task.ps1 | 24 +++ common/Test-PowerShell.Task.ps1 | 87 +++++++++ common/base.ps1 | 34 ++-- docs/Extends.md | 10 +- dotnet/Convert-Coverage.Task.ps1 | 2 +- dotnet/Restore-DotNet.Task.ps1 | 2 +- dotnet/Test-DotNet.Task.ps1 | 2 +- dotnet/base.ps1 | 13 +- helm/Test-Helm.Task.ps1 | 2 +- helm/base.ps1 | 7 +- powershell/Build-Module.Task.ps1 | 64 +++++++ powershell/Import-Module.Task.ps1 | 21 +++ powershell/PSModuleAnalyze.Task.ps1 | 19 -- powershell/PSModuleBuild.Task.ps1 | 20 -- powershell/PSModuleImport.Task.ps1 | 16 -- powershell/PSModulePush.Task.ps1 | 56 ------ powershell/PSModuleRestore.Task.ps1 | 9 - powershell/PSModuleTest.Task.ps1 | 172 ------------------ powershell/Publish-Module.Task.ps1 | 28 +++ powershell/Test-PowerShellSyntax.Task.ps1 | 43 +++++ powershell/base.ps1 | 105 +++++++++++ scripts/Bootstrap.ps1 | 11 +- ...odule.ps1 => Install-PowerShellModule.ps1} | 63 +++---- 35 files changed, 666 insertions(+), 444 deletions(-) create mode 100644 PSScriptAnalyzerSettings.psd1 rename RequiredModules.psd1 => build.requires.psd1 (100%) create mode 100644 common/Install-All.Task.ps1 delete mode 100644 common/Install-BuildDependencies.Task.ps1 rename common/{Restore-DotNetTools.Task.ps1 => Install-DotNetTool.Task.ps1} (95%) rename common/{Install-GitHubTools.Task.ps1 => Install-FromGitHub.Task.ps1} (93%) create mode 100644 common/Install-PowerShellModule.Task.ps1 delete mode 100644 common/Install-RequiredModules.Task.ps1 create mode 100644 common/MonoRepoGitVersion.Task.ps1 create mode 100644 common/MonoRepoTagSource.Task.ps1 create mode 100644 common/Test-PowerShell.Task.ps1 create mode 100644 powershell/Build-Module.Task.ps1 create mode 100644 powershell/Import-Module.Task.ps1 delete mode 100644 powershell/PSModuleAnalyze.Task.ps1 delete mode 100644 powershell/PSModuleBuild.Task.ps1 delete mode 100644 powershell/PSModuleImport.Task.ps1 delete mode 100644 powershell/PSModulePush.Task.ps1 delete mode 100644 powershell/PSModuleRestore.Task.ps1 delete mode 100644 powershell/PSModuleTest.Task.ps1 create mode 100644 powershell/Publish-Module.Task.ps1 create mode 100644 powershell/Test-PowerShellSyntax.Task.ps1 create mode 100644 powershell/base.ps1 rename scripts/{Install-RequiredModule.ps1 => Install-PowerShellModule.ps1} (51%) diff --git a/.agent/skills/new-build-dotnet/assets/build.build.ps1 b/.agent/skills/new-build-dotnet/assets/build.build.ps1 index 5fe3b0d..a0fb405 100644 --- a/.agent/skills/new-build-dotnet/assets/build.build.ps1 +++ b/.agent/skills/new-build-dotnet/assets/build.build.ps1 @@ -40,7 +40,7 @@ if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { } # Define your preferred default build for local dev: -Add-BuildTask . Get-Version, Build, Test +Add-BuildTask . "Get-Version", "Build", "Test" # Each build is responsible to define the five core tasks for CI # But each base adds opinionated tasks to these variables diff --git a/GitVersion.yml b/GitVersion.yml index 7993e83..dc95a2c 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,74 +1,28 @@ -# Each merged branch against main will increment the version unless otherwise specified in a commit message -# TrunkBased is the only workflow where each commit to a feature changes the pre-release tag -workflow: TrunkBased/preview1 -# mode: ContinuousDeployment -# mode: ContinuousDelivery -# mode: ManualDeployment -# No dashes in date -commit-date-format: "yyyyMMddTHHmmss" -# Use BuildId from Azure DevOps (with fallback) -assembly-versioning-format: '{Major}.{Minor}.{Patch}.{env:BUILD_COUNT ?? 0}' -assembly-informational-format: '{Major}.{Minor}.{Patch}{PreReleaseTagWithDash}+Build.{env:BUILD_COUNT ?? 0}.Date.{CommitDate}.Branch.{env:SafeBranchName ?? unknown}.Sha.{Sha}' -# Format version bump messages as git trailers -major-version-bump-message: 'semver:\s?(breaking|major)' -minor-version-bump-message: 'semver:\s?(feature|minor)' -patch-version-bump-message: 'semver:\s?(fix|patch)' -no-bump-message: 'semver:\s?(none|skip)' -commit-message-incrementing: Enabled -# semantic-version-format: Loose +# https://gitversion.net/docs/reference/configuration +workflow: GitFlow/v1 +mode: ContinuousDelivery +increment: Minor strategies: - TaggedCommit -- Mainline - TrackReleaseBranches - VersionInBranchName -- MergeMessage - branches: main: - increment: Minor - prevent-increment: - # If false, rebuilds of the same code will increment the version! - when-current-commit-tagged: true + regex: ^production$ + develop: + regex: ^main$ + label: "beta" release: - mode: ManualDeployment label: rc - increment: None - prevent-increment: - of-merged-branch: true - when-current-commit-tagged: false - track-merge-target: false - is-release-branch: true - # A hotfix is just a release with bad habits - regex: ^(?:releases?)/(?\d+\.\d+(\.\d+)?)$ + mode: ContinuousDelivery hotfix: - mode: ManualDeployment - label: hotfix - regex: ^(?:hotfix(?:es)?)/(?\d+\.\d+(\.\d+)?)$ - increment: None - prevent-increment: - of-merged-branch: true - when-current-commit-tagged: false - track-merge-target: false - is-release-branch: true - - feature: - # any branch name that starts with feature - # (with any number of / separated segments) - # we use the last segment as the BranchName label... - regex: ^features?[/-](.+[/-])*(?[^/-]+)$ - # label: alpha.{BranchName}. - # Since we *know* it's a feature, then we can increment the minor version - increment: Minor - source-branches: [ "main", "feature", "release" ] + label: rc + mode: ContinuousDelivery pull-request: - label: pr{BranchName} - regex: ^pull/(?[^/-]+)/merge$ - unknown: - # we usually don't distinguish feature from fix in our branch names - # So EVERYTHING just increments the minor version - regex: ^.*[-/](?[^/-]+)$ - increment: Minor - # label: alpha.{BranchName}. - source-branches: [ "main", "release", "feature" ] + regex: ^pull/(?[^/-]+)/merge$ + label: apr.{Number}.c track-merge-target: true - tracks-release-branches: true + feature: + regex: ^feat(ure)?/[^\d]*(?\d+)?.*?$ + mode: ContinuousDelivery + label: alpha.{Number}.c diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..caf1fb0 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,33 @@ +@{ + # Use Severity when you want to limit the generated diagnostic records to a + # subset of: Error, Warning and Information. + # Uncomment the following line if you only want Errors and Warnings but + # not Information diagnostic records. + Severity = @('Error','Warning') + + # Use IncludeRules when you want to run only a subset of the default rule set. + #IncludeRules = @('PSAvoidDefaultValueSwitchParameter', + # 'PSMisleadingBacktick', + # 'PSMissingModuleManifestField', + # 'PSReservedCmdletChar', + # 'PSReservedParams', + # 'PSShouldProcess', + # 'PSUseApprovedVerbs', + # 'PSUseDeclaredVarsMoreThanAssigments') + + # Use ExcludeRules when you want to run most of the default set of rules except + # for a few rules you wish to "exclude". Note: if a rule is in both IncludeRules + # and ExcludeRules, the rule will be excluded. + ExcludeRules = @('PSUseToExportFieldsInManifest','PSMissingModuleManifestField','PSReviewUnusedParameter') + + # You can use the following entry to supply parameters to rules that take parameters. + # For instance, the PSAvoidUsingCmdletAliases rule takes a whitelist for aliases you + # want to allow. + Rules = @{ + PSAvoidUsingCmdletAliases = @{Whitelist = @('Where','Select')} + PSAvoidUsingPositionalParameters = @{CommandAllowList = @('Join-Path') } + + # The fact that these have not been updated since PowerShell 7.0.0 probably means they're not worth anything. + PSUseCompatibleCmdlets = @{Compatibility = @("ubuntu_x64_18.04_7.0.0_x64_3.1.2_core", "win-8_x64_10.0.14393.0_7.0.0_x64_3.1.2_core") } + } +} diff --git a/RequiredModules.psd1 b/build.requires.psd1 similarity index 100% rename from RequiredModules.psd1 rename to build.requires.psd1 diff --git a/common/Get-Version.Task.ps1 b/common/Get-Version.Task.ps1 index fca3244..a9b8396 100644 --- a/common/Get-Version.Task.ps1 +++ b/common/Get-Version.Task.ps1 @@ -15,7 +15,7 @@ Add-BuildTask Get-Version @{ # we can skip (return $false) return (${script:Version}.Sha -ne $head) } - Jobs = "Initialize-Git", "Restore-DotNetTools", { + Jobs = "Initialize-Git", "Install-DotNetTool", { # Support a config file in the repo (BuildRoot) to override the one in here (PSScriptRoot) [string]$VersionConfig = Resolve-Path "$BuildRoot/GitVersion.y*ml", "$PSScriptRoot/GitVersion.y*ml" -ErrorAction Ignore | Select-Object -First 1 diff --git a/common/Install-All.Task.ps1 b/common/Install-All.Task.ps1 new file mode 100644 index 0000000..9198556 --- /dev/null +++ b/common/Install-All.Task.ps1 @@ -0,0 +1 @@ +Add-BuildTask Install-All Install-PowerShellModule, Install-DotNetTool, Install-FromGitHub diff --git a/common/Install-BuildDependencies.Task.ps1 b/common/Install-BuildDependencies.Task.ps1 deleted file mode 100644 index 99c27b7..0000000 --- a/common/Install-BuildDependencies.Task.ps1 +++ /dev/null @@ -1 +0,0 @@ -Add-BuildTask Install-BuildDependencies Install-RequiredModules diff --git a/common/Restore-DotNetTools.Task.ps1 b/common/Install-DotNetTool.Task.ps1 similarity index 95% rename from common/Restore-DotNetTools.Task.ps1 rename to common/Install-DotNetTool.Task.ps1 index 52d6c00..c74c6a2 100644 --- a/common/Restore-DotNetTools.Task.ps1 +++ b/common/Install-DotNetTool.Task.ps1 @@ -6,7 +6,7 @@ Then `dotnet tool restore` will be run to ensure the tools are installed. #> -Add-BuildTask Restore-DotNetTools @{ +Add-BuildTask Install-DotNetTool @{ Jobs = { $DotNetToolManifest = @( Join-Path $BuildRoot .config/dotnet-tools.json diff --git a/common/Install-GitHubTools.Task.ps1 b/common/Install-FromGitHub.Task.ps1 similarity index 93% rename from common/Install-GitHubTools.Task.ps1 rename to common/Install-FromGitHub.Task.ps1 index dec6015..889c29f 100644 --- a/common/Install-GitHubTools.Task.ps1 +++ b/common/Install-FromGitHub.Task.ps1 @@ -1,5 +1,5 @@ # TODO: in pipeline environments, we should trigger the "cache" task for these to speed up using them -Add-BuildTask Install-GitHubTools @{ +Add-BuildTask Install-FromGitHub @{ If = { $script:GHTools.keys.Count -gt 0 } Jobs = { foreach ($tool in $script:GHTools.keys) { diff --git a/common/Install-PowerShellModule.Task.ps1 b/common/Install-PowerShellModule.Task.ps1 new file mode 100644 index 0000000..b0b2a62 --- /dev/null +++ b/common/Install-PowerShellModule.Task.ps1 @@ -0,0 +1,7 @@ +Add-BuildTask Install-PowerShellModule @{ + If = { Test-Path $BuildRoot/*.requires.psd1 } + Inputs = { Get-Item "$BuildRoot/*.requires.psd1" } + Outputs = { process { Join-Path -Path $OutputPath -ChildPath $_.Name } } + Jobs = (Get-Command "$PSScriptRoot/../scripts/Install-PowerShellModule.ps1").ScriptBlock, + { Copy-Item "$BuildRoot/*.requires.psd1" -Destination "$OutputPath/" } +} diff --git a/common/Install-RequiredModules.Task.ps1 b/common/Install-RequiredModules.Task.ps1 deleted file mode 100644 index fc9d5dc..0000000 --- a/common/Install-RequiredModules.Task.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -Add-BuildTask Install-RequiredModules @{ - If = { Test-Path $BuildRoot/RequiredModules.psd1 } - Inputs = { "$BuildRoot/RequiredModules.psd1" } - Outputs = { "$OutputPath/RequiredModules.psd1" } - Jobs = (Get-Command "$PSScriptRoot/../scripts/Install-RequiredModule.ps1").ScriptBlock, - { Copy-Item "$BuildRoot/RequiredModules.psd1" -Destination "$OutputPath/RequiredModules.psd1" } -} diff --git a/common/MonoRepoGitVersion.Task.ps1 b/common/MonoRepoGitVersion.Task.ps1 new file mode 100644 index 0000000..b52895e --- /dev/null +++ b/common/MonoRepoGitVersion.Task.ps1 @@ -0,0 +1,163 @@ +<# +Return child folders/files from paths listed in MonoRepoConfig.psd1, run a git diff to find files +that have changed in that path. For each of the folders containing changed files, run gitversion +and export to version.json in the same folder. + +Supports an override setting $script:ForceVersionAllProjects = $true to calculate a version for +every single project. +#> +Add-BuildTask MonoRepoGitVersion @{ + If = { + # MonoRepoGitVersion requires MonoRepoConfig.psd1 + (Test-Path $script:BuildRoot/MonoRepoConfig.psd1) -and $( + # If that's present, then we check for changes in those subfolders + $MonoRepo = Import-Metadata $script:BuildRoot/MonoRepoConfig.psd1 + + $Projects = $MonoRepo.Projects.GetEnumerator().ForEach{ + Write-Verbose "Searching $($_.Key)" + foreach ($Path in Get-ChildItem $_.Key | Resolve-Path -Relative) { + $VersionPath = Join-Path $Path version.json + [PSCustomObject]@{ + Name = ($_.Value -f ($Path | Split-Path -Leaf)).ToLower() + Path = $Path + GitVersion = if (Test-Path $VersionPath) { Get-Content $VersionPath | ConvertFrom-Json } + } + } + } + + # In builds, the checkout is frequently shallow with no cloned origin/HEAD + # Trying and failing this is many times faster than `git remote show` + git remote set-head origin main 2>$null + if ($LASTEXITCODE) { git remote set-head origin master } + # the full output would be refs/remotes/origin/main + $MainBranch = (git symbolic-ref refs/remotes/origin/HEAD) -replace 'refs/remotes/' + + # If we are on the main branch, compare against previous commit, otherwise, against the main branch + $commitish = if ((git branch -a --contains) -match "$MainBranch$") { + 'HEAD~1' + } else { + "$MainBranch..HEAD" + } + + $gitChanges = git diff --name-only --diff-filter=CMARTUX $commitish + $fileChanges = $gitChanges | Resolve-Path -Relative -OutVariable script:MonoRepoGitVersionInput + + $script:MonoRepoChangedProjects = $Projects.Foreach({ + $Project = $_ + $projectFiles = $fileChanges.Where({ $_.StartsWith($Project.Path + '\') -or $_.StartsWith($Project.Path + '/') }) + if ($script:ForceVersionAllProjects -or $projectFiles) { + $Project | Add-Member NoteProperty changedFiles $projectFiles -PassThru + } + }) + @($script:MonoRepoChangedProjects).Count -gt 0 + ) + } + Input = { + $script:MonoRepoGitVersionInput + } + Output = { + $script:MonoRepoChangedProjects.Path | Join-Path -ChildPath version.json + } + Jobs = "Install-DotNetTool", { + + # Configure git identity so that `git commit --amend` can succeed on CI agents + # that have no global git identity configured (e.g. ephemeral Linux build agents). + git config user.email "gitversion@pipeline.local" + git config user.name "GitVersion Pipeline" + + # If this is a PR build, fetch the "description" which will go into the commit later + if ($Env:SYSTEM_PULLREQUEST_PULLREQUESTID -and $Env:SYSTEM_ACCESSTOKEN -and $Env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI -and $Env:BUILD_REPOSITORY_URI -notmatch "github.com") { + $BaseUri = "$($Env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI)$($Env:SYSTEM_TEAMPROJECTID)/_apis" + # The highest our on-prem can handle is 5.1-preview.1 + $ApiVersion = 'api-version=5.1-preview.1' + $PullRequest = Invoke-RestMethod "$($BaseUri)/git/pullrequests/${Env:SYSTEM_PULLREQUEST_PULLREQUESTID}?$($ApiVersion)" -Headers @{ + Authorization = "Bearer $Env:SYSTEM_ACCESSTOKEN" + } + + $commitMessage = $PullRequest.title + "`n`n" + $PullRequest.description + # change the PR merge message so that gitversion can do it's thing + git commit --amend -m "Merged PR $($Env:SYSTEM_PULLREQUEST_PULLREQUESTID): $($commitMessage)" + } elseif ($Env:SYSTEM_PULLREQUEST_PULLREQUESTID -and $Env:BUILD_REPOSITORY_URI -match "github.com") { + # GitHub-hosted repo: extract token from git credentials and fetch PR via GitHub API + $extraHeaderLine = git config --get-regexp 'http\.https://github\.com.*\.extraheader' 2>$null | Select-Object -First 1 + if ($extraHeaderLine) { + $base64Auth = (($extraHeaderLine -split '\s+', 2)[1] -replace '^AUTHORIZATION:\s*basic\s*', '').Trim() + $githubToken = ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($base64Auth)) -split ':', 2)[1] + } + if ($githubToken) { + Write-Host "getting PR information from url:" + Write-Host " https://api.github.com/repos/$($Env:BUILD_REPOSITORY_NAME)/pulls/$($Env:SYSTEM_PULLREQUEST_PULLREQUESTNUMBER)" -ForegroundColor Cyan + $PullRequest = Invoke-RestMethod "https://api.github.com/repos/$($Env:BUILD_REPOSITORY_NAME)/pulls/$($Env:SYSTEM_PULLREQUEST_PULLREQUESTNUMBER)" -Headers @{ + Authorization = "Bearer $githubToken" + Accept = 'application/vnd.github.v3+json' + } + $commitMessage = $PullRequest.title + "`n`n" + $PullRequest.body + git commit --amend -m "Merged PR $($Env:SYSTEM_PULLREQUEST_PULLREQUESTNUMBER): $($commitMessage)" + } else { + throw "Unable to extract GitHub token from git config. Please ensure your pipeline is configured correctly to provide access tokens for GitHub API calls. In a pipeline make sure 'persistCredentials: true'." + } + } elseif ($script:SemverTrailer) { + # If SemverTrailer is set, alter the commit message so gitversion follows... + $originalCommitMessage = (git log -1 --format=%B) -join "`n" + $commitMessage = $originalCommitMessage + "`n`n" + $script:SemverTrailer + git commit --amend -m $commitMessage + } else { + # %B is for the raw "Body" of the commit message. See https://git-scm.com/docs/git-show#_pretty_formats + $commitMessage = (git log -1 --format=%B) -join "`n" + } + + # In PR pipelines for MonoRepos we are very strict about PR commit message incrementing + if ($PullRequest -and $commitMessage -notmatch 'semver-[-a-z]+:\s*(breaking|major|feature|minor|fix|patch|none|skip)') { + throw "In a MonoRepo, you must specify a semantic version increment in your merge request descriptions.`nPlease add a line like: `"semver-$($script:MonoRepoChangedProjects[0].Name):patch`" for each module to indicate the type of change.`nAllowed changes are:`n- 'breaking' or 'major' for the first number`n- 'feature' or 'minor' for the middle number`n- 'fix' or 'patch' for the last number`n`nYour Commit Message:`n$commitMessage" + } + + foreach ($Project in $script:MonoRepoChangedProjects) { + $semverMessagePattern = "semver-$($Project.Name):\s*(breaking|major|feature|minor|fix|patch|none|skip)" + if ($commitMessage -notmatch $semverMessagePattern) { + if ($PullRequest) { + throw "You changed $($Project.Name) but did not specify the semantic version increment for it.`nPlease add a line like: `"semver-$($Project.Name):patch`" to indicate the type of change.`nAllowed increments are:`n- 'breaking' or 'major' for the first number`n- 'feature' or 'minor' for the middle number`n- 'fix' or 'patch' for the last number`n`nYour Commit Message:`n$commitMessage" + } else { + Write-Warning "You changed $($Project.Name), assuming: `"semver-$($Project.Name):patch`"" + } + } + + # For the sake of other MonoRepo tasks, we must calculate a VersionChange + # When building locally, if you have not specified one _in this commit_ our default is "patch" + $VersionChange = if (($increment = (($commitMessage | Select-String -Pattern $semverMessagePattern).Matches.Value -split ':')[-1].trim())) { + switch -regex ($increment) { + 'major|breaking' { 'Major' } + 'minor|feature' { 'Minor' } + 'fix|patch' { 'Patch' } + 'none|skip' { 'Skip' } + } + } else { "Patch" } + + $GitVersionYaml = if (Test-Path "$($Project.Path)/GitVersion.yml") { + "$($Project.Path)/GitVersion.yml" + } else { + "$PSScriptRoot/GitVersion.yml" + } + + $ProjectVersionFile = "$($Project.Path)/version.json" + # NOTE: tag-prefix is NOT overridden here - each module's GitVersion.yml sets it correctly + # (e.g. 'BicepFlex/v', 'LDAzOps/v') to match actual git tags. Overriding with the + # lowercased project name would cause TaggedCommitVersionStrategy to find no tags. + dotnet gitversion -config $GitVersionYaml -output file -outputfile $ProjectVersionFile ` + -overrideconfig major-version-bump-message="semver-$($Project.Name):\s*(breaking|major)" ` + -overrideconfig minor-version-bump-message="semver-$($Project.Name):\s*(feature|minor)" ` + -overrideconfig patch-version-bump-message="semver-$($Project.Name):\s*(fix|patch)" ` + -overrideconfig no-bump-message=".*" ` + -overrideconfig commit-message-incrementing=MergeMessageOnly + + # prepend the VersionChange to the gitversion output and save + Get-Content $ProjectVersionFile | ConvertFrom-Json | Add-Member NoteProperty VersionChange $VersionChange -PassThru -OutVariable versionJson | ConvertTo-Json | Set-Content $ProjectVersionFile + + $Project.GitVersion = $versionJson + " Updated $($Project.Name) $($VersionChange) version: $($versionJson.InformationalVersion)" + } + if ($script:SemverTrailer -and $originalCommitMessage) { + # Put back the commit message before we added the SemverTrailer + git commit --amend -m $originalCommitMessage + } + } +} diff --git a/common/MonoRepoTagSource.Task.ps1 b/common/MonoRepoTagSource.Task.ps1 new file mode 100644 index 0000000..8eb9518 --- /dev/null +++ b/common/MonoRepoTagSource.Task.ps1 @@ -0,0 +1,24 @@ +Add-BuildTask MonoRepoTagSource @{ + If = { + if ($script:BuildSystem -eq 'None') { + Write-Warning "Skipping MonoRepoTagSource: not running in a known build system" + return $false + } + if ($script:BranchName -notmatch "^main") { + Write-Warning "We should only tag main, not $script:BranchName" + return $false + } + if (!$script:MonoRepoChangedProjects) { + Write-Warning "No changed projects detected, nothing to tag" + return $false + } + return $true + } + Jobs = { + foreach ($Project in $script:MonoRepoChangedProjects) { + $gitVersion = Get-Content (Join-Path $Project.Path "version.json") | ConvertFrom-Json + # Convention: each project tag is the project name (lowercased folder name) + MajorMinorPatch + New-GitTag -TagName ($Project.Name + $gitVersion.MajorMinorPatch) -Sha $gitVersion.Sha + } + } +} diff --git a/common/Test-PowerShell.Task.ps1 b/common/Test-PowerShell.Task.ps1 new file mode 100644 index 0000000..e54ed92 --- /dev/null +++ b/common/Test-PowerShell.Task.ps1 @@ -0,0 +1,87 @@ + +#requires -Module @{ ModuleName = "Pester"; ModuleVersion = "5.6.0" } +Add-BuildTask Test-PowerShell @{ + Inputs = { + Get-ChildItem $ModuleOutputPath -Recurse -File + $Tests = Join-Path $BuildRoot [Tt]ests | Resolve-Path + Get-ChildItem $Tests -Recurse -File -Filter *.tests.ps1 + } + Outputs = { + if ($Clean) { + $BuildRoot # guaranteed to be old + } else { + Join-Path $ModuleTestResultsRoot "results.xml" + } + } + Jobs = { + $script:OldModulePath = $Env:PSModulePath + }, { + + # For PowerShell Modules with classes to work in tests: + # 1. The $OutputPath directory must be first on Env:PSModulePath + # 2. The $ModuleName directory must be in $OutputPath directory + # 3. The $ModuleName.psd1 file must be in the $ModuleName directory + if (Test-Path $script:ManifestPath) { + $Env:PSModulePath = @($script:OutputPath) + @($Env:PSModulePath -split [IO.Path]::PathSeparator -ne $script:OutputPath) -join ([IO.Path]::PathSeparator) + Write-Output (@( + "Set PSModulePath:" + $Env:PSModulePath + "" + "Module Under Test at: $ManifestPath" + Get-Module $ModuleName -ListAvailable | Format-Table Version, Path | Out-String + "" + "Module Imported:" + Get-Module $ModuleName -ErrorAction SilentlyContinue | Format-Table Version, Path | Out-String + ) -join "`n") + } + + # But we don't need all that to run PowerShell tests ... + $Configuration = @{ + Run = @{ + Path = "$BuildRoot/[Tt]ests" + Passthru = $true + } + Filter = $PesterFilter + TestResult = @{ + Enabled = $true + OutputPath = Join-Path $ModuleTestResultsRoot "results.xml" + } + Debug = @{ + ShowNavigationMarkers = $Host.Name -match "Visual Studio Code" + } + Output = @{ + Verbosity = if ($VerbosePreference -eq "Continue") { "Detailed" } else { "Normal" } + RenderMode = "Ansi" + CIFormat = $BuildSystem + } + CodeCoverage = @{ + Enabled = !$SkipCoverage + Path = Get-Item $ModuleOutputPath\*.psm1, $ModuleOutputPath\*.ps1 + OutputPath = Join-Path $ModuleTestResultsRoot "coverage.xml" + CoveragePercentTarget = $CodeCoveragePercentTarget * 100 + UseBreakpoints = $false + } + } + + $results = Invoke-Pester -Configuration (New-PesterConfiguration $Configuration) + + if ($null -eq $results -or $results.FailedCount -gt 0 -or $results.FailedContainersCount -gt 0) { + throw "##[error]Failed Pester tests." + } + + if (!$SkipCoverage -and $Script:PassingCodeCoverage -gt 0.00) { + $ExecutedPercent = if ($results.CodeCoverage.NumberOfCommandsExecuted) { + $results.CodeCoverage.NumberOfCommandsExecuted / $results.CodeCoverage.NumberOfCommandsAnalyzed + } else { + $results.CodeCoverage.CommandsExecutedCount / $results.CodeCoverage.CommandsAnalyzedCount + } + if ($ExecutedPercent -lt $CodeCoveragePercentTarget) { + throw ("##[error]Failed {0:P} code coverage is below {1:P}." -f $ExecutedPercent, $CodeCoveragePercentTarget) + } + } + + }, { + Write-Verbose "Restoring PSModulePath to $OldModulePath" -Verbose + $Env:PSModulePath = $script:OldModulePath + } +} \ No newline at end of file diff --git a/common/base.ps1 b/common/base.ps1 index 4cf67cf..b4e8f6c 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -15,8 +15,11 @@ param( # Add the clean task before the default build [switch]$Clean, - # Collect code coverage when tests are run - [switch]$CollectCoverage + # Default to collecting code coverage when tests are run + [switch]$SkipCoverage, + + # The base goal is 85% code coverage + $PassingCodeCoverage = 0.85 ) ## Guard against double-initialization in diamond inheritance @@ -142,11 +145,10 @@ Enter-Build { $Script:TestResultsRoot = $script:TestResultsRoot ?? # An override for build script parameters $Env:LDBUILD_TEST_ROOT ?? # An override for machine-level settings - $Env:COMMON_TESTRESULTSDIRECTORY ?? # Azure $Env:TEST_RESULTS_DIRECTORY ?? (Join-Path $OutputPath testresults) - $Script:TempDirectory = @(Get-Content Env:LDBUILD_TEMP_DIRECTORY, Env:AGENT_TEMPDIRECTORY, Env:COMMON_TESTRESULTSDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | + $Script:TempDirectory = @(Get-Content Env:LDBUILD_TEMP_DIRECTORY, Env:AGENT_TEMPDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $Script:TempDirectory) { $Script:TempDirectory = if ($IsLinux) { "/tmp" } else { [System.IO.Path]::GetTempPath() } } @@ -172,26 +174,32 @@ Enter-Build { Write-Build Cyan " TempDirectory: $TempDirectory" Write-Build Cyan " UniversalPackageRoot: $UniversalPackageRoot" - # The default goal is 90% code coverage - $Script:RequiredCodeCoverage ??= 0.9 + # If we're skipping coverage, make sure there are no demands on passing + if ($SkipCoverage) { + $Script:PassingCodeCoverage = -1.0 + } } # Our common task definitions -$script:InitializeTasks = "Install-RequiredModules", "Install-GitHubTools", "Restore-DotNetTools", "Get-Version" +$script:InitializeTasks = @( + # In CI pipelines (or if you specify $Clean) + if ($BuildSystem -ne "None" -or $Script:Clean) { + # Run the Clean-Output task before the rest of the build tasks + "Clean-Output" + } + # Note that we run *all* of the Install tasks via the alias which must be kept up to date + "Install-All" + "Get-Version" +) $script:BuildTasks = @() $script:PublishTasks = @() $script:TestTasks = @() $script:PushTasks = @("Push-Docker") $script:CheckpointTasks = @("Tag-Source") + # Initially define the CI task as Get-Version...Tag-Source using virtual task names Add-BuildTask CI @( - # In CI pipelines (or if you specify $Clean) - if ($BuildSystem -ne "None" -or $Script:Clean) { - # Run the Clean-Output task before the rest of the build tasks - "Clean-Output" - } - "Get-Version" "Initialize" "Build" "Test" diff --git a/docs/Extends.md b/docs/Extends.md index a6aa7e9..8416933 100644 --- a/docs/Extends.md +++ b/docs/Extends.md @@ -1,6 +1,6 @@ # Build Script Inheritance with Invoke-Build `Extends` -Invoke-Build (v5.11+) supports a special `$Extends` parameter that enables **build script inheritance**. A project's build script can extend the base scripts and inherit their parameters, initialization, and task definitions. We've organized our tasks into framework folders, and each framework has a `base.ps1` script in it. To create a build, you'll want to extend one or more of those! +Invoke-Build (v5.14+) supports a special `$Extends` parameter that enables **build script inheritance**. A project's build script can extend the base scripts and inherit their parameters, initialization, and task definitions. We've organized our tasks into framework folders, and each framework has a `base.ps1` script in it. To create a build, you'll want to extend one or more of those! ## How It Works @@ -86,9 +86,9 @@ Parameter discovery order (depth-first recursion): ┌─────────────────────────────────────────────────────────────────────┐ │ PARAMETER DISCOVERY (depth-first) │ │ │ -│ 1. common/base.ps1 → $Clean, $CollectCoverage │ +│ 1. common/base.ps1 → $Clean, $SkipCoverage │ │ 2. helm/base.ps1 → $HelmChartRoot, $ChartName │ -│ 3. common/base.ps1 → $Clean, $CollectCoverage (same, no-op) │ +│ 3. common/base.ps1 → $Clean, $SkipCoverage (same, no-op) │ │ 4. dotnet/base.ps1 → $Configuration, $Solution, | | $dotnetSolution, $dotnetOptions, │ │ $TargetFramework = "net10.0", ← registered │ @@ -534,10 +534,10 @@ Because task `If` conditions are evaluated **at task definition time** (during l └─────────────────────────────────────────────────────────────────────┘ # WRONG -- evaluates to $null during loading, task always skips: -Add-BuildTask Install-GitHubTools @{ If = $script:GHTools.Count -gt 0 } +Add-BuildTask Install-FromGitHub @{ If = $script:GHTools.Count -gt 0 } # RIGHT -- deferred to runtime, evaluates after Enter-Build sets $GHTools: -Add-BuildTask Install-GitHubTools @{ If = { $script:GHTools.Count -gt 0 } } +Add-BuildTask Install-FromGitHub @{ If = { $script:GHTools.Count -gt 0 } } ``` ### What Goes Where -- Decision Guide diff --git a/dotnet/Convert-Coverage.Task.ps1 b/dotnet/Convert-Coverage.Task.ps1 index 5777ec0..47d13e7 100644 --- a/dotnet/Convert-Coverage.Task.ps1 +++ b/dotnet/Convert-Coverage.Task.ps1 @@ -1,5 +1,5 @@ Add-BuildTask Convert-Coverage @{ - If = { $Script:CollectCoverage } + If = { !$Script:SkipCoverage } Jobs = { Set-Location $SolutionTestResultsRoot # ------------------------------ diff --git a/dotnet/Restore-DotNet.Task.ps1 b/dotnet/Restore-DotNet.Task.ps1 index 1591976..b1b6c6d 100644 --- a/dotnet/Restore-DotNet.Task.ps1 +++ b/dotnet/Restore-DotNet.Task.ps1 @@ -22,7 +22,7 @@ Add-BuildTask Restore-DotNet @{ Join-Path $script:SolutionOutputPath "obj/$ProjectName/project.assets.json" } } - Jobs = "Restore-DotNetTools", { + Jobs = "Install-DotNetTool", { $local:options = @{} + $script:dotnetOptions $NugetConfig = Get-ChildItem $BuildRoot -File | Where-Object { $_.Name -ieq "NuGet.config" } if ($NugetConfig) { diff --git a/dotnet/Test-DotNet.Task.ps1 b/dotnet/Test-DotNet.Task.ps1 index 0302c5d..00cffb3 100644 --- a/dotnet/Test-DotNet.Task.ps1 +++ b/dotnet/Test-DotNet.Task.ps1 @@ -27,7 +27,7 @@ Add-BuildTask Test-DotNet @{ "-configuration" = $configuration } + $script:dotnetOptions - if ($Script:CollectCoverage) { + if (!$Script:SkipCoverage) { # Because we wrapt it in dotnet coverage, we need to build this as a string $Command = "dotnet test $dotnetSolution --no-build" $options.GetEnumerator() | ForEach-Object { diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index 317aebe..3b4a60c 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -1,10 +1,8 @@ <# .SYNOPSIS - DotNet build script -- extends always.ps1 with .NET build support. + DotNet build script -- extends common base with .NET build support. .EXAMPLE Invoke-Build -.NOTES - 0.6.0 - Split from build.example.ps1 #> [CmdletBinding()] param( @@ -130,14 +128,7 @@ Enter-Build { #endregion # Add the dotnet tasks to the common tasks -$script:InitializeTasks = @( - # In CI pipelines (or if you specify $Clean) - if ($BuildSystem -ne "None" -or $Script:Clean) { - # Run the Clean-Output task before the rest of the build tasks - "Clean-Output" - } -) + $InitializeTasks + @("Restore-DotNet") - +$script:InitializeTasks += @("Restore-DotNet") $script:BuildTasks += @("Build-DotNet") $script:PublishTasks += @("Pack-DotNet", "Publish-DotNet") $script:TestTasks += $script:BuildSystem -eq "None" ? @("Test-DotNet") : @("Test-DotNet", "Convert-Trx2JUnit", "Convert-Coverage") diff --git a/helm/Test-Helm.Task.ps1 b/helm/Test-Helm.Task.ps1 index 46979db..43c8424 100644 --- a/helm/Test-Helm.Task.ps1 +++ b/helm/Test-Helm.Task.ps1 @@ -25,7 +25,7 @@ Add-BuildTask Test-Helm @{ Write-Build Yellow "kubeconform not found, attempting installation..." &(Join-Path $script:BuildTasksRoot "scripts" "Install-GithubRelease.ps1") -Org "yannh" -Repo "kubeconform" -Verbose -ErrorAction SilentlyContinue } - # TODO: Why is this here? This should be handled by Install-GitHubTools + # TODO: Why is this here? This should be handled by Install-FromGitHub Write-Build Yellow "kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput" Invoke-Native { kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput diff --git a/helm/base.ps1 b/helm/base.ps1 index 876edfa..46e6f08 100644 --- a/helm/base.ps1 +++ b/helm/base.ps1 @@ -1,15 +1,14 @@ <# .SYNOPSIS - Helm build script -- extends always.ps1 with Helm chart support. + Helm build script -- extends common base with Helm chart support. .EXAMPLE - Invoke-Build Build-Helm -.NOTES - 0.6.0 - Split from build.example.ps1 + Invoke-Build #> [CmdletBinding()] param( [ValidateScript({ "../common/base.ps1" })] $Extends, + # Path to the Helm charts directory -- defaults to $BuildRoot/charts [string]$HelmChartRoot, diff --git a/powershell/Build-Module.Task.ps1 b/powershell/Build-Module.Task.ps1 new file mode 100644 index 0000000..09b761e --- /dev/null +++ b/powershell/Build-Module.Task.ps1 @@ -0,0 +1,64 @@ +Add-BuildTask Build-Module @{ + Inputs = { + @( + Get-ChildItem -Path $BuildRoot -Recurse -Filter *.ps* + Get-ChildItem -Path $BuildRoot -Recurse -Filter *.cs | Where-Object FullName -NotLike (Join-Path $BuildRoot obj/*) + # This is here because on Dev workstations we build _into_ $BuildRoot + ) | Where-Object FullName -NotLike (Join-Path $script:OutputPath /*) + } + # don't take off the script block, need to resolve AFTER init + Outputs = { + $InputObject = $_ + $out = $script:OutputPath + switch -regex ("$InputObject") { + "ps1$" { + if ($Module) { + $Module + } elseif ($out -and (Test-Path $out -PathType Container)) { + Get-Item $out + } else { + $out + } + } + "cs$" { + if ($out -and ($Assemblies = Get-ChildItem -Path $out -Recurse -Filter *.dll -ErrorAction Ignore)) { + $Assemblies + } elseif ($out -and (Test-Path $out -PathType Container)) { + Get-Item $out + } else { + $out + } + } + default { + # .psd1, .psm1, .pssc etc — use the output directory as the comparison target + if ($out -and (Test-Path $out -PathType Container)) { + Get-Item $out + } else { + $out + } + } + } + } + Jobs = "Install-PowerShellModule", "Get-Version", { + $version = @{ + # We need a PowerShellGallery / PSGet compatible version with only digits in the version, and only alphanumerics in the pre-release + # This pattern anticipates SemVer v2 versions like: + # 0.1.31-ldd-0123.14+Build.34865.Branch.joelbennett-gitversion.Sha.4c49c2650396e41efe0d491894a88cc3954b0ee9.Date.20211122T222522 + # 1.0.1+Branch.testing.Sha.4c49c2650396e41efe0d491894a88cc3954b0ee9 + semver = [regex]::replace($script:Version.InformationalVersion, "(?\d+\.\d+\.\d+)(?:-(?[^+]*)\.(?\d+))?\+(?.*)$", { + $g = $args[0].Groups + if ($g['prerelease'].Value) { + # If the Prerelease ends in digits, add a 'c' to separate the commit count + '{0}-{1}{2:d4}+{3}' -f $g['version'], ($g['prerelease'] -replace '[^a-zA-Z0-9]' -replace '(?<=\d)$', 'c'), ('{0:d4}' -f ([int]$g['digit'].value)), $g['metadata'] + } else { + '{0}+{1}' -f $g['version'], $g['metadata'] + } + }) + } + + $Module = Build-Module -Output $script:OutputPath -UnversionedOutputDirectory @version -Passthru -Verbose + $script:ModuleName = $Module.Name + $script:ManifestPath = $Module.Path + $script:ModuleOutputPath = Split-Path $Module.Path + } +} diff --git a/powershell/Import-Module.Task.ps1 b/powershell/Import-Module.Task.ps1 new file mode 100644 index 0000000..3dec82d --- /dev/null +++ b/powershell/Import-Module.Task.ps1 @@ -0,0 +1,21 @@ +Add-BuildTask Import-Module { + # Always re-import the module -- don't try to guess if it's been changed + if (-not (Test-Path $script:ManifestPath)) { + throw "Could not find ManifestPath '$script:ManifestPath'" + } + + if (($loaded = Get-Module -Name $script:ModuleName -All -ErrorAction Ignore)) { + "Unloading Module '$script:ModuleName' $($loaded.Version -join ', ')" + $loaded | Remove-Module -Force -Verbose:$false + } + + try { + "Importing Module '$script:ModuleName' $($script:Version.SemVer) from '$script:ManifestPath'" + Import-Module -Name $script:ManifestPath -Force -ErrorAction Stop -Verbose:$false + } catch { + Write-Warning "Failed to import module '$script:ModuleName' from '$script:ManifestPath'" + Write-Warning $_.Exception.Message + Get-ChildItem (Split-Path $script:ManifestPath) -Recurse | Out-String -Width 120 | Out-Host + throw $_ + } +} diff --git a/powershell/PSModuleAnalyze.Task.ps1 b/powershell/PSModuleAnalyze.Task.ps1 deleted file mode 100644 index 4890467..0000000 --- a/powershell/PSModuleAnalyze.Task.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -Add-BuildTask PSModuleAnalyze PSModuleBuild, PSModuleImport, { - $ScriptAnalyzer = @{ - IncludeDefaultRules = $true - Path = @(Get-ChildItem $PSModuleOutputPath -Filter "$PSModuleName.psm1" -Recurse)[-1] - Settings = if (Test-Path "$BuildRoot${/}ScriptAnalyzerSettings.psd1") { - "$BuildRoot${/}ScriptAnalyzerSettings.psd1" - } else { - "$PSScriptRoot${/}ScriptAnalyzerSettings.psd1" - } - } - - "Analyze $($ScriptAnalyzer.Path) -Settings $($ScriptAnalyzer.Settings)" - $results = Invoke-ScriptAnalyzer @ScriptAnalyzer - if ($results) { - Write-Warning 'Please investigate and correct, or add the required SuppressMessage attribute.' - $results | Format-Table -AutoSize | Out-String - throw 'One or more issues were found by PSScriptAnalyzer' - } -} \ No newline at end of file diff --git a/powershell/PSModuleBuild.Task.ps1 b/powershell/PSModuleBuild.Task.ps1 deleted file mode 100644 index 309474a..0000000 --- a/powershell/PSModuleBuild.Task.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -Add-BuildTask PSModuleBuild @{ - If = $PSModuleSourcePath - Inputs = { Get-ChildItem -Path $PSModuleSourceRoot -Recurse -Filter *.ps* } - Outputs = { Join-Path $OutputRoot $PSModuleName "$PSModuleName.psm1" } # don't take off the script block, need to resolve AFTER init - Jobs = "PSModuleRestore", "GitVersion",{ - $InformationPreference = "Continue" - - $SemVer = $GitVersion.$PSModuleName.InformationalVersion - - Write-Information "Build-Module -SourcePath $PSModuleSourcePath -Destination $PSModuleOutputPath -SemVer $SemVer" - $Module = Build-Module -SourcePath $PSModuleSourcePath -Destination $PSModuleOutputPath -SemVer $SemVer -Verbose:$Verbose -Debug:$Debug -Passthru - - if ($DotNetPublishRoot -and (Test-Path $DotNetPublishRoot)) { - $Libraries = New-Item (Join-Path $Module.ModuleBase lib) -Type Directory -Force | Convert-Path - Get-ChildItem $DotNetPublishRoot - | Where-Object { $_.BaseName -notmatch "System.*" -and $_.Extension -notin ".nupkg" } - | Copy-Item -Destination $Libraries -Recurse - } - } -} diff --git a/powershell/PSModuleImport.Task.ps1 b/powershell/PSModuleImport.Task.ps1 deleted file mode 100644 index 705fab2..0000000 --- a/powershell/PSModuleImport.Task.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -Add-BuildTask PSModuleImport "PSModuleRestore", "PSModuleBuild", { - $ModuleVer = $GitVersion.$PSModuleName.MajorMinorPatch - # Always re-import the module -- don't try to guess if it's been changed - if ($script:PSModuleManifestPath = Get-ChildItem $PSModuleOutputPath/$ModuleVer -Filter "$PSModuleName.psd1" -Recurse -ErrorAction Ignore) { - - if (($loaded = Get-Module -Name $PSModuleName -All -ErrorAction Ignore)) { - "Unloading Module '$PSModuleName' $($loaded.Version -join ', ')" - $loaded | Remove-Module -Force - } - - "Importing Module '$PSModuleName' $($Script:GitVersion.$PSModuleName.MajorMinorPatch) from '$PSModuleManifestPath'" - Import-Module -Name $PSModuleManifestPath -Force -PassThru:$PassThru - } else { - throw "Cannot find module manifest $PSModuleName in '$PSModuleOutputPath'" - } -} diff --git a/powershell/PSModulePush.Task.ps1 b/powershell/PSModulePush.Task.ps1 deleted file mode 100644 index 8aea2ff..0000000 --- a/powershell/PSModulePush.Task.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -Add-BuildTask PSModulePush { - if ($BuildSystem -ne 'None' -and - $BranchName -in "master","main" -and - -not [string]::IsNullOrWhiteSpace($PSGalleryKey)) { - - # If the $PSGalleryUri is set, make sure that's where we publish.... - if ($PSGalleryUri -and $Script:PSRepository) { - $PackageSources = Get-PackageSource - foreach($source in $PackageSources) { - if ($source.Name -eq $Script:PSRepository -or $source.Location -eq $PSGalleryUri -or $source.PublishLocation -eq $PSGalleryPublishUri) { - Unregister-PackageSource -Name $source.Name - } - } - - $source = @{ - Name = $Script:PSRepository - Force = $true - Trusted = $True - ForceBootstrap = $True - } - if (($PSRepository -eq "PSGallery")) { - $source["ProviderName"] = "PowerShellGet" - } else { - if ($PSGalleryUri) { - $source["Location"] = $PSGalleryUri - } - if ($PSGalleryPublishUri) { - $source["PublishLocation"] = $PSGalleryPublishUri - } - } - - Register-PackageSource @source - } - $publishModuleSplat = @{ - Path = $PSModuleOutputPath - NuGetApiKey = $PSGalleryKey - Verbose = $true - Force = $true - Repository = $Script:PSRepository - ErrorAction = 'Stop' - } - "Files in module output:" - Get-ChildItem $PSModuleOutputPath -Recurse -File | - Select-Object -Expand FullName - - "Publishing [$PSModuleOutputPath] to [$Script:PSRepository]" - - Publish-Module @publishModuleSplat - } else { - Write-Warning ("Skipping publish: To publish, ensure that...`n" + - "`t* You are in a known build system (Current: $BuildSystem)`n" + - "`t* You are committing to the main branch (Current: $BranchName) `n" + - "`t* The repository APIKey is defined in `$PSGalleryKey (Current: $(![string]::IsNullOrWhiteSpace($PSGalleryKey)))") - } -} -Add-BuildTask PSGallery PSModulePush \ No newline at end of file diff --git a/powershell/PSModuleRestore.Task.ps1 b/powershell/PSModuleRestore.Task.ps1 deleted file mode 100644 index 53c2d0c..0000000 --- a/powershell/PSModuleRestore.Task.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -Add-BuildTask PSModuleRestore @{ - If = Test-Path "$BuildRoot${/}*.requires.psd1" - Inputs = "$BuildRoot${/}*.requires.psd1" | Convert-Path -ErrorAction ignore - Outputs = "$OutputRoot${/}requires.lock.json" - Jobs = { - Install-ModuleFast -Scope CurrentUser -Verbose -CI - Copy-Item -Path "$BuildRoot${/}requires.lock.json" -Destination $OutputRoot - } -} diff --git a/powershell/PSModuleTest.Task.ps1 b/powershell/PSModuleTest.Task.ps1 deleted file mode 100644 index 77fb72e..0000000 --- a/powershell/PSModuleTest.Task.ps1 +++ /dev/null @@ -1,172 +0,0 @@ -<# - .Synopsis - Wrap Invoke-Pester for Invoke-Build - .Description - Wrap Invoke-Pester to determine the Pester version from the required module - There is a LOT of code here because we are: - - Handling both Pester 4 and Pester 5, and generating appropriate options for both - - Getting code coverage requirements, and failing the build (still needed for Pester 4) - .Notes - Later, when we're ready to remove Pester 4 support, we should refactor to: - 1. Depend on an options file - 2. Generate or change the options file in TestModule - 3. Have default options file here, in case of missing options file -#> - -Add-BuildTask PSModuleTest @{ - If = Get-ChildItem ($PSModuleTestPath ?? "$BuildRoot${/}[Tt]ests") | Get-ChildItem -Recurse -File -Filter *.?ests.ps1 - Inputs = { - Get-ChildItem $PSModuleOutputPath -Recurse -File - Get-ChildItem ($PSModuleTestPath ?? "$BuildRoot${/}[Tt]ests") | Get-ChildItem -Recurse -File -Filter *.?ests.ps1 - } - Outputs = { - if ($Clean) { - $BuildRoot # guaranteed to be old - } else { - "$TestResultsRoot${/}$PSModuleName-results.xml" - } - } - Jobs = "PSModuleImport", { - $PSModuleTestPath ??= "$BuildRoot${/}[Tt]ests" - # The output path, by convention: TestResults.xml in your output folder - $TestResultOutputPath ??= Join-Path $TestResultsRoot "$PSModuleName-results.xml" - - $PesterFilter ??= if ($BuildSystem -ne "None") { @{ "ExcludeTag" = 'NoCI' } } - - - $Version = $GitVersion.$PSModuleName.MajorMinorPatch - - # Write-Information "Build-Module -SourcePath $PSModuleSourcePath -Destination $PSModuleOutputPath -SemVer $SemVer" - # $Module = Build-Module -SourcePath $PSModuleSourcePath -Destination $PSModuleOutputPath -SemVer $SemVer -Verbose:$Verbose -Debug:$Debug -Passthru - - # For PowerShell Modules with classes to work in tests: - # 1. The $OutputRoot directory must be first on Env:PSModulePath - # 2. The $PSModuleName directory must be in $OutputRoot directory - # 3. The $PSModuleName.psd1 file must be in the $PSModuleName directory - if (-not ((Test-Path "$OutputRoot${/}$PSModuleName${/}$PSModuleName.psd1", "$OutputRoot${/}$PSModuleName${/}$Version${/}$PSModuleName.psd1") -contains $true)) { - throw "Cannot test module if it's not in $OutputRoot${/}$PSModuleName" - } else { - $TestModulePath = @($OutputRoot) + @($Env:PSModulePath -split [IO.Path]::PathSeparator -ne $OutputRoot) -join [IO.Path]::PathSeparator - $Env:PSModulePath, $OldModulePath = $TestModulePath, $Env:PSModulePath - try { - $PSModuleManifestPath = Get-ChildItem $PSModuleOutputPath -Filter "$PSModuleName.psm1" -Recurse -ErrorAction Ignore - Write-Output (@( - "Set PSModulePath:" - $Env:PSModulePath - "" - "Module Under Test at: $PSModuleManifestPath" - Get-Module $PSModuleName -ListAvailable | Format-Table Version, Path | Out-String - "" - "Module Imported:" - Get-Module $PSModuleName -ErrorAction SilentlyContinue | Format-Table Version, Path | Out-String - ) -join "`n") - - - if ($Script:RequiredCodeCoverage -gt 0.00) { - $CodeCoveragePath = $PSModuleManifestPath - $CodeCoverageOutputPath = "$TestResultsRoot${/}$PSModuleName-coverage.xml" - $CodeCoveragePercentTarget = $RequiredCodeCoverage - } - - # The version of Pester to use (by default, reads *.requires.psd1 and supports 4.x or 5.x) - if (!$PesterVersion) { - $PesterVersion = Get-Item "$Script:BuildRoot${/}*.requires.psd1", "$PSScriptRoot${/}*.requires.psd1" -ErrorAction SilentlyContinue | - Select-Object -First 1 | - Import-Metadata | - ForEach-Object { $_.Pester -Split "[[,]" } | - Where-Object { $_ -as [Version] } | - Select-Object -First 1 - } - - Write-Verbose "Using Pester v$PesterVersion" -Verbose - - # Force reimporting Pester - Get-Module Pester -All | Remove-Module -Force - - $PesterModule = @{ - Name = "Pester" - MinimumVersion = $PesterVersion - } - - # For unspecified version of Pester, assume 5.x - if ([Version]"5.0" -le $PesterVersion -or -not $PesterVersion) { - $PesterModule["MinimumVersion"] = $PesterVersion ?? "5.3.0" - - # Frankly, the Pester 5 options interface is a bit ridiculous, and we should use an options file - # But I'm not going to remove this until all my modules upgrade from Pester 4 - $Configuration = @{ - Run = @{ - Path = $PSModuleTestPath - Passthru = $true - } - Filter = $PesterFilter - TestResult = @{ - Enabled = $true - OutputPath = $TestResultOutputPath - } - Debug = @{ - ShowNavigationMarkers = $Host.Name -match "Visual Studio Code" - } - } - if ($Script:RequiredCodeCoverage -gt 0.00) { - $Configuration['CodeCoverage'] = @{ - Enabled = $true - Path = $CodeCoveragePath - OutputPath = $CodeCoverageOutputPath - CoveragePercentTarget = $CodeCoveragePercentTarget * 100 - UseBreakpoints = $false - } - } - $PesterOptions = @{ - Config = New-PesterConfiguration $Configuration - } - - if ($Script:RequiredCodeCoverage -gt 0.00) { - # Work around bug in CodeCoverage Config - $PesterOptions.Config.CodeCoverage.CoveragePercentTarget = $CodeCoveragePercentTarget * 100 - } - # Work around bug in output format. Valid values are "AzureDevOps", "None", "Auto", "GithubActions" - $PesterOptions.Config.Output.CIFormat = $BuildSystem -ne 'Earthly' ? $BuildSystem : 'Auto' - } else { - $PesterModule["MaximumVersion"] = "4.99.99" - - $PesterOptions = @{ - Path = $PSModuleTestPath - OutputFile = $TestResultOutputPath - OutputFormat = 'NUnitXml' - PassThru = $true - Show = 'Failed', 'Summary', 'Header', 'All' - Tag = @($PesterFilter.Tag) - ExcludeTag = @($PesterFilter.ExcludeTags) - } - if ($Script:RequiredCodeCoverage -gt 0.00) { - $PesterOptions['CodeCoverage'] = $CodeCoveragePath - $PesterOptions['CodeCoverageOutputFile'] = $CodeCoverageOutputPath - } - } - - Import-Module @PesterModule - $results = Invoke-Pester @PesterOptions - - if ($null -eq $results -or $results.FailedCount -gt 0 -or $results.FailedContainersCount -gt 0) { - throw "##[error]Failed Pester tests." - } - - if ($Script:RequiredCodeCoverage -gt 0.00) { - $ExecutedPercent = if ($results.CodeCoverage.NumberOfCommandsExecuted) { - $results.CodeCoverage.NumberOfCommandsExecuted / $results.CodeCoverage.NumberOfCommandsAnalyzed - } else { - $results.CodeCoverage.CommandsExecutedCount / $results.CodeCoverage.CommandsAnalyzedCount - } - if ($ExecutedPercent -lt $CodeCoveragePercentTarget) { - throw ("##[error]Failed {0:P} code coverage is below {1:P}." -f $ExecutedPercent, $CodeCoveragePercentTarget) - } - } - - } finally { - Write-Verbose "Restoring PSModulePath to $OldModulePath" -Verbose - $Env:PSModulePath = $OldModulePath - } - } - } -} diff --git a/powershell/Publish-Module.Task.ps1 b/powershell/Publish-Module.Task.ps1 new file mode 100644 index 0000000..8f86ece --- /dev/null +++ b/powershell/Publish-Module.Task.ps1 @@ -0,0 +1,28 @@ +Add-BuildTask Publish-Module { + if ($BuildSystem -ne 'None' -and + $BranchName -in "master", "main", "release", "production" -and + -not [string]::IsNullOrWhiteSpace($Script:PowerShellModulePublishKey)) { + + $publishModuleSplat = @{ + Path = $Script:ModuleOutputPath + NuGetApiKey = $Script:PowerShellModulePublishKey + Verbose = $true + Force = $true + Repository = $Script:PSRepository + ErrorAction = 'Stop' + } + "Files in module output:" + Get-ChildItem $Script:ModuleOutputPath -Recurse -File | + Select-Object -Expand FullName + + "Publishing [$Script:ModuleOutputPath] to [$Script:PSRepository]" + + Publish-Module @publishModuleSplat + } else { + Write-Warning ("Skipping deployment: To deploy, ensure that...`n" + + "`t* You are in a known build system (Current: $BuildSystem)`n" + + "`t* You are committing to the main branch (Current: $BranchName) `n" + + "`t* The repository APIKey is defined in `$Script:PowerShellModulePublishKey (Current: $(![string]::IsNullOrWhiteSpace($Script:PowerShellModulePublishKey))) `n" + + "`t* This is not a pull request") + } +} diff --git a/powershell/Test-PowerShellSyntax.Task.ps1 b/powershell/Test-PowerShellSyntax.Task.ps1 new file mode 100644 index 0000000..9aae909 --- /dev/null +++ b/powershell/Test-PowerShellSyntax.Task.ps1 @@ -0,0 +1,43 @@ +Add-BuildTask Test-PowerShellSyntax @{ + Outputs = { + if ($Clean) { + $BuildRoot # guaranteed to be old + } else { + "$script:OutputPath${/}results.sarif" + } + } + Inputs = { + # Build Output + Get-ChildItem $ModuleOutputPath -Recurse -File + # Test Source + $Tests = Join-Path $BuildRoot [Tt]ests | Resolve-Path + Get-ChildItem $Tests -Recurse -File -Filter *.tests.ps1 + } + Jobs = { + $ScriptAnalyzer = @{ + IncludeDefaultRules = $true + Settings = Get-Item -ErrorAction SilentlyContinue @( + "$BuildRoot/PSScriptAnalyzerSettings.psd1", + # This is a little bit of a weird hack for monorepo structure + "$BuildRoot/../PSScriptAnalyzerSettings.psd1", + "$BuildTasksRoot/PSScriptAnalyzerSettings.psd1" + ) | Select-Object -First 1 -ExpandProperty FullName + } + $Files = Get-ChildItem $ModuleOutputPath -Recurse -File -Filter *.ps*1 + + "Analyzing $($Files -join "`n ")" + $results = $Files | Invoke-ScriptAnalyzer @ScriptAnalyzer + if (Get-Module ConvertToSARIF -List) { + Write-Verbose "Converting ScriptAnalyzer results to SARIF..." + $results | ConvertToSARIF\ConvertTo-SARIF -FilePath "$script:OutputPath/results.sarif" + } else { + Write-Warning "ConvertToSARIF module not found. Sarif results will not be generated. Please add ConvertToSARIF to your build.requires.psd1 file." + } + + if ($results) { + 'One or more PSScriptAnalyzer errors/warnings were found.' + 'Please investigate or add the required SuppressMessage attribute.' + $results | Format-Table -AutoSize + } + } +} diff --git a/powershell/base.ps1 b/powershell/base.ps1 new file mode 100644 index 0000000..bc7314a --- /dev/null +++ b/powershell/base.ps1 @@ -0,0 +1,105 @@ +<# +.SYNOPSIS + PowerShell module build script -- extends common base with PowerShell module build support. +.DESCRIPTION + Extends common base to provide PowerShell module build, test, analysis, and publishing. +.EXAMPLE + Invoke-Build +#> +[CmdletBinding()] +param( + [ValidateScript({ "../common/base.ps1" })] + $Extends, + + # Name of the PowerShell module (defaults to the directory/project name) + [string]$ModuleName, + + # Name of the PSRepository to publish to + [string]$PSRepository = "DevOpsPowerShell", + + # NuGet-compatible publish URI for the PS module repository + [string]$PowerShellModulePublishUri, + + # API key for publishing to the PS module repository + [string]$PowerShellModulePublishKey, + + # Pester filter hashtable (Tag, ExcludeTag, etc.) + $PesterFilter, + + # Skip code coverage measurement + [switch]$SkipCoverage +) + +# Redirect $BuildRoot to the consuming project's directory +if ($BuildRoots.Count -gt 1) { + $BuildRoot = $BuildRoots[-1] +} + +# Assign params to script scope early -- task If conditions evaluate at definition time +$script:ModuleName ??= $ModuleName +$script:PSRepository ??= $PSRepository + +Enter-Build { + # Resolve credentials and repository from environment if not passed as parameters + $script:PSRepository = Get-Content Variable:PSRepository, Env:PSREPOSITORY -ErrorAction Ignore | + Select-Object -First 1 + if (-not $script:PSRepository) { $script:PSRepository = "DevOpsPowerShell" } + + $script:PowerShellModulePublishUri = Get-Content Variable:PowerShellModulePublishUri, + Env:LDBUILD_PS_PUBLISH_URI -ErrorAction Ignore | + Select-Object -First 1 + + $script:PowerShellModulePublishKey = Get-Content Variable:PowerShellModulePublishKey, + Env:LDBUILD_PS_PUBLISH_KEY -ErrorAction Ignore | + Select-Object -First 1 + + # Default ModuleName to the project folder name + if (-not $script:ModuleName) { + $script:ModuleName = Split-Path $BuildRoot -Leaf + } + + $script:ModuleOutputPath = Join-Path $script:OutputPath $script:ModuleName + $script:ManifestPath = Join-Path $script:ModuleOutputPath "$script:ModuleName.psd1" + $script:ModuleTestResultsRoot = Join-Path $Script:TestResultsRoot $script:ModuleName + New-Item -Type Directory -Path $script:ModuleTestResultsRoot -Force | Out-Null + + Write-Build Cyan " ModuleName [$script:ModuleName]" + Write-Build Cyan " ModuleOutputPath [$script:ModuleOutputPath]" + + $script:SourcePath ??= (Join-Path $BuildRoot src), (Join-Path $BuildRoot $script:ModuleName) | Convert-Path -ErrorAction Ignore | Select-Object -First 1 + + Write-Build Cyan " PSRepository [$script:PSRepository]" + + # Register PSRepository if a publish URI is provided and it isn't already registered correctly + if ($script:PowerShellModulePublishUri -and $script:PSRepository) { + $existing = Get-PSRepository -Name $script:PSRepository -ErrorAction Ignore + if (-not $existing -or $existing.PublishLocation -ne $script:PowerShellModulePublishUri) { + if ($existing) { Unregister-PSRepository -Name $script:PSRepository } + Register-PSRepository -Name $script:PSRepository ` + -SourceLocation $script:PowerShellModulePublishUri ` + -PublishLocation $script:PowerShellModulePublishUri ` + -InstallationPolicy Trusted + } + } +} + +# Add the dotnet tasks to the common tasks +$script:InitializeTasks = @( + # In CI pipelines (or if you specify $Clean) + if ($BuildSystem -ne "None" -or $Script:Clean) { + # Run the Clean-Output task before the rest of the build tasks + "Clean-Output" + } +) + $InitializeTasks + +$script:BuildTasks += @("Build-Module") +# TODO: Need to separate package & push +$script:PublishTasks += @() +$script:TestTasks += @("Import-Module", "Test-PowerShell", "Test-PowerShellSyntax") +$script:PushTasks += @("Publish-Module") +$script:CheckpointTasks += @() + +foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { + # Write-Information "$($PSStyle.Foreground.BrightBlue) $($taskfile.FullName)$($PSStyle.Reset)" + . $taskfile.FullName +} \ No newline at end of file diff --git a/scripts/Bootstrap.ps1 b/scripts/Bootstrap.ps1 index a803b6c..2766585 100644 --- a/scripts/Bootstrap.ps1 +++ b/scripts/Bootstrap.ps1 @@ -11,15 +11,12 @@ #> [CmdletBinding()] param( - # Path to a RequiredModules.psd1 (if missing will only install InvokeBuild) - $RequiredModulesPath = "$PSScriptRoot/../RequiredModules.psd1", - - # Scope for installation (of scripts and modules). Defaults to CurrentUser - [ValidateSet("AllUsers", "CurrentUser")] - $Scope = "CurrentUser" + # Path to a .requires.psd1 (if missing will only install InvokeBuild) + [Alias("RequiredModulesPath")] + $Path = "$PSScriptRoot/../build.requires.psd1" ) Push-Location -StackName BootStrap -& "$PSScriptRoot/../scripts/Install-RequiredModule.ps1" $RequiredModulesPath +& "$PSScriptRoot/Install-PowerShellModule.ps1" $Path Pop-Location -StackName BootStrap diff --git a/scripts/Install-RequiredModule.ps1 b/scripts/Install-PowerShellModule.ps1 similarity index 51% rename from scripts/Install-RequiredModule.ps1 rename to scripts/Install-PowerShellModule.ps1 index db2e235..d4eebff 100644 --- a/scripts/Install-RequiredModule.ps1 +++ b/scripts/Install-PowerShellModule.ps1 @@ -6,52 +6,46 @@ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand', 'InputObject', Justification = 'For Invoke-Build Compabitility')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', "InputObject", Justification = 'For Invoke-Build Compabitility')] param( - [string]$RequiredModulesFile = "$pwd/RequiredModules.psd1", + [Alias("RequiredModulesPath")] + [string]$path = "$pwd/*.requires.psd1", - [hashtable]$RequiredModules = @{ - 'InvokeBuild' = '[5.11.1, 6.0)' - }, - # This command ignores pipeline input + [string[]]$Specification = @( + 'InvokeBuild:[5.11.1, 6.0)' + ), + # This command explicitly ignores pipeline input + # But is sometimes called with input ... [Parameter(ValueFromPipeline, ValueFromRemainingArguments)] [PSObject[]]$InputObject, # This allows passing a different url for modulefastparam source. Used for Harness which must use APIM url to reach proget [string]$ModuleFastSourceUrl = "https://nuget.loandepot.com/nuget/PowerShell/v3/index.json", - # ProGet API token for authenticated access (required for Harness/APIM endpoint, not needed for ADO private link) - [string]$ProGetToken + # API token for APIM access (only needed for accessing the APIM from outside the firewall) + [SecureString]$ApiToken ) -# Construct credential if token is provided (for Harness APIM authentication) -$Credential = if ($ProGetToken) { - $secureToken = ConvertTo-SecureString $ProGetToken -AsPlainText -Force - [System.Management.Automation.PSCredential]::new('api', $secureToken) -} else { - $null +$Destination = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'powershell/Modules' +# If we have not yet migrated our PowerShell modules to LocalApplicationData on Windows +if ($Env:PSModulePath -split ([Io.Path]::PathSeparator) -notcontains $Destination) { + # On Windows, the modules folder is not pre-created? + $Destination = mkdir (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell/Modules') -Force | Convert-Path } -# We have not yet migrated our PowerShell modules to LocalApplicationData on Windows $ModuleFastParam = @{ Source = $ModuleFastSourceUrl - Destination = if ($IsWindows) { - # On Windows, the modules folder is not pre-created? - mkdir (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell/Modules') -Force | Convert-Path - } else { - Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'powershell/Modules' - } + Destination = $Destination } -if (-not (Test-Path $RequiredModulesFile)) { - $ModuleFastParam['Specification'] = $RequiredModules.GetEnumerator().ForEach{ $_.Key + ":" + $_.Value } +# This wrapper uses the path if it exists, otherwise uses the Specification +if (-not (Test-Path $Path)) { + $ModuleFastParam['Specification'] = $Specification # Update this for the environment variable - $RequiredModulesFile = $RequiredModules.Keys -join ";" + $Path = "'$($Specification -join "', '")'" } else { - $ModuleFastParam['Path'] = $RequiredModulesFile + $ModuleFastParam['Path'] = $Path } +# If ModuleFast is not already installed, install it to $Destination if (!(Get-Module ModuleFast -ListAvailable -ErrorAction SilentlyContinue)) { - # $PSModulePaths = @("PSModulePaths:") + $env:PSModulePath.Split([IO.Path]::PathSeparator, [StringSplitOptions]::RemoveEmptyEntries) - # Write-Verbose $($PSModulePaths -join "`n $($PSStyle.Formatting.Verbose)") -Verbose - Write-Verbose "ModuleFast not found. Installing to $($ModuleFastParam.Destination)" -Verbose # When we get redirected beyond our limit, IWR throws [string]$Location = try { @@ -71,11 +65,14 @@ if (!(Get-Module ModuleFast -ListAvailable -ErrorAction SilentlyContinue)) { Remove-Item $file } -# Install modules from ProGet (with auth for Harness/APIM, without auth for ADO private link) -if ($Credential) { - Install-ModuleFast @ModuleFastParam -Credential $Credential -Verbose -} else { - Install-ModuleFast @ModuleFastParam -Verbose +# Use APIM authentication token if provided +if ($ApiToken) { + $ModuleFastParam['Credential'] = [System.Management.Automation.PSCredential]::new('api', $ApiToken) } -Write-Host "##vso[task.setvariable variable=RequiredModules]$RequiredModulesFile" +Install-ModuleFast @ModuleFastParam -Verbose + +if ($BuildSystem -eq "Azure") { + # We use this as a condition in the Azure step, to skip rerunning this job + Write-Host "##vso[task.setvariable variable=RequiredModules]$Path" +} \ No newline at end of file From 150fc0569b214ff1b2ee00de81594efa55f25eef Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Tue, 21 Apr 2026 01:28:15 -0400 Subject: [PATCH 09/43] Add a skill for PowerShell builds --- .agent/skills/new-build-powershell/SKILL.md | 28 ++++++++++ .../assets/build.build.ps1 | 51 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .agent/skills/new-build-powershell/SKILL.md create mode 100644 .agent/skills/new-build-powershell/assets/build.build.ps1 diff --git a/.agent/skills/new-build-powershell/SKILL.md b/.agent/skills/new-build-powershell/SKILL.md new file mode 100644 index 0000000..6f78103 --- /dev/null +++ b/.agent/skills/new-build-powershell/SKILL.md @@ -0,0 +1,28 @@ +--- +name: new-build-powershell +description: configuring PowerShell Module projects for build and creating a new build script +--- + +## Requirements + +1. Projects must target PowerShell Core (7.x) +2. The .NET SDK must be available +3. If there is a csproj, the `dotnet` base must also be included + +## Reference + +There are NUMBERED documents in ./references with more detailed instructions for each of these steps, which you should refer to as you go through them. + +## Process Overview + +1. Ensure you're building a ModuleBuilder module. There should be a `build.psd1` file in the project root. +2. Copy the files from assets/ to your project root and customize them + - Add a `$ModuleName` parameter to the build.build.ps1 and hard-code the name of the module + - If you have not reached 85% code coverage in tests, add a $PassingCodeCoverage parameter with a default, and a comment requiring it be increased for each pull request. +3. Update your projects: + - Rename your RequiredModules.psd1 to build.requires.psd1 and if necessary, update the syntax for ModuleFast + - Pester 5 Tests + +Once those steps are done, make sure that `.gitignore` includes `Output/` directory and run your `build.build.ps1` to verify that the build is working. You may need to further customize and troubleshoot, but the build should work locally. + +You may want to copy the `invoke-build` skill into your project. diff --git a/.agent/skills/new-build-powershell/assets/build.build.ps1 b/.agent/skills/new-build-powershell/assets/build.build.ps1 new file mode 100644 index 0000000..5d54ef0 --- /dev/null +++ b/.agent/skills/new-build-powershell/assets/build.build.ps1 @@ -0,0 +1,51 @@ +<# +.SYNOPSIS + Builds the project +.DESCRIPTION + Controls which steps are used in the build of a project, including helm charts, etc. +.EXAMPLE + Invoke-Build + + Runs a build and test of the project +.EXAMPLE + Invoke-Build CI + + Runs the full CI build, which is what your pipeline runs. This includes all steps: calculating version, cleaning output, converting test results, and packaging (and publishing) artifacts, etc. +#> +[CmdletBinding()] +param( + [ValidateScript( + { + @( + "../*BuildTasks/powershell/base.ps1" + ) | Convert-Path + } + )] + $Extends +) + +## Self-contained build script - can be invoked directly or via Invoke-Build +if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { + Write-Information "Bootstrap Build Dependencies" -Tag "InvokeBuild" + . (Convert-Path ../*BuildTasks/scripts/Bootstrap.ps1) + + Invoke-Build -File $MyInvocation.MyCommand.Path @PSBoundParameters -Result Result + + if ($Result.Error) { + $Error[-1].ScriptStackTrace | Out-Host + exit 1 + } + exit 0 +} + +# Define your preferred default build for local dev: +Add-BuildTask . Get-Version, Build, Test + +# Each build is responsible to define the five core tasks for CI +# But each base adds opinionated tasks to these variables +# So it's usually safe to just use these: +Add-BuildTask Initialize $script:InitializeTasks +Add-BuildTask Build $script:BuildTasks +Add-BuildTask Test $script:TestTasks +Add-BuildTask Publish $script:PublishTasks +Add-BuildTask Push $script:PushTasks \ No newline at end of file From c43c049496e269be9daf8c0a379a5d4f168cc196 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 22 Apr 2026 00:12:25 -0400 Subject: [PATCH 10/43] Switch from LDBUILD_ to IB_ environment variable prefixes for build configuration. Update SKILL to add a step to clean out old obj output Co-authored-by: Copilot --- .agent/skills/new-build-dotnet/SKILL.md | 5 ++-- .../assets/Directory.Build.props | 30 +++++++++---------- .../references/2_Clean_Old_Output.md | 28 +++++++++++++++++ ...Build_Assets.md => 3_Copy_Build_Assets.md} | 0 ...pdate_Projects.md => 4_Update_Projects.md} | 0 common/base.ps1 | 12 ++++---- docs/Extends.md | 2 +- dotnet/base.ps1 | 4 +-- powershell/base.ps1 | 7 +++-- 9 files changed, 57 insertions(+), 31 deletions(-) create mode 100644 .agent/skills/new-build-dotnet/references/2_Clean_Old_Output.md rename .agent/skills/new-build-dotnet/references/{2_Copy_Build_Assets.md => 3_Copy_Build_Assets.md} (100%) rename .agent/skills/new-build-dotnet/references/{3_Update_Projects.md => 4_Update_Projects.md} (100%) diff --git a/.agent/skills/new-build-dotnet/SKILL.md b/.agent/skills/new-build-dotnet/SKILL.md index a48abb8..3c5a189 100644 --- a/.agent/skills/new-build-dotnet/SKILL.md +++ b/.agent/skills/new-build-dotnet/SKILL.md @@ -15,8 +15,9 @@ There are NUMBERED documents in ./references with more detailed instructions for ## Process Overview 1. Move solution files to the root of the project -2. Copy the `*.Build.*` files from assets/ to your project root and customize them as needed -3. Update your projects: +2. Clean old intermediate and output directories, and update .gitignore to add the new `Output/` directory +3. Copy the `*.Build.*` files from assets/ to your project root and customize them as needed +4. Update your projects: - Ensure direct project references - Add the `` property as appropriate - Add the `` property as appropriate diff --git a/.agent/skills/new-build-dotnet/assets/Directory.Build.props b/.agent/skills/new-build-dotnet/assets/Directory.Build.props index 0d8f08e..8cf0dcb 100644 --- a/.agent/skills/new-build-dotnet/assets/Directory.Build.props +++ b/.agent/skills/new-build-dotnet/assets/Directory.Build.props @@ -1,45 +1,43 @@ - devops@loandepot.com - loanDepot + + _ - - $(MSBuildThisFileDirectory)Output/$(SolutionName)/ + $(MSBuildThisFileDirectory)Output/$(SolutionName)/ - - $([MSBuild]::EnsureTrailingSlash($(LDBUILD_OUTPUT_ROOT)))$(SolutionName)/ + $([MSBuild]::EnsureTrailingSlash($(IB_OUTPUT_ROOT)))$(SolutionName)/ $(RootOutputPath)bin/$(MSBuildProjectName) $(RootOutputPath)obj/$(MSBuildProjectName) $(RootOutputPath)publish/$(MSBuildProjectName) $(RootOutputPath)containers/ - $(LDBUILD_TARGET_RUNTIME) + $(IB_TARGET_RUNTIME) $(RuntimeIdentifier) false - crazusw2dvosl1.azurecr.io/dotnet/aspnet:8.0 - crazusw2dvosl1.azurecr.io + False False - \ No newline at end of file + diff --git a/.agent/skills/new-build-dotnet/references/2_Clean_Old_Output.md b/.agent/skills/new-build-dotnet/references/2_Clean_Old_Output.md new file mode 100644 index 0000000..b23476a --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/2_Clean_Old_Output.md @@ -0,0 +1,28 @@ +# Clean old Output and Intermediate directories + +This only matters for developer workstations, where the project is checked out, +and has either been built previously or has been opened in Visual Studio +(or VS Code with the C# Dev Kit) which will automatically build it. + +We need to get rid of the `obj` (and `bin`) directories in the project folders +because they contain intermediate output files including **source** files +like AssemblyInfo.cs, that will get recreated in the new `Output/` directory +causing conflicts and build errors. + +## There are two easy ways + +### Run `dotnet clean` + +Ensure the project is not open in Visual Studio (or VS Code with the C# Dev Kit), +and run the `dotnet clean` command against each solution file. + +This has to be done **before** you copy in the new `Directory.Build.props`, +while the projects will still point at the original output locations. + +### Run `git clean` + +As an alternative, you can run `git clean -ndX .` to list untracked files. +Review that list carefully to ensure that there's nothing you want to keep, +then run `git clean -fdX` to actually delete them. + +The risk is that this includes local configuration files you want to keep. diff --git a/.agent/skills/new-build-dotnet/references/2_Copy_Build_Assets.md b/.agent/skills/new-build-dotnet/references/3_Copy_Build_Assets.md similarity index 100% rename from .agent/skills/new-build-dotnet/references/2_Copy_Build_Assets.md rename to .agent/skills/new-build-dotnet/references/3_Copy_Build_Assets.md diff --git a/.agent/skills/new-build-dotnet/references/3_Update_Projects.md b/.agent/skills/new-build-dotnet/references/4_Update_Projects.md similarity index 100% rename from .agent/skills/new-build-dotnet/references/3_Update_Projects.md rename to .agent/skills/new-build-dotnet/references/4_Update_Projects.md diff --git a/common/base.ps1 b/common/base.ps1 index b4e8f6c..9f0680e 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -136,19 +136,17 @@ Enter-Build { # Build-system information. There are a few different sources for the information # But each variable should have a default here: - $Script:OutputPath = if ($Env:BUILD_BINARIESDIRECTORY) { - $Env:BUILD_BINARIESDIRECTORY - } else { - Join-Path $BuildRoot 'Output' - } + $Script:OutputPath = $Env:BUILD_BINARIESDIRECTORY ?? + $Env:IB_OUTPUT_PATH ?? + (Join-Path $BuildRoot 'Output') New-Item -Type Directory -Path $OutputPath -Force | Out-Null $Script:TestResultsRoot = $script:TestResultsRoot ?? # An override for build script parameters - $Env:LDBUILD_TEST_ROOT ?? # An override for machine-level settings + $Env:IB_TEST_ROOT ?? # An override for machine-level settings $Env:TEST_RESULTS_DIRECTORY ?? (Join-Path $OutputPath testresults) - $Script:TempDirectory = @(Get-Content Env:LDBUILD_TEMP_DIRECTORY, Env:AGENT_TEMPDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | + $Script:TempDirectory = @(Get-Content Env:IB_TEMP_DIRECTORY, Env:AGENT_TEMPDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $Script:TempDirectory) { $Script:TempDirectory = if ($IsLinux) { "/tmp" } else { [System.IO.Path]::GetTempPath() } } diff --git a/docs/Extends.md b/docs/Extends.md index 8416933..4009b9a 100644 --- a/docs/Extends.md +++ b/docs/Extends.md @@ -563,7 +563,7 @@ Add-BuildTask Install-FromGitHub @{ If = { $script:GHTools.Count -gt 0 } } │ $script:TestResultsRoot = ... │ │ ✓ │ │ $script:GHTools = @{} │ │ ✓ │ │ $script:dotnetProjects = ... │ │ ✓ │ -│ $Env:LDBUILD_* = ... │ │ ✓ │ +│ $Env:IB_* = ... │ │ ✓ │ └──────────────────────────────────┴────────────┴──────────────┘ ``` diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index 3b4a60c..e035f3a 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -83,7 +83,7 @@ Enter-Build { $script:SolutionName = Split-Path $script:dotnetSolution -LeafBase $script:SolutionOutputPath = Join-Path $script:OutputPath $script:SolutionName # This is used in Directory.build.props to configure the default output directory for dotnet restore and build (and publish?) - $Env:LDBUILD_OUTPUT_ROOT = $script:OutputPath + $Env:IB_OUTPUT_ROOT = $script:OutputPath # The DotNetPublishRoot is the "publish" folder within the Output (used for dotnet publish output) $script:DotNetPublishRoot ??= Join-Path $script:OutputPath publish @@ -94,7 +94,7 @@ Enter-Build { $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) $script:TargetFramework ??= $Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".") $script:TargetRuntime ??= $ENV:DOTNET_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64") - $ENV:LDBUILD_TARGET_RUNTIME = $script:TargetRuntime + $ENV:IB_TARGET_RUNTIME = $script:TargetRuntime $script:dotnetProjects = @(dotnet sln $script:dotnetSolution list | Where-Object { $_ -like "*.*proj" }) $script:dotnetTestProjects = @($script:dotnetProjects | Where-Object { $_ -like "*Test*.*proj" }) diff --git a/powershell/base.ps1 b/powershell/base.ps1 index bc7314a..322b4aa 100644 --- a/powershell/base.ps1 +++ b/powershell/base.ps1 @@ -46,11 +46,11 @@ Enter-Build { if (-not $script:PSRepository) { $script:PSRepository = "DevOpsPowerShell" } $script:PowerShellModulePublishUri = Get-Content Variable:PowerShellModulePublishUri, - Env:LDBUILD_PS_PUBLISH_URI -ErrorAction Ignore | + Env:IB_PS_PUBLISH_URI -ErrorAction Ignore | Select-Object -First 1 $script:PowerShellModulePublishKey = Get-Content Variable:PowerShellModulePublishKey, - Env:LDBUILD_PS_PUBLISH_KEY -ErrorAction Ignore | + Env:IB_PS_PUBLISH_KEY -ErrorAction Ignore | Select-Object -First 1 # Default ModuleName to the project folder name @@ -66,7 +66,8 @@ Enter-Build { Write-Build Cyan " ModuleName [$script:ModuleName]" Write-Build Cyan " ModuleOutputPath [$script:ModuleOutputPath]" - $script:SourcePath ??= (Join-Path $BuildRoot src), (Join-Path $BuildRoot $script:ModuleName) | Convert-Path -ErrorAction Ignore | Select-Object -First 1 + $script:SourcePath ??= (Join-Path $BuildRoot src), (Join-Path $BuildRoot source), (Join-Path $BuildRoot $script:ModuleName) | + Convert-Path -ErrorAction Ignore | Select-Object -First 1 Write-Build Cyan " PSRepository [$script:PSRepository]" From 1372a470509ddb91fd52b8fc0e26b8c7461168e0 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Fri, 1 May 2026 02:03:46 -0400 Subject: [PATCH 11/43] Fix path normalization in Directory.Build.props --- .../new-build-dotnet/assets/Directory.Build.props | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.agent/skills/new-build-dotnet/assets/Directory.Build.props b/.agent/skills/new-build-dotnet/assets/Directory.Build.props index 8cf0dcb..165e5a4 100644 --- a/.agent/skills/new-build-dotnet/assets/Directory.Build.props +++ b/.agent/skills/new-build-dotnet/assets/Directory.Build.props @@ -11,16 +11,16 @@ _ - $(MSBuildThisFileDirectory)Output/$(SolutionName)/ + $([MSBuild]::NormalizeDirectory($(MSBuildThisFileDirectory), "Output")) - $([MSBuild]::EnsureTrailingSlash($(IB_OUTPUT_ROOT)))$(SolutionName)/ - $(RootOutputPath)bin/$(MSBuildProjectName) - $(RootOutputPath)obj/$(MSBuildProjectName) - $(RootOutputPath)publish/$(MSBuildProjectName) - $(RootOutputPath)containers/ + $([MSBuild]::NormalizeDirectory($(IB_OUTPUT_ROOT))) + $([MSBuild]::NormalizeDirectory($(RootOutputPath), $(SolutionName), "bin", $(MSBuildProjectName))) + $([MSBuild]::NormalizeDirectory($(RootOutputPath), $(SolutionName), "obj", $(MSBuildProjectName))) + $([MSBuild]::NormalizeDirectory($(RootOutputPath), "publish", $(MSBuildProjectName))) + $([MSBuild]::NormalizeDirectory($(RootOutputPath), "containers")) $(IB_TARGET_RUNTIME) From 4198ec8ecfff4331436cdccfb568ff62c68b76dd Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Fri, 1 May 2026 02:11:22 -0400 Subject: [PATCH 12/43] Normalize the dotnet build Configuration property via IB_CONFIGURATION Co-authored-by: Copilot --- .../new-build-dotnet/assets/Directory.Build.props | 1 + dotnet/Build-DotNet.Task.ps1 | 11 +---------- dotnet/Test-DotNet.Task.ps1 | 1 - dotnet/base.ps1 | 5 ++--- 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/.agent/skills/new-build-dotnet/assets/Directory.Build.props b/.agent/skills/new-build-dotnet/assets/Directory.Build.props index 165e5a4..d837235 100644 --- a/.agent/skills/new-build-dotnet/assets/Directory.Build.props +++ b/.agent/skills/new-build-dotnet/assets/Directory.Build.props @@ -24,6 +24,7 @@ $(IB_TARGET_RUNTIME) + $(IB_CONFIGURATION) $(RuntimeIdentifier) false diff --git a/dotnet/Build-DotNet.Task.ps1 b/dotnet/Build-DotNet.Task.ps1 index 6e56f3f..f6d5926 100644 --- a/dotnet/Build-DotNet.Task.ps1 +++ b/dotnet/Build-DotNet.Task.ps1 @@ -29,17 +29,8 @@ Add-BuildTask Build-DotNet @{ } } Jobs = "Restore-DotNet", "Get-Version", { - $Name = (Split-Path $dotnetSolution -LeafBase).ToLower() - - $local:options = @{ - '-configuration' = $script:configuration - } + $script:dotnetOptions - - if (${script:Version}.$Name) { - $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" - } else { + $local:options = @{} + $script:dotnetOptions $options["p"] = "Version=$(${script:Version}.InformationalVersion)" - } Write-Build Yellow "dotnet build $dotnetSolution --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" # Invoke-BuildExec [-Command] ScriptBlock [[-ExitCode] Int32[]] [[-ErrorMessage] String] [-Echo] [-StdErr] diff --git a/dotnet/Test-DotNet.Task.ps1 b/dotnet/Test-DotNet.Task.ps1 index 00cffb3..b1db76e 100644 --- a/dotnet/Test-DotNet.Task.ps1 +++ b/dotnet/Test-DotNet.Task.ps1 @@ -24,7 +24,6 @@ Add-BuildTask Test-DotNet @{ $local:options = @{ "-logger" = "trx" "-results-directory" = $SolutionTestResultsRoot - "-configuration" = $configuration } + $script:dotnetOptions if (!$Script:SkipCoverage) { diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index e035f3a..8c8a810 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -56,7 +56,6 @@ if ($BuildRoots.Count -gt 1) { #region DotNet task variables -- initialized in Enter-Build (runs only when actually building) Enter-Build { - $script:Configuration ??= "Release" # Resolve $Solution to a full path -- path separators indicate a direct path, otherwise search $BuildRoot $script:dotnetSolution = if ($Solution -match '[\\/]') { @@ -94,10 +93,10 @@ Enter-Build { $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) $script:TargetFramework ??= $Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".") $script:TargetRuntime ??= $ENV:DOTNET_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64") + $ENV:IB_TARGET_RUNTIME = $script:TargetRuntime + $ENV:IB_CONFIGURATION = $script:Configuration - $script:dotnetProjects = @(dotnet sln $script:dotnetSolution list | Where-Object { $_ -like "*.*proj" }) - $script:dotnetTestProjects = @($script:dotnetProjects | Where-Object { $_ -like "*Test*.*proj" }) $script:dotnetOptions ??= @{} $script:NuGetPublishKey ??= $Env:NUGET_API_KEY From 012573188324d5e84af76d00cc85b4778d936087 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Fri, 1 May 2026 02:12:09 -0400 Subject: [PATCH 13/43] Archive tasks we're not using --- {common => archive}/MonoRepoGitVersion.Task.ps1 | 0 {common => archive}/MonoRepoTagSource.Task.ps1 | 0 {common => archive}/Pack-UniversalPackage.Task.ps1 | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {common => archive}/MonoRepoGitVersion.Task.ps1 (100%) rename {common => archive}/MonoRepoTagSource.Task.ps1 (100%) rename {common => archive}/Pack-UniversalPackage.Task.ps1 (100%) diff --git a/common/MonoRepoGitVersion.Task.ps1 b/archive/MonoRepoGitVersion.Task.ps1 similarity index 100% rename from common/MonoRepoGitVersion.Task.ps1 rename to archive/MonoRepoGitVersion.Task.ps1 diff --git a/common/MonoRepoTagSource.Task.ps1 b/archive/MonoRepoTagSource.Task.ps1 similarity index 100% rename from common/MonoRepoTagSource.Task.ps1 rename to archive/MonoRepoTagSource.Task.ps1 diff --git a/common/Pack-UniversalPackage.Task.ps1 b/archive/Pack-UniversalPackage.Task.ps1 similarity index 100% rename from common/Pack-UniversalPackage.Task.ps1 rename to archive/Pack-UniversalPackage.Task.ps1 From 6d2473a81426d3dbd3490974c2a5a32b077c271d Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Fri, 1 May 2026 02:14:17 -0400 Subject: [PATCH 14/43] Make sure the SolutionTestResultsRoot directory exists --- {dotnet => common}/Convert-Coverage.Task.ps1 | 1 + dotnet/Convert-Trx2JUnit.Task.ps1 | 1 + dotnet/Test-DotNet.Task.ps1 | 3 ++- dotnet/base.ps1 | 1 - 4 files changed, 4 insertions(+), 2 deletions(-) rename {dotnet => common}/Convert-Coverage.Task.ps1 (93%) diff --git a/dotnet/Convert-Coverage.Task.ps1 b/common/Convert-Coverage.Task.ps1 similarity index 93% rename from dotnet/Convert-Coverage.Task.ps1 rename to common/Convert-Coverage.Task.ps1 index 47d13e7..a82e8c9 100644 --- a/dotnet/Convert-Coverage.Task.ps1 +++ b/common/Convert-Coverage.Task.ps1 @@ -1,6 +1,7 @@ Add-BuildTask Convert-Coverage @{ If = { !$Script:SkipCoverage } Jobs = { + New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null Set-Location $SolutionTestResultsRoot # ------------------------------ dotnet reportgenerator -reports:'./coverage/*.xml' ` diff --git a/dotnet/Convert-Trx2JUnit.Task.ps1 b/dotnet/Convert-Trx2JUnit.Task.ps1 index 24ca42f..2daa85b 100644 --- a/dotnet/Convert-Trx2JUnit.Task.ps1 +++ b/dotnet/Convert-Trx2JUnit.Task.ps1 @@ -6,6 +6,7 @@ Add-BuildTask Convert-Trx2JUnit @{ } } Partial = $true Input = { + New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null Get-ChildItem $SolutionTestResultsRoot/*.trx } Output = { diff --git a/dotnet/Test-DotNet.Task.ps1 b/dotnet/Test-DotNet.Task.ps1 index b1db76e..6f1280b 100644 --- a/dotnet/Test-DotNet.Task.ps1 +++ b/dotnet/Test-DotNet.Task.ps1 @@ -12,7 +12,8 @@ Add-BuildTask Test-DotNet @{ } Outputs = { # Return any .trx files in the test results directory - # dotnet test generates .trx files with machine/user-based names, not project names + # dotnet test generates .trx files with machine/user-based names, not project or solution names + New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null $TrxFiles = Get-ChildItem $SolutionTestResultsRoot -Filter "*.trx" -ErrorAction SilentlyContinue if ($TrxFiles) { diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index 8c8a810..2c1fc6e 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -89,7 +89,6 @@ Enter-Build { $script:DotNetPackRoot ??= Join-Path $script:OutputPath nuget $script:SolutionTestResultsRoot = Join-Path $Script:TestResultsRoot $script:SolutionName - New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) $script:TargetFramework ??= $Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".") $script:TargetRuntime ??= $ENV:DOTNET_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64") From cf5985f9bc83a220f3e3fb595c326ab9bb301e9b Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Fri, 1 May 2026 02:27:25 -0400 Subject: [PATCH 15/43] Fix PowerShell Build for projects with C# assemblies Co-authored-by: Copilot --- powershell/Build-Module.Task.ps1 | 38 +++++++++++++++----------------- powershell/base.ps1 | 7 +++++- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/powershell/Build-Module.Task.ps1 b/powershell/Build-Module.Task.ps1 index 09b761e..6d4c7b1 100644 --- a/powershell/Build-Module.Task.ps1 +++ b/powershell/Build-Module.Task.ps1 @@ -2,40 +2,26 @@ Add-BuildTask Build-Module @{ Inputs = { @( Get-ChildItem -Path $BuildRoot -Recurse -Filter *.ps* - Get-ChildItem -Path $BuildRoot -Recurse -Filter *.cs | Where-Object FullName -NotLike (Join-Path $BuildRoot obj/*) - # This is here because on Dev workstations we build _into_ $BuildRoot + Get-ChildItem -Path $BuildRoot -Recurse -Filter *.cs | Where-Object FullName -NotLike "*/obj/*" ) | Where-Object FullName -NotLike (Join-Path $script:OutputPath /*) } # don't take off the script block, need to resolve AFTER init Outputs = { $InputObject = $_ - $out = $script:OutputPath switch -regex ("$InputObject") { "ps1$" { - if ($Module) { - $Module - } elseif ($out -and (Test-Path $out -PathType Container)) { - Get-Item $out - } else { - $out - } + $script:ModuleOutputPath } "cs$" { - if ($out -and ($Assemblies = Get-ChildItem -Path $out -Recurse -Filter *.dll -ErrorAction Ignore)) { + if ($out -and ($Assemblies = Get-ChildItem -Path $script:OutputPath -Recurse -Filter *.dll -ErrorAction Ignore)) { $Assemblies - } elseif ($out -and (Test-Path $out -PathType Container)) { - Get-Item $out } else { - $out + Join-Path $script:ModuleOutputPath lib } } default { # .psd1, .psm1, .pssc etc — use the output directory as the comparison target - if ($out -and (Test-Path $out -PathType Container)) { - Get-Item $out - } else { - $out - } + $script:OutputPath } } } @@ -56,7 +42,19 @@ Add-BuildTask Build-Module @{ }) } - $Module = Build-Module -Output $script:OutputPath -UnversionedOutputDirectory @version -Passthru -Verbose + $Module = Build-Module -Output $script:OutputPath -UnversionedOutputDirectory @version -Passthru -Verbose:($VerbosePreference -eq "Continue") + + # If there's output from a DotNetPublish task, copy it into a "lib" folder in the module output + if ($DotNetPublishRoot -and (Test-Path $DotNetPublishRoot)) { + $Libraries = New-Item (Join-Path $Module.ModuleBase lib) -Type Directory -Force | Convert-Path + Write-Build Yellow "Copying dotnet publish output from $DotNetPublishRoot to module lib $Libraries" + Get-ChildItem $DotNetPublishRoot -Filter *.dll -Recurse -ErrorAction Ignore + | Where-Object { $_.BaseName -notmatch "System.*" -and $_.Extension -notin ".nupkg" } + | Copy-Item -Destination $Libraries -Recurse + } else { + Write-Build Yellow "No assemblies to copy $DotNetPublishRoot" + } + $script:ModuleName = $Module.Name $script:ManifestPath = $Module.Path $script:ModuleOutputPath = Split-Path $Module.Path diff --git a/powershell/base.ps1 b/powershell/base.ps1 index 322b4aa..3b20ddc 100644 --- a/powershell/base.ps1 +++ b/powershell/base.ps1 @@ -93,7 +93,12 @@ $script:InitializeTasks = @( } ) + $InitializeTasks -$script:BuildTasks += @("Build-Module") +# When we have dotnet combined in a PowerShell module +# We need to build the module after the dotnet publish +# So that we can include the output assemblies in the module +$script:BuildTasks += $BuildTasks -contains "Build-DotNet" ? + @("Publish-DotNet", "Build-Module") : + @("Build-Module") # TODO: Need to separate package & push $script:PublishTasks += @() $script:TestTasks += @("Import-Module", "Test-PowerShell", "Test-PowerShellSyntax") From 25a013b5c541ad24367d2a0241ca774c47885ff9 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Fri, 1 May 2026 02:27:44 -0400 Subject: [PATCH 16/43] Clean up Install-PowerShellModule Co-authored-by: Copilot --- build.requires.psd1 | 7 ++++--- scripts/Install-PowerShellModule.ps1 | 27 ++++++++++++--------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/build.requires.psd1 b/build.requires.psd1 index db424fd..c4c09f1 100644 --- a/build.requires.psd1 +++ b/build.requires.psd1 @@ -7,7 +7,8 @@ yayaml = "0.*" "Az.ContainerRegistry" = "5.*" "Az.Accounts" = "5.*" - "LDNative" = "[1.0.6,2.0)" - "LDGit" = "[2.2.2,3.0)" - "LDAzOps" = "[0.3.0,1.0)" + # "LDNative" = "[1.0.6,2.0)" + # "LDGit" = "[2.2.2,3.0)" + # "LDAzOps" = "[0.3.0,1.0)" + "ConvertToSARIF" = "1.*" } diff --git a/scripts/Install-PowerShellModule.ps1 b/scripts/Install-PowerShellModule.ps1 index d4eebff..d12bbed 100644 --- a/scripts/Install-PowerShellModule.ps1 +++ b/scripts/Install-PowerShellModule.ps1 @@ -12,16 +12,16 @@ param( [string[]]$Specification = @( 'InvokeBuild:[5.11.1, 6.0)' ), - # This command explicitly ignores pipeline input - # But is sometimes called with input ... + # This command does not support pipeline input + # But is sometimes called with pipeline input ... [Parameter(ValueFromPipeline, ValueFromRemainingArguments)] - [PSObject[]]$InputObject, + [PSObject[]]$IgnoredPipelineInput, - # This allows passing a different url for modulefastparam source. Used for Harness which must use APIM url to reach proget - [string]$ModuleFastSourceUrl = "https://nuget.loandepot.com/nuget/PowerShell/v3/index.json", + # This allows passing a different url for source. + [string]$Source, - # API token for APIM access (only needed for accessing the APIM from outside the firewall) - [SecureString]$ApiToken + # This might be needed for a proxy like passing through APIM to a feed.... + [System.Management.Automation.PSCredential]$Credential ) $Destination = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'powershell/Modules' @@ -30,12 +30,14 @@ if ($Env:PSModulePath -split ([Io.Path]::PathSeparator) -notcontains $Destinatio # On Windows, the modules folder is not pre-created? $Destination = mkdir (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell/Modules') -Force | Convert-Path } +$PSBoundParameters.Remove('IgnoredPipelineInput') | Out-Null -$ModuleFastParam = @{ - Source = $ModuleFastSourceUrl +$ModuleFastParam = $PSBoundParameters + @{ Destination = $Destination } -# This wrapper uses the path if it exists, otherwise uses the Specification + +# The defaults are not "bound" +# ... use the path if it exists, otherwise the specification if (-not (Test-Path $Path)) { $ModuleFastParam['Specification'] = $Specification # Update this for the environment variable @@ -65,11 +67,6 @@ if (!(Get-Module ModuleFast -ListAvailable -ErrorAction SilentlyContinue)) { Remove-Item $file } -# Use APIM authentication token if provided -if ($ApiToken) { - $ModuleFastParam['Credential'] = [System.Management.Automation.PSCredential]::new('api', $ApiToken) -} - Install-ModuleFast @ModuleFastParam -Verbose if ($BuildSystem -eq "Azure") { From 70ef6e39b86d0a9afe198a0af9d1be379e52a291 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Fri, 1 May 2026 02:26:41 -0400 Subject: [PATCH 17/43] You cannot parse project files yourself (Always) run the restore with -getProperty to read properties Normalize DotNet*Variable names Simplify Conditions on DotNet Tasks Stop trying to avoid publishing Web SDK projects Use MTP syntax for dotnet test Add (some) Documentation for DotNet Conventions Co-authored-by: Copilot --- .../new-build-dotnet/assets/global.json | 9 +++ dotnet/Build-DotNet.Task.ps1 | 34 ++-------- dotnet/Clean-DotNet.Task.ps1 | 4 +- dotnet/Pack-DotNet.Task.ps1 | 51 +++------------ dotnet/Publish-DotNet.Task.ps1 | 63 ++----------------- dotnet/README.md | 18 ++++++ dotnet/Restore-DotNet.Task.ps1 | 50 ++++++++------- dotnet/Test-DotNet.Task.ps1 | 35 +++-------- dotnet/base.ps1 | 45 +++++++++---- 9 files changed, 121 insertions(+), 188 deletions(-) create mode 100644 .agent/skills/new-build-dotnet/assets/global.json create mode 100644 dotnet/README.md diff --git a/.agent/skills/new-build-dotnet/assets/global.json b/.agent/skills/new-build-dotnet/assets/global.json new file mode 100644 index 0000000..dc46def --- /dev/null +++ b/.agent/skills/new-build-dotnet/assets/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMajor" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/dotnet/Build-DotNet.Task.ps1 b/dotnet/Build-DotNet.Task.ps1 index f6d5926..840f3e8 100644 --- a/dotnet/Build-DotNet.Task.ps1 +++ b/dotnet/Build-DotNet.Task.ps1 @@ -1,40 +1,18 @@ Add-BuildTask Build-DotNet @{ + # TODO: Are these Inputs/Outputs actually ever saving us time? Inputs = { - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - $Projects - - # Also include source files from each project directory - foreach ($Proj in $Projects) { - $ProjectDir = Split-Path $Proj -Parent - Get-ChildItem $ProjectDir -Recurse -File -Include *.cs,*.csproj,*.resx,*.json -ErrorAction SilentlyContinue | - Where-Object FullName -NotMatch "[\\/]obj[\\/]|[\\/]bin[\\/]" - } + $DotNetProjects.ForEach({ Get-ChildItem (Split-Path $_.Path) -Recurse -File -ErrorAction SilentlyContinue }) } Outputs = { - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - - # Return corresponding DLL files in OutputPath bin directory - foreach ($Proj in $Projects) { - $ProjectName = [IO.Path]::GetFileNameWithoutExtension($Proj) - - # linux edge case where csproj name does not match the dll name (case sensitivity) - $AssemblyName = $ProjectName - $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue - if ($Content -match '([^<]+)') { - $AssemblyName = $Matches[1] - } - - $DllPath = Join-Path $script:SolutionOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$AssemblyName.dll" - $DllPath - } + $DotNetProjects.ForEach({ Join-Path $_.OutDir $_.TargetFileName }) } Jobs = "Restore-DotNet", "Get-Version", { $local:options = @{} + $script:dotnetOptions - $options["p"] = "Version=$(${script:Version}.InformationalVersion)" + $options["p"] = "Version=$(${script:Version}.InformationalVersion)" - Write-Build Yellow "dotnet build $dotnetSolution --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" + Write-Build Yellow "dotnet build $DotNetSolutionFile --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" # Invoke-BuildExec [-Command] ScriptBlock [[-ExitCode] Int32[]] [[-ErrorMessage] String] [-Echo] [-StdErr] - dotnet build $dotnetSolution --no-restore @options + dotnet build $DotNetSolutionFile --no-restore @options } } diff --git a/dotnet/Clean-DotNet.Task.ps1 b/dotnet/Clean-DotNet.Task.ps1 index dfbe2ec..d1fe496 100644 --- a/dotnet/Clean-DotNet.Task.ps1 +++ b/dotnet/Clean-DotNet.Task.ps1 @@ -1,6 +1,6 @@ Add-BuildTask Clean-DotNet @{ - Jobs = { + Jobs = { Write-Build Yellow "dotnet clean $Name" - dotnet clean $dotnetSolution + dotnet clean $DotNetSolutionFile } } diff --git a/dotnet/Pack-DotNet.Task.ps1 b/dotnet/Pack-DotNet.Task.ps1 index 00dbe29..e3969a7 100644 --- a/dotnet/Pack-DotNet.Task.ps1 +++ b/dotnet/Pack-DotNet.Task.ps1 @@ -1,40 +1,13 @@ #! If this is trying to pack a test project, you must add true to the project file. Add-BuildTask Pack-DotNet @{ + If = { + [bool]$DotNetProjects.Where({ $_.IsPackable }, "First", 1) + } Inputs = { - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - $Projects - - foreach ($Proj in $Projects) { - $ProjectName = Split-Path $Proj -LeafBase - - # Check if project is packable by reading the .csproj file - # Directory.Build.props sets IsPackable=false by default, so only projects - # that explicitly set IsPackable=true should be packed - $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue - if ($Content -match '\s*(true|True|TRUE)\s*') { - $DllPath = Join-Path $script:SolutionOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" - if (Test-Path $DllPath) { - $DllPath - } - } - } + $DotNetProjects.Where({ $_.IsPackable }).ForEach({ Join-Path $_.OutDir $_.TargetFileName }) } Outputs = { - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - - foreach ($Proj in $Projects) { - $ProjectName = Split-Path $Proj -LeafBase - - $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue - if ($Content -match '\s*(true|True|TRUE)\s*') { - $NupkgPattern = Join-Path $script:DotNetPackRoot "$ProjectName.*.nupkg" - $ExistingPkg = Get-Item $NupkgPattern -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName - - if ($ExistingPkg) { - $ExistingPkg - } else { $BuildRoot } - } - } + $DotNetProjects.Where({ $_.IsPackable }).ForEach({ Join-Path $script:DotNetPackRoot ($_.AssemblyName + ".*.nupkg") }) } Jobs = "Build-DotNet", { $script:DotNetPackRoot = New-Item $script:DotNetPackRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path @@ -43,16 +16,10 @@ Add-BuildTask Pack-DotNet @{ "-output" = $script:DotNetPackRoot } - $Name = Split-Path $dotnetSolution -LeafBase - if (${script:Version}.$Name) { - $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" - } else { - $options["p"] = "Version=$(${script:Version}.InformationalVersion)" - } - - Write-Host "Packing $Name" + Write-Build Yellow "Packing $SolutionName" + $options["p"] = "Version=$(${script:Version}.InformationalVersion)" - Write-Build Yellow "dotnet pack $dotnetSolution --no-build --include-symbols $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" - dotnet pack $dotnetSolution --no-build --include-symbols @options + Write-Build Yellow "dotnet pack $DotNetSolutionFile --no-build --include-symbols $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" + dotnet pack $DotNetSolutionFile --no-build --include-symbols @options } } diff --git a/dotnet/Publish-DotNet.Task.ps1 b/dotnet/Publish-DotNet.Task.ps1 index aa27636..4c89dc4 100644 --- a/dotnet/Publish-DotNet.Task.ps1 +++ b/dotnet/Publish-DotNet.Task.ps1 @@ -1,69 +1,18 @@ Add-BuildTask Publish-DotNet @{ Inputs = { - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - - foreach ($Proj in $Projects) { - $ProjectName = Split-Path $Proj -LeafBase - - # Check if project is publishable by reading the .csproj file - # Directory.Build.props sets IsPublishable=false by default, so only projects - # that explicitly set IsPublishable=true should be published - $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue - if ($Content -imatch '\s*true\s*') { - $DllPath = Join-Path $script:SolutionOutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" - if (Test-Path $DllPath) { $DllPath } - } - } + $DotNetProjects.Where({ $_.IsPublishable }).ForEach({ Join-Path $_.OutDir $_.TargetFileName }) } Outputs = { - # TODO: This is rerunning every time. Need to figure out why. - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - foreach ($Proj in $Projects) { - $ProjectName = Split-Path $Proj -LeafBase - - $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue - if ($Content -imatch '\s*true\s*') { - # Look for published dll in the publish directory - $PublishedDll = Join-Path $script:DotNetPublishRoot "$ProjectName/$ProjectName.dll" - $ExistingPublish = Get-Item $PublishedDll -ErrorAction SilentlyContinue - - if ($ExistingPublish) { - $ExistingPublish.FullName - } else { $BuildRoot } - } - } + $DotNetProjects.Where({ $_.IsPublishable }).ForEach({ Join-Path $_.PublishDir $_.TargetFileName }) } Jobs = "Build-DotNet", "Pack-DotNet", { - #! This handles a known issue with dotnet: any csproj identifying itself as Microsoft.NET.Sdk.Web will ALWAYS be published, even if IsPublishable is set to false - $script:ProjectsToIgnore = @() - foreach ($Proj in $dotnetProjects) { - $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue - if (($Content -imatch '') -and ($Proj -in $script:dotnetTestProjects)) { - $script:ProjectsToIgnore += $Proj - } - } - }, { - if ($script:ProjectsToIgnore.Count -gt 0) { - Write-Host "Skipping publish for projects: $($script:ProjectsToIgnore -join ', ')" - dotnet sln $dotnetSolution remove $script:ProjectsToIgnore - } $script:DotNetPublishRoot = New-Item $script:DotNetPublishRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path - $Name = Split-Path $dotnetSolution -LeafBase $local:options = @{} + $script:dotnetOptions + $options["p"] = "Version=$(${script:Version}.InformationalVersion)" - if (${script:Version}.$Name) { - $options["p"] = "Version=$(${script:Version}.$Name.InformationalVersion)" - } else { - $options["p"] = "Version=$(${script:Version}.InformationalVersion)" - } - - Set-Location (Split-Path $dotnetSolution) - Write-Build Yellow "dotnet publish $dotnetSolution --no-build --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" - dotnet publish $dotnetSolution --no-build --no-restore @options - },{ - if ($script:ProjectsToIgnore.Count -gt 0) { - dotnet sln $dotnetSolution add $script:ProjectsToIgnore - } + Set-Location (Split-Path $DotNetSolutionFile) + Write-Build Yellow "dotnet publish $DotNetSolutionFile --no-build --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" + dotnet publish $DotNetSolutionFile --no-build --no-restore @options } } diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 0000000..c5ae467 --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,18 @@ +# Build Tasks for .NET Projects + +To use the dotnet build, be sure to [follow the instructions](.agent\skills\new-build-dotnet\SKILL.md) +in the `new-build-dotnet` skill, copying the assets and reading the references. + +There are several files in the [`assets/`](.agent\skills\new-build-dotnet\assets) directory +that normalize output paths, properties, and tasks for our conventions. +These build scripts depend on those conventions about output paths and properties, +and won't work correctly without the Directory.Build.props and global.json from the assets. + +The test task assumes the use of the Microsoft Testing Platform test runner, +which should be configured in a `global.json` in the project root. Otherwise, you'll need +to override the `Test-DotNet` task to use the test runner of your choice. + +Additionally, all the dotnet commands are run against a _solution_ rather than individual projects. +This means we require (at least one) solution file, and we can only build one at a time. + +See: diff --git a/dotnet/Restore-DotNet.Task.ps1 b/dotnet/Restore-DotNet.Task.ps1 index b1b6c6d..6ec1aff 100644 --- a/dotnet/Restore-DotNet.Task.ps1 +++ b/dotnet/Restore-DotNet.Task.ps1 @@ -7,29 +7,37 @@ #> Add-BuildTask Restore-DotNet @{ - Inputs = { - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - $Projects - if (Test-Path "$BuildRoot/NuGet.config") { - "$BuildRoot/NuGet.config" - } - } - Outputs = { - $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - # Return corresponding project.assets.json files - foreach ($Proj in $Projects) { - $ProjectName = [IO.Path]::GetFileNameWithoutExtension($Proj) - Join-Path $script:SolutionOutputPath "obj/$ProjectName/project.assets.json" - } - } - Jobs = "Install-DotNetTool", { + # Inputs = { + # $DotNetProjects.Path + # if (Test-Path "$BuildRoot/NuGet.config") { + # "$BuildRoot/NuGet.config" + # } + # } + # Outputs = { + # # Return corresponding project.assets.json files + # $Project.BaseIntermediateOutputPath | Join-Path -ChildPath "project.assets.json" + # } + Jobs = "Install-DotNetTool", { $local:options = @{} + $script:dotnetOptions - $NugetConfig = Get-ChildItem $BuildRoot -File | Where-Object { $_.Name -ieq "NuGet.config" } - if ($NugetConfig) { - $options["-configfile"] = "$BuildRoot/$($NugetConfig.Name)" + # We're doing this for case-sensitive reasons + if (($NugetConfig = Get-ChildItem $BuildRoot -Filter "[Nn]u[Gg]et.config")) { + $options["-configfile"] = $NugetConfig.FullName } + Write-Build Yellow "dotnet restore $DotNetSolutionFile $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" - Write-Build Yellow "dotnet restore $dotnetSolution $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" - dotnet restore $dotnetSolution @options + # dotnet restore $DotNetSolutionFile @options + foreach ($Project in $script:DotNetProjects) { + $RestoreOutput = dotnet restore $Project.Path @options -getProperty:$($Project.PSObject.Properties.Name -ne "Path" -join ",") | ConvertFrom-Json -AsHashtable + if (!$?) { throw "dotnet restore failed for project $($Project.Path)" } + foreach ($Property in $Project.PSObject.Properties.Name -ne "Path") { + if ($RestoreOutput.Properties.$Property) { + if ($Property -match "^Is") { + $Project.$Property = [bool]::Parse($RestoreOutput.Properties.$Property) + } else { + $Project.$Property = $RestoreOutput.Properties.$Property + } + } + } + } } } diff --git a/dotnet/Test-DotNet.Task.ps1 b/dotnet/Test-DotNet.Task.ps1 index 6f1280b..e4a7662 100644 --- a/dotnet/Test-DotNet.Task.ps1 +++ b/dotnet/Test-DotNet.Task.ps1 @@ -1,24 +1,10 @@ Add-BuildTask Test-DotNet @{ Inputs = { - $Projects = $dotnetTestProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - $Projects - - # Also include source files from each project directory - foreach ($Proj in $Projects) { - $ProjectDir = Split-Path $Proj -Parent - Get-ChildItem $ProjectDir -Recurse -File -Include *.cs,*.csproj,*.resx,*.json -ErrorAction SilentlyContinue | - Where-Object FullName -NotMatch "[\\/]obj[\\/]|[\\/]bin[\\/]" - } + $DotNetProjects.Where({ $_.IsTestProject }).ForEach({ Get-ChildItem (Split-Path $_.Path) -Recurse -File -ErrorAction SilentlyContinue }) } Outputs = { - # Return any .trx files in the test results directory - # dotnet test generates .trx files with machine/user-based names, not project or solution names New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null - $TrxFiles = Get-ChildItem $SolutionTestResultsRoot -Filter "*.trx" -ErrorAction SilentlyContinue - - if ($TrxFiles) { - $TrxFiles | Select-Object -ExpandProperty FullName - } else { $BuildRoot } + Join-Path $SolutionTestResultsRoot "*.trx" } Jobs = "Build-DotNet", { @@ -27,19 +13,14 @@ Add-BuildTask Test-DotNet @{ "-results-directory" = $SolutionTestResultsRoot } + $script:dotnetOptions + # Because we might wrap it in `dotnet coverage collect`, we need to build this as a string + $Command = "dotnet test --solution $DotNetSolutionFile -p:SolutionName=$SolutionName --no-build $(($options.GetEnumerator().ForEach({"-$($_.Key) $($_.Value)"})) -join ' ')" if (!$Script:SkipCoverage) { - # Because we wrapt it in dotnet coverage, we need to build this as a string - $Command = "dotnet test $dotnetSolution --no-build" - $options.GetEnumerator() | ForEach-Object { - $Command += " -$($_.Key) $($_.Value)" - } - $Command += " -p:SolutionName=$SolutionName" - $Name = (Split-Path $dotnetSolution -LeafBase).ToLower() - Write-Build Yellow "dotnet coverage collect '$Command' --output '$SolutionTestResultsRoot/coverage/$Name.xml' --output-format xml" - dotnet coverage collect $Command --output "$SolutionTestResultsRoot/coverage/$Name.xml" --output-format xml + Write-Build Yellow "dotnet coverage collect '$Command' --output '$SolutionTestResultsRoot/coverage/$SolutionName.xml' --output-format xml" + dotnet coverage collect $Command --output "$SolutionTestResultsRoot/coverage/$SolutionName.xml" --output-format xml } else { - Write-Build Yellow "dotnet test $dotnetSolution --no-build $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" - dotnet test $dotnetSolution --no-build @options + Write-Build Yellow $Command + dotnet test --solution $DotNetSolutionFile -p:SolutionName=$SolutionName --no-build @options } }, "Convert-Trx2JUnit" } diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index 2c1fc6e..c27fcde 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -58,7 +58,7 @@ if ($BuildRoots.Count -gt 1) { Enter-Build { # Resolve $Solution to a full path -- path separators indicate a direct path, otherwise search $BuildRoot - $script:dotnetSolution = if ($Solution -match '[\\/]') { + $script:DotNetSolutionFile = if ($Solution -match '[\\/]') { $solutionPath = if ([System.IO.Path]::IsPathRooted($Solution)) { $Solution } else { @@ -79,14 +79,13 @@ Enter-Build { } $found[0] | Convert-Path } - $script:SolutionName = Split-Path $script:dotnetSolution -LeafBase - $script:SolutionOutputPath = Join-Path $script:OutputPath $script:SolutionName - # This is used in Directory.build.props to configure the default output directory for dotnet restore and build (and publish?) - $Env:IB_OUTPUT_ROOT = $script:OutputPath + $script:SolutionName = Split-Path $script:DotNetSolutionFile -LeafBase + # This is used in Directory.build.props to configure the root output directory for dotnet + $Env:IB_OUTPUT_ROOT ??= $script:OutputPath - # The DotNetPublishRoot is the "publish" folder within the Output (used for dotnet publish output) $script:DotNetPublishRoot ??= Join-Path $script:OutputPath publish $script:DotNetPackRoot ??= Join-Path $script:OutputPath nuget + $script:SolutionOutputPath ??= Join-Path $script:OutputPath $script:SolutionName $script:SolutionTestResultsRoot = Join-Path $Script:TestResultsRoot $script:SolutionName $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) @@ -96,22 +95,46 @@ Enter-Build { $ENV:IB_TARGET_RUNTIME = $script:TargetRuntime $ENV:IB_CONFIGURATION = $script:Configuration + $script:DotNetProjects = dotnet sln $script:DotNetSolutionFile list | + Where-Object { $_ -like "*.*proj" } | + Join-Path $script:BuildRoot -ChildPath { $_ } | + ForEach-Object { + $BaseName = Split-Path $_ -LeafBase + [PSCustomObject]@{ + PSTypeName = "DotNet.Project" + Path = $_ + # The rest of these properties MUST BE populated by getProperty in the Restore task + BaseIntermediateOutputPath = Join-Path $script:SolutionOutputPath "obj/$BaseName" + AssemblyName = $BaseName + IsPackable = [Nullable[bool]]$null + IsPublishable = [Nullable[bool]]$null + IsTestProject = [Nullable[bool]]$null + TargetFileName = [NullString]::Value + OutDir = [NullString]::Value + PublishDir = [NullString]::Value + } + } + + + $script:dotnetTestProjects = @($script:DotNetProjects | Where-Object { $_ -like "*Test*.*proj" }) $script:dotnetOptions ??= @{} $script:NuGetPublishKey ??= $Env:NUGET_API_KEY - $script:NuGetPublishUri ??= $Env:NUGET_API_URI ?? "https://nuget.loandepot.com/nuget/LDTS/v3/index.json" + $script:NuGetPublishUri ??= $Env:NUGET_API_URI $script:UPackPublishKey ??= $Env:UPACK_API_KEY - $script:UPackPublishUri ??= $Env:UPACK_PUBLISH_URI ?? "https://nuget.loandepot.com" + $script:UPackPublishUri ??= $Env:UPACK_PUBLISH_URI $script:UPackFeed ??= $Env:UPACK_FEED_NAME ?? "build-output" - Write-Build Cyan "Initializing DotNet task variables (Solution: $script:dotnetSolution)" + Write-Build Cyan "Initializing DotNet task variables (Solution: $script:DotNetSolutionFile)" Write-Build Cyan " Configuration: $script:Configuration" - Write-Build Cyan " dotnetSolution: $script:dotnetSolution" + Write-Build Cyan " TargetFramework: $script:TargetFramework" + Write-Build Cyan " TargetRuntime: $script:TargetRuntime" + Write-Build Cyan " DotNetSolutionFile: $script:DotNetSolutionFile" Write-Build Cyan " SolutionOutputPath: $script:SolutionOutputPath" Write-Build Cyan " DotNetPublishRoot: $DotNetPublishRoot" Write-Build Cyan " DotNetPackRoot: $DotNetPackRoot" Write-Build Cyan " SolutionTestResultsRoot: $SolutionTestResultsRoot" - Write-Build Cyan " DotNetProjects: $(($script:dotnetProjects).Count)" + Write-Build Cyan " DotNetProjects: $(($script:DotNetProjects).Count)" Write-Build Cyan " DotNetTestProjects: $(($script:dotnetTestProjects).Count)" Write-Build Cyan " NuGetPublishUri: $NuGetPublishUri" Write-Build Cyan " UPackPublishUri: $UPackPublishUri" From 41302dd61b0545f3c2164dcc9477f9d8d7567f7f Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 01:11:35 -0400 Subject: [PATCH 18/43] Use ModuleTestResultsRoot for syntax test output --- powershell/Test-PowerShellSyntax.Task.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/powershell/Test-PowerShellSyntax.Task.ps1 b/powershell/Test-PowerShellSyntax.Task.ps1 index 9aae909..f89fd70 100644 --- a/powershell/Test-PowerShellSyntax.Task.ps1 +++ b/powershell/Test-PowerShellSyntax.Task.ps1 @@ -3,7 +3,7 @@ Add-BuildTask Test-PowerShellSyntax @{ if ($Clean) { $BuildRoot # guaranteed to be old } else { - "$script:OutputPath${/}results.sarif" + "$script:ModuleTestResultsRoot/results.sarif" } } Inputs = { @@ -29,7 +29,7 @@ Add-BuildTask Test-PowerShellSyntax @{ $results = $Files | Invoke-ScriptAnalyzer @ScriptAnalyzer if (Get-Module ConvertToSARIF -List) { Write-Verbose "Converting ScriptAnalyzer results to SARIF..." - $results | ConvertToSARIF\ConvertTo-SARIF -FilePath "$script:OutputPath/results.sarif" + $results | ConvertToSARIF\ConvertTo-SARIF -FilePath "$script:ModuleTestResultsRoot/results.sarif" } else { Write-Warning "ConvertToSARIF module not found. Sarif results will not be generated. Please add ConvertToSARIF to your build.requires.psd1 file." } From 3dfa7b12b2e58aef253b3f7cec89b39bfe75910f Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 01:35:54 -0400 Subject: [PATCH 19/43] Normalize Directory Variables and initialization output formatting Co-authored-by: Copilot --- archive/DotNetDockerBuild.Task.ps1 | 4 +- archive/Pack-UniversalPackage.Task.ps1 | 14 +++---- common/Clean-Output.Task.ps1 | 4 +- common/Get-Version.Task.ps1 | 10 ++--- common/Install-PowerShellModule.Task.ps1 | 4 +- common/Push-Docker.Task.ps1 | 8 ++-- common/Test-PowerShell.Task.ps1 | 12 +++--- common/base.ps1 | 46 +++++++++++------------ dotnet/Restore-DotNet.Task.ps1 | 2 +- dotnet/base.ps1 | 14 +++---- helm/Install-Helm.Task.ps1 | 8 ++-- helm/Pack-Helm.Task.ps1 | 2 +- helm/Push-Helm.Task.ps1 | 2 +- helm/Test-Helm.Task.ps1 | 6 +-- helm/base.ps1 | 4 +- powershell/Build-Module.Task.ps1 | 14 +++---- powershell/Publish-Module.Task.ps1 | 6 +-- powershell/Test-PowerShellSyntax.Task.ps1 | 4 +- powershell/base.ps1 | 12 +++--- scripts/Bootstrap.ps1 | 12 ++++++ 20 files changed, 100 insertions(+), 88 deletions(-) diff --git a/archive/DotNetDockerBuild.Task.ps1 b/archive/DotNetDockerBuild.Task.ps1 index 2f72412..b17b9ba 100644 --- a/archive/DotNetDockerBuild.Task.ps1 +++ b/archive/DotNetDockerBuild.Task.ps1 @@ -11,7 +11,7 @@ Add-BuildTask DotNetDockerBuild @{ if ($PublishedDockerFiles) { $PublishedDockerfiles.ForEach({ $Project = $_.DirectoryName - Join-Path $script:OutputPath "docker/$Project-metadata.json" + Join-Path $script:OutputRoot "docker/$Project-metadata.json" }) } else { $BuildRoot @@ -24,7 +24,7 @@ Add-BuildTask DotNetDockerBuild @{ ======== Jobs = "Get-Version", "Publish-DotNet", "Connect-AzACR", { >>>>>>>> c4aeb6a (Move Tasks and Update Documentation (#29)):archive/DotNetDockerBuild.Task.ps1 - $script:DockerMetadataRoot = New-Item (Join-Path $script:OutputPath "docker") -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path + $script:DockerMetadataRoot = New-Item (Join-Path $script:OutputRoot "docker") -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue foreach ($Dockerfile in $PublishedDockerfiles) { diff --git a/archive/Pack-UniversalPackage.Task.ps1 b/archive/Pack-UniversalPackage.Task.ps1 index 567ed59..1afff18 100644 --- a/archive/Pack-UniversalPackage.Task.ps1 +++ b/archive/Pack-UniversalPackage.Task.ps1 @@ -1,23 +1,23 @@ Add-BuildTask Pack-UniversalPackage @{ - Inputs = { + Inputs = { $Projects = $dotnetProjects | ForEach-Object { Join-Path (Split-Path $dotnetSolution) $_ } - + foreach ($Proj in $Projects) { $ProjectName = Split-Path $Proj -LeafBase - + # Check if project is publishable by reading the .csproj file # Directory.Build.props sets IsPublishable=false by default, so only projects # that explicitly set IsPublishable=true should be published $Content = Get-Content $Proj -Raw -ErrorAction SilentlyContinue if ($Content -imatch '\s*true\s*') { - $DllPath = Join-Path $script:OutputPath "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" + $DllPath = Join-Path $script:OutputRoot "bin/$ProjectName/$script:Configuration/$script:TargetFramework/$script:TargetRuntime/$ProjectName.dll" if (Test-Path $DllPath) { $DllPath } } } } - outputs = { - if (($ExistingPack = Get-ChildItem $script:UniversalPackageRoot/*.upack -ErrorAction Ignore) -ne $null) { + outputs = { + if (($ExistingPack = Get-ChildItem $script:UniversalPackageRoot/*.upack -ErrorAction Ignore) -ne $null) { $ExistingPack } else { $BuildRoot @@ -25,7 +25,7 @@ Add-BuildTask Pack-UniversalPackage @{ } # Requires dotnetpublish but future state this task should be able to publish any library (python, npm, whatever). These tasks were just initially written for dotnet projects jobs = 'Publish-DotNet', { - $VersionInfo = Get-Content (Join-Path $script:OutputPath version.json) | ConvertFrom-Json + $VersionInfo = Get-Content (Join-Path $script:OutputRoot version.json) | ConvertFrom-Json $script:UniversalPackageRoot = New-Item $script:UniversalPackageRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path Get-ChildItem $script:DotNetPublishRoot -Directory | ForEach-Object { $Solution = $_ diff --git a/common/Clean-Output.Task.ps1 b/common/Clean-Output.Task.ps1 index 16fdf57..9d05f95 100644 --- a/common/Clean-Output.Task.ps1 +++ b/common/Clean-Output.Task.ps1 @@ -1,4 +1,4 @@ Add-BuildTask Clean-Output { - Remove-BuildItem $OutputPath - New-Item $OutputPath -ItemType Directory -Force | Out-Null + Remove-BuildItem $OutputRoot + New-Item $OutputRoot -ItemType Directory -Force | Out-Null } diff --git a/common/Get-Version.Task.ps1 b/common/Get-Version.Task.ps1 index a9b8396..6299366 100644 --- a/common/Get-Version.Task.ps1 +++ b/common/Get-Version.Task.ps1 @@ -1,4 +1,4 @@ -$script:VersionCacheFile = "$Script:OutputPath/version.json" +$script:VersionCacheFile = "$Script:OutputRoot/version.json" $script:Version = @{} <# NOTE: this version does not include support for multiple versions per-repo #> @@ -7,8 +7,8 @@ Add-BuildTask Get-Version @{ $head = git rev-parse HEAD # If there's an existing GitVersion in output, load it if (${script:Version}.Sha -ne $head) { - ${script:Version} = if (Test-Path $Script:OutputPath/version.json) { - Get-Content $Script:OutputPath/version.json | ConvertFrom-Json + ${script:Version} = if (Test-Path $Script:OutputRoot/version.json) { + Get-Content $Script:OutputRoot/version.json | ConvertFrom-Json } } # Skip if ${script:Version} is set correctly for this commit... @@ -21,7 +21,7 @@ Add-BuildTask Get-Version @{ | Select-Object -First 1 # agent temp SHOULD be cleaned after each pipeline job - $VersionCacheFile = "$TempDirectory/version.json" + $VersionCacheFile = "$TempRoot/version.json" # Delete the VersionCache so that importing it will fail if gitversion fails if (Test-Path $VersionCacheFile) { @@ -53,7 +53,7 @@ Add-BuildTask Get-Version @{ Write-Build Gray "Version Tag: $(($script:Version).Tag)" # Cache the final object in output so we can skip rerunning - ${script:Version} | ConvertTo-Json -Compress | Out-File $Script:OutputPath/version.json + ${script:Version} | ConvertTo-Json -Compress | Out-File $Script:OutputRoot/version.json if ($Script:BuildSystem -ieq "AzureDevOps") { # Replace "$(Gitversion.*)" tokens in the BuildNumber (AKA name) diff --git a/common/Install-PowerShellModule.Task.ps1 b/common/Install-PowerShellModule.Task.ps1 index b0b2a62..7cbe4e9 100644 --- a/common/Install-PowerShellModule.Task.ps1 +++ b/common/Install-PowerShellModule.Task.ps1 @@ -1,7 +1,7 @@ Add-BuildTask Install-PowerShellModule @{ If = { Test-Path $BuildRoot/*.requires.psd1 } Inputs = { Get-Item "$BuildRoot/*.requires.psd1" } - Outputs = { process { Join-Path -Path $OutputPath -ChildPath $_.Name } } + Outputs = { process { Join-Path -Path $OutputRoot -ChildPath $_.Name } } Jobs = (Get-Command "$PSScriptRoot/../scripts/Install-PowerShellModule.ps1").ScriptBlock, - { Copy-Item "$BuildRoot/*.requires.psd1" -Destination "$OutputPath/" } + { Copy-Item "$BuildRoot/*.requires.psd1" -Destination "$OutputRoot/" } } diff --git a/common/Push-Docker.Task.ps1 b/common/Push-Docker.Task.ps1 index 8b782c6..d16b44d 100644 --- a/common/Push-Docker.Task.ps1 +++ b/common/Push-Docker.Task.ps1 @@ -1,7 +1,7 @@ Add-BuildTask Push-Docker @{ Inputs = { # Docker metadata files created by DockerBuild task - $MetadataFiles = Get-ChildItem (Join-Path $script:OutputPath "docker") -Filter "*-metadata.json" -ErrorAction SilentlyContinue + $MetadataFiles = Get-ChildItem (Join-Path $script:OutputRoot "docker") -Filter "*-metadata.json" -ErrorAction SilentlyContinue if ($MetadataFiles) { $MetadataFiles.FullName } @@ -11,11 +11,11 @@ Add-BuildTask Push-Docker @{ } Outputs = { # Create a marker file for each pushed image - $MetadataFiles = Get-ChildItem (Join-Path $script:OutputPath "docker") -Filter "*-metadata.json" -ErrorAction SilentlyContinue + $MetadataFiles = Get-ChildItem (Join-Path $script:OutputRoot "docker") -Filter "*-metadata.json" -ErrorAction SilentlyContinue if ($MetadataFiles) { $MetadataFiles.ForEach({ $ProjectName = $_.BaseName -replace '-metadata$', '' - Join-Path $script:OutputPath "docker/$ProjectName-pushed.txt" + Join-Path $script:OutputRoot "docker/$ProjectName-pushed.txt" }) } else { @@ -24,7 +24,7 @@ Add-BuildTask Push-Docker @{ } Jobs = "Connect-AzACR", { if ($script:PushEnabled) { - $script:DockerMetadataRoot = Join-Path $script:OutputPath "docker" + $script:DockerMetadataRoot = Join-Path $script:OutputRoot "docker" $MetadataFiles = Get-ChildItem $script:DockerMetadataRoot -Filter "*-metadata.json" -ErrorAction SilentlyContinue diff --git a/common/Test-PowerShell.Task.ps1 b/common/Test-PowerShell.Task.ps1 index e54ed92..5e929b3 100644 --- a/common/Test-PowerShell.Task.ps1 +++ b/common/Test-PowerShell.Task.ps1 @@ -2,7 +2,7 @@ #requires -Module @{ ModuleName = "Pester"; ModuleVersion = "5.6.0" } Add-BuildTask Test-PowerShell @{ Inputs = { - Get-ChildItem $ModuleOutputPath -Recurse -File + Get-ChildItem $ModuleOutputRoot -Recurse -File $Tests = Join-Path $BuildRoot [Tt]ests | Resolve-Path Get-ChildItem $Tests -Recurse -File -Filter *.tests.ps1 } @@ -18,11 +18,11 @@ Add-BuildTask Test-PowerShell @{ }, { # For PowerShell Modules with classes to work in tests: - # 1. The $OutputPath directory must be first on Env:PSModulePath - # 2. The $ModuleName directory must be in $OutputPath directory + # 1. The $OutputRoot directory must be first on Env:PSModulePath + # 2. The $ModuleName directory must be in $OutputRoot directory # 3. The $ModuleName.psd1 file must be in the $ModuleName directory if (Test-Path $script:ManifestPath) { - $Env:PSModulePath = @($script:OutputPath) + @($Env:PSModulePath -split [IO.Path]::PathSeparator -ne $script:OutputPath) -join ([IO.Path]::PathSeparator) + $Env:PSModulePath = @($script:OutputRoot) + @($Env:PSModulePath -split [IO.Path]::PathSeparator -ne $script:OutputRoot) -join ([IO.Path]::PathSeparator) Write-Output (@( "Set PSModulePath:" $Env:PSModulePath @@ -44,7 +44,7 @@ Add-BuildTask Test-PowerShell @{ Filter = $PesterFilter TestResult = @{ Enabled = $true - OutputPath = Join-Path $ModuleTestResultsRoot "results.xml" + OutputRoot = Join-Path $ModuleTestResultsRoot "results.xml" } Debug = @{ ShowNavigationMarkers = $Host.Name -match "Visual Studio Code" @@ -56,7 +56,7 @@ Add-BuildTask Test-PowerShell @{ } CodeCoverage = @{ Enabled = !$SkipCoverage - Path = Get-Item $ModuleOutputPath\*.psm1, $ModuleOutputPath\*.ps1 + Path = Get-Item $ModuleOutputRoot\*.psm1, $ModuleOutputRoot\*.ps1 OutputPath = Join-Path $ModuleTestResultsRoot "coverage.xml" CoveragePercentTarget = $CodeCoveragePercentTarget * 100 UseBreakpoints = $false diff --git a/common/base.ps1 b/common/base.ps1 index 9f0680e..f25d5b1 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -74,10 +74,10 @@ $script:BuildSystem = if (Test-Path Env:HARNESS_STAGE_ID) { "None" } -Write-Information "$($PSStyle.Foreground.BrightBlue) BuildSystem [$BuildSystem]$($PSStyle.Reset)" -Write-Information "$($PSStyle.Foreground.BrightBlue) Information [$InformationPreference]$($PSStyle.Reset)" -Write-Information "$($PSStyle.Foreground.BrightBlue) Verbose [$VerbosePreference]$($PSStyle.Reset)" -Write-Information "$($PSStyle.Foreground.BrightBlue) Debug [$DebugPreference]$($PSStyle.Reset)" +Write-Information "$($PSStyle.Foreground.BrightBlue) BuildSystem: $BuildSystem$($PSStyle.Reset)" +Write-Information "$($PSStyle.Foreground.BrightBlue) Information: $InformationPreference$($PSStyle.Reset)" +Write-Information "$($PSStyle.Foreground.BrightBlue) Verbose: $VerbosePreference$($PSStyle.Reset)" +Write-Information "$($PSStyle.Foreground.BrightBlue) Debug: $DebugPreference$($PSStyle.Reset)" # A little extra BuildEnvironment magic Set-BuildHeader { Write-Build 11 "Start Task: $($args[0])" } @@ -88,7 +88,7 @@ Set-BuildFooter { Write-Build 11 "Finish Task: $($args[0]) $($Task.Elapsed) [Tot ${script:/} = [IO.Path]::DirectorySeparatorChar # BuildRoot is provided by Invoke-Build -Write-Information "$($PSStyle.Foreground.BrightBlue) BuildRoot [$BuildRoot]$($PSStyle.Reset)" +Write-Information "$($PSStyle.Foreground.BrightBlue) BuildRoot: $BuildRoot$($PSStyle.Reset)" # Enter-Build runs only when actually building (not during ??, ?, or WhatIf). # Each script in the Extends tree gets its own Enter-Build invoked with its $BuildRoot. @@ -109,17 +109,17 @@ Enter-Build { [string]$script:PipelineId = $script:PipelineId ?? $Env:PIPELINE_ID ?? $Env:HARNESS_PIPELINE_ID ?? $Env:PLUGIN_PIPELINE ?? "local build" [string]$script:PipelineExecutionId = $script:PipelineExecutionId ?? $Env:PIPELINE_EXECUTION_ID ?? $Env:BUILD_ID ?? $Env:HARNESS_EXECUTION_ID ?? $Env:HARNESS_BUILD_ID ?? $Env:DRONE_BUILD_NUMBER ?? "0" - Write-Information "$($PSStyle.Foreground.BrightBlue) BranchName [$BranchName]$($PSStyle.Reset)" - Write-Information "$($PSStyle.Foreground.BrightBlue) IsPullRequest [$IsPullRequest]$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) BranchName: $BranchName$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) IsPullRequest: $IsPullRequest$($PSStyle.Reset)" if ($IsPullRequest) { - Write-Information "$($PSStyle.Foreground.BrightBlue) PullRequestId [$PullRequestId]$($PSStyle.Reset)" - Write-Information "$($PSStyle.Foreground.BrightBlue) SourceBranch [$SourceBranch]$($PSStyle.Reset)" - Write-Information "$($PSStyle.Foreground.BrightBlue) TargetBranch [$TargetBranch]$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) PullRequestId: $PullRequestId$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) SourceBranch: $SourceBranch$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) TargetBranch: $TargetBranch$($PSStyle.Reset)" } if ($BuildSystem -ne "None") { - Write-Information "$($PSStyle.Foreground.BrightBlue) ProductName [$ProductName]$($PSStyle.Reset)" - Write-Information "$($PSStyle.Foreground.BrightBlue) PipelineId [$PipelineId]$($PSStyle.Reset)" - Write-Information "$($PSStyle.Foreground.BrightBlue) PipelineExecutionId [$PipelineExecutionId]$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) ProductName: $ProductName$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) PipelineId: $PipelineId$($PSStyle.Reset)" + Write-Information "$($PSStyle.Foreground.BrightBlue) PipelineExecutionId: $PipelineExecutionId$($PSStyle.Reset)" } #> @@ -136,20 +136,20 @@ Enter-Build { # Build-system information. There are a few different sources for the information # But each variable should have a default here: - $Script:OutputPath = $Env:BUILD_BINARIESDIRECTORY ?? - $Env:IB_OUTPUT_PATH ?? + $Script:OutputRoot = $Env:BUILD_BINARIESDIRECTORY ?? + $Env:IB_OUTPUT_ROOT ?? (Join-Path $BuildRoot 'Output') - New-Item -Type Directory -Path $OutputPath -Force | Out-Null + New-Item -Type Directory -Path $OutputRoot -Force | Out-Null $Script:TestResultsRoot = $script:TestResultsRoot ?? # An override for build script parameters - $Env:IB_TEST_ROOT ?? # An override for machine-level settings + $Env:IB_TEST_RESULTS_ROOT ?? # An override for machine-level settings $Env:TEST_RESULTS_DIRECTORY ?? - (Join-Path $OutputPath testresults) + (Join-Path $OutputRoot testresults) - $Script:TempDirectory = @(Get-Content Env:IB_TEMP_DIRECTORY, Env:AGENT_TEMPDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | + $Script:TempRoot = @(Get-Content Env:IB_TEMP_ROOT, Env:AGENT_TEMPDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $Script:TempDirectory) { $Script:TempDirectory = if ($IsLinux) { "/tmp" } else { [System.IO.Path]::GetTempPath() } } + if (-not $Script:TempRoot) { $Script:TempRoot = if ($IsLinux) { "/tmp" } else { [System.IO.Path]::GetTempPath() } } # If you need to install additional tools, we use Install-GitHubRelease # Set the Tools hashtable to @{ exe = "org", "project" } @@ -160,16 +160,16 @@ Enter-Build { # } [hashtable]$Script:GHTools = @{} + ($Script:GHTools ?? @{}) - $script:UniversalPackageRoot ??= Join-Path $script:OutputPath universal + $script:UniversalPackageRoot ??= Join-Path $script:OutputRoot universal # Allow a -Clean switch to add the "Clean-Output" task on the front if ($Clean -and -not ($BuildTask -eq "Clean-Output")) { $BuildTask = @("Clean-Output") + $BuildTask } - Write-Build Cyan " Output [$OutputPath]" + Write-Build Cyan " OutputRoot: $OutputRoot" Write-Build Cyan " TestResultsRoot: $TestResultsRoot" - Write-Build Cyan " TempDirectory: $TempDirectory" + Write-Build Cyan " TempRoot: $TempRoot" Write-Build Cyan " UniversalPackageRoot: $UniversalPackageRoot" # If we're skipping coverage, make sure there are no demands on passing diff --git a/dotnet/Restore-DotNet.Task.ps1 b/dotnet/Restore-DotNet.Task.ps1 index 6ec1aff..f46450a 100644 --- a/dotnet/Restore-DotNet.Task.ps1 +++ b/dotnet/Restore-DotNet.Task.ps1 @@ -15,7 +15,7 @@ Add-BuildTask Restore-DotNet @{ # } # Outputs = { # # Return corresponding project.assets.json files - # $Project.BaseIntermediateOutputPath | Join-Path -ChildPath "project.assets.json" + # $Project.BaseIntermediateOutputRoot | Join-Path -ChildPath "project.assets.json" # } Jobs = "Install-DotNetTool", { $local:options = @{} + $script:dotnetOptions diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index c27fcde..aa2b9f0 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -10,7 +10,7 @@ param( $Extends, # dotnet build configuration parameter (Debug or Release) [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Release', + [string]$Configuration = ($Env:IB_CONFIGURATION ?? 'Release'), # Solution to build -- accepts a name, a glob pattern, or a path (relative or full) to a .sln file. [ArgumentCompleter({ @@ -81,11 +81,11 @@ Enter-Build { } $script:SolutionName = Split-Path $script:DotNetSolutionFile -LeafBase # This is used in Directory.build.props to configure the root output directory for dotnet - $Env:IB_OUTPUT_ROOT ??= $script:OutputPath + $Env:IB_OUTPUT_ROOT ??= $script:OutputRoot - $script:DotNetPublishRoot ??= Join-Path $script:OutputPath publish - $script:DotNetPackRoot ??= Join-Path $script:OutputPath nuget - $script:SolutionOutputPath ??= Join-Path $script:OutputPath $script:SolutionName + $script:DotNetPublishRoot ??= Join-Path $script:OutputRoot publish + $script:DotNetPackRoot ??= Join-Path $script:OutputRoot nuget + $script:SolutionOutputRoot ??= Join-Path $script:OutputRoot $script:SolutionName $script:SolutionTestResultsRoot = Join-Path $Script:TestResultsRoot $script:SolutionName $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) @@ -104,7 +104,7 @@ Enter-Build { PSTypeName = "DotNet.Project" Path = $_ # The rest of these properties MUST BE populated by getProperty in the Restore task - BaseIntermediateOutputPath = Join-Path $script:SolutionOutputPath "obj/$BaseName" + BaseIntermediateOutputRoot = Join-Path $script:SolutionOutputRoot "obj/$BaseName" AssemblyName = $BaseName IsPackable = [Nullable[bool]]$null IsPublishable = [Nullable[bool]]$null @@ -130,7 +130,7 @@ Enter-Build { Write-Build Cyan " TargetFramework: $script:TargetFramework" Write-Build Cyan " TargetRuntime: $script:TargetRuntime" Write-Build Cyan " DotNetSolutionFile: $script:DotNetSolutionFile" - Write-Build Cyan " SolutionOutputPath: $script:SolutionOutputPath" + Write-Build Cyan " SolutionOutputRoot: $script:SolutionOutputRoot" Write-Build Cyan " DotNetPublishRoot: $DotNetPublishRoot" Write-Build Cyan " DotNetPackRoot: $DotNetPackRoot" Write-Build Cyan " SolutionTestResultsRoot: $SolutionTestResultsRoot" diff --git a/helm/Install-Helm.Task.ps1 b/helm/Install-Helm.Task.ps1 index 3c61444..5bb1e71 100644 --- a/helm/Install-Helm.Task.ps1 +++ b/helm/Install-Helm.Task.ps1 @@ -7,12 +7,12 @@ Add-BuildTask Install-Helm @{ if ($IsLinux) { $HelmVersion = "v4.1.0" Write-Build Yellow "Invoke-WebRequest -Uri https://get.helm.sh/helm-${HelmVersion}-linux-amd64.tar.gz -OutFile $TarFile" - $TarFile = Join-Path $script:TempDirectory "helm.tar.gz" + $TarFile = Join-Path $script:TempRoot "helm.tar.gz" Invoke-WebRequest -Uri "https://get.helm.sh/helm-${HelmVersion}-linux-amd64.tar.gz" -OutFile $TarFile - tar -zxvf $TarFile -C $script:TempDirectory - Move-Item (Join-Path $script:TempDirectory "linux-amd64/helm") "/usr/local/bin/helm" -Force + tar -zxvf $TarFile -C $script:TempRoot + Move-Item (Join-Path $script:TempRoot "linux-amd64/helm") "/usr/local/bin/helm" -Force Remove-Item $TarFile -Force -ErrorAction SilentlyContinue - Remove-Item (Join-Path $script:TempDirectory "linux-amd64") -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $script:TempRoot "linux-amd64") -Recurse -Force -ErrorAction SilentlyContinue } else { throw "Helm is not installed. Please install Helm: https://helm.sh/docs/intro/install/" } diff --git a/helm/Pack-Helm.Task.ps1 b/helm/Pack-Helm.Task.ps1 index b845fa7..03a8a61 100644 --- a/helm/Pack-Helm.Task.ps1 +++ b/helm/Pack-Helm.Task.ps1 @@ -12,7 +12,7 @@ Add-BuildTask Package-Helm @{ } Jobs = "Get-Version", "Test-Helm", { foreach ($Chart in $script:HelmCharts) { - $Destination = Join-Path $script:helmOutputPath $Chart.Name + $Destination = Join-Path $script:helmOutputRoot $Chart.Name New-Item $Destination -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null $options = @( "--destination", $Destination, diff --git a/helm/Push-Helm.Task.ps1 b/helm/Push-Helm.Task.ps1 index c61ccc6..c9cfbce 100644 --- a/helm/Push-Helm.Task.ps1 +++ b/helm/Push-Helm.Task.ps1 @@ -3,7 +3,7 @@ Add-BuildTask Push-Helm @{ if ($script:PushEnabled) { foreach ($Chart in $script:HelmCharts) { # If this sort turns out to not be enough, we need to split the name and cast to [semver] to sort - $ChartToPush = Get-ChildItem (Join-Path $script:helmOutputPath $Chart.Name) -Filter *.tgz | Sort-Object LastWriteTime | Select-Object -Last 1 + $ChartToPush = Get-ChildItem (Join-Path $script:helmOutputRoot $Chart.Name) -Filter *.tgz | Sort-Object LastWriteTime | Select-Object -Last 1 Write-Build Yellow "helm push $($ChartToPush.FullName) oci://$($script:ACRName).azurecr.io/helm" Invoke-Native { helm push $ChartToPush.FullName "oci://$($script:ACRName).azurecr.io/helm" } -ExceptionalExit } diff --git a/helm/Test-Helm.Task.ps1 b/helm/Test-Helm.Task.ps1 index 43c8424..a2b2570 100644 --- a/helm/Test-Helm.Task.ps1 +++ b/helm/Test-Helm.Task.ps1 @@ -2,17 +2,17 @@ Add-BuildTask Test-Helm @{ Inputs = { Get-ChildItem $script:HelmCharts -File -Recurse } Outputs = { foreach ($chart in $script:HelmCharts) { - Join-Path $script:helmOutputPath "$($Chart.Name)-compiled.yaml" + Join-Path $script:helmOutputRoot "$($Chart.Name)-compiled.yaml" } } Jobs = "Build-Helm", { # helm lint requires the chart directory, not the chart.yaml file Set-Location $script:HelmChartRoot - New-Item $script:helmOutputPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + New-Item $script:helmOutputRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null # each $chart is a directory object foreach ($chart in $script:HelmCharts) { $TestValues = Join-Path $chart values.yaml - $CompiledOutput = Join-Path $script:helmOutputPath "$($Chart.Name)-compiled.yaml" + $CompiledOutput = Join-Path $script:helmOutputRoot "$($Chart.Name)-compiled.yaml" Write-Build Yellow "helm lint $($Chart.FullName) --values $TestValues" Invoke-Native { helm lint $chart.FullName --values $TestValues } -ExceptionalExit diff --git a/helm/base.ps1 b/helm/base.ps1 index 46e6f08..230edc8 100644 --- a/helm/base.ps1 +++ b/helm/base.ps1 @@ -29,7 +29,7 @@ Enter-Build { if ($script:HelmChartRoot) { $script:ACRName = $script:ACRName ?? $ENV:ACR_URI ?? "crazusw2dvosl1" - $script:helmOutputPath = Join-Path $Script:OutputPath "charts" + $script:helmOutputRoot = Join-Path $Script:OutputRoot "charts" $script:ChartName ??= Get-ChildItem -Path $script:HelmChartRoot -File -Filter Chart.yaml -Recurse -Depth 1 | ForEach-Object { $_.Directory.Name } $script:HelmCharts = $script:ChartName | Join-Path -Path $script:HelmChartRoot -ChildPath { $_ } | Get-Item $script:GHTools.add("kubeconform", "https://github.com/yannh/kubeconform/releases/tag/v0.7.0") @@ -37,7 +37,7 @@ Enter-Build { Write-Build Cyan "Initializing Helm task variables (HelmChartRoot: $script:HelmChartRoot)" Write-Build Cyan " HelmChartRoot: $script:HelmChartRoot" Write-Build Cyan " ACRName: $script:ACRName" - Write-Build Cyan " helmOutputPath: $script:helmOutputPath" + Write-Build Cyan " helmOutputRoot: $script:helmOutputRoot" Write-Build Cyan " HelmCharts: $(($script:HelmCharts).Count)" } } diff --git a/powershell/Build-Module.Task.ps1 b/powershell/Build-Module.Task.ps1 index 6d4c7b1..6ee1c81 100644 --- a/powershell/Build-Module.Task.ps1 +++ b/powershell/Build-Module.Task.ps1 @@ -3,25 +3,25 @@ Add-BuildTask Build-Module @{ @( Get-ChildItem -Path $BuildRoot -Recurse -Filter *.ps* Get-ChildItem -Path $BuildRoot -Recurse -Filter *.cs | Where-Object FullName -NotLike "*/obj/*" - ) | Where-Object FullName -NotLike (Join-Path $script:OutputPath /*) + ) | Where-Object FullName -NotLike (Join-Path $script:OutputRoot /*) } # don't take off the script block, need to resolve AFTER init Outputs = { $InputObject = $_ switch -regex ("$InputObject") { "ps1$" { - $script:ModuleOutputPath + $script:ModuleOutputRoot } "cs$" { - if ($out -and ($Assemblies = Get-ChildItem -Path $script:OutputPath -Recurse -Filter *.dll -ErrorAction Ignore)) { + if ($out -and ($Assemblies = Get-ChildItem -Path $script:OutputRoot -Recurse -Filter *.dll -ErrorAction Ignore)) { $Assemblies } else { - Join-Path $script:ModuleOutputPath lib + Join-Path $script:ModuleOutputRoot lib } } default { # .psd1, .psm1, .pssc etc — use the output directory as the comparison target - $script:OutputPath + $script:OutputRoot } } } @@ -42,7 +42,7 @@ Add-BuildTask Build-Module @{ }) } - $Module = Build-Module -Output $script:OutputPath -UnversionedOutputDirectory @version -Passthru -Verbose:($VerbosePreference -eq "Continue") + $Module = Build-Module -Output $script:OutputRoot -UnversionedOutputDirectory @version -Passthru -Verbose:($VerbosePreference -eq "Continue") # If there's output from a DotNetPublish task, copy it into a "lib" folder in the module output if ($DotNetPublishRoot -and (Test-Path $DotNetPublishRoot)) { @@ -57,6 +57,6 @@ Add-BuildTask Build-Module @{ $script:ModuleName = $Module.Name $script:ManifestPath = $Module.Path - $script:ModuleOutputPath = Split-Path $Module.Path + $script:ModuleOutputRoot = Split-Path $Module.Path } } diff --git a/powershell/Publish-Module.Task.ps1 b/powershell/Publish-Module.Task.ps1 index 8f86ece..535f0cf 100644 --- a/powershell/Publish-Module.Task.ps1 +++ b/powershell/Publish-Module.Task.ps1 @@ -4,7 +4,7 @@ Add-BuildTask Publish-Module { -not [string]::IsNullOrWhiteSpace($Script:PowerShellModulePublishKey)) { $publishModuleSplat = @{ - Path = $Script:ModuleOutputPath + Path = $Script:ModuleOutputRoot NuGetApiKey = $Script:PowerShellModulePublishKey Verbose = $true Force = $true @@ -12,10 +12,10 @@ Add-BuildTask Publish-Module { ErrorAction = 'Stop' } "Files in module output:" - Get-ChildItem $Script:ModuleOutputPath -Recurse -File | + Get-ChildItem $Script:ModuleOutputRoot -Recurse -File | Select-Object -Expand FullName - "Publishing [$Script:ModuleOutputPath] to [$Script:PSRepository]" + "Publishing [$Script:ModuleOutputRoot] to [$Script:PSRepository]" Publish-Module @publishModuleSplat } else { diff --git a/powershell/Test-PowerShellSyntax.Task.ps1 b/powershell/Test-PowerShellSyntax.Task.ps1 index f89fd70..4c9de41 100644 --- a/powershell/Test-PowerShellSyntax.Task.ps1 +++ b/powershell/Test-PowerShellSyntax.Task.ps1 @@ -8,7 +8,7 @@ Add-BuildTask Test-PowerShellSyntax @{ } Inputs = { # Build Output - Get-ChildItem $ModuleOutputPath -Recurse -File + Get-ChildItem $ModuleOutputRoot -Recurse -File # Test Source $Tests = Join-Path $BuildRoot [Tt]ests | Resolve-Path Get-ChildItem $Tests -Recurse -File -Filter *.tests.ps1 @@ -23,7 +23,7 @@ Add-BuildTask Test-PowerShellSyntax @{ "$BuildTasksRoot/PSScriptAnalyzerSettings.psd1" ) | Select-Object -First 1 -ExpandProperty FullName } - $Files = Get-ChildItem $ModuleOutputPath -Recurse -File -Filter *.ps*1 + $Files = Get-ChildItem $ModuleOutputRoot -Recurse -File -Filter *.ps*1 "Analyzing $($Files -join "`n ")" $results = $Files | Invoke-ScriptAnalyzer @ScriptAnalyzer diff --git a/powershell/base.ps1 b/powershell/base.ps1 index 3b20ddc..ad52dd2 100644 --- a/powershell/base.ps1 +++ b/powershell/base.ps1 @@ -12,7 +12,7 @@ param( $Extends, # Name of the PowerShell module (defaults to the directory/project name) - [string]$ModuleName, + [string]$ModuleName = $Env:IB_MODULE_NAME, # Name of the PSRepository to publish to [string]$PSRepository = "DevOpsPowerShell", @@ -58,18 +58,18 @@ Enter-Build { $script:ModuleName = Split-Path $BuildRoot -Leaf } - $script:ModuleOutputPath = Join-Path $script:OutputPath $script:ModuleName - $script:ManifestPath = Join-Path $script:ModuleOutputPath "$script:ModuleName.psd1" + $script:ModuleOutputRoot = Join-Path $script:OutputRoot $script:ModuleName + $script:ManifestPath = Join-Path $script:ModuleOutputRoot "$script:ModuleName.psd1" $script:ModuleTestResultsRoot = Join-Path $Script:TestResultsRoot $script:ModuleName New-Item -Type Directory -Path $script:ModuleTestResultsRoot -Force | Out-Null - Write-Build Cyan " ModuleName [$script:ModuleName]" - Write-Build Cyan " ModuleOutputPath [$script:ModuleOutputPath]" + Write-Build Cyan " ModuleName: $script:ModuleName" + Write-Build Cyan " ModuleOutputRoot: $script:ModuleOutputRoot" $script:SourcePath ??= (Join-Path $BuildRoot src), (Join-Path $BuildRoot source), (Join-Path $BuildRoot $script:ModuleName) | Convert-Path -ErrorAction Ignore | Select-Object -First 1 - Write-Build Cyan " PSRepository [$script:PSRepository]" + Write-Build Cyan " PSRepository: $script:PSRepository" # Register PSRepository if a publish URI is provided and it isn't already registered correctly if ($script:PowerShellModulePublishUri -and $script:PSRepository) { diff --git a/scripts/Bootstrap.ps1 b/scripts/Bootstrap.ps1 index 2766585..687b5a6 100644 --- a/scripts/Bootstrap.ps1 +++ b/scripts/Bootstrap.ps1 @@ -17,6 +17,18 @@ param( ) Push-Location -StackName BootStrap +$script:ErrorView = "DetailedView" +$script:InformationPreference = "Continue" +$script:ErrorActionPreference = "Stop" + +# Force distinct colors for Verbose and Debug +if ($PSStyle.Formatting.Verbose -eq $PSStyle.Formatting.Warning) { + $PSStyle.Formatting.Verbose = $PSStyle.Foreground.BrightCyan +} +if ($PSStyle.Formatting.Debug -eq $PSStyle.Formatting.Warning) { + $PSStyle.Formatting.Debug = $PSStyle.Foreground.BrightGreen +} + & "$PSScriptRoot/Install-PowerShellModule.ps1" $Path Pop-Location -StackName BootStrap From f0883ba7bd9a703f77b12f630249ba0a48614f4d Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 01:50:13 -0400 Subject: [PATCH 20/43] Handle Earthly in Test-PowerShell Co-authored-by: Copilot --- common/Test-PowerShell.Task.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/Test-PowerShell.Task.ps1 b/common/Test-PowerShell.Task.ps1 index 5e929b3..91c0194 100644 --- a/common/Test-PowerShell.Task.ps1 +++ b/common/Test-PowerShell.Task.ps1 @@ -52,7 +52,7 @@ Add-BuildTask Test-PowerShell @{ Output = @{ Verbosity = if ($VerbosePreference -eq "Continue") { "Detailed" } else { "Normal" } RenderMode = "Ansi" - CIFormat = $BuildSystem + CIFormat = $BuildSystem -ne "Earthly" ? $BuildSystem : "Auto" } CodeCoverage = @{ Enabled = !$SkipCoverage From 2186cb37318912875f3279906d7d99b10b75714d Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 13:37:20 -0400 Subject: [PATCH 21/43] Add the Invoke-Build script to the repo so it's not an external dependency --- scripts/Invoke-Build.ps1 | 915 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 915 insertions(+) create mode 100644 scripts/Invoke-Build.ps1 diff --git a/scripts/Invoke-Build.ps1 b/scripts/Invoke-Build.ps1 new file mode 100644 index 0000000..37028d0 --- /dev/null +++ b/scripts/Invoke-Build.ps1 @@ -0,0 +1,915 @@ +<# +Copyright (c) Roman Kuzmin + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +#> + +#.ExternalHelp Help.xml +param( + [Parameter(Position=0)][string[]]$Task, + [Parameter(Position=1)]$File, + $Result, + [switch]$Safe, + [switch]$Summary, + [switch]$WhatIf +) + +dynamicparam { +trap {*Die $_ 5} +function *Die($M, $C=0) {$PSCmdlet.ThrowTerminatingError((New-Object System.Management.Automation.ErrorRecord ([Exception]"$M"), $null, $C, $null))} + +Set-Alias assert Assert-Build +Set-Alias equals Assert-BuildEquals +Set-Alias exec Invoke-BuildExec +Set-Alias print Write-Build +Set-Alias property Get-BuildProperty +Set-Alias remove Remove-BuildItem +Set-Alias requires Test-BuildAsset +Set-Alias task Add-BuildTask +Set-Alias use Use-BuildAlias +Set-Alias Invoke-Build ([System.IO.Path]::Combine($PSScriptRoot, 'Invoke-Build.ps1')) +Set-Alias Build-Parallel ([System.IO.Path]::Combine($PSScriptRoot, 'Build-Parallel.ps1')) +Set-Alias Resolve-MSBuild ([System.IO.Path]::Combine($PSScriptRoot, 'Resolve-MSBuild.ps1')) + +#.ExternalHelp Help.xml +function Add-BuildTask( + [Parameter(Position=0, Mandatory=1)][string]$Name, + [Parameter(Position=1)]$Jobs, + [string[]]$After, + [string[]]$Before, + $If=-9, + $Inputs, + $Outputs, + $Data, + $Done, + $Source=$MyInvocation, + [switch]$Partial +) +{ + trap {*Die "Task '$Name': $_" 5} + if (${*}.A -eq 0) {throw 'Cannot add tasks.'} + if ($Jobs -is [hashtable]) { + if ($PSBoundParameters.get_Count() -ne 2) {throw 'Invalid parameters.'} + Add-BuildTask $Name @Jobs -Source:$Source + return + } + if ($Name[0] -eq '?') {throw 'Invalid task name.'} + $B1 = ${*}.B1 + if ($PX = $B1.PX) { + filter PX { + $r = $_.TrimStart('?') + if (!$r.Contains('::') -and $r -ne '.' -and !${*}.All[$r]) {$r = $PX+$r} + if ($_[0] -eq '?') {"?$r"} else {$r} + } + $Name = $Name | PX + if ($After) {$After = $After | PX} + if ($Before) {$Before = $Before | PX} + } + if ($_ = ${*}.All[$Name]) { + ${*}.Redefined += $_ + ${*}.All.Remove($Name) + } + ${*}.All[$Name] = [PSCustomObject]@{ + Name = $Name + Error = $null + Started = $null + Elapsed = $null + Jobs = $1 = [System.Collections.Generic.List[object]]@() + After = $After + Before = $Before + If = $If + Inputs = $Inputs + Outputs = $Outputs + Data = $Data + Done = $Done + Partial = $Partial + InvocationInfo = $Source + B1 = $B1 + } + if ($Jobs) {$2 = @(); foreach($j in $Jobs) { + $r, $s = *Job $j + if ($r -is [string]) { + if ($PX) {$r = $r | PX} + if ($r -in $2) {${*}.Doubles += ,($Name, $r)} else {$2 += $r} + if ($s) {$r = "?$r"} + } + $1.Add($r) + }} +} + +#.ExternalHelp Help.xml +function Assert-Build([Parameter()]$Condition, [string]$Message) { + if (!$Condition) { + *Die "Assertion failed.$(if ($Message) {" $Message"})" 7 + } +} + +#.ExternalHelp Help.xml +function Assert-BuildEquals([Parameter()]$A, $B) { + if (![Object]::Equals($A, $B)) { + *Die @" +Objects are not equal: +A:$(if ($null -ne $A) {" $A [$($A.GetType())]"}) +B:$(if ($null -ne $B) {" $B [$($B.GetType())]"}) +"@ 7 + } +} + +#.ExternalHelp Help.xml +function Confirm-Build([Parameter()][string]$Query, [string]$Caption=$Task.Name) { + $PSCmdlet.ShouldContinue($Query, $Caption) +} + +#.ExternalHelp Help.xml +function Get-BuildError([Parameter(Mandatory=1)][string]$Task) { + if (!($_ = ${*}.All[$Task])) { + *Die "Missing task '$Task'." 5 + } + $_.Error +} + +#.ExternalHelp Help.xml +function Get-BuildFile($Path, [switch]$Here) { + do { + if (($f = [System.IO.Directory]::GetFiles($Path, '*.build.ps1')).Length -eq 1) {return $f} + if ($f) {return $($f | Sort-Object)[0]} + if (($c = $env:InvokeBuildGetFile) -and ($f = & $c $Path)) {return $f} + } while(!$Here -and ($Path = Split-Path $Path)) +} + +#.ExternalHelp Help.xml +function Get-BuildProperty([Parameter(Mandatory=1)][string]$Name, $Value, [switch]$Boolean) { + ${*n} = $Name + ${*v} = $Value + Remove-Variable Name, Value + $_ = if (($null -ne ($_ = $PSCmdlet.GetVariableValue(${*n})) -and '' -ne $_) -or ($_ = [Environment]::GetEnvironmentVariable(${*n}))) {$_} + elseif ($null -eq ${*v}) {*Die "Missing property '${*n}'." 13} + else {${*v}} + if ($Boolean) {if (1 -eq $_) {$true} elseif (0 -eq $_) {$false} else {[System.Convert]::ToBoolean($_)}} else {$_} +} + +#.ExternalHelp Help.xml +function Get-BuildSynopsis([Parameter(Mandatory=1)]$Task, $Hash=${*}.H) { + $f = ($I = $Task.InvocationInfo).ScriptName + if (!($d = $Hash[$f])) { + $Hash[$f] = $d = @{T = Get-Content -LiteralPath $f; C = @{}} + foreach($_ in [System.Management.Automation.PSParser]::Tokenize($d.T, [ref]$null)) { + if ($_.Type -eq 15) {$d.C[$_.EndLine] = $_.Content} + } + } + for($n = $I.ScriptLineNumber; --$n -ge 1) { + if ($c = $d.C[$n]) {if ($c -match '(?m)^\s*(?:#*\s*Synopsis\s*:|\.Synopsis\s*^)(.*)') {return $Matches[1].Trim()}} + elseif ($d.T[$n - 1].Trim()) {break} + } +} + +#.ExternalHelp Help.xml +function Get-BuildVersion([Parameter(Mandatory=1)][string]$Path, [Parameter(Mandatory=1)]$Regex) { + trap {*Die $_ 5} + foreach($_ in [System.IO.File]::ReadAllLines($PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path))) { + if ($_ -match $Regex) { + return $Matches[1] + } + } + throw "Cannot find version in '$Path'." +} + +#.ExternalHelp Help.xml +function Invoke-BuildExec([Parameter(Mandatory=1)][scriptblock]$Command, [int[]]$ExitCode=0, [string]$ErrorMessage, [switch]$Echo, [switch]$StdErr) { + ${private:*c} = $Command + ${private:*x} = $ExitCode + ${private:*m} = $ErrorMessage + ${private:*v} = $Echo + ${private:*s} = $StdErr + ${private:*e} = '' + Remove-Variable Command, ExitCode, ErrorMessage, Echo, StdErr + if (${*v}) { + *Echo ${*c} + } + + $global:LastExitCode = 0 + if (${*s}) { + $ErrorActionPreference = 2 + try { + & ${*c} 2>&1 | .{process{ + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_ = $_.Exception.Message + ${*e} += "`n$_" + } + $_ + }} + } + catch {throw} + } + else { + & ${*c} + } + + if (${*x} -notcontains $global:LastExitCode) { + *Die "$(if (${*m}) {"${*m} "})Command exited with code $global:LastExitCode. {${*c}}${*e}" 8 + } +} + +#.ExternalHelp Help.xml +function Remove-BuildItem([Parameter(Mandatory=1)][string[]]$Path) { + if ($Path -match '^[.*/\\]*$') {*Die 'Not allowed paths.' 5} + $v = $PSBoundParameters['Verbose'] + $p = @{Force=$true; Recurse=$true} + if ($PSVersionTable.PSVersion -ge ([version]'7.4')) {$p.ProgressAction='Ignore'} + try { + foreach($_ in $Path) { + if (Get-Item $_ -Force -ErrorAction 0) { + if ($v) {Write-Verbose "remove: removing $_" -Verbose} + Remove-Item $_ @p + } + elseif ($v) {Write-Verbose "remove: skipping $_" -Verbose} + } + } + catch { + *Die $_ + } +} + +#.ExternalHelp Help.xml +function Set-BuildFooter([Parameter()][scriptblock]$Script) { + ${*}.Footer = $Script +} + +#.ExternalHelp Help.xml +function Set-BuildHeader([Parameter()][scriptblock]$Script) { + ${*}.Header = $Script +} + +#.ExternalHelp Help.xml +function Test-BuildAsset( + [ValidateNotNullOrEmpty()][string[]][Parameter(Position=0)]$Variable, + [ValidateNotNullOrEmpty()][string[]]$Environment, + [ValidateNotNullOrEmpty()][string[]]$Property, + [ValidateNotNullOrEmpty()][string[]]$Path +) { + ${*v} = $Variable + ${*e} = $Environment + ${*p} = $Property + ${*f} = $Path + Remove-Variable Variable, Environment, Property, Path + foreach($_ in ${*v}) { + if ($null -eq ($$ = $PSCmdlet.GetVariableValue($_)) -or '' -eq $$) {*Die "Missing variable '$_'." 13} + } + foreach($_ in ${*e}) { + if (!([Environment]::GetEnvironmentVariable($_))) {*Die "Missing environment variable '$_'." 13} + } + foreach($_ in ${*p}) { + if ('' -eq (Get-BuildProperty $_ '')) {*Die "Missing property '$_'." 13} + } + foreach($_ in ${*f}) { + if (!(Test-Path -LiteralPath $_)) {*Die "Missing path '$_'." 13} + } +} + +#.ExternalHelp Help.xml +function Use-BuildAlias([Parameter(Mandatory=1)][string]$Path, [string[]]$Name) { + trap {*Die $_ 5} + $d = switch -regex ($Path) { + '^\*|^\d+\.' {Split-Path (Resolve-MSBuild $_)} + ^Framework {"$env:windir\Microsoft.NET\$_"} + default {*Path $_} + } + if (![System.IO.Directory]::Exists($d)) {throw "Cannot resolve '$Path'."} + foreach($_ in $Name) { + Set-Alias $_ (Join-Path $d $_) -Scope 1 + } +} + +#.ExternalHelp Help.xml +function Use-BuildEnv([Parameter(Mandatory=1)][hashtable]$Env, [Parameter(Mandatory=1)][scriptblock]$Script) { + ${private:*e} = @{} + ${private:*s} = $Script + function *set($n, $v) { + [Environment]::SetEnvironmentVariable($n, $(if ($null -eq $v) {[System.Management.Automation.Language.NullString]::Value} else {$v})) + } + foreach($_ in $Env.GetEnumerator()) { + ${*e}[$_.Key] = [Environment]::GetEnvironmentVariable($_.Key) + *set $_.Key $_.Value + } + Remove-Variable Env, Script + try { + & ${*s} + } + finally { + foreach($_ in ${*e}.GetEnumerator()) { + *set $_.Key $_.Value + } + } +} + +#.ExternalHelp Help.xml +function Write-Build([ConsoleColor]$Color, [string]$Text) { + *Write $Color ($Text -split '\r\n|[\r\n]') +} + +function *Msg($M, $I) { + "$M`n$(*At $I)" +} + +function *Path($P) { + $PSCmdlet.GetUnresolvedProviderPathFromPSPath($P) +} + +if ($PSVersionTable.PSVersion -ge [Version]'7.2' -and $PSStyle.OutputRendering -ne 'PlainText' -and !$env:MSBuildLoadMicrosoftTargetsReadOnly) { + function *Write($C, $T) { + $f = "`e[$((30,34,32,36,31,35,33,37,90,94,92,96,91,95,93,97)[$C])m{0}`e[0m" + foreach($_ in $T) { + $f -f $_ + } + } +} +else { + function *Write($C, $T) { + $i = $Host.UI.RawUI + $_ = $i.ForegroundColor + try { + $i.ForegroundColor = $C + $T + } + finally { + $i.ForegroundColor = $_ + } + } + try { + $null = *Write 0 + } + catch { + function *Write {$args[1]} + } +} + +### init +if ($MyInvocation.InvocationName -eq '.') {return} +${private:*p} = if ($_ = $PSCmdlet.SessionState.PSVariable.Get('*')) {if ($_.Description -eq 'IB') {$_.Value}} +New-Variable * -Description IB ([PSCustomObject]@{ + All = [ordered]@{} + Tasks = [System.Collections.Generic.List[object]]@() + Errors = [System.Collections.Generic.List[object]]@() + Warnings = [System.Collections.Generic.List[object]]@() + Redefined = @() + Doubles = @() + Started = [DateTime]::Now + Elapsed = $null + Error = 'Invalid arguments.' + Task = $null + File = $BuildFile = $PSBoundParameters['File'] + Safe = $PSBoundParameters['Safe'] + Summary = $PSBoundParameters['Summary'] + CD = $OriginalLocation = *Path + DP = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary + BB = [System.Collections.Generic.List[object]]@() + B1 = $null + P = ${*p} + A = 1 + B = 0 + Q = 0 + H = @{} + Header = if (${*p}) {${*p}.Header} else {{print 11 "Task $($args[0])"}} + Footer = if (${*p}) {${*p}.Footer} else {{print 11 "Done $($args[0]) $($Task.Elapsed)"}} + Data = @{} + XBuild = $null + XCheck = $null +}) + +if ($_ = $PSBoundParameters['Result']) { + if ($_ -is [string]) { + New-Variable $_ ${*} -Scope 1 -Force + } + elseif ($_ -is [hashtable]) { + ${*}.XBuild = $_['XBuild'] + ${*}.XCheck = $_['XCheck'] + $_.Value = ${*} + } + else {throw 'Invalid parameter Result.'} +} + +function *BB($FS, $PX, $BR) { + $_ = if ($FS -is [string]) {$FS} else {$FS.File} + @{FS=$FS; PX=$PX; BR=@(if ($_) {Split-Path $_} else {${*}.CD}; $BR); DP=@{}; EnterBuild=$null; ExitBuild=$null; EnterTask=$null; ExitTask=$null; EnterJob=$null; ExitJob=$null} +} + +$BuildTask = $PSBoundParameters['Task'] +if ($BuildFile -is [scriptblock]) { + ${*}.BB.Add((*BB $BuildFile '' @())) + $BuildFile = $BuildFile.File + return +} + +if ($BuildTask -eq '**') { + if (![System.IO.Directory]::Exists(($_ = *Path $BuildFile))) {throw "Missing directory '$_'."} + $BuildFile = @(Get-ChildItem -LiteralPath $_ -Filter *.test.ps1 -Recurse -Force) + return +} + +if ($BuildFile) { + if (![System.IO.File]::Exists(($BuildFile = *Path $BuildFile))) { + if (![System.IO.Directory]::Exists($BuildFile)) {throw "Missing script '$BuildFile'."} + if (!($_ = Get-BuildFile $BuildFile -Here)) {throw "Missing script in '$BuildFile'."} + $BuildFile = $_ + } +} +elseif (!($BuildFile = Get-BuildFile ${*}.CD)) { + throw 'Missing default script.' +} +${*}.File = $BuildFile + +### param +function *DP($FS, $PX, $BR) { + if (!($p = (Get-Command $FS -ErrorAction 1).Parameters)) {throw & $FS} + $b = *BB $FS $PX $BR + if ($p.get_Count()) { + $c = 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'ErrorVariable', 'WarningVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'InformationAction', 'InformationVariable', 'ProgressAction' + $r = 'Task', 'File', 'Result', 'Safe', 'Summary', 'WhatIf' + :param foreach($p in $p.get_Values()) { + if (($n = $p.Name) -in $c) {continue} + if ($n -in $r) {throw "Script uses reserved parameter '$n'."} + foreach ($a in $p.Attributes) { + if ($a -is [System.Management.Automation.ValidateScriptAttribute]) {if ($n -eq 'Extends') { + foreach($s in & $a.ScriptBlock) { + $x = '' + if (($_ = $s.IndexOf('::')) -ge 0) { + $x = $s.Substring(0, $_ + 2) + $s = $s.Substring($_ + 2) + } + $s = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($b.BR[0], $s)) + try {*DP $s $x $b.BR} catch {throw "Parameter 'Extends': $_"} + } + continue param + }} + elseif ($a -is [System.Management.Automation.ParameterAttribute]) {if ($a.Position -ge 0) { + $a.Position += 2 + }} + } + $_ = New-Object System.Management.Automation.RuntimeDefinedParameter $n, $p.ParameterType, $p.Attributes + $b.DP[$n] = $_ + ${*}.DP[$n] = $_ + } + } + ${*}.BB.Add($b) +} +*DP $BuildFile '' @() +${*}.DP +} +end { +Remove-Variable Task, File, Result, Safe, Summary +if ($MyInvocation.InvocationName -eq '.') { + Remove-Variable WhatIf + return +} + +function Enter-Build([Parameter()][scriptblock]$Script) {${*}.B1.EnterBuild = $Script} +function Exit-Build([Parameter()][scriptblock]$Script) {${*}.B1.ExitBuild = $Script} +function Enter-BuildTask([Parameter()][scriptblock]$Script) {${*}.B1.EnterTask = $Script} +function Exit-BuildTask([Parameter()][scriptblock]$Script) {${*}.B1.ExitTask = $Script} +function Enter-BuildJob([Parameter()][scriptblock]$Script) {${*}.B1.EnterJob = $Script} +function Exit-BuildJob([Parameter()][scriptblock]$Script) {${*}.B1.ExitJob = $Script} +function Set-BuildData([Parameter()]$Key, $Value) {${*}.Data[$Key] = $Value} + +function Write-Warning([Parameter()]$Message) { + $PSCmdlet.WriteWarning($Message) + ${*}.Warnings.Add([PSCustomObject]@{Message = $Message; File = $BuildFile; Task = ${*}.Task; InvocationInfo=$MyInvocation}) +} + +function *Amend($X, $J, $B) { + $n = $X.Name + foreach($_ in $J) { + $r, $s = *Job $_ + if (!($t = ${*}.All[$r])) {*Fin (*Msg "Task '$n': Missing task '$r'." $X) 5} + $j = $t.Jobs + $i = $j.Count + if ($B) { + for($k = -1; ++$k -lt $i -and $j[$k] -is [string]) {} + $i = $k + } + $j.Insert($i, $(if ($s) {"?$n"} else {$n})) + } +} + +function *At($I) { + $I.InvocationInfo.PositionMessage.Trim() +} + +function *Check($J, $T, $P=@()) { + foreach($_ in $J) {if ($_ -is [string]) { + $_ = $_.TrimStart('?') + if (!($r = ${*}.All[$_])) { + $_ = "Missing task '$_'." + *Fin $(if ($T) {*Msg "Task '$($T.Name)': $_" $T} else {"File '$BuildFile': $_"}) 5 + } + if ($r -in $P) { + *Fin (*Msg "Task '$($T.Name)': Cyclic reference to '$_'." $T) 5 + } + *Check $r.Jobs $r ($P + $r) + }} +} + +function *Echo { + ${*c} = $args[0] + ${*t} = "${*c}".Replace("`t", ' ') + print 3 "exec {$(if (${*t} -match '((?:\r\n|[\r\n]) *)\S') {"$(${*t}.TrimEnd().Replace($matches[1], "`n "))`n"} else {${*t}})}" + print 8 "cd $global:pwd" + foreach(${*v} in ${*c}.Ast.FindAll({$args[0] -is [System.Management.Automation.Language.VariableExpressionAst]}, $true)) { + ${*p} = ${*v}.Parent + if (${*p} -is [System.Management.Automation.Language.MemberExpressionAst]) { + if (${*p} -is [System.Management.Automation.Language.InvokeMemberExpressionAst]) {continue} + ${*v} = ${*p} + } + if (${*v}.Parent -isnot [System.Management.Automation.Language.AssignmentStatementAst]) { + ${*t} = "${*v}" -replace '^@', '$' + print 8 "${*t}: $(& ([scriptblock]::Create(${*t})))" + } + } +} + +function *Err($T) { + ${*}.Errors.Add([PSCustomObject]@{Error = $_; File = $BuildFile; Task = $T}) + print 12 "ERROR: $(if (*My) {$_} else {*Msg $_ $_})" + if ($T) {$T.Error = $_} +} + +function *Fin([Parameter()]$M, $C=0) { + *Die $M $C +} + +function *Help {process{ + [PSCustomObject]@{ + Name = $_.Name + Synopsis = Get-BuildSynopsis $_ + Jobs = foreach($j in $_.Jobs) {if ($j -is [string]) {$j} else {'{}'}} + } +}} + +function *IO { + if ((${private:*i} = $Task.Inputs) -is [scriptblock]) { + *SL + ${*i} = @(& ${*i}) + } + *SL + ${private:*p} = [System.Collections.Generic.List[object]]@() + ${*i} = foreach($_ in ${*i}) { + if ($_ -isnot [System.IO.FileInfo]) {$_ = [System.IO.FileInfo](*Path $_)} + if (!$_.Exists) {*Fin "Missing input '$_'." 13} + ${*p}.Add($_.FullName) + $_ + } + if (!${*p}) {return 2, 'Skipping empty input.'} + + ${private:*o} = $Task.Outputs + if ($Task.Partial) { + ${*o} = @( + if (${*o} -is [scriptblock]) { + ${*p} | & ${*o} + *SL + } + else { + ${*o} + } + ) + if (${*p}.Count -ne ${*o}.Count) {*Fin "Different Inputs/Outputs counts: $(${*p}.Count)/$(${*o}.Count)." 6} + + $k = -1 + $Task.Inputs = $i = [System.Collections.Generic.List[object]]@() + $Task.Outputs = $o = [System.Collections.Generic.List[object]]@() + foreach($_ in ${*i}) { + $f = *Path ($p = ${*o}[++$k]) + if (![System.IO.File]::Exists($f) -or $_.LastWriteTime -gt [System.IO.File]::GetLastWriteTime($f)) { + $i.Add(${*p}[$k]) + $o.Add($p) + } + } + if ($i) {return $null, "Out-of-date outputs: $($o.Count)/$(${*p}.Count)."} + } + else { + if (${*o} -is [scriptblock]) { + $Task.Outputs = ${*o} = ${*p} | & ${*o} + *SL + } + if (!${*o}) {*Fin 'Outputs must not be empty.' 5} + + $Task.Inputs = ${*p} + $m = (${*i} | .{process{$_.LastWriteTime.Ticks}} | Measure-Object -Maximum).Maximum + foreach($_ in ${*o}) { + $p = *Path $_ + if (![System.IO.File]::Exists($p)) {return $null, "Missing output '$_'."} + if ($m -gt [System.IO.File]::GetLastWriteTime($p).Ticks) {return $null, "Out-of-date output '$_'."} + } + } + 2, 'Skipping up-to-date output.' +} + +function *Job($J) { + if ($J -is [string]) {if ($J[0] -eq '?') {$J.Substring(1), 1} else {$J}} + elseif ($J -is [scriptblock]) {$J} + else {*Fin 'Invalid job.' 5} +} + +function *My { + $_.InvocationInfo.ScriptName -eq $MyInvocation.ScriptName +} + +function *Root { + $t = foreach($_ in ${*}.All.get_Values()) {if ($_.InvocationInfo.ScriptName -eq $BuildFile -and $_.Name -ne '.') {$_}} + $n = foreach($_ in $t) {$_.Name} + *Check $n + $j = foreach($_ in $t) {foreach($_ in $_.Jobs) {if ($_ -is [string]) {$_.TrimStart('?')}}} + foreach($_ in $n) {if ($_ -notin $j) {$_}} +} + +function *Run($_) {if ($_) { + *SL + . $_ @args +}} + +function *SL($P=$BuildRoot) { + Set-Location -LiteralPath $P -ErrorAction 1 +} + +function *Task { + ${private:*p} = "$($args[1])/$($args[0])" + ${private:*n}, ${private:*s} = *Job $args[0] + New-Variable Task (${*}.Task = ${*}.All[${*n}]) -Option Constant + + if ($Task.Elapsed) { + print 8 "Done ${*p}" + return + } + + $BuildRoot = $Task.B1.BR[0] + $Task.Started = [DateTime]::Now + if ((${private:*x} = $Task.If) -is [scriptblock]) { + *SL + try { + ${*x} = & ${*x} + } + catch { + *Err $Task + print 8 (*At $Task) + ${*}.Tasks.Add($Task) + $Task.Elapsed = [TimeSpan]::Zero + throw + } + } + if (!${*x}) { + print 8 "Task ${*p} skipped." + return + } + + ${private:*i} = , [int]($null -ne $Task.Inputs) + try { + . *Run $Task.B1.EnterTask + foreach($_ in $Task.Jobs) { + if ($_ -is [string]) { + try { + *Task $_ ${*p} + } + finally { + ${*}.Task = $Task + } + continue + } + New-Variable Job $_ -Option ReadOnly -Force + & ${*}.Header ${*p} + + if (1 -eq ${*i}[0]) { + try { + ${*i} = *IO + } + catch { + *Err $Task + throw + } + print 8 ${*i}[1] + } + if (${*i}[0]) { + continue + } + + try { + . *Run $Task.B1.EnterJob + *SL + if (0 -eq ${*i}[0]) { + & $Job + } + else { + $Inputs = $Task.Inputs + $Outputs = $Task.Outputs + if ($Task.Partial) { + ${*x} = 0 + $Inputs | .{process{ + $2 = $Outputs[${*x}++] + $_ + }} | & $Job + } + else { + $Inputs | & $Job + } + } + } + catch { + *Err $Task + print 8 (*At $Task) + throw + } + finally { + . *Run $Task.B1.ExitJob + } + } + } + catch { + $Task.Error = $_ + if (!${*s} -or (*Unsafe ${*n} $BuildTask)) {throw} + } + finally { + $Task.Elapsed = [DateTime]::Now - $Task.Started + ${*}.Tasks.Add($Task) + if (!$Task.Error) { + if (${*}.XCheck) {& ${*}.XCheck} + & ${*}.Footer ${*p} + } + *Run $Task.Done + . *Run $Task.B1.ExitTask + } +} + +function *Unsafe($N, $J) { + if ($N -in $J) {return 1} + foreach($_ in $J) {if ($_ -is [string]) { + $_ = $_.TrimStart('?') + if ($_ -ne $N -and ($t = ${*}.All[$_]) -and $t.If -and (*Unsafe $N $t.Jobs)) {return 1} + }} +} + +function *What { + & $PSScriptRoot/Show-TaskHelp.ps1 +} + +$ErrorActionPreference=1 +if (${*}.Q = $BuildTask -eq '?' -or $BuildTask -eq '??') { + $WhatIf = $true +} + +${*}.Error = $null +try { + if ($BuildTask -eq '**') { + ${*}.A = 0 + foreach($_ in $BuildFile) { + Invoke-Build * $_.FullName -Safe:${*}.Safe + } + ${*}.B = 1 + exit + } + + ### load + New-Variable Task @{Name = $BuildFile} -Option Constant + ${*p} = @(foreach($_ in ${*}.DP.get_Values()) {if ($_.IsSet) {$_}}) + ${private:**} = @( + foreach(${private:*b} in ${*}.BB) { + ${*}.B1 = ${*b} + ${private:*s} = @{} + foreach($_ in ${*p}) { + if (${*b}.DP.ContainsKey($_.Name)) { + ${*s}[$_.Name] = $_.Value + } + } + $BuildRoots = @(${*b}.BR) + $BuildRoot = $BuildRoots[0] + *SL + $_ = ${*s} + . ${*b}.FS @_ + if (![System.IO.Directory]::Exists(($_ = *Path $BuildRoot))) {*Fin "Missing build root '$BuildRoot'." 13} + ${*b}.BR[0] = $_ + } + ) + Remove-Variable BuildRoots + foreach($_ in ${**}) { + Write-Warning "Unexpected output: $_." + if ($_ -is [scriptblock]) {*Fin "Dangling scriptblock at $($_.File):$($_.StartPosition.StartLine)" 6} + } + if (!(${**} = ${*}.All).get_Count()) {*Fin "No tasks in '$BuildFile'." 6} + + foreach($_ in ${**}.get_Values()) { + if ($_.Before) {*Amend $_ $_.Before 1} + } + foreach($_ in ${**}.get_Values()) { + if ($_.After) {*Amend $_ $_.After} + } + + if (${*}.Q) { + *Check ${**}.get_Keys() + if ($BuildTask -eq '?') { + ${**}.get_Values() | *Help + } + else { + ${**} + } + exit + } + + if ($BuildTask -eq '*') { + $BuildTask = *Root + } + else { + if (!$BuildTask -or '.' -eq $BuildTask) { + $BuildTask = if (${**}['.']) {'.'} else {${**}.Item(0).Name} + } + *Check $BuildTask + } + if ($WhatIf) { + *What + exit + } + + print 11 "Build $($BuildTask -join ', ') $BuildFile" + foreach($_ in ${*}.Redefined) { + if (($_ = $_.Name) -ne '.') {print 8 "Redefined task '$_'."} + } + foreach($_ in ${*}.Doubles) { + if (${*}.All[$_[1]].If -isnot [scriptblock]) { + Write-Warning "Task '$($_[0])' always skips '$($_[1])'." + } + } + + ### build + ${*}.A = 0 + try { + foreach($_ in ${*}.BB) { + $BuildRoot = $_.BR[0] + . *Run $_.EnterBuild + } + if (${*}.XBuild) {. ${*}.XBuild} + if (${*}.XCheck) {& ${*}.XCheck} + foreach($_ in $BuildTask) { + *Task $_ '' + } + } + finally { + ${*}.Task = $null + for($$ = ${*}.BB.Count; --$$ -ge 0) { + $BuildRoot = ${*}.BB[$$].BR[0] + . *Run ${*}.BB[$$].ExitBuild + } + } + ${*}.B = 1 + exit +} +catch { + ${*}.B = 2 + ${*}.Error = $_ + if (!${*}.Errors) {*Err} + if ($_.FullyQualifiedErrorId -eq 'PositionalParameterNotFound,Add-BuildTask') { + Write-Warning 'Check task parameters: Name and comma separated Jobs.' + } + if (${*}.Safe) { + exit + } + elseif (*My) { + $PSCmdlet.ThrowTerminatingError($_) + } + throw +} +finally { + *SL ${*}.CD + if (${*}.B -and !${*}.Q) { + $t = ${*}.Tasks + $e = ${*}.Errors + if (${*}.Summary) { + print 11 'Build summary:' + foreach($_ in $t) { + '{0,-16} {1} - {2}:{3}' -f $_.Elapsed, $_.Name, $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber + if ($_ = $_.Error) { + print 12 "ERROR: $(if (*My) {$_} else {*Msg $_ $_})" + } + } + } + if ($w = ${*}.Warnings) { + foreach($_ in $w) { + "WARNING: $(if ($_.Task) {"/$($_.Task.Name) "})$($_.InvocationInfo.ScriptName):$($_.InvocationInfo.ScriptLineNumber)" + print 14 $_.Message + } + } + if ($_ = ${*}.P) { + $_.Tasks.AddRange($t) + $_.Errors.AddRange($e) + $_.Warnings.AddRange($w) + } + $c, $m = if (${*}.A) {12, "Build ABORTED $BuildFile"} + elseif (${*}.B -eq 2) {12, 'Build FAILED'} + elseif ($e) {14, 'Build completed with errors'} + elseif ($w) {14, 'Build succeeded with warnings'} + else {10, 'Build succeeded'} + print $c "$m. $($t.Count) tasks, $($e.Count) errors, $($w.Count) warnings $((${*}.Elapsed = [DateTime]::Now - ${*}.Started))" + } +} +} From fa584b376b7caee00bcf70bb51b7d499d145fb7e Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 19:46:21 -0400 Subject: [PATCH 22/43] Switch dotnet tool usage to dotnet tool exec Switch dotnet tool usage to dotnet tool exec Co-authored-by: Copilot --- dotnet-tools.json => .config/dotnet-tools.json | 0 archive/MonoRepoGitVersion.Task.ps1 | 2 +- archive/Pack-UniversalPackage.Task.ps1 | 2 +- archive/SonarQubeEnd.Task.ps1 | 2 +- archive/SonarQubeStart.Task.ps1 | 2 +- common/Convert-Coverage.Task.ps1 | 2 +- common/Get-Version.Task.ps1 | 11 +++++------ common/Install-DotNetTool.Task.ps1 | 12 ++++++++---- common/base.ps1 | 1 + dotnet/Convert-Trx2JUnit.Task.ps1 | 7 +------ dotnet/Restore-DotNet.Task.ps1 | 6 +++--- dotnet/Test-DotNet.Task.ps1 | 2 +- global.json | 9 +++++++++ 13 files changed, 33 insertions(+), 25 deletions(-) rename dotnet-tools.json => .config/dotnet-tools.json (100%) create mode 100644 global.json diff --git a/dotnet-tools.json b/.config/dotnet-tools.json similarity index 100% rename from dotnet-tools.json rename to .config/dotnet-tools.json diff --git a/archive/MonoRepoGitVersion.Task.ps1 b/archive/MonoRepoGitVersion.Task.ps1 index b52895e..803603e 100644 --- a/archive/MonoRepoGitVersion.Task.ps1 +++ b/archive/MonoRepoGitVersion.Task.ps1 @@ -142,7 +142,7 @@ Add-BuildTask MonoRepoGitVersion @{ # NOTE: tag-prefix is NOT overridden here - each module's GitVersion.yml sets it correctly # (e.g. 'BicepFlex/v', 'LDAzOps/v') to match actual git tags. Overriding with the # lowercased project name would cause TaggedCommitVersionStrategy to find no tags. - dotnet gitversion -config $GitVersionYaml -output file -outputfile $ProjectVersionFile ` + dotnet tool execute gitversion.tool -config $GitVersionYaml -output file -outputfile $ProjectVersionFile ` -overrideconfig major-version-bump-message="semver-$($Project.Name):\s*(breaking|major)" ` -overrideconfig minor-version-bump-message="semver-$($Project.Name):\s*(feature|minor)" ` -overrideconfig patch-version-bump-message="semver-$($Project.Name):\s*(fix|patch)" ` diff --git a/archive/Pack-UniversalPackage.Task.ps1 b/archive/Pack-UniversalPackage.Task.ps1 index 1afff18..1307413 100644 --- a/archive/Pack-UniversalPackage.Task.ps1 +++ b/archive/Pack-UniversalPackage.Task.ps1 @@ -36,7 +36,7 @@ Add-BuildTask Pack-UniversalPackage @{ "--target-directory=$($script:UniversalPackageRoot)" ) Write-Build Yellow "dotnet pgutil upack create $($Options -join ' ')" - dotnet pgutil upack create @options + dotnet tool execute pgutil upack create @options } # pgutil packages upload --feed=build-output --input-file=..\DevOpsScripts-Upack-Demo-0.0.0-rc.1+sha.df5b663.260206.upack --source=https://nuget.loandepot.com --api-key=04f1ab532b9397408b349e83420c762ac42eb98d } diff --git a/archive/SonarQubeEnd.Task.ps1 b/archive/SonarQubeEnd.Task.ps1 index f6ee641..3fe07f8 100644 --- a/archive/SonarQubeEnd.Task.ps1 +++ b/archive/SonarQubeEnd.Task.ps1 @@ -1,6 +1,6 @@ Add-BuildTask SonarQubeEnd @{ If = { $script:SonarProjectKey -and $script:SonarToken } Jobs = { - dotnet sonarscanner end -d:"sonar.token=${script:SonarToken}" + dotnet tool execute sonarscanner end -d:"sonar.token=${script:SonarToken}" } } \ No newline at end of file diff --git a/archive/SonarQubeStart.Task.ps1 b/archive/SonarQubeStart.Task.ps1 index e93eb0a..8c01ee3 100644 --- a/archive/SonarQubeStart.Task.ps1 +++ b/archive/SonarQubeStart.Task.ps1 @@ -1,7 +1,7 @@ Add-BuildTask SonarQubeStart @{ If = { $script:SonarProjectKey -and $script:SonarToken } Jobs = "Get-Version", { - dotnet sonarscanner begin ` + dotnet tool execute sonarscanner begin ` -key:"$($Script:SonarProjectKey)" ` -version:"$(${script:Version}.SemVer)" ` -d:"sonar.token=${script:SonarToken}" ` diff --git a/common/Convert-Coverage.Task.ps1 b/common/Convert-Coverage.Task.ps1 index a82e8c9..a0cd954 100644 --- a/common/Convert-Coverage.Task.ps1 +++ b/common/Convert-Coverage.Task.ps1 @@ -4,7 +4,7 @@ Add-BuildTask Convert-Coverage @{ New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null Set-Location $SolutionTestResultsRoot # ------------------------------ - dotnet reportgenerator -reports:'./coverage/*.xml' ` + dotnet tool execute dotnet-reportgenerator-globaltool -reports:'./coverage/*.xml' ` -targetdir:'./coverage' ` -reporttypes:'Html;MarkdownSummaryGithub;TextSummary' ` -filefilters:'+*;-/_*' ` diff --git a/common/Get-Version.Task.ps1 b/common/Get-Version.Task.ps1 index 6299366..9e55602 100644 --- a/common/Get-Version.Task.ps1 +++ b/common/Get-Version.Task.ps1 @@ -28,15 +28,14 @@ Add-BuildTask Get-Version @{ Remove-Item $VersionCacheFile } - Write-Build Yellow "dotnet gitversion -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile" - dotnet gitversion -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile | Out-Host + Write-Build Yellow "dotnet tool execute gitversion.tool -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile" + dotnet tool execute gitversion.tool -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile | Out-Host try { $local:GitVersion = Get-Content $VersionCacheFile | ConvertFrom-Json -ErrorAction Stop - } - catch { - Write-Warning "dotnet gitversion -config $VersionConfig -showconfig" - dotnet gitversion -config $VersionConfig -showconfig | Out-Host + } catch { + Write-Warning "dotnet tool execute gitversion.tool -config $VersionConfig -showconfig" + dotnet tool execute gitversion.tool -config $VersionConfig -showconfig | Out-Host Write-Warning "VersionTagPrefix: $($VersionTagPrefix)" Write-Warning "VersionMessagePrefix: $($VersionMessagePrefix)" Write-Warning 'git log --graph --format="%h %cr %d" --decorate --date=relative --all --remotes=* -n 100' diff --git a/common/Install-DotNetTool.Task.ps1 b/common/Install-DotNetTool.Task.ps1 index c74c6a2..49048cb 100644 --- a/common/Install-DotNetTool.Task.ps1 +++ b/common/Install-DotNetTool.Task.ps1 @@ -11,11 +11,15 @@ Add-BuildTask Install-DotNetTool @{ $DotNetToolManifest = @( Join-Path $BuildRoot .config/dotnet-tools.json Join-Path $BuildRoot dotnet-tools.json + Join-Path $BuildRoot build.tools.json + Join-Path $PSScriptRoot "../.config/dotnet-tools.json" ) | Resolve-Path -ErrorAction Ignore | Select-Object -First 1 - if (-not $DotNetToolManifest) { - New-Item -ItemType Directory -Path "$BuildRoot/.config" -Force -ErrorAction Ignore - Copy-Item "$PSScriptRoot/../dotnet-tools.json" "$BuildRoot/.config/dotnet-tools.json" -Force + $local:options = @{ + "-tool-manifest" = $DotNetToolManifest } - dotnet tool restore + if ($script:NugetConfigFile) { + $options["-configfile"] = $script:NugetConfigFile + } + dotnet tool restore @options } } diff --git a/common/base.ps1 b/common/base.ps1 index f25d5b1..b4524f6 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -166,6 +166,7 @@ Enter-Build { if ($Clean -and -not ($BuildTask -eq "Clean-Output")) { $BuildTask = @("Clean-Output") + $BuildTask } + $script:NugetConfigFile = Get-ChildItem $BuildRoot -Filter "[Nn]u[Gg]et.config" | Convert-Path Write-Build Cyan " OutputRoot: $OutputRoot" Write-Build Cyan " TestResultsRoot: $TestResultsRoot" diff --git a/dotnet/Convert-Trx2JUnit.Task.ps1 b/dotnet/Convert-Trx2JUnit.Task.ps1 index 2daa85b..33f4009 100644 --- a/dotnet/Convert-Trx2JUnit.Task.ps1 +++ b/dotnet/Convert-Trx2JUnit.Task.ps1 @@ -1,9 +1,4 @@ Add-BuildTask Convert-Trx2JUnit @{ - If = { if ($script:TargetFramework -eq "net8.0") { - dotnet tool list trx2junit | Select-Object -Skip 2 - } else { - (dotnet tool list trx2junit --format json | ConvertFrom-Json).data - } } Partial = $true Input = { New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null @@ -16,7 +11,7 @@ Add-BuildTask Convert-Trx2JUnit @{ } Jobs = { Get-ChildItem $SolutionTestResultsRoot/*.trx | ForEach-Object -ThrottleLimit ([Environment]::ProcessorCount - 1) -Parallel { - dotnet trx2junit $_ | Select-String -Pattern "Converting\s'" + dotnet tool execute trx2junit $_ | Select-String -Pattern "Converting\s'" } } } \ No newline at end of file diff --git a/dotnet/Restore-DotNet.Task.ps1 b/dotnet/Restore-DotNet.Task.ps1 index f46450a..74dd8c8 100644 --- a/dotnet/Restore-DotNet.Task.ps1 +++ b/dotnet/Restore-DotNet.Task.ps1 @@ -19,10 +19,10 @@ Add-BuildTask Restore-DotNet @{ # } Jobs = "Install-DotNetTool", { $local:options = @{} + $script:dotnetOptions - # We're doing this for case-sensitive reasons - if (($NugetConfig = Get-ChildItem $BuildRoot -Filter "[Nn]u[Gg]et.config")) { - $options["-configfile"] = $NugetConfig.FullName + if ($script:NugetConfigFile) { + $options["-configfile"] = $script:NugetConfigFile } + Write-Build Yellow "dotnet restore $DotNetSolutionFile $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" # dotnet restore $DotNetSolutionFile @options diff --git a/dotnet/Test-DotNet.Task.ps1 b/dotnet/Test-DotNet.Task.ps1 index e4a7662..e7757c7 100644 --- a/dotnet/Test-DotNet.Task.ps1 +++ b/dotnet/Test-DotNet.Task.ps1 @@ -17,7 +17,7 @@ Add-BuildTask Test-DotNet @{ $Command = "dotnet test --solution $DotNetSolutionFile -p:SolutionName=$SolutionName --no-build $(($options.GetEnumerator().ForEach({"-$($_.Key) $($_.Value)"})) -join ' ')" if (!$Script:SkipCoverage) { Write-Build Yellow "dotnet coverage collect '$Command' --output '$SolutionTestResultsRoot/coverage/$SolutionName.xml' --output-format xml" - dotnet coverage collect $Command --output "$SolutionTestResultsRoot/coverage/$SolutionName.xml" --output-format xml + dotnet tool execute dotnet-coverage collect $Command --output "$SolutionTestResultsRoot/coverage/$SolutionName.xml" --output-format xml } else { Write-Build Yellow $Command dotnet test --solution $DotNetSolutionFile -p:SolutionName=$SolutionName --no-build @options diff --git a/global.json b/global.json new file mode 100644 index 0000000..dc46def --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMajor" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} From 921545ac7dd0b990fde96799130a5636f87180eb Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 19:27:44 -0400 Subject: [PATCH 23/43] Fix "requires" in Test-PowerShell --- common/Test-PowerShell.Task.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/Test-PowerShell.Task.ps1 b/common/Test-PowerShell.Task.ps1 index 91c0194..a251f13 100644 --- a/common/Test-PowerShell.Task.ps1 +++ b/common/Test-PowerShell.Task.ps1 @@ -1,5 +1,4 @@ -#requires -Module @{ ModuleName = "Pester"; ModuleVersion = "5.6.0" } Add-BuildTask Test-PowerShell @{ Inputs = { Get-ChildItem $ModuleOutputRoot -Recurse -File @@ -16,6 +15,8 @@ Add-BuildTask Test-PowerShell @{ Jobs = { $script:OldModulePath = $Env:PSModulePath }, { + # We can't use `requires` because installing dependencies is one of the build steps... + Import-Module Pester -MinimumVersion 5.6 -ErrorAction Stop # For PowerShell Modules with classes to work in tests: # 1. The $OutputRoot directory must be first on Env:PSModulePath From 678a9e4eadd0c34869e3f3c3655463ff30378fbd Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 19:38:25 -0400 Subject: [PATCH 24/43] Skip git if we're not in a git repo yet --- common/base.ps1 | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/common/base.ps1 b/common/base.ps1 index b4524f6..279192c 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -94,11 +94,11 @@ Write-Information "$($PSStyle.Foreground.BrightBlue) BuildRoot: $BuildRoot$($PS # Each script in the Extends tree gets its own Enter-Build invoked with its $BuildRoot. Enter-Build { # In CI builds you have a BranchName - $script:BranchName = if ($Env:BUILD_SOURCEBRANCHNAME) { - $Env:BUILD_SOURCEBRANCHNAME - } elseif (Get-Command git -CommandType Application -ErrorAction SilentlyContinue) { - git branch --show-current - } + $script:BranchName = $Env:BUILD_SOURCEBRANCHNAME ?? $Env:EARTHLY_GIT_BRANCH ?? $( + if ((Test-Path ".git") -and (Get-Command git -CommandType Application -ErrorAction Ignore)) { + git branch --show-current + } + ) ?? "dirty" <# ? None of this information is being used except to print it out here... [bool]$script:IsPullRequest = $script:IsPullRequest ?? ($Env:BUILD_REASON -eq "PullRequest" -or $Env:DRONE_BUILD_EVENT -eq "pull_request") @@ -188,7 +188,10 @@ $script:InitializeTasks = @( } # Note that we run *all* of the Install tasks via the alias which must be kept up to date "Install-All" - "Get-Version" + # Skip Get-Version if we're not in a git repo (yet -- e.g. initialize dependencies in a container) + if (Test-Path ".git") { + "Get-Version" + } ) $script:BuildTasks = @() $script:PublishTasks = @() From 788ee5dfea32467212c627361fada59df39b7294 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 20:02:28 -0400 Subject: [PATCH 25/43] Update earthbuild file --- Earthfile => build.earth | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename Earthfile => build.earth (63%) diff --git a/Earthfile b/build.earth similarity index 63% rename from Earthfile rename to build.earth index e7b555c..6f2b8d7 100644 --- a/Earthfile +++ b/build.earth @@ -1,5 +1,5 @@ VERSION 0.8 -FROM mcr.microsoft.com/dotnet/sdk:9.0 +FROM mcr.microsoft.com/dotnet/sdk:10.0 WORKDIR /tasks tasks: From 636dcc6fa6dc1a567ee010009d68fbab306fa524 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 2 May 2026 20:25:04 -0400 Subject: [PATCH 26/43] Remove duplication from powershell/base --- powershell/base.ps1 | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/powershell/base.ps1 b/powershell/base.ps1 index ad52dd2..d6a3b34 100644 --- a/powershell/base.ps1 +++ b/powershell/base.ps1 @@ -84,17 +84,11 @@ Enter-Build { } } -# Add the dotnet tasks to the common tasks -$script:InitializeTasks = @( - # In CI pipelines (or if you specify $Clean) - if ($BuildSystem -ne "None" -or $Script:Clean) { - # Run the Clean-Output task before the rest of the build tasks - "Clean-Output" - } -) + $InitializeTasks +# Add the PowerShell tasks to the common tasks +$script:InitializeTasks += @() # When we have dotnet combined in a PowerShell module -# We need to build the module after the dotnet publish +# We need to Build-Module AFTER Publish-DotNet # So that we can include the output assemblies in the module $script:BuildTasks += $BuildTasks -contains "Build-DotNet" ? @("Publish-DotNet", "Build-Module") : From 52091a7e337a8d082a75d315ca56ba1d1f6dd8aa Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 6 May 2026 20:57:06 -0400 Subject: [PATCH 27/43] Change the default output directory to lowercase 'output' to match all the other folders --- common/base.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/base.ps1 b/common/base.ps1 index 279192c..bbbed13 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -138,13 +138,13 @@ Enter-Build { # But each variable should have a default here: $Script:OutputRoot = $Env:BUILD_BINARIESDIRECTORY ?? $Env:IB_OUTPUT_ROOT ?? - (Join-Path $BuildRoot 'Output') + (Join-Path $BuildRoot 'output') New-Item -Type Directory -Path $OutputRoot -Force | Out-Null $Script:TestResultsRoot = $script:TestResultsRoot ?? # An override for build script parameters $Env:IB_TEST_RESULTS_ROOT ?? # An override for machine-level settings $Env:TEST_RESULTS_DIRECTORY ?? - (Join-Path $OutputRoot testresults) + (Join-Path $OutputRoot 'testresults') $Script:TempRoot = @(Get-Content Env:IB_TEMP_ROOT, Env:AGENT_TEMPDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | Where-Object { Test-Path $_ } | From 796df8250c9025fbd7e2d8aa3bb7f73d7214dbf9 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 6 May 2026 21:42:14 -0400 Subject: [PATCH 28/43] dotnet tool execute --yes so tools can be auto-downloaded --- common/Convert-Coverage.Task.ps1 | 3 ++- common/Get-Version.Task.ps1 | 2 +- dotnet/Convert-Trx2JUnit.Task.ps1 | 2 +- dotnet/Test-DotNet.Task.ps1 | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/common/Convert-Coverage.Task.ps1 b/common/Convert-Coverage.Task.ps1 index a0cd954..5bfb1d9 100644 --- a/common/Convert-Coverage.Task.ps1 +++ b/common/Convert-Coverage.Task.ps1 @@ -9,7 +9,8 @@ Add-BuildTask Convert-Coverage @{ -reporttypes:'Html;MarkdownSummaryGithub;TextSummary' ` -filefilters:'+*;-/_*' ` -title:"$script:ProductName" ` - -tag:"$(${script:Version}.InformationalVersion)" + -tag:"$(${script:Version}.InformationalVersion)" ` + --yes switch ($script:BuildSystem) { "AzureDevOps" { diff --git a/common/Get-Version.Task.ps1 b/common/Get-Version.Task.ps1 index 9e55602..bdace85 100644 --- a/common/Get-Version.Task.ps1 +++ b/common/Get-Version.Task.ps1 @@ -29,7 +29,7 @@ Add-BuildTask Get-Version @{ } Write-Build Yellow "dotnet tool execute gitversion.tool -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile" - dotnet tool execute gitversion.tool -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile | Out-Host + dotnet tool execute gitversion.tool -config $VersionConfig -nofetch -output file -outputfile $VersionCacheFile --yes | Out-Host try { $local:GitVersion = Get-Content $VersionCacheFile | ConvertFrom-Json -ErrorAction Stop diff --git a/dotnet/Convert-Trx2JUnit.Task.ps1 b/dotnet/Convert-Trx2JUnit.Task.ps1 index 33f4009..aac8ea7 100644 --- a/dotnet/Convert-Trx2JUnit.Task.ps1 +++ b/dotnet/Convert-Trx2JUnit.Task.ps1 @@ -11,7 +11,7 @@ Add-BuildTask Convert-Trx2JUnit @{ } Jobs = { Get-ChildItem $SolutionTestResultsRoot/*.trx | ForEach-Object -ThrottleLimit ([Environment]::ProcessorCount - 1) -Parallel { - dotnet tool execute trx2junit $_ | Select-String -Pattern "Converting\s'" + dotnet tool execute trx2junit $_ --yes | Select-String -Pattern "Converting\s'" } } } \ No newline at end of file diff --git a/dotnet/Test-DotNet.Task.ps1 b/dotnet/Test-DotNet.Task.ps1 index e7757c7..b11f536 100644 --- a/dotnet/Test-DotNet.Task.ps1 +++ b/dotnet/Test-DotNet.Task.ps1 @@ -17,7 +17,7 @@ Add-BuildTask Test-DotNet @{ $Command = "dotnet test --solution $DotNetSolutionFile -p:SolutionName=$SolutionName --no-build $(($options.GetEnumerator().ForEach({"-$($_.Key) $($_.Value)"})) -join ' ')" if (!$Script:SkipCoverage) { Write-Build Yellow "dotnet coverage collect '$Command' --output '$SolutionTestResultsRoot/coverage/$SolutionName.xml' --output-format xml" - dotnet tool execute dotnet-coverage collect $Command --output "$SolutionTestResultsRoot/coverage/$SolutionName.xml" --output-format xml + dotnet tool execute dotnet-coverage collect $Command --output "$SolutionTestResultsRoot/coverage/$SolutionName.xml" --output-format xml --yes } else { Write-Build Yellow $Command dotnet test --solution $DotNetSolutionFile -p:SolutionName=$SolutionName --no-build @options From ed7400c72bdc4cd655de6a45727c721dd34fbc0b Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 6 May 2026 21:58:51 -0400 Subject: [PATCH 29/43] simplify TestResults to Results --- common/base.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/base.ps1 b/common/base.ps1 index bbbed13..2898f53 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -142,9 +142,9 @@ Enter-Build { New-Item -Type Directory -Path $OutputRoot -Force | Out-Null $Script:TestResultsRoot = $script:TestResultsRoot ?? # An override for build script parameters - $Env:IB_TEST_RESULTS_ROOT ?? # An override for machine-level settings + $Env:IB_RESULTS_ROOT ?? # An override for machine-level settings $Env:TEST_RESULTS_DIRECTORY ?? - (Join-Path $OutputRoot 'testresults') + (Join-Path $OutputRoot 'results') $Script:TempRoot = @(Get-Content Env:IB_TEMP_ROOT, Env:AGENT_TEMPDIRECTORY, Env:TEMP, Env:TMP -ErrorAction Ignore) | Where-Object { Test-Path $_ } | From b40d72cf2afa72440010368b074d4171ac5f1caf Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 6 May 2026 22:40:46 -0400 Subject: [PATCH 30/43] Fix Initialize so we don't Get-Version while installing dependencies --- common/base.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/base.ps1 b/common/base.ps1 index 2898f53..7f9fc76 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -188,12 +188,12 @@ $script:InitializeTasks = @( } # Note that we run *all* of the Install tasks via the alias which must be kept up to date "Install-All" - # Skip Get-Version if we're not in a git repo (yet -- e.g. initialize dependencies in a container) - if (Test-Path ".git") { - "Get-Version" - } ) -$script:BuildTasks = @() +$script:BuildTasks = @( + # Get-Version should run first in Build, but not before + # Otherwise it complicates our ability to cache dependencies + "Get-Version" +) $script:PublishTasks = @() $script:TestTasks = @() $script:PushTasks = @("Push-Docker") From 62bfcb469711a69d8022ac8241deaf9b357d9fb2 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 6 May 2026 22:41:29 -0400 Subject: [PATCH 31/43] Normalize the name of Install-FromGitHub --- common/Install-FromGitHub.Task.ps1 | 2 +- helm/Install-Helm.Task.ps1 | 26 ++++++++++++------- ...thubRelease.ps1 => Install-FromGitHub.ps1} | 24 ++++++++--------- 3 files changed, 29 insertions(+), 23 deletions(-) rename scripts/{Install-GithubRelease.ps1 => Install-FromGitHub.ps1} (97%) diff --git a/common/Install-FromGitHub.Task.ps1 b/common/Install-FromGitHub.Task.ps1 index 889c29f..72b971d 100644 --- a/common/Install-FromGitHub.Task.ps1 +++ b/common/Install-FromGitHub.Task.ps1 @@ -5,7 +5,7 @@ Add-BuildTask Install-FromGitHub @{ foreach ($tool in $script:GHTools.keys) { if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { Write-Build Gray "Installing $tool..." - $script:GHTools[$tool] | &(Join-Path (Split-Path $PSScriptRoot) "scripts/Install-GithubRelease.ps1") -ErrorAction SilentlyContinue + $script:GHTools[$tool] | &(Join-Path (Split-Path $PSScriptRoot) "scripts" "Install-FromGitHub.ps1") -ErrorAction SilentlyContinue } } } diff --git a/helm/Install-Helm.Task.ps1 b/helm/Install-Helm.Task.ps1 index 5bb1e71..69f8b0e 100644 --- a/helm/Install-Helm.Task.ps1 +++ b/helm/Install-Helm.Task.ps1 @@ -20,16 +20,22 @@ Add-BuildTask Install-Helm @{ $HelmVersionShort = helm version --short Write-Build Gray "Helm version: $HelmVersionShort" - # Install helm-schema plugin if not already installed - # TODO: This will be a PITA for windows - if ('schema' -notin (helm plugin list | ForEach-Object { ($_ -split '\t')[0] })) { - # bug was introduced by owner of the helm-schema plugin in 0.23.0 and they "unreleased" it but didn't remove the latest tag on GitHub for it. So we have to pin to 0.22.0 for now until they fix it. - if ($HelmVersionShort -ilike "v4*") { - Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 --verify=false" - helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 --verify=false - } else { - Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --version 0.22.0" - helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 + # Install helm-schema + if ($IsLinux -or $IsMacOS) { + # helm plugin install NEVER works if you don't have bash + if ('schema' -notin (helm plugin list | ForEach-Object { ($_ -split '\t')[0] })) { + # bug was introduced by owner of the helm-schema plugin in 0.23.0 and they "unreleased" it but didn't remove the latest tag on GitHub for it. So we have to pin to 0.22.0 for now until they fix it. + if ($HelmVersionShort -ilike "v4*") { + Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 --verify=false" + helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 --verify=false + } else { + Write-Build Yellow "helm plugin install https://github.com/dadav/helm-schema --version 0.22.0" + helm plugin install https://github.com/dadav/helm-schema --version 0.22.0 + } + } + } else { + if (-not (Get-Command 'helm-schema' -ErrorAction Ignore)) { + Install-FromGithub "dadav/helm-schema" } } } diff --git a/scripts/Install-GithubRelease.ps1 b/scripts/Install-FromGitHub.ps1 similarity index 97% rename from scripts/Install-GithubRelease.ps1 rename to scripts/Install-FromGitHub.ps1 index 8c336f9..bb64157 100644 --- a/scripts/Install-GithubRelease.ps1 +++ b/scripts/Install-FromGitHub.ps1 @@ -16,9 +16,9 @@ .PROJECTURI https://github.com/Jaykul/FromGitHub -.ICONURI +.ICONURI -.EXTERNALMODULEDEPENDENCIES +.EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS @@ -30,11 +30,11 @@ 1.5.2 - Fix metadata problems in published module and script 1.5.1 - Fix a bug in SelectAssetByPlatform not using the order of OS and Architecture to select the best match. 1.5.0 - Convert to a module with a build that exports the script - + .PRIVATEDATA -#> +#> @@ -56,32 +56,32 @@ All these examples are (only) tested on Windows and WSL Ubuntu .EXAMPLE -Install-GithubRelease FluxCD Flux2 +Install-FromGitHub FluxCD Flux2 Install `Flux` from the https://github.com/FluxCD/Flux2 repository .EXAMPLE -Install-GithubRelease earthly earthly +Install-FromGitHub earthly earthly Install `earthly` from the https://github.com/earthly/earthly repository .EXAMPLE -Install-GithubRelease junegunn fzf +Install-FromGitHub junegunn fzf Install `fzf` from the https://github.com/junegunn/fzf repository .EXAMPLE -Install-GithubRelease BurntSushi ripgrep +Install-FromGitHub BurntSushi ripgrep Install `rg` from the https://github.com/BurntSushi/ripgrep repository .EXAMPLE -Install-GithubRelease opentofu opentofu +Install-FromGitHub opentofu opentofu Install `opentofu` from the https://github.com/opentofu/opentofu repository .EXAMPLE -Install-GithubRelease twpayne chezmoi +Install-FromGitHub twpayne chezmoi Install `chezmoi` from the https://github.com/twpayne/chezmoi repository @@ -91,8 +91,8 @@ Install-GitHubRelease https://github.com/mikefarah/yq/releases/tag/v4.44.6 Install `yq` version v4.44.6 from it's release on github.com .EXAMPLE -Install-GithubRelease sharkdp/bat -Install-GithubRelease sharkdp/fd +Install-FromGitHub sharkdp/bat +Install-FromGitHub sharkdp/fd Install `bat` and `fd` from their repositories From df7405d06a6b2acfc629874a4d81ccb40aee1bfe Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 6 May 2026 22:42:22 -0400 Subject: [PATCH 32/43] Fix the Helm task dependencies and name casing --- helm/Build-Helm.Task.ps1 | 8 +++++++- helm/Pack-Helm.Task.ps1 | 6 +++--- helm/Push-Helm.Task.ps1 | 2 +- helm/Test-Helm.Task.ps1 | 12 ++++++------ helm/base.ps1 | 4 ++-- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/helm/Build-Helm.Task.ps1 b/helm/Build-Helm.Task.ps1 index 53a7acd..6419a4a 100644 --- a/helm/Build-Helm.Task.ps1 +++ b/helm/Build-Helm.Task.ps1 @@ -4,7 +4,13 @@ Add-BuildTask Build-Helm @{ foreach ($Chart in $script:HelmCharts) { Set-Location $Chart Write-Build Yellow "helm schema" - Invoke-Native { helm schema } -ExceptionalExit + if ('schema' -in (helm plugin list | ForEach-Object { ($_ -split '\t')[0] })) { + Invoke-Native { helm schema } -ExceptionalExit + } elseif (Get-Command 'helm-schema' -ErrorAction Ignore) { + Invoke-Native { helm-schema } -ExceptionalExit + } else { + Write-Error "helm schema plugin not found, can't update values-schema.json files" + } } } } diff --git a/helm/Pack-Helm.Task.ps1 b/helm/Pack-Helm.Task.ps1 index 03a8a61..0c07753 100644 --- a/helm/Pack-Helm.Task.ps1 +++ b/helm/Pack-Helm.Task.ps1 @@ -1,7 +1,7 @@ # The actual helm command is helm package # But we alias it as pack and publish for consistency with other frameworks Add-BuildTask Pack-Helm Package-Helm -Add-BuildTask Publish-Helm Pack-Helm +Add-BuildTask Publish-Helm Package-Helm Add-BuildTask Package-Helm @{ Inputs = { Get-ChildItem $script:HelmCharts -File -Recurse } @@ -10,9 +10,9 @@ Add-BuildTask Package-Helm @{ Join-Path $Chart.FullName "$($Chart.Name)-$($script:Version.SemVer).tgz" } } - Jobs = "Get-Version", "Test-Helm", { + Jobs = "Get-Version", { foreach ($Chart in $script:HelmCharts) { - $Destination = Join-Path $script:helmOutputRoot $Chart.Name + $Destination = Join-Path $script:HelmOutputRoot $Chart.Name New-Item $Destination -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null $options = @( "--destination", $Destination, diff --git a/helm/Push-Helm.Task.ps1 b/helm/Push-Helm.Task.ps1 index c9cfbce..f6dd1d5 100644 --- a/helm/Push-Helm.Task.ps1 +++ b/helm/Push-Helm.Task.ps1 @@ -3,7 +3,7 @@ Add-BuildTask Push-Helm @{ if ($script:PushEnabled) { foreach ($Chart in $script:HelmCharts) { # If this sort turns out to not be enough, we need to split the name and cast to [semver] to sort - $ChartToPush = Get-ChildItem (Join-Path $script:helmOutputRoot $Chart.Name) -Filter *.tgz | Sort-Object LastWriteTime | Select-Object -Last 1 + $ChartToPush = Get-ChildItem (Join-Path $script:HelmOutputRoot $Chart.Name) -Filter *.tgz | Sort-Object LastWriteTime | Select-Object -Last 1 Write-Build Yellow "helm push $($ChartToPush.FullName) oci://$($script:ACRName).azurecr.io/helm" Invoke-Native { helm push $ChartToPush.FullName "oci://$($script:ACRName).azurecr.io/helm" } -ExceptionalExit } diff --git a/helm/Test-Helm.Task.ps1 b/helm/Test-Helm.Task.ps1 index a2b2570..e63e64d 100644 --- a/helm/Test-Helm.Task.ps1 +++ b/helm/Test-Helm.Task.ps1 @@ -2,17 +2,17 @@ Add-BuildTask Test-Helm @{ Inputs = { Get-ChildItem $script:HelmCharts -File -Recurse } Outputs = { foreach ($chart in $script:HelmCharts) { - Join-Path $script:helmOutputRoot "$($Chart.Name)-compiled.yaml" + Join-Path $script:HelmOutputRoot "$($Chart.Name)-compiled.yaml" } } - Jobs = "Build-Helm", { + Jobs = { # helm lint requires the chart directory, not the chart.yaml file Set-Location $script:HelmChartRoot - New-Item $script:helmOutputRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + New-Item $script:HelmOutputRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null # each $chart is a directory object foreach ($chart in $script:HelmCharts) { $TestValues = Join-Path $chart values.yaml - $CompiledOutput = Join-Path $script:helmOutputRoot "$($Chart.Name)-compiled.yaml" + $CompiledOutput = Join-Path $script:HelmOutputRoot "$($Chart.Name)-compiled.yaml" Write-Build Yellow "helm lint $($Chart.FullName) --values $TestValues" Invoke-Native { helm lint $chart.FullName --values $TestValues } -ExceptionalExit @@ -23,9 +23,9 @@ Add-BuildTask Test-Helm @{ # Shouldn't this be taken care of elsewhere as a pre-requisite? if (-not (Get-Command kubeconform -ErrorAction SilentlyContinue)) { Write-Build Yellow "kubeconform not found, attempting installation..." - &(Join-Path $script:BuildTasksRoot "scripts" "Install-GithubRelease.ps1") -Org "yannh" -Repo "kubeconform" -Verbose -ErrorAction SilentlyContinue + &(Join-Path $script:BuildTasksRoot "scripts" "Install-FromGitHub.ps1") -Org "yannh" -Repo "kubeconform" -Verbose -ErrorAction SilentlyContinue } - # TODO: Why is this here? This should be handled by Install-FromGitHub + Write-Build Yellow "kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput" Invoke-Native { kubeconform -strict -ignore-missing-schemas -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' "-verbose" -output pretty $CompiledOutput diff --git a/helm/base.ps1 b/helm/base.ps1 index 230edc8..0c9b3ac 100644 --- a/helm/base.ps1 +++ b/helm/base.ps1 @@ -29,7 +29,7 @@ Enter-Build { if ($script:HelmChartRoot) { $script:ACRName = $script:ACRName ?? $ENV:ACR_URI ?? "crazusw2dvosl1" - $script:helmOutputRoot = Join-Path $Script:OutputRoot "charts" + $script:HelmOutputRoot = Join-Path $Script:OutputRoot "charts" $script:ChartName ??= Get-ChildItem -Path $script:HelmChartRoot -File -Filter Chart.yaml -Recurse -Depth 1 | ForEach-Object { $_.Directory.Name } $script:HelmCharts = $script:ChartName | Join-Path -Path $script:HelmChartRoot -ChildPath { $_ } | Get-Item $script:GHTools.add("kubeconform", "https://github.com/yannh/kubeconform/releases/tag/v0.7.0") @@ -37,7 +37,7 @@ Enter-Build { Write-Build Cyan "Initializing Helm task variables (HelmChartRoot: $script:HelmChartRoot)" Write-Build Cyan " HelmChartRoot: $script:HelmChartRoot" Write-Build Cyan " ACRName: $script:ACRName" - Write-Build Cyan " helmOutputRoot: $script:helmOutputRoot" + Write-Build Cyan " HelmOutputRoot: $script:HelmOutputRoot" Write-Build Cyan " HelmCharts: $(($script:HelmCharts).Count)" } } From b58c841971bc1ede525566c4e61baabc5af6a47b Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Thu, 7 May 2026 22:22:00 -0400 Subject: [PATCH 33/43] Use EARTHLY_GIT_BRANCH to detect Earthly instead of EARTHLY_BUILD_SHA Because it changes less often, so I can set it more often --- common/base.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/base.ps1 b/common/base.ps1 index 7f9fc76..2f1b3aa 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -68,7 +68,7 @@ $script:BuildSystem = if (Test-Path Env:HARNESS_STAGE_ID) { "GithubActions" } elseif (Test-Path Env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { "AzureDevops" -} elseif (Test-Path Env:EARTHLY_BUILD_SHA) { +} elseif (Test-Path Env:EARTHLY_GIT_BRANCH) { "Earthly" } else { "None" From 2cfcbcfb6ef8874fefa4576f10c3efa8efbaf82b Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Thu, 7 May 2026 22:22:37 -0400 Subject: [PATCH 34/43] Improve reliability of Test-PowerShell output --- common/Test-PowerShell.Task.ps1 | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/common/Test-PowerShell.Task.ps1 b/common/Test-PowerShell.Task.ps1 index a251f13..ea5d387 100644 --- a/common/Test-PowerShell.Task.ps1 +++ b/common/Test-PowerShell.Task.ps1 @@ -1,15 +1,18 @@ Add-BuildTask Test-PowerShell @{ Inputs = { - Get-ChildItem $ModuleOutputRoot -Recurse -File - $Tests = Join-Path $BuildRoot [Tt]ests | Resolve-Path - Get-ChildItem $Tests -Recurse -File -Filter *.tests.ps1 + if ($ModuleOutputRoot) { + Get-ChildItem $ModuleOutputRoot -Recurse -File + } + if ($Tests = Join-Path $BuildRoot [Tt]ests | Resolve-Path -ErrorAction Ignore) { + Get-ChildItem $Tests -Recurse -File -Filter *.tests.ps1 + } } Outputs = { if ($Clean) { $BuildRoot # guaranteed to be old } else { - Join-Path $ModuleTestResultsRoot "results.xml" + Join-Path ($script:ModuleTestResultsRoot ?? $script:TestResultsRoot) "results.xml" } } Jobs = { @@ -36,6 +39,11 @@ Add-BuildTask Test-PowerShell @{ ) -join "`n") } + # Wvoid depending on the PowerShell/base variables (but respect them if they are set) + $local:ModuleTestResultsRoot = $script:ModuleTestResultsRoot ?? $script:TestResultsRoot + $local:CoverageRoot = $script:ModuleOutputRoot ?? $script:OutputRoot + $local:SkipCoverage = $script:SkipCoverage -or -not $script:ModuleOutputRoot + # But we don't need all that to run PowerShell tests ... $Configuration = @{ Run = @{ @@ -45,7 +53,7 @@ Add-BuildTask Test-PowerShell @{ Filter = $PesterFilter TestResult = @{ Enabled = $true - OutputRoot = Join-Path $ModuleTestResultsRoot "results.xml" + OutputPath = Join-Path $local:ModuleTestResultsRoot "results.xml" } Debug = @{ ShowNavigationMarkers = $Host.Name -match "Visual Studio Code" @@ -57,8 +65,8 @@ Add-BuildTask Test-PowerShell @{ } CodeCoverage = @{ Enabled = !$SkipCoverage - Path = Get-Item $ModuleOutputRoot\*.psm1, $ModuleOutputRoot\*.ps1 - OutputPath = Join-Path $ModuleTestResultsRoot "coverage.xml" + Path = Get-Item $local:CoverageRoot\*.psm1, $local:CoverageRoot\*.ps1 + OutputPath = Join-Path $local:ModuleTestResultsRoot "coverage.xml" CoveragePercentTarget = $CodeCoveragePercentTarget * 100 UseBreakpoints = $false } From 997479a8927a7ce3abf49bb384657e4e9e08e3bb Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 9 May 2026 20:36:30 -0400 Subject: [PATCH 35/43] Disable the AzACR stuff if the variables aren't set --- common/Connect-AzACR.Task.ps1 | 1 + common/Push-Docker.Task.ps1 | 3 +++ 2 files changed, 4 insertions(+) diff --git a/common/Connect-AzACR.Task.ps1 b/common/Connect-AzACR.Task.ps1 index 155a290..2827bd2 100644 --- a/common/Connect-AzACR.Task.ps1 +++ b/common/Connect-AzACR.Task.ps1 @@ -1,4 +1,5 @@ Add-BuildTask Connect-AzACR @{ + If = { $ACRName -and $ACRUri } Jobs = { if ($env:AZURE_ACCESS_TOKEN) { # Pipeline path: use the OIDC plugin token directly diff --git a/common/Push-Docker.Task.ps1 b/common/Push-Docker.Task.ps1 index d16b44d..9995ede 100644 --- a/common/Push-Docker.Task.ps1 +++ b/common/Push-Docker.Task.ps1 @@ -1,4 +1,7 @@ Add-BuildTask Push-Docker @{ + # TODO: This should NOT be using metadata files + # TODO: This should work against GHCR (GitHub Container Registry), not require ACR + If = { $ACRName -and $ACRUri } Inputs = { # Docker metadata files created by DockerBuild task $MetadataFiles = Get-ChildItem (Join-Path $script:OutputRoot "docker") -Filter "*-metadata.json" -ErrorAction SilentlyContinue From f9b9849e5c80e2f75f122fb708d438bd0a94a4c4 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 9 May 2026 20:48:09 -0400 Subject: [PATCH 36/43] Update Invoke-Build to 5.14.23 --- scripts/Invoke-Build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Invoke-Build.ps1 b/scripts/Invoke-Build.ps1 index 37028d0..ccb195c 100644 --- a/scripts/Invoke-Build.ps1 +++ b/scripts/Invoke-Build.ps1 @@ -1,4 +1,4 @@ -<# +<# Invoke-Build 5.14.23 Copyright (c) Roman Kuzmin Licensed under the Apache License, Version 2.0 (the "License"); you may not use From b0e232d2cb4effc83ff7f3683e7ca40342fca2d5 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sat, 9 May 2026 22:57:07 -0400 Subject: [PATCH 37/43] Use EARTHLY_VERSION Show IB env var name in Publish-Module --- common/base.ps1 | 2 +- powershell/Publish-Module.Task.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/base.ps1 b/common/base.ps1 index 2f1b3aa..9eca9b6 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -68,7 +68,7 @@ $script:BuildSystem = if (Test-Path Env:HARNESS_STAGE_ID) { "GithubActions" } elseif (Test-Path Env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { "AzureDevops" -} elseif (Test-Path Env:EARTHLY_GIT_BRANCH) { +} elseif (Test-Path Env:EARTHLY_VERSION) { "Earthly" } else { "None" diff --git a/powershell/Publish-Module.Task.ps1 b/powershell/Publish-Module.Task.ps1 index 535f0cf..8acbdb3 100644 --- a/powershell/Publish-Module.Task.ps1 +++ b/powershell/Publish-Module.Task.ps1 @@ -22,7 +22,7 @@ Add-BuildTask Publish-Module { Write-Warning ("Skipping deployment: To deploy, ensure that...`n" + "`t* You are in a known build system (Current: $BuildSystem)`n" + "`t* You are committing to the main branch (Current: $BranchName) `n" + - "`t* The repository APIKey is defined in `$Script:PowerShellModulePublishKey (Current: $(![string]::IsNullOrWhiteSpace($Script:PowerShellModulePublishKey))) `n" + + "`t* The repository APIKey is in `$Script:PowerShellModulePublishKey (or Env:IB_PS_PUBLISH_KEY) (Current: $(![string]::IsNullOrWhiteSpace($Script:PowerShellModulePublishKey))) `n" + "`t* This is not a pull request") } } From df7e3fcd8b50742a40fd205d60dfda2f723b9003 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sun, 10 May 2026 00:11:22 -0400 Subject: [PATCH 38/43] This docker task is a mess --- {common => archive}/Push-Docker.Task.ps1 | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {common => archive}/Push-Docker.Task.ps1 (100%) diff --git a/common/Push-Docker.Task.ps1 b/archive/Push-Docker.Task.ps1 similarity index 100% rename from common/Push-Docker.Task.ps1 rename to archive/Push-Docker.Task.ps1 From 29510335734c89a640fb5cac24e90b7627fcaecf Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sun, 10 May 2026 00:14:16 -0400 Subject: [PATCH 39/43] Normalize the Publish/Push logic and make PowerShell follow it --- build.requires.psd1 | 11 ++++--- common/base.ps1 | 6 ++-- dotnet/Push-DotNet.Task.ps1 | 34 ++++++++++++-------- dotnet/base.ps1 | 9 +++--- helm/Push-Helm.Task.ps1 | 30 ++++++++++++------ helm/base.ps1 | 10 ++++-- powershell/Publish-Module.Task.ps1 | 30 +++--------------- powershell/Push-Module.Task.ps1 | 25 +++++++++++++++ powershell/base.ps1 | 51 +++++++----------------------- 9 files changed, 102 insertions(+), 104 deletions(-) create mode 100644 powershell/Push-Module.Task.ps1 diff --git a/build.requires.psd1 b/build.requires.psd1 index c4c09f1..15f2a2d 100644 --- a/build.requires.psd1 +++ b/build.requires.psd1 @@ -3,12 +3,13 @@ Configuration = "[1.5.0,2.0)" Metadata = '[1.5.0,6.0)' BicepFlex = '[5.2.0,6.0)' - LDEnvironments = '[1.0.0,2.0)' + PSPublishHelper = '[0.3.0,1.0)' yayaml = "0.*" + ConvertToSARIF = "1.*" "Az.ContainerRegistry" = "5.*" "Az.Accounts" = "5.*" - # "LDNative" = "[1.0.6,2.0)" - # "LDGit" = "[2.2.2,3.0)" - # "LDAzOps" = "[0.3.0,1.0)" - "ConvertToSARIF" = "1.*" + # LDEnvironments = '[1.0.0,2.0)' + # LDNative = "[1.0.6,2.0)" + # LDGit = "[2.2.2,3.0)" + # LDAzOps = "[0.3.0,1.0)" } diff --git a/common/base.ps1 b/common/base.ps1 index 9eca9b6..708b179 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -19,7 +19,9 @@ param( [switch]$SkipCoverage, # The base goal is 85% code coverage - $PassingCodeCoverage = 0.85 + $PassingCodeCoverage = 0.85, + + [switch]$PushEnabled = ($Env:EARTHLY_PUSH -eq "true") ) ## Guard against double-initialization in diamond inheritance @@ -196,7 +198,7 @@ $script:BuildTasks = @( ) $script:PublishTasks = @() $script:TestTasks = @() -$script:PushTasks = @("Push-Docker") +$script:PushTasks = @() $script:CheckpointTasks = @("Tag-Source") diff --git a/dotnet/Push-DotNet.Task.ps1 b/dotnet/Push-DotNet.Task.ps1 index 8d3c5b9..dfca42d 100644 --- a/dotnet/Push-DotNet.Task.ps1 +++ b/dotnet/Push-DotNet.Task.ps1 @@ -1,17 +1,25 @@ -Add-BuildTask Push-DotNet @{ - Jobs = "Pack-DotNet", { - $Package = Get-ChildItem $script:DotNetPackRoot -Recurse -Filter "*.nupkg" +Add-BuildTask Push-DotNet { + $Package = Get-ChildItem $script:DotNetPackRoot -Recurse -Filter "*.nupkg" - if ($script:PushEnabled -and "$NuGetPublishKey") { - foreach ($nupkg in $Package) { - Write-Build Yellow "dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri" - dotnet nuget push $nupkg --api-key $NuGetPublishKey --source $NuGetPublishUri - } - } else { - Write-Warning ("Skipping push: To push $Package ensure that...`n" + - "`t* You are in a known build system (Current: $BuildSystem)`n" + - "`t* You are committing to the main branch (Current: $BranchName) `n" + - "`t* The repository APIKey is defined in `$NuGetPublishKey (Current: $(!!"$NuGetPublishKey"))") + $DotNetPushEnabled = $script:NuGetPublishKey -and $script:NuGetPublishUri -and $Package -and ( + $script:PushEnabled -or ( + $script:BuildSystem -ne "None" -and + ($script:BranchName -eq "main" -or $script:BranchName -like "release/*") + ) + ) + + if ($DotNetPushEnabled) { + foreach ($nupkg in $Package) { + Write-Build Yellow "dotnet nuget push $nupkg --api-key $script:NuGetPublishKey --source $script:NuGetPublishUri" + dotnet nuget push $nupkg --api-key $script:NuGetPublishKey --source $script:NuGetPublishUri } + } else { + Write-Warning ("Skipping Push for DotNet. To push ensure that...`n" + + "`t* You have packages to push in $script:DotNetPackRoot (Current: $(@($Package).Count))`n" + + "`t* The repository Key is defined in `$NuGetPublishKey (Current: $(!!"$NuGetPublishKey"))" + + "`t* The repository URI is defined in `$NuGetPublishUri (Current: $(!!"$NuGetPublishUri"))" + + "`t* You have set PushEnabled (Current: $script:PushEnabled) OR`n" + + "`t* You are in a known build system (Current: $BuildSystem) AND`n" + + "`t* You are committing to the main branch (Current: $BranchName)") } } diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index aa2b9f0..c82ae95 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -42,11 +42,11 @@ param( # Sets framework for solution, included in build output path [ValidatePattern('^net\d+\.\d+$')] - $TargetFramework = "net10.0", + $TargetFramework = ($Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".")), # Sets runtime for solution, included in build output path [ValidateSet('linux-x64', 'win-x64', 'any')] - $TargetRuntime + $TargetRuntime = ($ENV:DOTNET_TARGET_RUNTIME ?? $Env:IB_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64")) ) # Redirect $BuildRoot to the root script's directory @@ -86,12 +86,11 @@ Enter-Build { $script:DotNetPublishRoot ??= Join-Path $script:OutputRoot publish $script:DotNetPackRoot ??= Join-Path $script:OutputRoot nuget $script:SolutionOutputRoot ??= Join-Path $script:OutputRoot $script:SolutionName - $script:SolutionTestResultsRoot = Join-Path $Script:TestResultsRoot $script:SolutionName + $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) - $script:TargetFramework ??= $Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".") - $script:TargetRuntime ??= $ENV:DOTNET_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64") + # These environment variables aren't just inputs, they're used by our Directory.Build.props $ENV:IB_TARGET_RUNTIME = $script:TargetRuntime $ENV:IB_CONFIGURATION = $script:Configuration diff --git a/helm/Push-Helm.Task.ps1 b/helm/Push-Helm.Task.ps1 index f6dd1d5..bde6409 100644 --- a/helm/Push-Helm.Task.ps1 +++ b/helm/Push-Helm.Task.ps1 @@ -1,16 +1,26 @@ Add-BuildTask Push-Helm @{ - Jobs = "Pack-Helm", "Connect-AzACR", { - if ($script:PushEnabled) { - foreach ($Chart in $script:HelmCharts) { - # If this sort turns out to not be enough, we need to split the name and cast to [semver] to sort - $ChartToPush = Get-ChildItem (Join-Path $script:HelmOutputRoot $Chart.Name) -Filter *.tgz | Sort-Object LastWriteTime | Select-Object -Last 1 - Write-Build Yellow "helm push $($ChartToPush.FullName) oci://$($script:ACRName).azurecr.io/helm" - Invoke-Native { helm push $ChartToPush.FullName "oci://$($script:ACRName).azurecr.io/helm" } -ExceptionalExit + Jobs = "Connect-AzACR", { + $Package = Get-ChildItem $script:HelmOutputRoot -Recurse -Filter *.tgz + + $HelmPushEnabled = $script:HelmRepository -and $Package -and ( + $script:PushEnabled -or ( + $script:BuildSystem -ne "None" -and + ($script:BranchName -eq "main" -or $script:BranchName -like "release/*") + ) + ) + + if ($HelmPushEnabled) { + foreach ($Chart in $Package) { + Write-Build Yellow "helm push $($Chart.FullName) $script:HelmRepository" + Invoke-Native { helm push $Chart.FullName $script:HelmRepository } -ExceptionalExit } } else { - Write-Warning ("Skipping push: To push charts ensure that...`n" + - "`t* You are in a known build system (Current: $BuildSystem)`n" + - "`t* You are committing to the main or release or hotfix branch (Current: $BranchName) `n") + Write-Warning ("Skipping Push for Helm. To push ensure that...`n" + + "`t* You have charts to push in $script:HelmOutputRoot (Current: $(@($Package).Count))`n" + + "`t* The repository URI is defined in `$HelmRepository (Current: $(!!"$HelmRepository"))`n" + + "`t* You have set PushEnabled (Current: $script:PushEnabled) OR`n" + + "`t* You are in a known build system (Current: $BuildSystem) AND`n" + + "`t* You are committing to the main branch (Current: $BranchName)") } } } diff --git a/helm/base.ps1 b/helm/base.ps1 index 0c9b3ac..37559ba 100644 --- a/helm/base.ps1 +++ b/helm/base.ps1 @@ -13,7 +13,12 @@ param( [string]$HelmChartRoot, # Build specific charts by name, e.g. -ChartName "macpublicservice" - [string[]]$ChartName + [string[]]$ChartName, + + [string]$ACRName = ($ENV:IB_ACR_NAME ?? "crazusw2dvosl1"), + + [string]$HelmRepository = ($Env:IB_HELM_REPOSITORY ?? "oci://$ACRName.azurecr.io/helm") + ) # Redirect $BuildRoot to the derived (root) script's directory @@ -28,7 +33,6 @@ Enter-Build { $script:HelmChartRoot = if (Test-Path $HelmChartRoot) { Convert-Path $HelmChartRoot } if ($script:HelmChartRoot) { - $script:ACRName = $script:ACRName ?? $ENV:ACR_URI ?? "crazusw2dvosl1" $script:HelmOutputRoot = Join-Path $Script:OutputRoot "charts" $script:ChartName ??= Get-ChildItem -Path $script:HelmChartRoot -File -Filter Chart.yaml -Recurse -Depth 1 | ForEach-Object { $_.Directory.Name } $script:HelmCharts = $script:ChartName | Join-Path -Path $script:HelmChartRoot -ChildPath { $_ } | Get-Item @@ -36,9 +40,9 @@ Enter-Build { Write-Build Cyan "Initializing Helm task variables (HelmChartRoot: $script:HelmChartRoot)" Write-Build Cyan " HelmChartRoot: $script:HelmChartRoot" - Write-Build Cyan " ACRName: $script:ACRName" Write-Build Cyan " HelmOutputRoot: $script:HelmOutputRoot" Write-Build Cyan " HelmCharts: $(($script:HelmCharts).Count)" + Write-Build Cyan " HelmRepository: $script:HelmRepository" } } #endregion diff --git a/powershell/Publish-Module.Task.ps1 b/powershell/Publish-Module.Task.ps1 index 8acbdb3..265a7af 100644 --- a/powershell/Publish-Module.Task.ps1 +++ b/powershell/Publish-Module.Task.ps1 @@ -1,28 +1,6 @@ -Add-BuildTask Publish-Module { - if ($BuildSystem -ne 'None' -and - $BranchName -in "master", "main", "release", "production" -and - -not [string]::IsNullOrWhiteSpace($Script:PowerShellModulePublishKey)) { - - $publishModuleSplat = @{ - Path = $Script:ModuleOutputRoot - NuGetApiKey = $Script:PowerShellModulePublishKey - Verbose = $true - Force = $true - Repository = $Script:PSRepository - ErrorAction = 'Stop' - } - "Files in module output:" - Get-ChildItem $Script:ModuleOutputRoot -Recurse -File | - Select-Object -Expand FullName - - "Publishing [$Script:ModuleOutputRoot] to [$Script:PSRepository]" - - Publish-Module @publishModuleSplat - } else { - Write-Warning ("Skipping deployment: To deploy, ensure that...`n" + - "`t* You are in a known build system (Current: $BuildSystem)`n" + - "`t* You are committing to the main branch (Current: $BranchName) `n" + - "`t* The repository APIKey is in `$Script:PowerShellModulePublishKey (or Env:IB_PS_PUBLISH_KEY) (Current: $(![string]::IsNullOrWhiteSpace($Script:PowerShellModulePublishKey))) `n" + - "`t* This is not a pull request") +Add-BuildTask Publish-Module @{ + If = { Test-Path $script:ManifestPath } + Jobs = { + Get-Module -List $script:ManifestPath | Publish-PSModuleNuget -OutputPath $script:PSPackageRoot } } diff --git a/powershell/Push-Module.Task.ps1 b/powershell/Push-Module.Task.ps1 new file mode 100644 index 0000000..81af401 --- /dev/null +++ b/powershell/Push-Module.Task.ps1 @@ -0,0 +1,25 @@ +Add-BuildTask Push-Module { + $Package = Get-ChildItem $script:PSPackageRoot -Recurse -Filter "*.nupkg" + + $PSPushEnabled = $script:PSPublishKey -and $script:PSPublishUri -and $Package -and ( + $script:PushEnabled -or ( + $script:BuildSystem -ne "None" -and + ($script:BranchName -eq "main" -or $script:BranchName -like "release/*") + ) + ) + + if ($PSPushEnabled) { + foreach ($nupkg in $Package) { + Write-Build Yellow "dotnet nuget push $nupkg --api-key $script:PSPublishKey --source $script:PSPublishUri" + dotnet nuget push $nupkg --api-key $script:PSPublishKey --source $script:PSPublishUri + } + } else { + Write-Warning ("Skipping Push for PowerShell. To push ensure that...`n" + + "`t* You have packages to push in $script:PSPackageRoot (Current: $(@($Package).Count))`n" + + "`t* The repository Key is defined in `$PSPublishKey (Current: $(!!"$PSPublishKey"))" + + "`t* The repository URI is defined in `$PSPublishUri (Current: $(!!"$PSPublishUri"))" + + "`t* You have set PushEnabled (Current: $script:PushEnabled) OR`n" + + "`t* You are in a known build system (Current: $BuildSystem) AND`n" + + "`t* You are committing to the main branch (Current: $BranchName)") + } +} diff --git a/powershell/base.ps1 b/powershell/base.ps1 index d6a3b34..fdcd047 100644 --- a/powershell/base.ps1 +++ b/powershell/base.ps1 @@ -14,14 +14,11 @@ param( # Name of the PowerShell module (defaults to the directory/project name) [string]$ModuleName = $Env:IB_MODULE_NAME, - # Name of the PSRepository to publish to - [string]$PSRepository = "DevOpsPowerShell", - # NuGet-compatible publish URI for the PS module repository - [string]$PowerShellModulePublishUri, + [string]$PSPublishUri, # API key for publishing to the PS module repository - [string]$PowerShellModulePublishKey, + [string]$PSPublishKey, # Pester filter hashtable (Tag, ExcludeTag, etc.) $PesterFilter, @@ -35,53 +32,28 @@ if ($BuildRoots.Count -gt 1) { $BuildRoot = $BuildRoots[-1] } -# Assign params to script scope early -- task If conditions evaluate at definition time -$script:ModuleName ??= $ModuleName -$script:PSRepository ??= $PSRepository - Enter-Build { - # Resolve credentials and repository from environment if not passed as parameters - $script:PSRepository = Get-Content Variable:PSRepository, Env:PSREPOSITORY -ErrorAction Ignore | - Select-Object -First 1 - if (-not $script:PSRepository) { $script:PSRepository = "DevOpsPowerShell" } - - $script:PowerShellModulePublishUri = Get-Content Variable:PowerShellModulePublishUri, - Env:IB_PS_PUBLISH_URI -ErrorAction Ignore | - Select-Object -First 1 - - $script:PowerShellModulePublishKey = Get-Content Variable:PowerShellModulePublishKey, - Env:IB_PS_PUBLISH_KEY -ErrorAction Ignore | - Select-Object -First 1 - # Default ModuleName to the project folder name if (-not $script:ModuleName) { $script:ModuleName = Split-Path $BuildRoot -Leaf } + $script:PSPublishUri ??= $Env:IB_PS_PUBLISH_URI + $script:PSPublishKey ??= $Env:IB_PS_PUBLISH_KEY + $script:ModuleOutputRoot = Join-Path $script:OutputRoot $script:ModuleName - $script:ManifestPath = Join-Path $script:ModuleOutputRoot "$script:ModuleName.psd1" + New-Item -Type Directory -Path $script:ModuleOutputRoot -Force | Out-Null + $script:PSPackageRoot = Join-Path $script:OutputRoot pspkg + New-Item -Type Directory -Path $script:PSPackageRoot -Force | Out-Null $script:ModuleTestResultsRoot = Join-Path $Script:TestResultsRoot $script:ModuleName New-Item -Type Directory -Path $script:ModuleTestResultsRoot -Force | Out-Null + $script:ManifestPath = Join-Path $script:ModuleOutputRoot "$script:ModuleName.psd1" Write-Build Cyan " ModuleName: $script:ModuleName" Write-Build Cyan " ModuleOutputRoot: $script:ModuleOutputRoot" $script:SourcePath ??= (Join-Path $BuildRoot src), (Join-Path $BuildRoot source), (Join-Path $BuildRoot $script:ModuleName) | Convert-Path -ErrorAction Ignore | Select-Object -First 1 - - Write-Build Cyan " PSRepository: $script:PSRepository" - - # Register PSRepository if a publish URI is provided and it isn't already registered correctly - if ($script:PowerShellModulePublishUri -and $script:PSRepository) { - $existing = Get-PSRepository -Name $script:PSRepository -ErrorAction Ignore - if (-not $existing -or $existing.PublishLocation -ne $script:PowerShellModulePublishUri) { - if ($existing) { Unregister-PSRepository -Name $script:PSRepository } - Register-PSRepository -Name $script:PSRepository ` - -SourceLocation $script:PowerShellModulePublishUri ` - -PublishLocation $script:PowerShellModulePublishUri ` - -InstallationPolicy Trusted - } - } } # Add the PowerShell tasks to the common tasks @@ -93,10 +65,9 @@ $script:InitializeTasks += @() $script:BuildTasks += $BuildTasks -contains "Build-DotNet" ? @("Publish-DotNet", "Build-Module") : @("Build-Module") -# TODO: Need to separate package & push -$script:PublishTasks += @() +$script:PublishTasks += @("Publish-Module") $script:TestTasks += @("Import-Module", "Test-PowerShell", "Test-PowerShellSyntax") -$script:PushTasks += @("Publish-Module") +$script:PushTasks += @("Push-Module") $script:CheckpointTasks += @() foreach ($taskfile in Get-ChildItem -Path $PSScriptRoot -Filter *.Task.ps1) { From 27deb0800fb659ce8e9f7090956609ba8302399f Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Tue, 12 May 2026 01:54:04 -0400 Subject: [PATCH 40/43] Put back the Publish-Module task, but don't use it anymore --- powershell/Pack-Module.Task.ps1 | 113 +++++++++++++++++++++++++++++ powershell/Publish-Module.Task.ps1 | 42 ++++++++++- powershell/base.ps1 | 9 +-- 3 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 powershell/Pack-Module.Task.ps1 diff --git a/powershell/Pack-Module.Task.ps1 b/powershell/Pack-Module.Task.ps1 new file mode 100644 index 0000000..7efc7cb --- /dev/null +++ b/powershell/Pack-Module.Task.ps1 @@ -0,0 +1,113 @@ +Add-BuildTask Pack-Module @{ + If = { Test-Path $script:ManifestPath } + Jobs = { + function Get-ModuleTag { + [CmdletBinding()] + param( + [PSModuleInfo]$Module, + [string[]]$Tags = @() + ) + end { + if ($Tags) { + $TagSet = [System.Collections.Generic.HashSet[string]]::new($Tags) + } else { + $TagSet = [System.Collections.Generic.HashSet[string]]::new() + } + + $null = $TagSet.add('PSModule') + + foreach ($Cmd in $Module.ExportedCmdlets.Keys) { + $null = $TagSet.Add('PSIncludes_Cmdlet') + $null = $TagSet.add(('PSCmdlet_{0}' -f $Cmd)) + } + + foreach ($Fn in $Module.ExportedFunctions.Keys) { + $null = $TagSet.Add('PSIncludes_Function') + $null = $TagSet.add(('PSFunction_{0}' -f $Fn)) + } + + foreach ($Cmd in $Module.ExportedCommands.Keys) { + $null = $TagSet.add(('PSCommand_{0}' -f $Cmd)) + } + + # TODO: DSC resources are not supported + # TODO: RoleCapabilities are not supported + + $TagSet -join ' ' + } + } + + function Convert-Required { + [OutputType([Microsoft.PowerShell.Commands.ModuleSpecification])] + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSModuleInfo]$Module, + [String[]]$ExternalModuleDependencies + ) + process { + # We require that the RequiredModules manifest and psm1 have the same name + $ModuleData = Import-PowerShellDataFile ([IO.Path]::ChangeExtension($Module.Path, ".psd1")) + [Microsoft.PowerShell.Commands.ModuleSpecification[]]$Required = $ModuleData.RequiredModules + + $Required.Where{ + # Don't put external dependencies in the nuspec + $_.Name -notin $ExternalModuleDependencies + }.ForEach{ + $Version = if ($_.RequiredVersion) { + 'version="[{0}]"' -f $_.RequiredVersion + } elseif ($_.Version -and $_.MaximumVersion) { + # Support wildcards? + 'version="[{0},{1}]"' -f $_.Version, ($_.MaximumVersion -replace "\*$", "99999") + } elseif ($_.MaximumVersion) { + # Support wildcards? + 'version="[, {0}]"' -f ($_.MaximumVersion -replace "\*$", "99999") + } elseif ($_.Version) { + 'version="{0}"' -f $_.Version + } else { + "" + } + '' -f $_.Name, $Version + } + } + } + + $NuspecPath = [IO.Path]::ChangeExtension($script:ManifestPath, ".nuspec") + $Module = Get-Module -List $script:ManifestPath + + @" + + + + {0} + {1} + {2} + {3} + {4} + {5} + {7} + {6} + {8} + {9} + + +"@ -f $Module.Name, + (@($Module.Version, $Module.PSData.Prerelease?.Trim("-")).Where{ $_ } -join '-'), + $Module.Author, + $Module.CompanyName, + $Module.Description, + $Module.ReleaseNotes, + ([bool]$Module.PSData.RequireLicenseAcceptance).ToString().ToLower(), + $Module.Copyright, + (@(Get-ModuleTag -Module $Module -Tags $Module.PSData.Tags) -join " "), + (@( + ($Module.ProjectUri ? "$($Module.ProjectUri)" : ""), + ($Module.IconUri ? "$($Module.IconUri)" : ""), + ($Module.LicenseUri ? "$($Module.LicenseUri)" : ""), + (Convert-Required $Module $Module.PSData.ExternalModuleDependencies) + ) -join "`n ") + | Set-Content $NuspecPath -Encoding UTF8 + + dotnet pack $NuspecPath --output $script:PSPackageRoot -v detailed -nologo + } +} diff --git a/powershell/Publish-Module.Task.ps1 b/powershell/Publish-Module.Task.ps1 index 265a7af..35da643 100644 --- a/powershell/Publish-Module.Task.ps1 +++ b/powershell/Publish-Module.Task.ps1 @@ -1,6 +1,46 @@ Add-BuildTask Publish-Module @{ If = { Test-Path $script:ManifestPath } Jobs = { - Get-Module -List $script:ManifestPath | Publish-PSModuleNuget -OutputPath $script:PSPackageRoot + $Package = Get-ChildItem $script:PSPackageRoot -Recurse -Filter "*.nupkg" + + $PSPushEnabled = $script:PSPublishKey -and $script:PSPublishUri -and $Package -and ( + $script:PushEnabled -or ( + $script:BuildSystem -ne "None" -and + ($script:BranchName -eq "main" -or $script:BranchName -like "release/*") + ) + ) + + if ($PSPushEnabled) { + # The need to register a PSRepository is why we don't use this anymore + $Repo = (Get-PSRepository -ErrorAction Ignore).Where({ $_.PublishLocation -eq $script:PSPublishUri }) + $PSRepository = if ($Repo) { + $Repo.Name + } else { + $PSRepository = [IO.Path]::GetRandomFileName() + Register-PSRepository -Name $PSRepository $script:PSPublishUri + } + + $publishModuleSplat = @{ + Path = $Script:ModuleOutputRoot + NuGetApiKey = $Script:PSPublishKey + Verbose = $true + Force = $true + Repository = $PSRepository + ErrorAction = 'Stop' + } + "Publishing [$Script:ModuleOutputRoot] to [$Script:PSPublishUri]" + Get-ChildItem $Script:ModuleOutputRoot -Recurse -File | + Select-Object -Expand FullName + + Publish-Module @publishModuleSplat + } else { + Write-Warning ("Skipping Publish-Module: To publish, ensure that...`n" + + "`t* You have packages to push in $script:PSPackageRoot (Current: $(@($Package).Count))`n" + + "`t* The repository Key is defined in `$PSPublishKey (Current: $(!!"$PSPublishKey"))" + + "`t* The repository URI is defined in `$PSPublishUri (Current: $(!!"$PSPublishUri"))" + + "`t* You have set PushEnabled (Current: $script:PushEnabled) OR`n" + + "`t* You are in a known build system (Current: $BuildSystem) AND`n" + + "`t* You are committing to the main branch (Current: $BranchName)") + } } } diff --git a/powershell/base.ps1 b/powershell/base.ps1 index fdcd047..43854d1 100644 --- a/powershell/base.ps1 +++ b/powershell/base.ps1 @@ -15,10 +15,10 @@ param( [string]$ModuleName = $Env:IB_MODULE_NAME, # NuGet-compatible publish URI for the PS module repository - [string]$PSPublishUri, + [string]$PSPublishUri = ($Env:IB_PS_PUBLISH_URI ?? "https://www.powershellgallery.com/api/v2/package/"), # API key for publishing to the PS module repository - [string]$PSPublishKey, + [string]$PSPublishKey = $Env:IB_PS_PUBLISH_KEY, # Pester filter hashtable (Tag, ExcludeTag, etc.) $PesterFilter, @@ -38,9 +38,6 @@ Enter-Build { $script:ModuleName = Split-Path $BuildRoot -Leaf } - $script:PSPublishUri ??= $Env:IB_PS_PUBLISH_URI - $script:PSPublishKey ??= $Env:IB_PS_PUBLISH_KEY - $script:ModuleOutputRoot = Join-Path $script:OutputRoot $script:ModuleName New-Item -Type Directory -Path $script:ModuleOutputRoot -Force | Out-Null $script:PSPackageRoot = Join-Path $script:OutputRoot pspkg @@ -65,7 +62,7 @@ $script:InitializeTasks += @() $script:BuildTasks += $BuildTasks -contains "Build-DotNet" ? @("Publish-DotNet", "Build-Module") : @("Build-Module") -$script:PublishTasks += @("Publish-Module") +$script:PublishTasks += @("Pack-Module") $script:TestTasks += @("Import-Module", "Test-PowerShell", "Test-PowerShellSyntax") $script:PushTasks += @("Push-Module") $script:CheckpointTasks += @() From 6287ade33c33c66eb1796c317692645a5bd90e37 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Wed, 13 May 2026 00:45:34 -0400 Subject: [PATCH 41/43] Fix a fearsome bug in the default parameter values Moral of the story: IB can't tell you where the error is when it's in the default parameters of the base build scripts... --- dotnet/base.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index c82ae95..0a71f91 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -8,6 +8,7 @@ param( [ValidateScript({ "../common/base.ps1" })] $Extends, + # dotnet build configuration parameter (Debug or Release) [ValidateSet('Debug', 'Release')] [string]$Configuration = ($Env:IB_CONFIGURATION ?? 'Release'), @@ -42,7 +43,7 @@ param( # Sets framework for solution, included in build output path [ValidatePattern('^net\d+\.\d+$')] - $TargetFramework = ($Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + $script:DotNetVersion.Split(".")[0..1] -join ".")), + $TargetFramework = ($Env:DOTNET_TARGET_FRAMEWORK ?? ("net" + ($Env:DOTNET_VERSION ?? (dotnet --version)).Split(".")[0..1] -join ".")), # Sets runtime for solution, included in build output path [ValidateSet('linux-x64', 'win-x64', 'any')] @@ -88,7 +89,6 @@ Enter-Build { $script:SolutionOutputRoot ??= Join-Path $script:OutputRoot $script:SolutionName $script:SolutionTestResultsRoot = Join-Path $Script:TestResultsRoot $script:SolutionName - $script:DotNetVersion ??= $Env:DOTNET_VERSION ?? (dotnet --version) # These environment variables aren't just inputs, they're used by our Directory.Build.props $ENV:IB_TARGET_RUNTIME = $script:TargetRuntime From b2ef2366e1938999d1c85c5a0047a82603ea5a0e Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Thu, 14 May 2026 02:04:51 -0400 Subject: [PATCH 42/43] Fix Configuration for dotnet tasks --- .../skills/new-build-dotnet/assets/Directory.Build.props | 1 - dotnet/Build-DotNet.Task.ps1 | 6 ++++-- dotnet/Pack-DotNet.Task.ps1 | 6 +++--- dotnet/Publish-DotNet.Task.ps1 | 6 ++++-- dotnet/Restore-DotNet.Task.ps1 | 7 +++++-- dotnet/base.ps1 | 2 -- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.agent/skills/new-build-dotnet/assets/Directory.Build.props b/.agent/skills/new-build-dotnet/assets/Directory.Build.props index d837235..165e5a4 100644 --- a/.agent/skills/new-build-dotnet/assets/Directory.Build.props +++ b/.agent/skills/new-build-dotnet/assets/Directory.Build.props @@ -24,7 +24,6 @@ $(IB_TARGET_RUNTIME) - $(IB_CONFIGURATION) $(RuntimeIdentifier) false diff --git a/dotnet/Build-DotNet.Task.ps1 b/dotnet/Build-DotNet.Task.ps1 index 840f3e8..8a75016 100644 --- a/dotnet/Build-DotNet.Task.ps1 +++ b/dotnet/Build-DotNet.Task.ps1 @@ -7,8 +7,10 @@ Add-BuildTask Build-DotNet @{ $DotNetProjects.ForEach({ Join-Path $_.OutDir $_.TargetFileName }) } Jobs = "Restore-DotNet", "Get-Version", { - $local:options = @{} + $script:dotnetOptions - $options["p"] = "Version=$(${script:Version}.InformationalVersion)" + $local:options = @{ + "-configuration" = $script:Configuration + "p" = "Version=$(${script:Version}.InformationalVersion)" + } + $script:dotnetOptions Write-Build Yellow "dotnet build $DotNetSolutionFile --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" # Invoke-BuildExec [-Command] ScriptBlock [[-ExitCode] Int32[]] [[-ErrorMessage] String] [-Echo] [-StdErr] diff --git a/dotnet/Pack-DotNet.Task.ps1 b/dotnet/Pack-DotNet.Task.ps1 index e3969a7..843f633 100644 --- a/dotnet/Pack-DotNet.Task.ps1 +++ b/dotnet/Pack-DotNet.Task.ps1 @@ -1,4 +1,3 @@ -#! If this is trying to pack a test project, you must add true to the project file. Add-BuildTask Pack-DotNet @{ If = { [bool]$DotNetProjects.Where({ $_.IsPackable }, "First", 1) @@ -13,11 +12,12 @@ Add-BuildTask Pack-DotNet @{ $script:DotNetPackRoot = New-Item $script:DotNetPackRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path $local:options = @{ - "-output" = $script:DotNetPackRoot + "-configuration" = $script:Configuration + "-output" = $script:DotNetPackRoot + "p" = "Version=$(${script:Version}.InformationalVersion)" } Write-Build Yellow "Packing $SolutionName" - $options["p"] = "Version=$(${script:Version}.InformationalVersion)" Write-Build Yellow "dotnet pack $DotNetSolutionFile --no-build --include-symbols $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" dotnet pack $DotNetSolutionFile --no-build --include-symbols @options diff --git a/dotnet/Publish-DotNet.Task.ps1 b/dotnet/Publish-DotNet.Task.ps1 index 4c89dc4..2bee87f 100644 --- a/dotnet/Publish-DotNet.Task.ps1 +++ b/dotnet/Publish-DotNet.Task.ps1 @@ -8,8 +8,10 @@ Add-BuildTask Publish-DotNet @{ Jobs = "Build-DotNet", "Pack-DotNet", { $script:DotNetPublishRoot = New-Item $script:DotNetPublishRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path - $local:options = @{} + $script:dotnetOptions - $options["p"] = "Version=$(${script:Version}.InformationalVersion)" + $local:options = @{ + "-configuration" = $script:Configuration + "p" = "Version=$(${script:Version}.InformationalVersion)" + } + $script:dotnetOptions Set-Location (Split-Path $DotNetSolutionFile) Write-Build Yellow "dotnet publish $DotNetSolutionFile --no-build --no-restore $(($options.GetEnumerator().ForEach({"-$($_.key) $($_.value)"})) -join ' ')" diff --git a/dotnet/Restore-DotNet.Task.ps1 b/dotnet/Restore-DotNet.Task.ps1 index 74dd8c8..058fc55 100644 --- a/dotnet/Restore-DotNet.Task.ps1 +++ b/dotnet/Restore-DotNet.Task.ps1 @@ -18,7 +18,10 @@ Add-BuildTask Restore-DotNet @{ # $Project.BaseIntermediateOutputRoot | Join-Path -ChildPath "project.assets.json" # } Jobs = "Install-DotNetTool", { - $local:options = @{} + $script:dotnetOptions + $local:options = @{ + "p" = "Configuration=$script:Configuration" + } + $script:dotnetOptions + if ($script:NugetConfigFile) { $options["-configfile"] = $script:NugetConfigFile } @@ -32,7 +35,7 @@ Add-BuildTask Restore-DotNet @{ foreach ($Property in $Project.PSObject.Properties.Name -ne "Path") { if ($RestoreOutput.Properties.$Property) { if ($Property -match "^Is") { - $Project.$Property = [bool]::Parse($RestoreOutput.Properties.$Property) + $Project.$Property = $RestoreOutput.Properties.$Property -eq "true" } else { $Project.$Property = $RestoreOutput.Properties.$Property } diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index 0a71f91..f6e5a6f 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -89,10 +89,8 @@ Enter-Build { $script:SolutionOutputRoot ??= Join-Path $script:OutputRoot $script:SolutionName $script:SolutionTestResultsRoot = Join-Path $Script:TestResultsRoot $script:SolutionName - # These environment variables aren't just inputs, they're used by our Directory.Build.props $ENV:IB_TARGET_RUNTIME = $script:TargetRuntime - $ENV:IB_CONFIGURATION = $script:Configuration $script:DotNetProjects = dotnet sln $script:DotNetSolutionFile list | Where-Object { $_ -like "*.*proj" } | From f9e31fa7b06abcc54fc10b884c12e60e016f376f Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Thu, 21 May 2026 01:21:21 -0400 Subject: [PATCH 43/43] Fix Dependencies in Pack-Module task Switch all the names from Publish to Pack for accuracy. Because publish implies pack and push Our Pack tasks should never push --- .../new-build-dotnet/assets/build.build.ps1 | 2 +- .../assets/build.build.ps1 | 2 +- build.build.ps1 | 2 +- common/base.ps1 | 4 +-- dotnet/base.ps1 | 2 +- helm/Pack-Helm.Task.ps1 | 1 - helm/base.ps1 | 2 +- powershell/Pack-Module.Task.ps1 | 33 ++++++++++++------- powershell/base.ps1 | 2 +- scripts/Update.ps1 | 0 10 files changed, 30 insertions(+), 20 deletions(-) create mode 100644 scripts/Update.ps1 diff --git a/.agent/skills/new-build-dotnet/assets/build.build.ps1 b/.agent/skills/new-build-dotnet/assets/build.build.ps1 index a0fb405..88ddcf1 100644 --- a/.agent/skills/new-build-dotnet/assets/build.build.ps1 +++ b/.agent/skills/new-build-dotnet/assets/build.build.ps1 @@ -48,5 +48,5 @@ Add-BuildTask . "Get-Version", "Build", "Test" Add-BuildTask Initialize $script:InitializeTasks Add-BuildTask Build $script:BuildTasks Add-BuildTask Test $script:TestTasks -Add-BuildTask Publish $script:PublishTasks +Add-BuildTask Pack $script:PackTasks Add-BuildTask Push $script:PushTasks \ No newline at end of file diff --git a/.agent/skills/new-build-powershell/assets/build.build.ps1 b/.agent/skills/new-build-powershell/assets/build.build.ps1 index 5d54ef0..4fe54af 100644 --- a/.agent/skills/new-build-powershell/assets/build.build.ps1 +++ b/.agent/skills/new-build-powershell/assets/build.build.ps1 @@ -47,5 +47,5 @@ Add-BuildTask . Get-Version, Build, Test Add-BuildTask Initialize $script:InitializeTasks Add-BuildTask Build $script:BuildTasks Add-BuildTask Test $script:TestTasks -Add-BuildTask Publish $script:PublishTasks +Add-BuildTask Pack $script:PackTasks Add-BuildTask Push $script:PushTasks \ No newline at end of file diff --git a/build.build.ps1 b/build.build.ps1 index 1dd71cc..e5e2651 100644 --- a/build.build.ps1 +++ b/build.build.ps1 @@ -38,5 +38,5 @@ Add-BuildTask . Get-Version, Tag-Source Add-BuildTask Initialize $script:InitializeTasks Add-BuildTask Build $script:BuildTasks Add-BuildTask Test $script:TestTasks -Add-BuildTask Publish $script:PublishTasks +Add-BuildTask Pack $script:PackTasks Add-BuildTask Push $script:PushTasks \ No newline at end of file diff --git a/common/base.ps1 b/common/base.ps1 index 708b179..191608f 100644 --- a/common/base.ps1 +++ b/common/base.ps1 @@ -196,7 +196,7 @@ $script:BuildTasks = @( # Otherwise it complicates our ability to cache dependencies "Get-Version" ) -$script:PublishTasks = @() +$script:PackTasks = @() $script:TestTasks = @() $script:PushTasks = @() $script:CheckpointTasks = @("Tag-Source") @@ -207,7 +207,7 @@ Add-BuildTask CI @( "Initialize" "Build" "Test" - "Publish" + "Pack" "Push" "Tag-Source" ) diff --git a/dotnet/base.ps1 b/dotnet/base.ps1 index f6e5a6f..f34656e 100644 --- a/dotnet/base.ps1 +++ b/dotnet/base.ps1 @@ -148,7 +148,7 @@ Enter-Build { # Add the dotnet tasks to the common tasks $script:InitializeTasks += @("Restore-DotNet") $script:BuildTasks += @("Build-DotNet") -$script:PublishTasks += @("Pack-DotNet", "Publish-DotNet") +$script:PackTasks += @("Pack-DotNet", "Publish-DotNet") $script:TestTasks += $script:BuildSystem -eq "None" ? @("Test-DotNet") : @("Test-DotNet", "Convert-Trx2JUnit", "Convert-Coverage") $script:PushTasks += @("Push-DotNet") $script:CheckpointTasks += @() diff --git a/helm/Pack-Helm.Task.ps1 b/helm/Pack-Helm.Task.ps1 index 0c07753..2494cbe 100644 --- a/helm/Pack-Helm.Task.ps1 +++ b/helm/Pack-Helm.Task.ps1 @@ -1,7 +1,6 @@ # The actual helm command is helm package # But we alias it as pack and publish for consistency with other frameworks Add-BuildTask Pack-Helm Package-Helm -Add-BuildTask Publish-Helm Package-Helm Add-BuildTask Package-Helm @{ Inputs = { Get-ChildItem $script:HelmCharts -File -Recurse } diff --git a/helm/base.ps1 b/helm/base.ps1 index 37559ba..9dd29ce 100644 --- a/helm/base.ps1 +++ b/helm/base.ps1 @@ -51,7 +51,7 @@ Enter-Build { # Add the helm tasks to the common tasks $script:InitializeTasks += @("Install-Helm", "Restore-Helm") $script:BuildTasks += @("Build-Helm") -$script:PublishTasks += @("Package-Helm") +$script:PackTasks += @("Package-Helm") $script:TestTasks += @("Test-Helm") $script:PushTasks += @("Push-Helm") $script:CheckpointTasks += @() diff --git a/powershell/Pack-Module.Task.ps1 b/powershell/Pack-Module.Task.ps1 index 7efc7cb..97cd7a1 100644 --- a/powershell/Pack-Module.Task.ps1 +++ b/powershell/Pack-Module.Task.ps1 @@ -38,7 +38,7 @@ Add-BuildTask Pack-Module @{ } function Convert-Required { - [OutputType([Microsoft.PowerShell.Commands.ModuleSpecification])] + [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] @@ -48,12 +48,16 @@ Add-BuildTask Pack-Module @{ process { # We require that the RequiredModules manifest and psm1 have the same name $ModuleData = Import-PowerShellDataFile ([IO.Path]::ChangeExtension($Module.Path, ".psd1")) - [Microsoft.PowerShell.Commands.ModuleSpecification[]]$Required = $ModuleData.RequiredModules - - $Required.Where{ + [Microsoft.PowerShell.Commands.ModuleSpecification[]]$RequiredModules = $ModuleData.RequiredModules + $Required = $RequiredModules.Where{ # Don't put external dependencies in the nuspec $_.Name -notin $ExternalModuleDependencies - }.ForEach{ + } + Wait-Debugger + if ($Required.Count -eq 0) { return } + + "" + $Required.ForEach{ $Version = if ($_.RequiredVersion) { 'version="[{0}]"' -f $_.RequiredVersion } elseif ($_.Version -and $_.MaximumVersion) { @@ -64,11 +68,18 @@ Add-BuildTask Pack-Module @{ 'version="[, {0}]"' -f ($_.MaximumVersion -replace "\*$", "99999") } elseif ($_.Version) { 'version="{0}"' -f $_.Version + } elseif (($Actual = Get-Module $_.Name)) { + # Best practice is not to specify an upper bound unless you KNOW of an incompatibility + 'version="{0}"' -f $Actual.Version.ToString(3) + } elseif (($Actual = (Get-Module $_.Name -ListAvailable)[0])) { + # Best practice is not to specify an upper bound unless you KNOW of an incompatibility + 'version="{0}"' -f $Actual.Version.ToString(3) } else { - "" + 'version="0.0"' } '' -f $_.Name, $Version } + "" } } @@ -85,8 +96,8 @@ Add-BuildTask Pack-Module @{ {3} {4} {5} - {7} {6} + {7} {8} {9} @@ -101,10 +112,10 @@ Add-BuildTask Pack-Module @{ $Module.Copyright, (@(Get-ModuleTag -Module $Module -Tags $Module.PSData.Tags) -join " "), (@( - ($Module.ProjectUri ? "$($Module.ProjectUri)" : ""), - ($Module.IconUri ? "$($Module.IconUri)" : ""), - ($Module.LicenseUri ? "$($Module.LicenseUri)" : ""), - (Convert-Required $Module $Module.PSData.ExternalModuleDependencies) + ($Module.ProjectUri ? "$($Module.ProjectUri)" : "") + ($Module.IconUri ? "$($Module.IconUri)" : "") + ($Module.LicenseUri ? "$($Module.LicenseUri)" : "") + @(Convert-Required $Module $Module.PSData.ExternalModuleDependencies) ) -join "`n ") | Set-Content $NuspecPath -Encoding UTF8 diff --git a/powershell/base.ps1 b/powershell/base.ps1 index 43854d1..dccf15b 100644 --- a/powershell/base.ps1 +++ b/powershell/base.ps1 @@ -62,7 +62,7 @@ $script:InitializeTasks += @() $script:BuildTasks += $BuildTasks -contains "Build-DotNet" ? @("Publish-DotNet", "Build-Module") : @("Build-Module") -$script:PublishTasks += @("Pack-Module") +$script:PackTasks += @("Pack-Module") $script:TestTasks += @("Import-Module", "Test-PowerShell", "Test-PowerShellSyntax") $script:PushTasks += @("Push-Module") $script:CheckpointTasks += @() diff --git a/scripts/Update.ps1 b/scripts/Update.ps1 new file mode 100644 index 0000000..e69de29