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..3c5a189 --- /dev/null +++ b/.agent/skills/new-build-dotnet/SKILL.md @@ -0,0 +1,35 @@ +--- +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. 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 + - 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..165e5a4 --- /dev/null +++ b/.agent/skills/new-build-dotnet/assets/Directory.Build.props @@ -0,0 +1,43 @@ + + + + + + + + + _ + $([MSBuild]::NormalizeDirectory($(MSBuildThisFileDirectory), "Output")) + + $([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) + $(RuntimeIdentifier) + false + + + + + + + False + False + + 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..88ddcf1 --- /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 Pack $script:PackTasks +Add-BuildTask Push $script:PushTasks \ No newline at end of file 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/.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_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/3_Copy_Build_Assets.md b/.agent/skills/new-build-dotnet/references/3_Copy_Build_Assets.md new file mode 100644 index 0000000..0e05388 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/3_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/4_Update_Projects.md b/.agent/skills/new-build-dotnet/references/4_Update_Projects.md new file mode 100644 index 0000000..8675012 --- /dev/null +++ b/.agent/skills/new-build-dotnet/references/4_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/.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..4fe54af --- /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 Pack $script:PackTasks +Add-BuildTask Push $script:PushTasks \ No newline at end of file diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6bb903f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "gitversion.tool": { + "version": "6.5.1", + "commands": [ + "dotnet-gitversion" + ], + "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/.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.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/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/DockerBuild.Task.ps1 b/DockerBuild.Task.ps1 deleted file mode 100644 index 2904ae3..0000000 --- a/DockerBuild.Task.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -Add-BuildTask DockerBuild @{ - # This task can only be skipped if the images are newer than the source files - If = $dotnetProjects - Inputs = { - $dotnetProjects.Where{ Get-ChildItem (Split-Path $_) -File -Filter Dockerfile } | - Get-ChildItem -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() } - } - Jobs = { - foreach ($project in $dotnetProjects.Where{ Get-ChildItem (Split-Path $_) -File -Filter Dockerfile }) { - Set-Location (Split-Path $project) - $name = (Split-Path $project -LeafBase).ToLower() - - Write-Build Gray "docker build . --tag $name --iidfile $(Join-Path $OutputRoot $name)" - docker build . --tag $name --iidfile (Join-Path $OutputRoot $name) - } - } -} 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 bb2a7ee..dc95a2c 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,46 +1,28 @@ -mode: mainline # Each merged branch against main will increment the patch version unless otherwise specified in a commit message -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}" -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 - +# https://gitversion.net/docs/reference/configuration +workflow: GitFlow/v1 +mode: ContinuousDelivery +increment: Minor +strategies: +- TaggedCommit +- TrackReleaseBranches +- VersionInBranchName branches: main: - tag: "" # explicitly no tag for main builds - regex: ^master$|^main$ - increment: Patch - is-mainline: true - tracks-release-branches: true - hotfix: - tag: rc - regex: hotfix(es)?/\d+\.\d+\.\d+ - increment: None - is-release-branch: true - prevent-increment-of-merged-branch-version: true - source-branches: [ "main" ] + regex: ^production$ + develop: + regex: ^main$ + label: "beta" release: - tag: rc - regex: releases?/\d+\.\d+\.\d+ - increment: None - is-release-branch: true - prevent-increment-of-merged-branch-version: true - source-branches: [ "main" ] + label: rc + mode: ContinuousDelivery + hotfix: + label: rc + mode: ContinuousDelivery pull-request: - regex: pull/ - tag: pr - tag-number-pattern: '[/-](?\d+)' - increment: Patch - source-branches: [ "main", "feature", "release", "hotfix" ] - feature: - regex: .*/ - tag: useBranchName - source-branches: [ "main", "feature" ] + regex: ^pull/(?[^/-]+)/merge$ + label: apr.{Number}.c track-merge-target: true - tracks-release-branches: true - increment: Patch + feature: + regex: ^feat(ure)?/[^\d]*(?\d+)?.*?$ + mode: ContinuousDelivery + label: alpha.{Number}.c diff --git a/PSModuleAnalyze.Task.ps1 b/PSModuleAnalyze.Task.ps1 deleted file mode 100644 index 4890467..0000000 --- a/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/PSModuleBuild.Task.ps1 b/PSModuleBuild.Task.ps1 deleted file mode 100644 index 309474a..0000000 --- a/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/PSModuleImport.Task.ps1 b/PSModuleImport.Task.ps1 deleted file mode 100644 index 705fab2..0000000 --- a/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/PSModulePush.Task.ps1 b/PSModulePush.Task.ps1 deleted file mode 100644 index 8aea2ff..0000000 --- a/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/PSModuleRestore.Task.ps1 b/PSModuleRestore.Task.ps1 deleted file mode 100644 index 3ea2ff4..0000000 --- a/PSModuleRestore.Task.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -Add-BuildTask PSModuleRestore @{ - If = Test-Path "$BuildRoot${/}*.requires.psd1" - Inputs = "$BuildRoot${/}*.requires.psd1" | Convert-Path -ErrorAction ignore - Outputs = "$OutputRoot${/}*.requires.psd1" - Jobs = { - Install-ModuleFast -Scope CurrentUser -Verbose - } -} diff --git a/PSModuleTest.Task.ps1 b/PSModuleTest.Task.ps1 deleted file mode 100644 index 11e4fa1..0000000 --- a/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${/}TestResults.xml" - } - } - Jobs = "PSModuleImport", { - $PSModuleTestPath ??= "$BuildRoot${/}[Tt]ests" - # The output path, by convention: TestResults.xml in your output folder - $TestResultOutputPath ??= Join-Path $TestResultsRoot "TestResult.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${/}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/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/README.md b/README.md index b2e442c..25e3e8a 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,111 @@ -# Opinionated Build Tasks for Invoke-Build - -- Requires PowerShell 7.2 or later. -- Should work with any module from my [PowerShellTemplate](/jaykul/PowerShellTemplate). - -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" +# LD.Platform.BuildTasks + +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/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 28fd475..0000000 --- a/_Bootstrap.ps1 +++ /dev/null @@ -1,134 +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 "7.0" channel, change it to change the minimum version - [double]$DotNet = "7.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), - - # 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 -} - -Write-Information "Restore dotnet tools" -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")) { - 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 -} - -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) { - 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/archive/DotNetDockerBuild.Task.ps1 b/archive/DotNetDockerBuild.Task.ps1 new file mode 100644 index 0000000..b17b9ba --- /dev/null +++ b/archive/DotNetDockerBuild.Task.ps1 @@ -0,0 +1,93 @@ +# TODO: Where is the repository name coming from? +Add-BuildTask DotNetDockerBuild @{ + Inputs = { + $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue + $PublishedDockerfiles.ForEach({ + Get-ChildItem $_.Directory -File + }) + } + Outputs = { + $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue + if ($PublishedDockerFiles) { + $PublishedDockerfiles.ForEach({ + $Project = $_.DirectoryName + Join-Path $script:OutputRoot "docker/$Project-metadata.json" + }) + } else { + $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:OutputRoot "docker") -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path + $PublishedDockerfiles = Get-ChildItem $script:DotNetPublishRoot -Recurse -File -Filter "Dockerfile" -ErrorAction SilentlyContinue + + foreach ($Dockerfile in $PublishedDockerfiles) { + $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 + $FullImageName = "$script:ACRUri/$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 ' ')" + + & docker @BuildArgs + } + } +} diff --git a/archive/MonoRepoGitVersion.Task.ps1 b/archive/MonoRepoGitVersion.Task.ps1 new file mode 100644 index 0000000..803603e --- /dev/null +++ b/archive/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 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)" ` + -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/archive/MonoRepoTagSource.Task.ps1 b/archive/MonoRepoTagSource.Task.ps1 new file mode 100644 index 0000000..8eb9518 --- /dev/null +++ b/archive/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/archive/Pack-UniversalPackage.Task.ps1 b/archive/Pack-UniversalPackage.Task.ps1 new file mode 100644 index 0000000..1307413 --- /dev/null +++ b/archive/Pack-UniversalPackage.Task.ps1 @@ -0,0 +1,43 @@ + +Add-BuildTask Pack-UniversalPackage @{ + 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: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) { + $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 = 'Publish-DotNet', { + $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 = $_ + $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 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/Push-Docker.Task.ps1 b/archive/Push-Docker.Task.ps1 new file mode 100644 index 0000000..9995ede --- /dev/null +++ b/archive/Push-Docker.Task.ps1 @@ -0,0 +1,63 @@ +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 + if ($MetadataFiles) { + $MetadataFiles.FullName + } + else { + $BuildRoot + } + } + Outputs = { + # Create a marker file for each pushed image + $MetadataFiles = Get-ChildItem (Join-Path $script:OutputRoot "docker") -Filter "*-metadata.json" -ErrorAction SilentlyContinue + if ($MetadataFiles) { + $MetadataFiles.ForEach({ + $ProjectName = $_.BaseName -replace '-metadata$', '' + Join-Path $script:OutputRoot "docker/$ProjectName-pushed.txt" + }) + } + else { + $BuildRoot + } + } + Jobs = "Connect-AzACR", { + if ($script:PushEnabled) { + $script:DockerMetadataRoot = Join-Path $script:OutputRoot "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/archive/SonarQubeEnd.Task.ps1 b/archive/SonarQubeEnd.Task.ps1 new file mode 100644 index 0000000..3fe07f8 --- /dev/null +++ b/archive/SonarQubeEnd.Task.ps1 @@ -0,0 +1,6 @@ +Add-BuildTask SonarQubeEnd @{ + If = { $script:SonarProjectKey -and $script:SonarToken } + Jobs = { + 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 new file mode 100644 index 0000000..8c01ee3 --- /dev/null +++ b/archive/SonarQubeStart.Task.ps1 @@ -0,0 +1,12 @@ +Add-BuildTask SonarQubeStart @{ + If = { $script:SonarProjectKey -and $script:SonarToken } + Jobs = "Get-Version", { + dotnet tool execute 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/build.build.ps1 b/build.build.ps1 new file mode 100644 index 0000000..e5e2651 --- /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 Pack $script:PackTasks +Add-BuildTask Push $script:PushTasks \ No newline at end of file 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: diff --git a/build.requires.psd1 b/build.requires.psd1 new file mode 100644 index 0000000..15f2a2d --- /dev/null +++ b/build.requires.psd1 @@ -0,0 +1,15 @@ +@{ + InvokeBuild = "5.*" + Configuration = "[1.5.0,2.0)" + Metadata = '[1.5.0,6.0)' + BicepFlex = '[5.2.0,6.0)' + PSPublishHelper = '[0.3.0,1.0)' + yayaml = "0.*" + ConvertToSARIF = "1.*" + "Az.ContainerRegistry" = "5.*" + "Az.Accounts" = "5.*" + # 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/Clean-Output.Task.ps1 b/common/Clean-Output.Task.ps1 new file mode 100644 index 0000000..9d05f95 --- /dev/null +++ b/common/Clean-Output.Task.ps1 @@ -0,0 +1,4 @@ +Add-BuildTask Clean-Output { + Remove-BuildItem $OutputRoot + New-Item $OutputRoot -ItemType Directory -Force | Out-Null +} diff --git a/common/Connect-AzACR.Task.ps1 b/common/Connect-AzACR.Task.ps1 new file mode 100644 index 0000000..2827bd2 --- /dev/null +++ b/common/Connect-AzACR.Task.ps1 @@ -0,0 +1,28 @@ +Add-BuildTask Connect-AzACR @{ + If = { $ACRName -and $ACRUri } + Jobs = { + 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://$script:ACRUri/oauth2/exchange" -Method Post -Body @{ + grant_type = "access_token" + service = $script:ACRUri + access_token = $env:AZURE_ACCESS_TOKEN + tenant = $TenantId + }).refresh_token + + 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 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/common/Convert-Coverage.Task.ps1 b/common/Convert-Coverage.Task.ps1 new file mode 100644 index 0000000..5bfb1d9 --- /dev/null +++ b/common/Convert-Coverage.Task.ps1 @@ -0,0 +1,31 @@ +Add-BuildTask Convert-Coverage @{ + If = { !$Script:SkipCoverage } + Jobs = { + New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null + Set-Location $SolutionTestResultsRoot + # ------------------------------ + dotnet tool execute dotnet-reportgenerator-globaltool -reports:'./coverage/*.xml' ` + -targetdir:'./coverage' ` + -reporttypes:'Html;MarkdownSummaryGithub;TextSummary' ` + -filefilters:'+*;-/_*' ` + -title:"$script:ProductName" ` + -tag:"$(${script:Version}.InformationalVersion)" ` + --yes + + switch ($script:BuildSystem) { + "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 "$SolutionTestResultsRoot/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/common/Get-Version.Task.ps1 b/common/Get-Version.Task.ps1 new file mode 100644 index 0000000..bdace85 --- /dev/null +++ b/common/Get-Version.Task.ps1 @@ -0,0 +1,71 @@ +$script:VersionCacheFile = "$Script:OutputRoot/version.json" +$script:Version = @{} + +<# NOTE: this version does not include support for multiple versions per-repo #> +Add-BuildTask Get-Version @{ + 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:OutputRoot/version.json) { + Get-Content $Script:OutputRoot/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 = "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 + + # agent temp SHOULD be cleaned after each pipeline job + $VersionCacheFile = "$TempRoot/version.json" + + # Delete the VersionCache so that importing it will fail if gitversion fails + if (Test-Path $VersionCacheFile) { + Remove-Item $VersionCacheFile + } + + 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 --yes | Out-Host + + try { + $local:GitVersion = Get-Content $VersionCacheFile | ConvertFrom-Json -ErrorAction Stop + } 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' + 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 + 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:OutputRoot/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/common/GitVersion.yml b/common/GitVersion.yml new file mode 100644 index 0000000..7993e83 --- /dev/null +++ b/common/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/common/Initialize-Git.Task.ps1 b/common/Initialize-Git.Task.ps1 new file mode 100644 index 0000000..cf38789 --- /dev/null +++ b/common/Initialize-Git.Task.ps1 @@ -0,0 +1,12 @@ +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)) + } + 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/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-DotNetTool.Task.ps1 b/common/Install-DotNetTool.Task.ps1 new file mode 100644 index 0000000..49048cb --- /dev/null +++ b/common/Install-DotNetTool.Task.ps1 @@ -0,0 +1,25 @@ +<# +.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 Install-DotNetTool @{ + Jobs = { + $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 + $local:options = @{ + "-tool-manifest" = $DotNetToolManifest + } + if ($script:NugetConfigFile) { + $options["-configfile"] = $script:NugetConfigFile + } + dotnet tool restore @options + } +} diff --git a/common/Install-FromGitHub.Task.ps1 b/common/Install-FromGitHub.Task.ps1 new file mode 100644 index 0000000..72b971d --- /dev/null +++ b/common/Install-FromGitHub.Task.ps1 @@ -0,0 +1,12 @@ +# TODO: in pipeline environments, we should trigger the "cache" task for these to speed up using them +Add-BuildTask Install-FromGitHub @{ + If = { $script:GHTools.keys.Count -gt 0 } + Jobs = { + 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-FromGitHub.ps1") -ErrorAction SilentlyContinue + } + } + } +} diff --git a/common/Install-PowerShellModule.Task.ps1 b/common/Install-PowerShellModule.Task.ps1 new file mode 100644 index 0000000..7cbe4e9 --- /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 $OutputRoot -ChildPath $_.Name } } + Jobs = (Get-Command "$PSScriptRoot/../scripts/Install-PowerShellModule.ps1").ScriptBlock, + { Copy-Item "$BuildRoot/*.requires.psd1" -Destination "$OutputRoot/" } +} 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/Test-PowerShell.Task.ps1 b/common/Test-PowerShell.Task.ps1 new file mode 100644 index 0000000..ea5d387 --- /dev/null +++ b/common/Test-PowerShell.Task.ps1 @@ -0,0 +1,96 @@ + +Add-BuildTask Test-PowerShell @{ + Inputs = { + 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 ($script:ModuleTestResultsRoot ?? $script:TestResultsRoot) "results.xml" + } + } + 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 + # 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:OutputRoot) + @($Env:PSModulePath -split [IO.Path]::PathSeparator -ne $script:OutputRoot) -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") + } + + # 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 = @{ + Path = "$BuildRoot/[Tt]ests" + Passthru = $true + } + Filter = $PesterFilter + TestResult = @{ + Enabled = $true + OutputPath = Join-Path $local:ModuleTestResultsRoot "results.xml" + } + Debug = @{ + ShowNavigationMarkers = $Host.Name -match "Visual Studio Code" + } + Output = @{ + Verbosity = if ($VerbosePreference -eq "Continue") { "Detailed" } else { "Normal" } + RenderMode = "Ansi" + CIFormat = $BuildSystem -ne "Earthly" ? $BuildSystem : "Auto" + } + CodeCoverage = @{ + Enabled = !$SkipCoverage + Path = Get-Item $local:CoverageRoot\*.psm1, $local:CoverageRoot\*.ps1 + OutputPath = Join-Path $local: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 new file mode 100644 index 0000000..191608f --- /dev/null +++ b/common/base.ps1 @@ -0,0 +1,218 @@ +#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, + + # Default to collecting code coverage when tests are run + [switch]$SkipCoverage, + + # The base goal is 85% code coverage + $PassingCodeCoverage = 0.85, + + [switch]$PushEnabled = ($Env:EARTHLY_PUSH -eq "true") +) + +## 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_VERSION) { + "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 = $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") + [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:OutputRoot = $Env:BUILD_BINARIESDIRECTORY ?? + $Env:IB_OUTPUT_ROOT ?? + (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_RESULTS_ROOT ?? # An override for machine-level settings + $Env:TEST_RESULTS_DIRECTORY ?? + (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 $_ } | + Select-Object -First 1 + 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" } + # For example: + # $Script:Tools = @{ + # yq = "mikefarah", "yq" + # flux = "fluxcd", "flux2" + # } + [hashtable]$Script:GHTools = @{} + ($Script:GHTools ?? @{}) + + $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 + } + $script:NugetConfigFile = Get-ChildItem $BuildRoot -Filter "[Nn]u[Gg]et.config" | Convert-Path + + Write-Build Cyan " OutputRoot: $OutputRoot" + Write-Build Cyan " TestResultsRoot: $TestResultsRoot" + Write-Build Cyan " TempRoot: $TempRoot" + Write-Build Cyan " UniversalPackageRoot: $UniversalPackageRoot" + + # 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 = @( + # 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" +) +$script:BuildTasks = @( + # Get-Version should run first in Build, but not before + # Otherwise it complicates our ability to cache dependencies + "Get-Version" +) +$script:PackTasks = @() +$script:TestTasks = @() +$script:PushTasks = @() +$script:CheckpointTasks = @("Tag-Source") + + +# Initially define the CI task as Get-Version...Tag-Source using virtual task names +Add-BuildTask CI @( + "Initialize" + "Build" + "Test" + "Pack" + "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 new file mode 100644 index 0000000..4009b9a --- /dev/null +++ b/docs/Extends.md @@ -0,0 +1,587 @@ +# Build Script Inheritance with Invoke-Build `Extends` + +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 + +### The `$Extends` 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({ + @( + "../*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 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 + +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 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** -- 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). + +## Multiple Inheritance and Prefixing + +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 +) +``` + +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): + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ PARAMETER DISCOVERY (depth-first) │ +│ │ +│ 1. common/base.ps1 → $Clean, $SkipCoverage │ +│ 2. helm/base.ps1 → $HelmChartRoot, $ChartName │ +│ 3. common/base.ps1 → $Clean, $SkipCoverage (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) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### User-Provided Values vs Defaults + +There are two cases when a parameter is defined in multiple scripts: + +**Case 1: User provides a value on the command line** + +```powershell +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" │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**The derived (root) script's default always wins**, because it runs last. + +### Parameters in Enter-Build + +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: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 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) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**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). + +### Parameter Default Expressions and `$PSScriptRoot` + +Parameter default expressions evaluate in the context of **their declaring script**. This matters when defaults use `$PSScriptRoot`: + +```powershell +# In helm/base.ps1 -- $PSScriptRoot = C:\...\LD.Platform.BuildTasks\helm +param( + [string]$HelmChartRoot = @( + if (Get-ChildItem -Path "$PSScriptRoot/charts" ...) { # ← helm.ps1's dir + Resolve-Path "$PSScriptRoot/charts" + } + )[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`: + +```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] +} +``` + +### Diamond Inheritance and the Guard Pattern + +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 +# 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) │ +└──────────────────────────────┴──────────────────────────────────────┘ +``` + +## `$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-FromGitHub @{ If = $script:GHTools.Count -gt 0 } + +# RIGHT -- deferred to runtime, evaluates after Enter-Build sets $GHTools: +Add-BuildTask Install-FromGitHub @{ 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:IB_* = ... │ │ ✓ │ +└──────────────────────────────────┴────────────┴──────────────┘ +``` + +## 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 | +| **`$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 + +- [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/dotnet/Build-DotNet.Task.ps1 b/dotnet/Build-DotNet.Task.ps1 new file mode 100644 index 0000000..8a75016 --- /dev/null +++ b/dotnet/Build-DotNet.Task.ps1 @@ -0,0 +1,20 @@ +Add-BuildTask Build-DotNet @{ + # TODO: Are these Inputs/Outputs actually ever saving us time? + Inputs = { + $DotNetProjects.ForEach({ Get-ChildItem (Split-Path $_.Path) -Recurse -File -ErrorAction SilentlyContinue }) + } + Outputs = { + $DotNetProjects.ForEach({ Join-Path $_.OutDir $_.TargetFileName }) + } + Jobs = "Restore-DotNet", "Get-Version", { + $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] + + dotnet build $DotNetSolutionFile --no-restore @options + } +} diff --git a/dotnet/Clean-DotNet.Task.ps1 b/dotnet/Clean-DotNet.Task.ps1 new file mode 100644 index 0000000..d1fe496 --- /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 $DotNetSolutionFile + } +} diff --git a/dotnet/Convert-Trx2JUnit.Task.ps1 b/dotnet/Convert-Trx2JUnit.Task.ps1 new file mode 100644 index 0000000..aac8ea7 --- /dev/null +++ b/dotnet/Convert-Trx2JUnit.Task.ps1 @@ -0,0 +1,17 @@ +Add-BuildTask Convert-Trx2JUnit @{ + Partial = $true + Input = { + New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null + Get-ChildItem $SolutionTestResultsRoot/*.trx + } + Output = { + process { + [System.IO.Path]::ChangeExtension($_, 'xml') + } + } + Jobs = { + Get-ChildItem $SolutionTestResultsRoot/*.trx | ForEach-Object -ThrottleLimit ([Environment]::ProcessorCount - 1) -Parallel { + dotnet tool execute trx2junit $_ --yes | Select-String -Pattern "Converting\s'" + } + } +} \ No newline at end of file diff --git a/dotnet/Pack-DotNet.Task.ps1 b/dotnet/Pack-DotNet.Task.ps1 new file mode 100644 index 0000000..843f633 --- /dev/null +++ b/dotnet/Pack-DotNet.Task.ps1 @@ -0,0 +1,25 @@ +Add-BuildTask Pack-DotNet @{ + If = { + [bool]$DotNetProjects.Where({ $_.IsPackable }, "First", 1) + } + Inputs = { + $DotNetProjects.Where({ $_.IsPackable }).ForEach({ Join-Path $_.OutDir $_.TargetFileName }) + } + Outputs = { + $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 + + $local:options = @{ + "-configuration" = $script:Configuration + "-output" = $script:DotNetPackRoot + "p" = "Version=$(${script:Version}.InformationalVersion)" + } + + Write-Build Yellow "Packing $SolutionName" + + 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 new file mode 100644 index 0000000..2bee87f --- /dev/null +++ b/dotnet/Publish-DotNet.Task.ps1 @@ -0,0 +1,20 @@ +Add-BuildTask Publish-DotNet @{ + Inputs = { + $DotNetProjects.Where({ $_.IsPublishable }).ForEach({ Join-Path $_.OutDir $_.TargetFileName }) + } + Outputs = { + $DotNetProjects.Where({ $_.IsPublishable }).ForEach({ Join-Path $_.PublishDir $_.TargetFileName }) + } + Jobs = "Build-DotNet", "Pack-DotNet", { + $script:DotNetPublishRoot = New-Item $script:DotNetPublishRoot -ItemType Directory -Force -ErrorAction SilentlyContinue | Convert-Path + + $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 ' ')" + dotnet publish $DotNetSolutionFile --no-build --no-restore @options + } +} diff --git a/dotnet/Push-DotNet.Task.ps1 b/dotnet/Push-DotNet.Task.ps1 new file mode 100644 index 0000000..dfca42d --- /dev/null +++ b/dotnet/Push-DotNet.Task.ps1 @@ -0,0 +1,25 @@ +Add-BuildTask Push-DotNet { + $Package = Get-ChildItem $script:DotNetPackRoot -Recurse -Filter "*.nupkg" + + $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/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 new file mode 100644 index 0000000..058fc55 --- /dev/null +++ b/dotnet/Restore-DotNet.Task.ps1 @@ -0,0 +1,46 @@ +<# +.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 = { + # $DotNetProjects.Path + # if (Test-Path "$BuildRoot/NuGet.config") { + # "$BuildRoot/NuGet.config" + # } + # } + # Outputs = { + # # Return corresponding project.assets.json files + # $Project.BaseIntermediateOutputRoot | Join-Path -ChildPath "project.assets.json" + # } + Jobs = "Install-DotNetTool", { + $local:options = @{ + "p" = "Configuration=$script:Configuration" + } + $script:dotnetOptions + + if ($script:NugetConfigFile) { + $options["-configfile"] = $script:NugetConfigFile + } + + Write-Build Yellow "dotnet restore $DotNetSolutionFile $(($options.GetEnumerator().ForEach({"$($_.key) $($_.value)"})) -join ' ')" + + # 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 = $RestoreOutput.Properties.$Property -eq "true" + } else { + $Project.$Property = $RestoreOutput.Properties.$Property + } + } + } + } + } +} diff --git a/dotnet/Test-DotNet.Task.ps1 b/dotnet/Test-DotNet.Task.ps1 new file mode 100644 index 0000000..b11f536 --- /dev/null +++ b/dotnet/Test-DotNet.Task.ps1 @@ -0,0 +1,26 @@ +Add-BuildTask Test-DotNet @{ + Inputs = { + $DotNetProjects.Where({ $_.IsTestProject }).ForEach({ Get-ChildItem (Split-Path $_.Path) -Recurse -File -ErrorAction SilentlyContinue }) + } + Outputs = { + New-Item -Type Directory -Path $SolutionTestResultsRoot -Force | Out-Null + Join-Path $SolutionTestResultsRoot "*.trx" + } + Jobs = "Build-DotNet", { + + $local:options = @{ + "-logger" = "trx" + "-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) { + 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 --yes + } else { + 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 new file mode 100644 index 0000000..f34656e --- /dev/null +++ b/dotnet/base.ps1 @@ -0,0 +1,159 @@ +<# +.SYNOPSIS + DotNet build script -- extends common base with .NET build support. +.EXAMPLE + Invoke-Build +#> +[CmdletBinding()] +param( + [ValidateScript({ "../common/base.ps1" })] + $Extends, + + # dotnet build configuration parameter (Debug or Release) + [ValidateSet('Debug', '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({ + 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 = ($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')] + $TargetRuntime = ($ENV:DOTNET_TARGET_RUNTIME ?? $Env:IB_TARGET_RUNTIME ?? ($IsLinux ? "linux-x64" : "win-x64")) +) + +# 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 { + + # Resolve $Solution to a full path -- path separators indicate a direct path, otherwise search $BuildRoot + $script:DotNetSolutionFile = 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:DotNetSolutionFile -LeafBase + # This is used in Directory.build.props to configure the root output directory for dotnet + $Env:IB_OUTPUT_ROOT ??= $script:OutputRoot + + $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 + + # These environment variables aren't just inputs, they're used by our Directory.Build.props + $ENV:IB_TARGET_RUNTIME = $script:TargetRuntime + + $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 + BaseIntermediateOutputRoot = Join-Path $script:SolutionOutputRoot "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 + $script:UPackPublishKey ??= $Env:UPACK_API_KEY + $script:UPackPublishUri ??= $Env:UPACK_PUBLISH_URI + $script:UPackFeed ??= $Env:UPACK_FEED_NAME ?? "build-output" + + Write-Build Cyan "Initializing DotNet task variables (Solution: $script:DotNetSolutionFile)" + Write-Build Cyan " Configuration: $script:Configuration" + Write-Build Cyan " TargetFramework: $script:TargetFramework" + Write-Build Cyan " TargetRuntime: $script:TargetRuntime" + Write-Build Cyan " DotNetSolutionFile: $script:DotNetSolutionFile" + Write-Build Cyan " SolutionOutputRoot: $script:SolutionOutputRoot" + 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 += @("Restore-DotNet") +$script:BuildTasks += @("Build-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 += @() + +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/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" + } +} diff --git a/helm/Build-Helm.Task.ps1 b/helm/Build-Helm.Task.ps1 new file mode 100644 index 0000000..6419a4a --- /dev/null +++ b/helm/Build-Helm.Task.ps1 @@ -0,0 +1,16 @@ +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" + 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/Install-Helm.Task.ps1 b/helm/Install-Helm.Task.ps1 new file mode 100644 index 0000000..69f8b0e --- /dev/null +++ b/helm/Install-Helm.Task.ps1 @@ -0,0 +1,42 @@ +# TODO: This task needs to install helm on linux and windows, in CI or in local +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)) { + 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:TempRoot "helm.tar.gz" + Invoke-WebRequest -Uri "https://get.helm.sh/helm-${HelmVersion}-linux-amd64.tar.gz" -OutFile $TarFile + 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:TempRoot "linux-amd64") -Recurse -Force -ErrorAction SilentlyContinue + } else { + throw "Helm is not installed. Please install Helm: https://helm.sh/docs/intro/install/" + } + } + $HelmVersionShort = helm version --short + Write-Build Gray "Helm version: $HelmVersionShort" + + # 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/helm/Pack-Helm.Task.ps1 b/helm/Pack-Helm.Task.ps1 new file mode 100644 index 0000000..2494cbe --- /dev/null +++ b/helm/Pack-Helm.Task.ps1 @@ -0,0 +1,25 @@ +# 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 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 = "Get-Version", { + foreach ($Chart in $script:HelmCharts) { + $Destination = Join-Path $script:HelmOutputRoot $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 ' ')" + Invoke-Native { helm package $Chart.FullName @options } -ExceptionalExit + } + } +} \ No newline at end of file diff --git a/helm/Push-Helm.Task.ps1 b/helm/Push-Helm.Task.ps1 new file mode 100644 index 0000000..bde6409 --- /dev/null +++ b/helm/Push-Helm.Task.ps1 @@ -0,0 +1,26 @@ +Add-BuildTask Push-Helm @{ + 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 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/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/helm/Test-Helm.Task.ps1 b/helm/Test-Helm.Task.ps1 new file mode 100644 index 0000000..e63e64d --- /dev/null +++ b/helm/Test-Helm.Task.ps1 @@ -0,0 +1,35 @@ +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" + } + } + 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 + # 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" + + Write-Build Yellow "helm lint $($Chart.FullName) --values $TestValues" + 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 + + # 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-FromGitHub.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 + } -ExceptionalExit + } + } +} diff --git a/helm/base.ps1 b/helm/base.ps1 new file mode 100644 index 0000000..9dd29ce --- /dev/null +++ b/helm/base.ps1 @@ -0,0 +1,62 @@ +<# +.SYNOPSIS + Helm build script -- extends common base with Helm chart support. +.EXAMPLE + Invoke-Build +#> +[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, + + [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 +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: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") + + Write-Build Cyan "Initializing Helm task variables (HelmChartRoot: $script:HelmChartRoot)" + Write-Build Cyan " HelmChartRoot: $script:HelmChartRoot" + Write-Build Cyan " HelmOutputRoot: $script:HelmOutputRoot" + Write-Build Cyan " HelmCharts: $(($script:HelmCharts).Count)" + Write-Build Cyan " HelmRepository: $script:HelmRepository" + } +} +#endregion + + +# Add the helm tasks to the common tasks +$script:InitializeTasks += @("Install-Helm", "Restore-Helm") +$script:BuildTasks += @("Build-Helm") +$script:PackTasks += @("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 new file mode 100644 index 0000000..71185ec --- /dev/null +++ b/pipeline/build.yml @@ -0,0 +1,45 @@ +# Docs: https://aka.ms/yaml +name: $(GitVersion.SemVer) +pool: LDLinux + +trigger: +- main +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 + 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/powershell/Build-Module.Task.ps1 b/powershell/Build-Module.Task.ps1 new file mode 100644 index 0000000..6ee1c81 --- /dev/null +++ b/powershell/Build-Module.Task.ps1 @@ -0,0 +1,62 @@ +Add-BuildTask Build-Module @{ + Inputs = { + @( + 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:OutputRoot /*) + } + # don't take off the script block, need to resolve AFTER init + Outputs = { + $InputObject = $_ + switch -regex ("$InputObject") { + "ps1$" { + $script:ModuleOutputRoot + } + "cs$" { + if ($out -and ($Assemblies = Get-ChildItem -Path $script:OutputRoot -Recurse -Filter *.dll -ErrorAction Ignore)) { + $Assemblies + } else { + Join-Path $script:ModuleOutputRoot lib + } + } + default { + # .psd1, .psm1, .pssc etc — use the output directory as the comparison target + $script:OutputRoot + } + } + } + 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: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)) { + $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:ModuleOutputRoot = 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/Pack-Module.Task.ps1 b/powershell/Pack-Module.Task.ps1 new file mode 100644 index 0000000..97cd7a1 --- /dev/null +++ b/powershell/Pack-Module.Task.ps1 @@ -0,0 +1,124 @@ +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([string])] + [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[]]$RequiredModules = $ModuleData.RequiredModules + $Required = $RequiredModules.Where{ + # Don't put external dependencies in the nuspec + $_.Name -notin $ExternalModuleDependencies + } + Wait-Debugger + if ($Required.Count -eq 0) { return } + + "" + $Required.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 + } 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 + } + "" + } + } + + $NuspecPath = [IO.Path]::ChangeExtension($script:ManifestPath, ".nuspec") + $Module = Get-Module -List $script:ManifestPath + + @" + + + + {0} + {1} + {2} + {3} + {4} + {5} + {6} + {7} + {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 new file mode 100644 index 0000000..35da643 --- /dev/null +++ b/powershell/Publish-Module.Task.ps1 @@ -0,0 +1,46 @@ +Add-BuildTask Publish-Module @{ + If = { Test-Path $script:ManifestPath } + Jobs = { + $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/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/Test-PowerShellSyntax.Task.ps1 b/powershell/Test-PowerShellSyntax.Task.ps1 new file mode 100644 index 0000000..4c9de41 --- /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:ModuleTestResultsRoot/results.sarif" + } + } + Inputs = { + # Build Output + Get-ChildItem $ModuleOutputRoot -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 $ModuleOutputRoot -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: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." + } + + 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..dccf15b --- /dev/null +++ b/powershell/base.ps1 @@ -0,0 +1,73 @@ +<# +.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 = $Env:IB_MODULE_NAME, + + # NuGet-compatible publish URI for the PS module repository + [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 = $Env:IB_PS_PUBLISH_KEY, + + # 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] +} + +Enter-Build { + # Default ModuleName to the project folder name + if (-not $script:ModuleName) { + $script:ModuleName = Split-Path $BuildRoot -Leaf + } + + $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 + 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 +} + +# Add the PowerShell tasks to the common tasks +$script:InitializeTasks += @() + +# When we have dotnet combined in a PowerShell module +# 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") : + @("Build-Module") +$script:PackTasks += @("Pack-Module") +$script:TestTasks += @("Import-Module", "Test-PowerShell", "Test-PowerShellSyntax") +$script:PushTasks += @("Push-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 new file mode 100644 index 0000000..687b5a6 --- /dev/null +++ b/scripts/Bootstrap.ps1 @@ -0,0 +1,34 @@ +<# + .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 .requires.psd1 (if missing will only install InvokeBuild) + [Alias("RequiredModulesPath")] + $Path = "$PSScriptRoot/../build.requires.psd1" +) +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 diff --git a/scripts/Install-FromGitHub.ps1 b/scripts/Install-FromGitHub.ps1 new file mode 100644 index 0000000..bb64157 --- /dev/null +++ b/scripts/Install-FromGitHub.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-FromGitHub FluxCD Flux2 + +Install `Flux` from the https://github.com/FluxCD/Flux2 repository + +.EXAMPLE +Install-FromGitHub earthly earthly + +Install `earthly` from the https://github.com/earthly/earthly repository + +.EXAMPLE +Install-FromGitHub junegunn fzf + +Install `fzf` from the https://github.com/junegunn/fzf repository + +.EXAMPLE +Install-FromGitHub BurntSushi ripgrep + +Install `rg` from the https://github.com/BurntSushi/ripgrep repository + +.EXAMPLE +Install-FromGitHub opentofu opentofu + +Install `opentofu` from the https://github.com/opentofu/opentofu repository + +.EXAMPLE +Install-FromGitHub 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-FromGitHub sharkdp/bat +Install-FromGitHub 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/scripts/Install-PowerShellModule.ps1 b/scripts/Install-PowerShellModule.ps1 new file mode 100644 index 0000000..d12bbed --- /dev/null +++ b/scripts/Install-PowerShellModule.ps1 @@ -0,0 +1,75 @@ +# .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( + [Alias("RequiredModulesPath")] + [string]$path = "$pwd/*.requires.psd1", + + [string[]]$Specification = @( + 'InvokeBuild:[5.11.1, 6.0)' + ), + # This command does not support pipeline input + # But is sometimes called with pipeline input ... + [Parameter(ValueFromPipeline, ValueFromRemainingArguments)] + [PSObject[]]$IgnoredPipelineInput, + + # This allows passing a different url for source. + [string]$Source, + + # 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' +# 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 +} +$PSBoundParameters.Remove('IgnoredPipelineInput') | Out-Null + +$ModuleFastParam = $PSBoundParameters + @{ + Destination = $Destination +} + +# 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 + $Path = "'$($Specification -join "', '")'" +} else { + $ModuleFastParam['Path'] = $Path +} + +# If ModuleFast is not already installed, install it to $Destination +if (!(Get-Module ModuleFast -ListAvailable -ErrorAction SilentlyContinue)) { + 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-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 diff --git a/scripts/Invoke-Build.ps1 b/scripts/Invoke-Build.ps1 new file mode 100644 index 0000000..ccb195c --- /dev/null +++ b/scripts/Invoke-Build.ps1 @@ -0,0 +1,915 @@ +<# Invoke-Build 5.14.23 +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))" + } +} +} diff --git a/scripts/Update.ps1 b/scripts/Update.ps1 new file mode 100644 index 0000000..e69de29