diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..b125e68 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,29 @@ +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +FROM mcr.microsoft.com/powershell:latest + +# This Dockerfile adds a non-root user with sudo access. Use the "remoteUser" +# property in devcontainer.json to use it. On Linux, the container user's GID/UIDs +# will be updated to match your local UID/GID (when using the dockerFile property). +# See https://aka.ms/vscode-remote/containers/non-root-user for details. +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +# install git iproute2, process tools +RUN apt-get update && apt-get -y install git openssh-client less iproute2 procps \ + # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/non-root-user. + && groupadd --gid $USER_GID $USERNAME \ + && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME \ + # [Optional] Add sudo support for the non-root user + && apt-get install -y sudo \ + && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME\ + && chmod 0440 /etc/sudoers.d/$USERNAME \ + # + # Clean up + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5b66b52 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,25 @@ +{ + "name": "PowerShell", + "dockerFile": "Dockerfile", + // "image": "mcr.microsoft.com/powershell", + + // Set *default* container specific settings.json values on container create. + "settings": { + "terminal.integrated.shell.linux": "/usr/bin/pwsh" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-vscode.powershell", + "davidanson.vscode-markdownlint" + ], + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Bootstrap build modules + "postCreateCommand": "pwsh -c './build.ps1 -Task Init -Bootstrap'", + + // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. + // "remoteUser": "vscode" +} diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index 889b83b..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,61 +0,0 @@ -# How to contribute - -Contributions to PowerShellBuild are highly encouraged and desired. -Below are some guidelines that will help make the process as smooth as possible. - -## Getting Started - -- Make sure you have a [GitHub account](https://github.com/signup/free) -- Submit a new issue, assuming one does not already exist. - - Clearly describe the issue including steps to reproduce when it is a bug. - - Make sure you fill in the earliest version that you know has the issue. -- Fork the repository on GitHub - -## Suggesting Enhancements - -I want to know what you think is missing from PowerShellBuild and how it can be made better. - -- When submitting an issue for an enhancement, please be as clear as possible about why you think the enhancement is needed and what the benefit of it would be. - -## Making Changes - -- From your fork of the repository, create a topic branch where work on your change will take place. -- To quickly create a topic branch based on master; `git checkout -b my_contribution master`. - Please avoid working directly on the `master` branch. -- Make commits of logical units. -- Check for unnecessary whitespace with `git diff --check` before committing. -- Please follow the prevailing code conventions in the repository. - Differences in style make the code harder to understand for everyone. -- Make sure your commit messages are in the proper format. - -``` - Add more cowbell to Get-Something.ps1 - - The functionality of Get-Something would be greatly improved if there was a little - more 'pizzazz' added to it. I propose a cowbell. Adding more cowbell has been - shown in studies to both increase one's mojo, and cement one's status - as a rock legend. -``` - -- Make sure you have added all the necessary Pester tests for your changes. -- Run _all_ Pester tests in the module to assure nothing else was accidentally broken. - -## Documentation - -I am infallible and as such my documenation needs no corectoin. -In the highly unlikely event that that is _not_ the case, commits to update or add documentation are highly apprecaited. - -## Submitting Changes - -- Push your changes to a topic branch in your fork of the repository. -- Submit a pull request to the main repository. -- Once the pull request has been reviewed and accepted, it will be merged with the master branch. -- Celebrate - -## Additional Resources - -- [General GitHub documentation](https://help.github.com/) -- [GitHub forking documentation](https://guides.github.com/activities/forking/) -- [GitHub pull request documentation](https://help.github.com/send-pull-requests/) -- [GitHub Flow guide](https://guides.github.com/introduction/flow/) -- [GitHub's guide to contributing to open source projects](https://guides.github.com/activities/contributing-to-open-source/) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 93065f7..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,30 +0,0 @@ - - -## Expected Behavior - - - -## Current Behavior - - - -## Possible Solution - - - -## Steps to Reproduce (for bugs) - - -1. -2. -3. -4. - -## Context - - - -## Your Environment - -* Module version used: -* Operating System and PowerShell version: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index fab5004..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,36 +0,0 @@ - - -## Description - - -## Related Issue - - - - - -## Motivation and Context - - -## How Has This Been Tested? - - - - -## Screenshots (if appropriate): - -## Types of changes - -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to change) - -## Checklist: - - -- [ ] My code follows the code style of this project. -- [ ] My change requires a change to the documentation. -- [ ] I have updated the documentation accordingly. -- [ ] I have read the **CONTRIBUTING** document. -- [ ] I have added tests to cover my changes. -- [ ] All new and existing tests passed. diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..10c7f7a --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,21 @@ +name: Publish +on: + workflow_dispatch: + release: + types: [published] + +permissions: + contents: read + +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Publish + shell: pwsh + run: | + $apiKey = '${{ secrets.PSGALLERY_API_KEY }}' | ConvertTo-SecureString -AsPlainText -Force + $cred = [pscredential]::new('apikey', $apiKey) + ./build.ps1 -Task Publish -PSGalleryApiKey $cred -Bootstrap diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml deleted file mode 100644 index 293b07c..0000000 --- a/.github/workflows/push.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: CI -on: [push] -jobs: - test: - name: Run Tests - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macOS-latest] - steps: - - uses: actions/checkout@v1 - - name: Test - shell: pwsh - run: ./build.ps1 -Task Test -Bootstrap diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9829f76 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test +permissions: + contents: read + checks: write + pull-requests: write + issues: write +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: +jobs: + # Delegate to the psake org's shared module CI workflow. It runs the full build/test suite + # (Build + Analyze + Pester) on PowerShell 7+ across Linux/Windows/macOS and on the real + # Windows PowerShell 5.1 (Desktop) engine, so regressions like the 0.8.0 ternary that broke + # module import on 5.1 are caught by the standard test run rather than a separate smoke test. + ci: + name: CI + uses: psake/.github/.github/workflows/ModuleCI.yml@main diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..788cb49 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/DavidAnson/vscode-markdownlint/refs/heads/main/markdownlint-config-schema.json", + "MD024": { + "siblings_only": true + }, + "MD013": { + "code_blocks": false, + "tables": false + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c1b568a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "PowerShell: Interactive Session", + "type": "PowerShell", + "request": "launch", + "cwd": "" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index bb482c1..bce2856 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,17 @@ "editor.insertSpaces": true, "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, - "powershell.codeFormatting.preset": "OTBS" + "powershell.codeFormatting.preset": "OTBS", + "powershell.codeFormatting.addWhitespaceAroundPipe": true, + "powershell.codeFormatting.useCorrectCasing": true, + "powershell.codeFormatting.newLineAfterOpenBrace": true, + "powershell.codeFormatting.alignPropertyValuePairs": true, + "search.useIgnoreFiles": true, + "search.exclude": { + "**/node_modules": true, + "**/bower_components": true, + "**/*.code-search": true, + "**/.ruby-lsp": true, + "Output/**": true + } } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 106a76c..69ccd58 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,13 +2,17 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", - // Start PowerShell (pwsh on *nix) "windows": { "options": { "shell": { - "executable": "powershell.exe", - "args": [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command" ] + "executable": "pwsh.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command" + ] } } }, @@ -16,7 +20,10 @@ "options": { "shell": { "executable": "/usr/bin/pwsh", - "args": [ "-NoProfile", "-Command" ] + "args": [ + "-NoProfile", + "-Command" + ] } } }, @@ -24,11 +31,13 @@ "options": { "shell": { "executable": "/usr/local/bin/pwsh", - "args": [ "-NoProfile", "-Command" ] + "args": [ + "-NoProfile", + "-Command" + ] } } }, - "tasks": [ { "label": "Clean", @@ -69,6 +78,12 @@ "label": "Publish", "type": "shell", "command": "${cwd}/build.ps1 -Task Publish -Verbose" + }, + { + "label": "Bootstrap", + "type": "shell", + "command": "${cwd}/build.ps1 -Task Init -Bootstrap -Verbose", + "problemMatcher": [] } ] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..177aaf5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,72 @@ +# AI Agent Instructions + +AI agents working in this repository must follow these instructions. + +Template Version: 0.8.14 + +Last sync: 2026-05-17 (Update this date when syncing from the centralized repository) + +## Instructions for AI Agents + +AI agents **must**: + +1. **When deploying or updating this template, follow `instructions/update.instructions.md` and + update the Last sync date above.** + +2. **Read `instructions/agent-workflow.instructions.md` FIRST to determine which other instruction + files apply to your task.** Follow all applicable instructions before proceeding with work. + +3. **Check `aim.config.json`** for module configuration and external source settings. + +## Instruction Applicability Matrix + +Use this matrix to determine which instruction files to read based on your task: + +| Task Type | Required Instructions | +| ---------------------------- | -------------------------------------- | +| Any task | `agent-workflow.instructions.md` | +| Any code or documentation | `shorthand.instructions.md` | +| Git operations | `git-workflow.instructions.md` | +| Writing tests | `testing.instructions.md` | +| PowerShell code | `powershell.instructions.md` | +| Documentation | `markdown.instructions.md` | +| README files | `readme.instructions.md` | +| GitHub CLI usage | `github-cli.instructions.md` | +| Creating releases | `releases.instructions.md` | +| Repository-specific work | `repository-specific.instructions.md` | +| Updating instructions | `update.instructions.md` | +| Contributing to upstream | `contributing.instructions.md` | + +## Available Instruction Files + +- `agent-workflow.instructions.md` - Pre-flight protocol and task workflow +- `shorthand.instructions.md` - Avoid shorthand and abbreviations +- `git-workflow.instructions.md` - Git branching, commits, and PR conventions +- `testing.instructions.md` - Test writing best practices +- `powershell.instructions.md` - PowerShell coding standards +- `markdown.instructions.md` - Markdown formatting standards +- `readme.instructions.md` - README maintenance guidelines +- `github-cli.instructions.md` - GitHub CLI usage guidelines +- `releases.instructions.md` - Release management guidelines +- `repository-specific.instructions.md` - Repository-specific customizations +- `update.instructions.md` - Procedures for updating instructions +- `contributing.instructions.md` - Contributing improvements to upstream + +## Quick Reference + +### Before Starting Any Task + +1. Identify the task type from the matrix above +2. Read all applicable instruction files +3. Follow the guidelines when implementing + +### Best Practices + +- Follow existing patterns in the codebase +- Keep solutions simple and focused +- Only make changes that are directly requested +- Follow language-specific guidelines + +## Repository-Specific Instructions + +See `instructions/repository-specific.instructions.md` for customizations specific to this repository. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd995e..b74bd8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,37 +1,195 @@ -# Change Log +# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.5.0] - Unreleased +## Unreleased + +## [0.8.1] 2026-06-03 + +### Fixed + +- Restore Windows PowerShell 5.1 (Desktop edition) compatibility, which regressed + in 0.8.0. `Get-PSBuildCertificate` used the PowerShell 7+-only ternary operator, + causing the file to fail to parse and the whole module to fail to import under + Windows PowerShell 5.1 — even though the manifest still declares support for it. + The ternary is replaced with an `if`/`else` expression, and the `$IsWindows` + platform guard now treats the absent automatic variable on Desktop edition as + Windows (matching the existing pattern in `Build-PSBuildUpdatableHelp`). Behavior + on PowerShell 7+ is unchanged. + +## [0.8.0] 2026-02-20 + +### Added + +- [**#92**](https://github.com/psake/PowerShellBuild/pull/92) Add Authenticode + code-signing support for PowerShell modules with three new public functions: + - `Get-PSBuildCertificate` - Resolves code-signing X509Certificate2 objects + from certificate store, PFX files, Base64-encoded environment variables, + or pre-resolved certificate objects + - `Invoke-PSBuildModuleSigning` - Signs PowerShell module files (*.psd1, + *.psm1, *.ps1) with Authenticode signatures supporting configurable + timestamp servers and hash algorithms + - `New-PSBuildFileCatalog` - Creates Windows catalog (.cat) files for + tamper detection +- New build tasks for module signing pipeline: `SignModule`, `BuildCatalog`, + `SignCatalog`, `Sign` (meta-task) +- Extended `$PSBPreference.Sign` configuration section with certificate + source selection, timestamp server configuration, hash algorithm options, + and catalog generation settings + +### Fixed + +- Remove extra backticks during localization text migration. + +## [0.7.3] 2025-08-01 + +### Added + +- Add new dependencies variables to allow end user to modify which tasks are + run. +- Add localization support. + +## [0.7.2] 2025-05-21 + +### Added + +- The `$PSBPreference` variable now supports the following PlatyPS + `New-MarkdownHelp` and `Update-MarkdownHelp` boolean options: + - `$PSBPreference.Docs.AlphabeticParamsOrder` + - `$PSBPreference.Docs.ExcludeDontShow` + - `$PSBPreference.Docs.UseFullTypeName` +- The `$PSBPreference` variable now supports the following Pester test + configuration options: + - `$PSBPreference.Test.SkipRemainingOnFailure` can be set to **None**, + **Run**, **Container** and **Block**. The default value is **None**. + - `$PSBPreference.Test.OutputVerbosity` can be set to **None**, **Normal**, + **Detailed**, and **Diagnostic**. The default value is **Detailed**. + +## [0.7.1] 2025-04-01 + +### Fixes + +- Fix a bug in `Build-PSBuildMarkdown` where a hashtable item was added twice. + +## [0.7.0] 2025-03-31 + +### Changed + +- [**#71**](https://github.com/psake/PowerShellBuild/pull/71) Compiled modules + are now explicitly created as UTF-8 files. +- [**#67**](https://github.com/psake/PowerShellBuild/pull/67) You can now + overwrite existing markdown files using `$PSBPreference.Docs.Overwrite` and + setting it to `$true`. +- [**#72**](https://github.com/psake/PowerShellBuild/pull/72) Loosen + dependencies by allowing them to be overwritten with + `$PSBPreference.TaskDependencies`. + +## [0.6.2] 2024-10-06 + +### Changed + +- Bump Pester to latest 5.6.1 + +### Fixed + +- [**#52**](https://github.com/psake/PowerShellBuild/pull/52) Pester object + wasn't being passed back after running tests, causing the Pester task to never + fail (via [@webtroter](https://github.com/webtroter)) +- [**#55**](https://github.com/psake/PowerShellBuild/pull/55) Add `-Module` + parameter to `Build-PSBuildUpdatableHelp` (via + [@IMJLA](https://github.com/IMJLA)) +- [**#60**](https://github.com/psake/PowerShellBuild/pull/60) Fix Windows + PowerShell compatibility in `Initialize-PSBuild` (via + [@joshooaj](https://github.com/joshooaj)) +- [**#62**](https://github.com/psake/PowerShellBuild/pull/62) Fix code coverage + output fle format not working (via + [@OpsM0nkey](https://github.com/OpsM0nkey)) + +## [0.6.1] 2021-03-14 + +### Fixed + +- Fixed bug in IB task `GenerateMarkdown` when dot sourcing precondition + +## [0.6.0] 2021-03-14 + +### Changed + +- [**#50**](https://github.com/psake/PowerShellBuild/pull/50) Invoke-Build tasks + brought inline with psake equivalents (via + [@JustinGrote](https://github.com/JustinGrote)) + +## [0.5.0] 2021-02-27 + +### Added + +- New code coverage parameters for setting output path and format: + - `$PSBPreference.Test.CodeCoverage.OutputFile` - Output file path for code + coverage results + - `$PSBPreference.Test.CodeCoverage.OutputFileFormat` - Code coverage output + format + +## [0.5.0] (beta1) - 2020-11-15 ### Added -- When "compiling" a monolithic PSM1, add support for both inserting headers/footers for the entire PSM1, and for each script file. Control these via the following new build parameters (via [@pauby](https://github.com/pauby)) +- When "compiling" a monolithic PSM1, add support for both inserting + headers/footers for the entire PSM1, and for each script file. Control these + via the following new build parameters (via + [@pauby](https://github.com/pauby)) - `$PSBPreference.Build.CompileHeader` - `$PSBPreference.Build.CompileFooter` - `$PSBPreference.Build.CompileScriptHeader` - `$PSBPreference.Build.CompileScriptFooter` -- Add ability to import project module from output directory prior to executing Pester tests. Toggle this with `$PSBPreference.Test.ImportModule`. Defaults to `false`. (via [@joeypiccola](https://github.com/joeypiccola)) +- Add ability to import project module from output directory prior to executing + Pester tests. Toggle this with `$PSBPreference.Test.ImportModule`. Defaults to + `$false`. (via [@joeypiccola](https://github.com/joeypiccola)) + +- Use `$PSBPreference.Build.CompileDirectories` to control directories who's + contents will be concatenated into the PSM1 when + `$PSBPreference.Build.CompileModule` is `$true`. Defaults to + `@('Enum', 'Classes', 'Private', 'Public')`. +- Use `$PSBPreference.Build.CopyDirectories` to control directories that will be + copied "as is" into the built module. Default is an empty array. + +### Changed + +- `$PSBPreference.Build.Exclude` now should be a list of regex expressions when + `$PSBPreference.Build.CompileModule` is `$false` (default). + +- Use Pester v5 ### Fixed -- Overriding `$PSBPreference.Build.OutDir` now correctly determines the final module output directory. `$PSBPreference.Build.ModuleOutDir` is now computed internally and **SHOULD NOT BE SET DIRECTLY**. ` $PSBPreference.Build.OutDir` will accept both relative and fully-qualified paths. +- Overriding `$PSBPreference.Build.OutDir` now correctly determines the final + module output directory. `$PSBPreference.Build.ModuleOutDir` is now computed + internally and **SHOULD NOT BE SET DIRECTLY**. `$PSBPreference.Build.OutDir` + will accept both relative and fully-qualified paths. + +- Before, when `$PSBPreference.Build.CompileModule` was set to `$true`, any + files listed in `$PSBPreference.Build.Exclude` weren't being excluded like + they should have been. Now, when it is `$true`, files matching regex + expressions in `$PSBPreference.Build.Exclude` will be properly excluded (via + [@pauby](https://github.com/pauby)) -- Before, when `$PSBPreference.Build.CompileModule` was set to `$true`, any files listed in `$PSBPreference.Build.Exclude` weren't being excluded like they should have been. Now, when it is `$true`, files matching regex expressions in `$PSBPreference.Build.Exclude` will be properly excluded (via [@pauby](https://github.com/pauby)) +- `$PSBPreference.Help.DefaultLocale` now defaults to `en-US` on Linux since it + is not correctly determined with `Get-UICulture`. ## [0.4.0] - 2019-08-31 ### Changed -- Allow using both `Credential` and `ApiKey` when publishing a module (via [@pauby](https://github.com/pauby)) +- Allow using both `Credential` and `ApiKey` when publishing a module (via + [@pauby](https://github.com/pauby)) ### Fixed -- Don't overwrite Pester parameters when specifying `OutputPath` or `OutputFormat` (via [@ChrisLGardner](https://github.com/ChrisLGardner)) +- Don't overwrite Pester parameters when specifying `OutputPath` or + `OutputFormat` (via [@ChrisLGardner](https://github.com/ChrisLGardner)) ## [0.3.1] - 2019-06-09 @@ -43,11 +201,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- [**#24**](https://github.com/psake/PowerShellBuild/pull/24) Fix case of 'Public' folder when dot sourcing functions in PSM1 (via [@pauby](https://github.com/pauby)) - -### Changed - -- [**#19**](https://github.com/psake/PowerShellBuild/pull/19) Allow the `BHBuildOutput` environment variable defined by `BuildHelpers` to be set via the `$PSBPreference.Build.ModuleOutDir` property of the build tasks (via [@pauby](https://github.com/pauby)) +- [**#24**](https://github.com/psake/PowerShellBuild/pull/24) Fix case of + 'Public' folder when dot sourcing functions in PSM1 (via + [@pauby](https://github.com/pauby)) ### Breaking changes @@ -55,22 +211,34 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- [**#11**](https://github.com/psake/PowerShellBuild/pull/11) The Invoke-Build tasks are now auto-generated from the psake tasks via a converter script (via [@JustinGrote](https://github.com/JustinGrote)) +- [**#11**](https://github.com/psake/PowerShellBuild/pull/11) The Invoke-Build + tasks are now auto-generated from the psake tasks via a converter script (via + [@JustinGrote](https://github.com/JustinGrote)) + +- [**#19**](https://github.com/psake/PowerShellBuild/pull/19) Allow the + `BHBuildOutput` environment variable defined by `BuildHelpers` to be set via + the `$PSBPreference.Build.ModuleOutDir` property of the build tasks (via + [@pauby](https://github.com/pauby)) ## [0.2.0] - 2018-11-15 ### Added -- Add `Publish` task to publish the module to the defined PowerShell Repository (PSGallery by default). +- Add `Publish` task to publish the module to the defined PowerShell Repository + (PSGallery by default). ## [0.1.1] - 2018-11-09 ### Fixed -- [**#4**](https://github.com/psake/PowerShellBuild/pull/4) Fix syntax for `Analyze` task in `IB.tasks.ps1` (via [@nightroman](https://github.com/nightroman)) +- [**#4**](https://github.com/psake/PowerShellBuild/pull/4) Fix syntax for + `Analyze` task in `IB.tasks.ps1` (via + [@nightroman](https://github.com/nightroman)) ## [0.1.0] - 2018-11-07 ### Added - Initial commit + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/PowerShellBuild/IB.tasks.ps1 b/PowerShellBuild/IB.tasks.ps1 index 73f9672..fe54942 100644 --- a/PowerShellBuild/IB.tasks.ps1 +++ b/PowerShellBuild/IB.tasks.ps1 @@ -1,24 +1,27 @@ Remove-Variable -Name PSBPreference -Scope Script -Force -ErrorAction Ignore -Set-Variable -Name PSBPreference -Option ReadOnly -Scope Script -Value (. (Join-Path -Path $PSScriptRoot -ChildPath build.properties.ps1)) +Set-Variable -Name PSBPreference -Option ReadOnly -Scope Script -Value (. ([IO.Path]::Combine($PSScriptRoot, 'build.properties.ps1'))) +$__DefaultBuildDependencies = $PSBPreference.Build.Dependencies # Synopsis: Initialize build environment variables -task Init { +Task Init { Initialize-PSBuild -UseBuildHelpers -BuildEnvironment $PSBPreference } # Synopsis: Clears module output directory -task Clean Init, { +Task Clean Init, { Clear-PSBuildOutputFolder -Path $PSBPreference.Build.ModuleOutDir } # Synopsis: Builds module based on source directory -task StageFiles Clean, { +Task StageFiles Clean, { $buildParams = @{ Path = $PSBPreference.General.SrcRootDir ModuleName = $PSBPreference.General.ModuleName DestinationPath = $PSBPreference.Build.ModuleOutDir Exclude = $PSBPreference.Build.Exclude Compile = $PSBPreference.Build.CompileModule + CompileDirectories = $PSBPreference.Build.CompileDirectories + CopyDirectories = $PSBPreference.Build.CopyDirectories Culture = $PSBPreference.Help.DefaultLocale } @@ -30,6 +33,7 @@ task StageFiles Clean, { } } + # only add these configuration values to the build parameters if they have been been set 'CompileHeader', 'CompileFooter', 'CompileScriptHeader', 'CompileScriptFooter' | ForEach-Object { if ($PSBPreference.Build.Keys -contains $_) { $buildParams.$_ = $PSBPreference.Build.$_ @@ -39,8 +43,7 @@ task StageFiles Clean, { Build-PSBuildModule @buildParams } -# Synopsis: Builds module and generate help documentation -Task Build $($PSBPreference.Build.Dependencies -join ", ") + $analyzePreReqs = { $result = $true @@ -56,7 +59,7 @@ $analyzePreReqs = { } # Synopsis: Execute PSScriptAnalyzer tests -task Analyze Build, { +Task Analyze -If (. $analyzePreReqs) Build, { $analyzeParams = @{ Path = $PSBPreference.Build.ModuleOutDir SeverityThreshold = $PSBPreference.Test.ScriptAnalysis.FailBuildOnSeverityLevel @@ -83,25 +86,26 @@ $pesterPreReqs = { } # Synopsis: Execute Pester tests -task Pester Build -If $pesterPreReqs, { +Task Pester -If (. $pesterPreReqs) Build, { $pesterParams = @{ - Path = $PSBPreference.Test.RootDir - ModuleName = $PSBPreference.General.ModuleName - OutputPath = $PSBPreference.Test.OutputFile - OutputFormat = $PSBPreference.Test.OutputFormat - CodeCoverage = $PSBPreference.Test.CodeCoverage.Enabled - CodeCoverageThreshold = $PSBPreference.Test.CodeCoverage.Threshold - CodeCoverageFiles = $PSBPreference.Test.CodeCoverage.Files + Path = $PSBPreference.Test.RootDir + ModuleName = $PSBPreference.General.ModuleName + ModuleManifest = Join-Path $PSBPreference.Build.ModuleOutDir "$($PSBPreference.General.ModuleName).psd1" + OutputPath = $PSBPreference.Test.OutputFile + OutputFormat = $PSBPreference.Test.OutputFormat + CodeCoverage = $PSBPreference.Test.CodeCoverage.Enabled + CodeCoverageThreshold = $PSBPreference.Test.CodeCoverage.Threshold + CodeCoverageFiles = $PSBPreference.Test.CodeCoverage.Files + CodeCoverageOutputFile = $PSBPreference.Test.CodeCoverage.OutputFile + CodeCoverageOutputFileFormat = $PSBPreference.Test.CodeCoverage.OutputFormat + ImportModule = $PSBPreference.Test.ImportModule + SkipRemainingOnFailure = $PSBPreference.Test.SkipRemainingOnFailure + OutputVerbosity = $PSBPreference.Test.OutputVerbosity } Test-PSBuildPester @pesterParams } -# Synopsis: Execute Pester and ScriptAnalyzer tests -task Test Pester, Analyze, { -} -# Synopsis: Builds help documentation -task BuildHelp GenerateMarkdown, GenerateMAML, {} $genMarkdownPreReqs = { $result = $true @@ -113,12 +117,16 @@ $genMarkdownPreReqs = { } # Synopsis: Generates PlatyPS markdown files from module help -task GenerateMarkdown StageFiles, { +Task GenerateMarkdown -if (. $genMarkdownPreReqs) StageFiles, { $buildMDParams = @{ - ModulePath = $PSBPreference.Build.ModuleOutDir - ModuleName = $PSBPreference.General.ModuleName - DocsPath = $PSBPreference.Docs.RootDir - Locale = $PSBPreference.Help.DefaultLocale + ModulePath = $PSBPreference.Build.ModuleOutDir + ModuleName = $PSBPreference.General.ModuleName + DocsPath = $PSBPreference.Docs.RootDir + Locale = $PSBPreference.Help.DefaultLocale + Overwrite = $PSBPreference.Docs.Overwrite + AlphabeticParamsOrder = $PSBPreference.Docs.AlphabeticParamsOrder + ExcludeDontShow = $PSBPreference.Docs.ExcludeDontShow + UseFullTypeName = $PSBPreference.Docs.UseFullTypeName } Build-PSBuildMarkdown @buildMDParams } @@ -133,7 +141,7 @@ $genHelpFilesPreReqs = { } # Synopsis: Generates MAML-based help from PlatyPS markdown files -task GenerateMAML GenerateMarkdown, { +Task GenerateMAML -if (. $genHelpFilesPreReqs) GenerateMarkdown, { Build-PSBuildMAMLHelp -Path $PSBPreference.Docs.RootDir -DestinationPath $PSBPreference.Build.ModuleOutDir } @@ -147,7 +155,7 @@ $genUpdatableHelpPreReqs = { } # Synopsis: Create updatable help .cab file based on PlatyPS markdown help -task GenerateUpdatableHelp BuildHelp, { +Task GenerateUpdatableHelp -if (. $genUpdatableHelpPreReqs) BuildHelp, { Build-PSBuildUpdatableHelp -DocsPath $PSBPreference.Docs.RootDir -OutputPath $PSBPreference.Help.UpdatableHelpOutDir } @@ -171,3 +179,154 @@ Task Publish Test, { Publish-PSBuildModule @publishParams } + + +#region Summary Tasks + +# Synopsis: Builds help documentation +Task BuildHelp GenerateMarkdown, GenerateMAML + +Task Build { + if ([String]$PSBPreference.Build.Dependencies -ne [String]$__DefaultBuildDependencies) { + throw [NotSupportedException]'You cannot use $PSBPreference.Build.Dependencies with Invoke-Build. Please instead redefine the build task or your default task to include your dependencies. Example: Task . Dependency1,Dependency2,Build,Test or Task Build Dependency1,Dependency2,StageFiles' + } +}, StageFiles, BuildHelp + +# Synopsis: Execute Pester and ScriptAnalyzer tests +Task Test Analyze, Pester + +Task . Build, Test + +# Synopsis: Signs module files (*.psd1, *.psm1, *.ps1) with an Authenticode signature +Task SignModule -If { + if (-not $PSBPreference.Sign.Enabled) { + Write-Warning 'Module signing is not enabled.' + return $false + } + if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { + Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.' + return $false + } + $true +} Build, { + $certParams = @{ + CertificateSource = $PSBPreference.Sign.CertificateSource + CertStoreLocation = $PSBPreference.Sign.CertStoreLocation + CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar + CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar + } + if ($PSBPreference.Sign.Thumbprint) { + $certParams.Thumbprint = $PSBPreference.Sign.Thumbprint + } + if ($PSBPreference.Sign.PfxFilePath) { + $certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath + } + if ($PSBPreference.Sign.PfxFilePassword) { + $certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword + } + + $certificate = if ($PSBPreference.Sign.Certificate) { + $PSBPreference.Sign.Certificate + } else { + Get-PSBuildCertificate @certParams + } + + if ($null -eq $certificate) { + throw $LocalizedData.NoCertificateFound + } + + $signingParams = @{ + Path = $PSBPreference.Build.ModuleOutDir + Certificate = $certificate + TimestampServer = $PSBPreference.Sign.TimestampServer + HashAlgorithm = $PSBPreference.Sign.HashAlgorithm + Include = $PSBPreference.Sign.FilesToSign + } + Invoke-PSBuildModuleSigning @signingParams +} + +# Synopsis: Creates a Windows catalog (.cat) file for the built module +Task BuildCatalog -If { + if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { + Write-Warning 'Catalog generation is not enabled.' + return $false + } + if (-not (Get-Command -Name 'New-FileCatalog' -ErrorAction Ignore)) { + Write-Warning 'New-FileCatalog is not available. Catalog generation requires Windows.' + return $false + } + $true +} SignModule, { + $catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) { + $PSBPreference.Sign.Catalog.FileName + } else { + "$($PSBPreference.General.ModuleName).cat" + } + $catalogFilePath = Join-Path -Path $PSBPreference.Build.ModuleOutDir -ChildPath $catalogFileName + + $catalogParams = @{ + ModulePath = $PSBPreference.Build.ModuleOutDir + CatalogFilePath = $catalogFilePath + CatalogVersion = $PSBPreference.Sign.Catalog.Version + } + New-PSBuildFileCatalog @catalogParams +} + +# Synopsis: Signs the module catalog (.cat) file with an Authenticode signature +Task SignCatalog -If { + if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { + Write-Warning 'Catalog signing is not enabled.' + return $false + } + if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { + Write-Warning 'Set-AuthenticodeSignature is not available. Catalog signing requires Windows.' + return $false + } + $true +} BuildCatalog, { + $certParams = @{ + CertificateSource = $PSBPreference.Sign.CertificateSource + CertStoreLocation = $PSBPreference.Sign.CertStoreLocation + CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar + CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar + } + if ($PSBPreference.Sign.Thumbprint) { + $certParams.Thumbprint = $PSBPreference.Sign.Thumbprint + } + if ($PSBPreference.Sign.PfxFilePath) { + $certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath + } + if ($PSBPreference.Sign.PfxFilePassword) { + $certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword + } + + $certificate = if ($PSBPreference.Sign.Certificate) { + $PSBPreference.Sign.Certificate + } else { + Get-PSBuildCertificate @certParams + } + + if ($null -eq $certificate) { + throw $LocalizedData.NoCertificateFound + } + + $catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) { + $PSBPreference.Sign.Catalog.FileName + } else { + "$($PSBPreference.General.ModuleName).cat" + } + + $signingParams = @{ + Path = $PSBPreference.Build.ModuleOutDir + Certificate = $certificate + TimestampServer = $PSBPreference.Sign.TimestampServer + HashAlgorithm = $PSBPreference.Sign.HashAlgorithm + Include = @($catalogFileName) + } + Invoke-PSBuildModuleSigning @signingParams +} + +# Synopsis: Signs module files and catalog (meta task) +Task Sign SignModule, SignCatalog + +#endregion Summary Tasks diff --git a/PowerShellBuild/PowerShellBuild.psd1 b/PowerShellBuild/PowerShellBuild.psd1 index c782b99..8b6a039 100644 --- a/PowerShellBuild/PowerShellBuild.psd1 +++ b/PowerShellBuild/PowerShellBuild.psd1 @@ -1,20 +1,28 @@ @{ RootModule = 'PowerShellBuild.psm1' - ModuleVersion = '0.5.0' + ModuleVersion = '0.8.1' GUID = '15431eb8-be2d-4154-b8ad-4cb68a488e3d' Author = 'Brandon Olin' CompanyName = 'Community' Copyright = '(c) Brandon Olin. All rights reserved.' Description = 'A common psake and Invoke-Build task module for PowerShell projects' PowerShellVersion = '3.0' - RequiredModules = @('BuildHelpers', 'platyPS') + RequiredModules = @( + @{ModuleName = 'BuildHelpers'; ModuleVersion = '2.0.16' } + @{ModuleName = 'Pester'; ModuleVersion = '5.6.1' } + @{ModuleName = 'platyPS'; ModuleVersion = '0.14.1' } + @{ModuleName = 'psake'; ModuleVersion = '4.9.0' } + ) FunctionsToExport = @( 'Build-PSBuildMAMLHelp' 'Build-PSBuildMarkdown' 'Build-PSBuildModule' 'Build-PSBuildUpdatableHelp' 'Clear-PSBuildOutputFolder' + 'Get-PSBuildCertificate' 'Initialize-PSBuild' + 'Invoke-PSBuildModuleSigning' + 'New-PSBuildFileCatalog' 'Publish-PSBuildModule' 'Test-PSBuildPester' 'Test-PSBuildScriptAnalysis' diff --git a/PowerShellBuild/PowerShellBuild.psm1 b/PowerShellBuild/PowerShellBuild.psm1 index 489ed33..0d4e7fa 100644 --- a/PowerShellBuild/PowerShellBuild.psm1 +++ b/PowerShellBuild/PowerShellBuild.psm1 @@ -1,6 +1,7 @@ # Dot source public functions -$public = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Public/*.ps1') -Recurse -ErrorAction Stop) -foreach ($import in $public) { +$private = @(Get-ChildItem -Path ([IO.Path]::Combine($PSScriptRoot, 'Private/*.ps1')) -Recurse) +$public = @(Get-ChildItem -Path ([IO.Path]::Combine($PSScriptRoot, 'Public/*.ps1')) -Recurse) +foreach ($import in $public + $private) { try { . $import.FullName } catch { @@ -8,6 +9,43 @@ foreach ($import in $public) { } } +data LocalizedData { + # Load here in case Import-LocalizedData is not available + ConvertFrom-StringData @' +NoCommandsExported=No commands have been exported. Skipping markdown generation. +FailedToGenerateMarkdownHelp=Failed to generate markdown help. : {0} +AddingFileToPsm1=Adding [{0}] to PSM1 +MakeCabNotAvailable=MakeCab.exe is not available. Cannot create help cab. +DirectoryAlreadyExists=Directory already exists [{0}]. +PathLongerThan3Chars=Path [{0}] must be longer than 3 characters. +BuildSystemDetails=Build System Details: +BuildModule=Build Module: {0}:{1} +PowerShellVersion=PowerShell Version: {0} +EnvironmentVariables={0}Environment variables: +PublishingVersionToRepository=Publishing version [{0}] to repository [{1}]... +FolderDoesNotExist=Folder does not exist: {0} +PathArgumentMustBeAFolder=The Path argument must be a folder. File paths are not allowed. +UnableToFindModuleManifest=Unable to find module manifest [{0}]. Can't import module +PesterTestsFailed=One or more Pester tests failed +CodeCoverage=Code Coverage +Type=Type +CodeCoverageLessThanThreshold=Code coverage: [{0}] is [{1:p}], which is less than the threshold of [{2:p}] +CodeCoverageCodeCoverageFileNotFound=Code coverage file [{0}] not found. +SeverityThresholdSetTo=SeverityThreshold set to: {0} +PSScriptAnalyzerResults=PSScriptAnalyzer results: +ScriptAnalyzerErrors=One or more ScriptAnalyzer errors were found! +ScriptAnalyzerWarnings=One or more ScriptAnalyzer warnings were found! +ScriptAnalyzerIssues=One or more ScriptAnalyzer issues were found! +'@ +} +$importLocalizedDataSplat = @{ + BindingVariable = 'LocalizedData' + FileName = 'Messages.psd1' + ErrorAction = 'SilentlyContinue' +} +Import-LocalizedData @importLocalizedDataSplat + + Export-ModuleMember -Function $public.Basename # $psakeTaskAlias = 'PowerShellBuild.psake.tasks' diff --git a/PowerShellBuild/Private/Remove-ExcludedItem.ps1 b/PowerShellBuild/Private/Remove-ExcludedItem.ps1 new file mode 100644 index 0000000..9cad7f6 --- /dev/null +++ b/PowerShellBuild/Private/Remove-ExcludedItem.ps1 @@ -0,0 +1,33 @@ +function Remove-ExcludedItem { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] + [cmdletbinding()] + [OutputType([IO.FileSystemInfo[]])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('PSPath', 'FullName')] + [AllowEmptyCollection()] + [IO.FileSystemInfo[]]$InputObject, + + [string[]]$Exclude + ) + + begin { + $keepers = [Collections.Generic.List[IO.FileSystemInfo]]::new() + } + + process { + :item + foreach ($item in $InputObject) { + foreach ($regex in $Exclude) { + if ($_ -match $regex) { + break item + } + } + $keepers.Add($_) + } + } + + end { + $keepers + } +} diff --git a/PowerShellBuild/Public/Build-PSBuildMAMLHelp.ps1 b/PowerShellBuild/Public/Build-PSBuildMAMLHelp.ps1 index c0f86bc..20d42a6 100644 --- a/PowerShellBuild/Public/Build-PSBuildMAMLHelp.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildMAMLHelp.ps1 @@ -14,7 +14,7 @@ function Build-PSBuildMAMLHelp { Uses PlatyPS to generate MAML XML help from markdown files in ./docs and saves the XML file to a directory under ./output/MyModule #> - [cmdletbinding()] + [CmdletBinding()] param( [parameter(Mandatory)] [string]$Path, @@ -28,8 +28,8 @@ function Build-PSBuildMAMLHelp { # Generate the module's primary MAML help file foreach ($locale in $helpLocales) { $externalHelpParams = @{ - Path = Join-Path $Path $locale - OutputPath = Join-Path $DestinationPath $locale + Path = [IO.Path]::Combine($Path, $locale) + OutputPath = [IO.Path]::Combine($DestinationPath, $locale) Force = $true ErrorAction = 'SilentlyContinue' Verbose = $VerbosePreference diff --git a/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 b/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 index f85ea0f..9010543 100644 --- a/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 @@ -12,12 +12,20 @@ function Build-PSBuildMarkdown { The path where PlatyPS markdown docs will be saved. .PARAMETER Locale The locale to save the markdown docs. + .PARAMETER Overwrite + Overwrite existing markdown files and use comment based help as the source of truth. + .PARAMETER AlphabeticParamsOrder + Order parameters alphabetically by name in PARAMETERS section. There are 5 exceptions: -Confirm, -WhatIf, -IncludeTotalCount, -Skip, and -First parameters will be the last. + .PARAMETER ExcludeDontShow + Exclude the parameters marked with `DontShow` in the parameter attribute from the help content. + .PARAMETER UseFullTypeName + Indicates that the target document will use a full type name instead of a short name for parameters. .EXAMPLE PS> Build-PSBuildMarkdown -ModulePath ./output/MyModule/0.1.0 -ModuleName MyModule -DocsPath ./docs -Locale en-US Analysis the comment-based help of the MyModule module and create markdown documents under ./docs/en-US. #> - [cmdletbinding()] + [CmdletBinding()] param( [parameter(Mandatory)] [string]$ModulePath, @@ -29,14 +37,26 @@ function Build-PSBuildMarkdown { [string]$DocsPath, [parameter(Mandatory)] - [string]$Locale + [string]$Locale, + + [parameter(Mandatory)] + [bool]$Overwrite, + + [parameter(Mandatory)] + [bool]$AlphabeticParamsOrder, + + [parameter(Mandatory)] + [bool]$ExcludeDontShow, + + [parameter(Mandatory)] + [bool]$UseFullTypeName ) $moduleInfo = Import-Module "$ModulePath/$ModuleName.psd1" -Global -Force -PassThru try { if ($moduleInfo.ExportedCommands.Count -eq 0) { - Write-Warning 'No commands have been exported. Skipping markdown generation.' + Write-Warning $LocalizedData.NoCommandsExported return } @@ -45,20 +65,35 @@ function Build-PSBuildMarkdown { } if (Get-ChildItem -LiteralPath $DocsPath -Filter *.md -Recurse) { + $updateMDParams = @{ + AlphabeticParamsOrder = $AlphabeticParamsOrder + ExcludeDontShow = $ExcludeDontShow + UseFullTypeName = $UseFullTypeName + Verbose = $VerbosePreference + } Get-ChildItem -LiteralPath $DocsPath -Directory | ForEach-Object { - Update-MarkdownHelp -Path $_.FullName -Verbose:$VerbosePreference > $null + Update-MarkdownHelp -Path $_.FullName @updateMDParams > $null } } # ErrorAction set to SilentlyContinue so this command will not overwrite an existing MD file. $newMDParams = @{ - Module = $ModuleName - Locale = $Locale - OutputFolder = (Join-Path $DocsPath $Locale) - ErrorAction = 'SilentlyContinue' - Verbose = $VerbosePreference + Module = $ModuleName + Locale = $Locale + OutputFolder = [IO.Path]::Combine($DocsPath, $Locale) + AlphabeticParamsOrder = $AlphabeticParamsOrder + ExcludeDontShow = $ExcludeDontShow + UseFullTypeName = $UseFullTypeName + ErrorAction = 'SilentlyContinue' + Verbose = $VerbosePreference + } + if ($Overwrite) { + $newMDParams.Add('Force', $true) + $newMDParams.Remove('ErrorAction') } New-MarkdownHelp @newMDParams > $null + } catch { + Write-Error ($LocalizedData.FailedToGenerateMarkdownHelp -f $_) } finally { Remove-Module $moduleName } diff --git a/PowerShellBuild/Public/Build-PSBuildModule.ps1 b/PowerShellBuild/Public/Build-PSBuildModule.ps1 index 0bcb093..e8373d1 100644 --- a/PowerShellBuild/Public/Build-PSBuildModule.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildModule.ps1 @@ -1,10 +1,12 @@ +# spell-checker:ignore modulename function Build-PSBuildModule { <# .SYNOPSIS Builds a PowerShell module based on source directory .DESCRIPTION - Builds a PowerShell module based on source directory and optionally concatenates - public/private functions from separete files into monolithic .PSM1 file. + Builds a PowerShell module based on source directory and optionally + concatenates public/private functions from separate files into + monolithic .PSM1 file. .PARAMETER Path The source module path. .PARAMETER DestinationPath @@ -12,7 +14,8 @@ function Build-PSBuildModule { .PARAMETER ModuleName The name of the module. .PARAMETER Compile - Switch to indicate if separete function files should be concatenated into monolithic .PSM1 file. + Switch to indicate if separate function files should be concatenated + into monolithic .PSM1 file. .PARAMETER CompileHeader String that will be at the top of your PSM1 file. .PARAMETER CompileFooter @@ -20,15 +23,23 @@ function Build-PSBuildModule { .PARAMETER CompileScriptHeader String that will be added to your PSM1 file before each script file. .PARAMETER CompileScriptFooter - String that will be added to your PSM1 file beforeafter each script file. + String that will be added to your PSM1 file after each script file. .PARAMETER ReadMePath - Path to project README. If present, this will become the "about_.help.txt" file in the build module. + Path to project README. If present, this will become the + "about_.help.txt" file in the build module. + .PARAMETER CompileDirectories + List of directories containing .ps1 files that will also be compiled + into the PSM1. + .PARAMETER CopyDirectories + List of directories that will copying "as-is" into the build module. .PARAMETER Exclude - Array of files to exclude from copying into built module. + Array of files (regular expressions) to exclude from copying into built + module. .PARAMETER Culture - UI Culture. This is used to determine what culture directory to store "about_.help.txt" in. + UI Culture. This is used to determine what culture directory to store + "about_.help.txt" in. .EXAMPLE - PS> $buildParams = @{ + $buildParams = @{ Path = ./MyModule DestinationPath = ./Output/MoModule/0.1.0 ModuleName = MyModule @@ -36,11 +47,12 @@ function Build-PSBuildModule { Compile = $false Culture = (Get-UICulture).Name } - PS> Build-PSBuildModule @buildParams + Build-PSBuildModule @buildParams - Build module from source directory './MyModule' and save to '/Output/MoModule/0.1.0' + Build module from source directory './MyModule' and save to + '/Output/MoModule/0.1.0' #> - [cmdletbinding()] + [CmdletBinding()] param( [parameter(Mandatory)] [string]$Path, @@ -63,48 +75,90 @@ function Build-PSBuildModule { [string]$ReadMePath, + [string[]]$CompileDirectories = @(), + + [string[]]$CopyDirectories = @(), + [string[]]$Exclude = @(), [string]$Culture = (Get-UICulture).Name ) if (-not (Test-Path -LiteralPath $DestinationPath)) { - New-Item -Path $DestinationPath -ItemType Directory -Verbose:$VerbosePreference > $null + $newItemSplat = @{ + Path = $DestinationPath + ItemType = 'Directory' + Verbose = $VerbosePreference + } + New-Item @newItemSplat > $null } # Copy "non-processed files" - Get-ChildItem -Path $Path -Include '*.psm1', '*.psd1', '*.ps1xml' -Depth 1 | Copy-Item -Destination $DestinationPath -Force + $getChildItemSplat = @{ + Path = $Path + Include = '*.psm1', '*.psd1', '*.ps1xml' + Depth = 1 + } + Get-ChildItem @getChildItemSplat | + Copy-Item -Destination $DestinationPath -Force + foreach ($dir in $CopyDirectories) { + $copyPath = [IO.Path]::Combine($Path, $dir) + Copy-Item -Path $copyPath -Destination $DestinationPath -Recurse -Force + } # Copy README as about_.help.txt if (-not [string]::IsNullOrEmpty($ReadMePath)) { - $culturePath = Join-Path -Path $DestinationPath -ChildPath $Culture - $aboutModulePath = Join-Path -Path $culturePath -ChildPath "about_$($ModuleName).help.txt" - if(-not (Test-Path $culturePath -PathType Container)) { + $culturePath = [IO.Path]::Combine($DestinationPath, $Culture) + $aboutModulePath = [IO.Path]::Combine( + $culturePath, + "about_$($ModuleName).help.txt" + ) + if (-not (Test-Path $culturePath -PathType Container)) { New-Item $culturePath -Type Directory -Force > $null - Copy-Item -LiteralPath $ReadMePath -Destination $aboutModulePath -Force + $copyItemSplat = @{ + LiteralPath = $ReadMePath + Destination = $aboutModulePath + Force = $true + } + Copy-Item @copyItemSplat } } - # Copy source files to destination and optionally combine *.ps1 files into the PSM1 + # Copy source files to destination and optionally combine *.ps1 files + # into the PSM1 if ($Compile.IsPresent) { - $rootModule = Join-Path -Path $DestinationPath -ChildPath "$ModuleName.psm1" + $rootModule = [IO.Path]::Combine($DestinationPath, "$ModuleName.psm1") + + # Grab the contents of the copied over PSM1 + # This will be appended to the end of the finished PSM1 + $psm1Contents = Get-Content -Path $rootModule -Raw + '' | Out-File -FilePath $rootModule -Encoding 'utf8' + if ($CompileHeader) { - $CompileHeader | Add-Content -Path $rootModule -Encoding utf8 + $CompileHeader | Add-Content -Path $rootModule -Encoding 'utf8' } - $allScripts = Get-ChildItem -Path (Join-Path -Path $Path -ChildPath '*.ps1') -Recurse -ErrorAction SilentlyContinue - # do this because -Exclude in Get-ChildItem is broken - $allScripts = $allScripts | ForEach-Object { - ForEach ($regex in $Exclude) { - if ($_ -notmatch $regex) { - $_ - } - } + $resolvedCompileDirectories = $CompileDirectories | ForEach-Object { + [IO.Path]::Combine($Path, $_) } + $getChildItemSplat = @{ + Path = $resolvedCompileDirectories + Filter = '*.ps1' + File = $true + Recurse = $true + ErrorAction = 'SilentlyContinue' + } + $allScripts = Get-ChildItem @getChildItemSplat + + $allScripts = $allScripts | Remove-ExcludedItem -Exclude $Exclude + $addContentSplat = @{ + Path = $rootModule + Encoding = 'utf8' + } $allScripts | ForEach-Object { $srcFile = Resolve-Path $_.FullName -Relative - Write-Verbose "Adding $srcFile to PSM1" + Write-Verbose ($LocalizedData.AddingFileToPsm1 -f $srcFile) if ($CompileScriptHeader) { Write-Output $CompileScriptHeader @@ -115,28 +169,52 @@ function Build-PSBuildModule { if ($CompileScriptFooter) { Write-Output $CompileScriptFooter } + } | Add-Content @addContentSplat - } | Add-Content -Path $rootModule -Encoding utf8 + $psm1Contents | Add-Content @addContentSplat if ($CompileFooter) { - $CompileFooter | Add-Content -Path $rootModule -Encoding utf8 + $CompileFooter | Add-Content @addContentSplat } - } else{ + } else { + # Copy everything over, then remove stuff that should have been excluded + # It's just easier this way $copyParams = @{ - Path = (Join-Path -Path $Path -ChildPath '*') + Path = [IO.Path]::Combine($Path, '*') Destination = $DestinationPath Recurse = $true - Exclude = $Exclude Force = $true Verbose = $VerbosePreference } Copy-Item @copyParams + $allItems = Get-ChildItem -Path $DestinationPath -Recurse + $toRemove = foreach ($item in $allItems) { + foreach ($regex in $Exclude) { + if ($item -match $regex) { + $item + } + } + } + $toRemove | Remove-Item -Recurse -Force -ErrorAction 'Ignore' } # Export public functions in manifest if there are any public functions - $publicFunctions = Get-ChildItem $Path/Public/*.ps1 -Recurse -ErrorAction SilentlyContinue + $getChildItemSplat = @{ + Recurse = $true + ErrorAction = 'SilentlyContinue' + Path = "$Path/Public/*.ps1" + } + $publicFunctions = Get-ChildItem @getChildItemSplat if ($publicFunctions) { - $outputManifest = Join-Path -Path $DestinationPath -ChildPath "$ModuleName.psd1" - Update-Metadata -Path $OutputManifest -PropertyName FunctionsToExport -Value $publicFunctions.BaseName + $outputManifest = [IO.Path]::Combine( + $DestinationPath, + "$ModuleName.psd1" + ) + $updateMetadataSplat = @{ + Path = $OutputManifest + PropertyName = 'FunctionsToExport' + Value = $publicFunctions.BaseName + } + Update-Metadata @updateMetadataSplat } } diff --git a/PowerShellBuild/Public/Build-PSBuildUpdatableHelp.ps1 b/PowerShellBuild/Public/Build-PSBuildUpdatableHelp.ps1 index ffc3ef9..de784eb 100644 --- a/PowerShellBuild/Public/Build-PSBuildUpdatableHelp.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildUpdatableHelp.ps1 @@ -8,22 +8,27 @@ function Build-PSBuildUpdatableHelp { Path to PlatyPS markdown help files. .PARAMETER OutputPath Path to create updatable help .cab file in. + .PARAMETER Module + Name of the module to create a .cab file for. Defaults to the + $ModuleName variable from the parent scope. .EXAMPLE PS> Build-PSBuildUpdatableHelp -DocsPath ./docs -OutputPath ./Output/UpdatableHelp Create help .cab file based on PlatyPS markdown help. #> - [cmdletbinding()] + [CmdletBinding()] param( [parameter(Mandatory)] [string]$DocsPath, [parameter(Mandatory)] - [string]$OutputPath + [string]$OutputPath, + + [string]$Module = $ModuleName ) if ($null -ne $IsWindows -and -not $IsWindows) { - Write-Warning 'MakeCab.exe is only available on Windows. Cannot create help cab.' + Write-Warning $LocalizedData.MakeCabNotAvailable return } @@ -31,18 +36,32 @@ function Build-PSBuildUpdatableHelp { # Create updatable help output directory if (-not (Test-Path -LiteralPath $OutputPath)) { - New-Item $OutputPath -ItemType Directory -Verbose:$VerbosePreference > $null + $newItemSplat = @{ + ItemType = 'Directory' + Verbose = $VerbosePreference + Path = $OutputPath + } + New-Item @newItemSplat > $null } else { - Write-Verbose "Directory already exists [$OutputPath]." - Get-ChildItem $OutputPath | Remove-Item -Recurse -Force -Verbose:$VerbosePreference + Write-Verbose ($LocalizedData.DirectoryAlreadyExists -f $OutputPath) + $removeItemSplat = @{ + Recurse = $true + Force = $true + Verbose = $VerbosePreference + } + Get-ChildItem $OutputPath | Remove-Item @removeItemSplat } - # Generate updatable help files. Note: this will currently update the version number in the module's MD - # file in the metadata. + # Generate updatable help files. Note: this will currently update the + # version number in the module's MD file in the metadata. foreach ($locale in $helpLocales) { $cabParams = @{ - CabFilesFolder = Join-Path $moduleOutDir $locale - LandingPagePath = "$DocsPath/$locale/$ModuleName.md" + CabFilesFolder = [IO.Path]::Combine($moduleOutDir, $locale) + LandingPagePath = [IO.Path]::Combine( + $DocsPath, + $locale, + "$Module.md" + ) OutputFolder = $OutputPath Verbose = $VerbosePreference } diff --git a/PowerShellBuild/Public/Clear-PSBuildOutputFolder.ps1 b/PowerShellBuild/Public/Clear-PSBuildOutputFolder.ps1 index a8001af..fd6149a 100644 --- a/PowerShellBuild/Public/Clear-PSBuildOutputFolder.ps1 +++ b/PowerShellBuild/Public/Clear-PSBuildOutputFolder.ps1 @@ -16,11 +16,11 @@ function Clear-PSBuildOutputFolder { # Maybe a bit paranoid but this task nuked \ on my laptop. Good thing I was not running as admin. [parameter(Mandatory)] [ValidateScript({ - if ($_.Length -le 3) { - throw "`$Path [$_] must be longer than 3 characters." - } - $true - })] + if ($_.Length -le 3) { + throw ($LocalizedData.PathLongerThan3Chars -f $_) + } + $true + })] [string]$Path ) diff --git a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 new file mode 100644 index 0000000..c8bdbec --- /dev/null +++ b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 @@ -0,0 +1,204 @@ +function Get-PSBuildCertificate { + <# + .SYNOPSIS + Resolves a code-signing X509Certificate2 from one of several common sources. + .DESCRIPTION + Resolves a code-signing certificate suitable for use with Set-AuthenticodeSignature. + Supports five certificate sources to accommodate local development, CI/CD pipelines, + and custom signing infrastructure: + + Auto - Checks the CertificateEnvVar environment variable first. If it is + populated, uses EnvVar mode; otherwise falls back to Store mode. + This is the recommended default for projects that run both locally + and in automated pipelines. + + Store - Selects the first valid, unexpired code-signing certificate that has + a private key from the Windows certificate store at CertStoreLocation. + Suitable for developer workstations where a certificate is installed. + + Thumbprint - Like Store, but matches a specific certificate by its thumbprint. + Recommended when multiple code-signing certificates are installed and + you need a deterministic selection. + + EnvVar - Decodes a Base64-encoded PFX from an environment variable and + optionally decrypts it with a password from a second variable. + The most common approach for GitHub Actions, Azure DevOps Pipelines, + and GitLab CI where secrets are stored as masked variables. + + PfxFile - Loads a PFX/P12 file from disk with an optional SecureString password. + Useful for local scripts, containers, and environments where a + certificate file is mounted or distributed via a secrets manager. + + Note: Authenticode signing is a Windows-only capability. This function will fail + on non-Windows platforms when using Store or Thumbprint sources. + .PARAMETER CertificateSource + The source from which to resolve the code-signing certificate. + Valid values: Auto, Store, Thumbprint, EnvVar, PfxFile. Default: Auto. + .PARAMETER CertStoreLocation + Windows certificate store path to search when CertificateSource is Store or Thumbprint. + Default: Cert:\CurrentUser\My. + .PARAMETER Thumbprint + The exact certificate thumbprint to look up. Required when CertificateSource is Thumbprint. + .PARAMETER CertificateEnvVar + Name of the environment variable holding the Base64-encoded PFX certificate. + Used by the EnvVar source and by Auto as the presence-detection key. + Default: SIGNCERTIFICATE. + .PARAMETER CertificatePasswordEnvVar + Name of the environment variable holding the PFX password. Used by EnvVar source. + Default: CERTIFICATEPASSWORD. + .PARAMETER PfxFilePath + File system path to a PFX/P12 certificate file. Required when CertificateSource is PfxFile. + .PARAMETER PfxFilePassword + Password for the PFX file as a SecureString. Used by PfxFile source. + .PARAMETER SkipValidation + Skip validation checks (private key presence, expiration, Code Signing EKU) for certificates + loaded from EnvVar or PfxFile sources. Use with caution; invalid certificates will fail during + actual signing operations with less descriptive errors. + .OUTPUTS + System.Security.Cryptography.X509Certificates.X509Certificate2 + Returns the resolved certificate, or $null if none was found (Store/Thumbprint sources). + .EXAMPLE + PS> $cert = Get-PSBuildCertificate + + Resolve automatically: use the SIGNCERTIFICATE env var when present, otherwise search + the current user's certificate store. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate -CertificateSource Store + + Explicitly load the first valid code-signing certificate from the current user's store. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'AB12CD34EF56...' + + Load a specific certificate from the certificate store by its thumbprint. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate -CertificateSource EnvVar ` + -CertificateEnvVar 'MY_PFX' -CertificatePasswordEnvVar 'MY_PFX_PASS' + + Decode a PFX certificate stored in a CI/CD secret environment variable. + .EXAMPLE + PS> $pass = Read-Host -Prompt 'Certificate password' -AsSecureString + PS> $cert = Get-PSBuildCertificate -CertificateSource PfxFile -PfxFilePath './codesign.pfx' -PfxFilePassword $pass + + Load a code-signing certificate from a PFX file on disk. + #> + [CmdletBinding()] + [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingPlainTextForPassword', + 'CertificatePasswordEnvVar', + Justification = 'This is not a password in plain text. It is the name of an environment variable that contains the password, which is a common pattern for CI/CD pipelines and secrets management.' + )] + param( + [ValidateSet('Auto', 'Store', 'Thumbprint', 'EnvVar', 'PfxFile')] + [string]$CertificateSource = 'Auto', + + [string]$CertStoreLocation = 'Cert:\CurrentUser\My', + + [string]$Thumbprint, + + [string]$CertificateEnvVar = 'SIGNCERTIFICATE', + + [string]$CertificatePasswordEnvVar = 'CERTIFICATEPASSWORD', + + [string]$PfxFilePath, + + [securestring]$PfxFilePassword, + + [switch]$SkipValidation + ) + + # Resolve 'Auto' to the actual source based on environment variable presence + $resolvedSource = $CertificateSource + if ($resolvedSource -eq 'Auto') { + $resolvedSource = if (-not [string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($CertificateEnvVar))) { + 'EnvVar' + } else { + 'Store' + } + Write-Verbose ($LocalizedData.CertificateSourceAutoResolved -f $resolvedSource) + } + + $cert = $null + + switch ($resolvedSource) { + 'Store' { + # Throw if running on a non-Windows platform since the certificate store is not supported. + # $IsWindows does not exist on Windows PowerShell 5.1 (Desktop edition), where it is $null + # and the platform is always Windows; only treat the platform as non-Windows when $IsWindows + # is explicitly $false (PowerShell 7+ on Linux/macOS). + if ($null -ne $IsWindows -and -not $IsWindows) { + throw $LocalizedData.CertificateSourceStoreNotSupported + } + $cert = Get-ChildItem -Path $CertStoreLocation -CodeSigningCert | + Where-Object { $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } | + Select-Object -First 1 + if ($cert) { + Write-Verbose ($LocalizedData.CertificateResolvedFromStore -f $CertStoreLocation, $cert.Subject) + } + } + 'Thumbprint' { + if ([string]::IsNullOrWhiteSpace($Thumbprint)) { + throw "CertificateSource 'Thumbprint' requires a non-empty Thumbprint value." + } + + # Normalize thumbprint input by removing whitespace for robust matching + $normalizedThumbprint = ($Thumbprint -replace '\s', '') + + $cert = Get-ChildItem -Path $CertStoreLocation -CodeSigningCert | + Where-Object { + ($_.Thumbprint -replace '\s', '') -ieq $normalizedThumbprint -and + $_.HasPrivateKey -and + $_.NotAfter -gt (Get-Date) + } | + Select-Object -First 1 + if ($cert) { + Write-Verbose ($LocalizedData.CertificateResolvedFromThumbprint -f $Thumbprint, $cert.Subject) + } + } + 'EnvVar' { + $b64Value = [System.Environment]::GetEnvironmentVariable($CertificateEnvVar) + if ([string]::IsNullOrWhiteSpace($b64Value)) { + throw "Environment variable '$CertificateEnvVar' is not set or is empty. When using CertificateSource='EnvVar', you must provide a Base64-encoded PFX in this variable." + } + + try { + $buffer = [System.Convert]::FromBase64String($b64Value) + } catch [System.FormatException] { + throw "Environment variable '$CertificateEnvVar' does not contain a valid Base64-encoded PFX value." + } + $password = [System.Environment]::GetEnvironmentVariable($CertificatePasswordEnvVar) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($buffer, $password) + Write-Verbose ($LocalizedData.CertificateResolvedFromEnvVar -f $CertificateEnvVar) + } + 'PfxFile' { + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxFilePath, $PfxFilePassword) + Write-Verbose ($LocalizedData.CertificateResolvedFromPfxFile -f $PfxFilePath) + } + } + + # Validate certificates loaded from EnvVar or PfxFile sources unless -SkipValidation is specified + if ($cert -and -not $SkipValidation -and ($resolvedSource -eq 'EnvVar' -or $resolvedSource -eq 'PfxFile')) { + # Check for private key + if (-not $cert.HasPrivateKey) { + throw ($LocalizedData.CertificateMissingPrivateKey -f $cert.Subject) + } + + # Check expiration + if ($cert.NotAfter -le (Get-Date)) { + throw ($LocalizedData.CertificateExpired -f $cert.NotAfter, $cert.Subject) + } + + # Check for Code Signing EKU (1.3.6.1.5.5.7.3.3) + $codeSigningOid = '1.3.6.1.5.5.7.3.3' + $hasCodeSigningEku = $cert.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq $codeSigningOid } + if (-not $hasCodeSigningEku) { + throw ($LocalizedData.CertificateMissingCodeSigningEku -f $cert.Subject) + } + + Write-Verbose "Certificate validation passed: HasPrivateKey=$($cert.HasPrivateKey), NotAfter=$($cert.NotAfter), CodeSigningEKU=Present" + } + + $certSubject = if ($cert) { $cert.Subject } else { 'No certificate found' } + Write-Verbose ('Certificate resolution complete: ' + $certSubject) + $cert +} diff --git a/PowerShellBuild/Public/Initialize-PSBuild.ps1 b/PowerShellBuild/Public/Initialize-PSBuild.ps1 index 1b2c36e..c45dfb7 100644 --- a/PowerShellBuild/Public/Initialize-PSBuild.ps1 +++ b/PowerShellBuild/Public/Initialize-PSBuild.ps1 @@ -7,13 +7,14 @@ function Initialize-PSBuild { .PARAMETER BuildEnvironment Contains the PowerShellBuild settings (known as $PSBPreference). .PARAMETER UseBuildHelpers - Use BuildHelpers module to populate common environment variables based on current build system context. + Use BuildHelpers module to populate common environment variables based + on current build system context. .EXAMPLE PS> Initialize-PSBuild -UseBuildHelpers Populate build system environment variables. #> - [cmdletbinding()] + [CmdletBinding()] param( [Parameter(Mandatory)] [Hashtable] @@ -22,10 +23,24 @@ function Initialize-PSBuild { [switch]$UseBuildHelpers ) - if ([IO.Path]::IsPathFullyQualified($BuildEnvironment.Build.OutDir)) { - $BuildEnvironment.Build.ModuleOutDir = [IO.Path]::Combine($BuildEnvironment.Build.OutDir, $env:BHProjectName, $BuildEnvironment.General.ModuleVersion) + if ( + $BuildEnvironment.Build.OutDir.StartsWith( + $env:BHProjectPath, + [StringComparison]::OrdinalIgnoreCase + ) + ) { + $BuildEnvironment.Build.ModuleOutDir = [IO.Path]::Combine( + $BuildEnvironment.Build.OutDir, + $env:BHProjectName, + $BuildEnvironment.General.ModuleVersion + ) } else { - $BuildEnvironment.Build.ModuleOutDir = [IO.Path]::Combine($env:BHProjectPath, $BuildEnvironment.Build.OutDir, $env:BHProjectName, $BuildEnvironment.General.ModuleVersion) + $BuildEnvironment.Build.ModuleOutDir = [IO.Path]::Combine( + $env:BHProjectPath, + $BuildEnvironment.Build.OutDir, + $env:BHProjectName, + $BuildEnvironment.General.ModuleVersion + ) } $params = @{ @@ -33,19 +48,19 @@ function Initialize-PSBuild { } Set-BuildEnvironment @params -Force - Write-Host 'Build System Details:' -ForegroundColor Yellow - $psVersion = $PSVersionTable.PSVersion.ToString() - $buildModuleName = $MyInvocation.MyCommand.Module.Name + Write-Host $LocalizedData.BuildSystemDetails -ForegroundColor 'Yellow' + $psVersion = $PSVersionTable.PSVersion.ToString() + $buildModuleName = $MyInvocation.MyCommand.Module.Name $buildModuleVersion = $MyInvocation.MyCommand.Module.Version - "Build Module: $buildModuleName`:$buildModuleVersion" - "PowerShell Version: $psVersion" + $LocalizedData.BuildModule -f $buildModuleName, $buildModuleVersion + $LocalizedData.PowerShellVersion -f $psVersion if ($UseBuildHelpers.IsPresent) { $nl = [System.Environment]::NewLine - Write-Host "$nl`Environment variables:" -ForegroundColor Yellow + Write-Host ($LocalizedData.EnvironmentVariables -f $nl) -ForegroundColor 'Yellow' (Get-Item ENV:BH*).Foreach({ - '{0,-20}{1}' -f $_.name, $_.value - }) + '{0,-20}{1}' -f $_.name, $_.value + }) } } diff --git a/PowerShellBuild/Public/Invoke-PSBuildModuleSigning.ps1 b/PowerShellBuild/Public/Invoke-PSBuildModuleSigning.ps1 new file mode 100644 index 0000000..58d9f24 --- /dev/null +++ b/PowerShellBuild/Public/Invoke-PSBuildModuleSigning.ps1 @@ -0,0 +1,88 @@ +function Invoke-PSBuildModuleSigning { + <# + .SYNOPSIS + Signs PowerShell module files with an Authenticode signature. + .DESCRIPTION + Signs all files matching the Include patterns found under Path using + Set-AuthenticodeSignature. Typically called after the module is staged to the output + directory and before the catalog file is created, so that all signed source files are + captured in the catalog hash. + + Authenticode signing is Windows-only. This function will fail on Linux or macOS. + + Use Get-PSBuildCertificate to resolve the certificate from any of the supported sources + (certificate store, PFX file, Base64 environment variable, thumbprint, etc.) before + calling this function. + .PARAMETER Path + The directory to search recursively for files to sign. Typically the module output + directory (PSBPreference.Build.ModuleOutDir). + .PARAMETER Certificate + The X509Certificate2 code-signing certificate to sign files with. Must have a private + key and an Extended Key Usage (EKU) of Code Signing (1.3.6.1.5.5.7.3.3). + .PARAMETER TimestampServer + RFC 3161 timestamp server URI to embed in the Authenticode signature, allowing the + signature to remain valid after the certificate expires. Default: http://timestamp.digicert.com. + + Other common timestamp servers: + http://timestamp.sectigo.com + http://timestamp.comodoca.com + http://tsa.starfieldtech.com + http://timestamp.globalsign.com/scripts/timstamp.dll + .PARAMETER HashAlgorithm + Hash algorithm for the Authenticode signature. + Valid values: SHA256 (default), SHA384, SHA512, SHA1. + SHA1 is deprecated; prefer SHA256 or higher. + .PARAMETER Include + Glob patterns of file names to sign. Searched recursively under Path. + Default: *.psd1, *.psm1, *.ps1. + .OUTPUTS + System.Management.Automation.Signature + Returns the Signature objects from Set-AuthenticodeSignature for each signed file. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate + PS> Invoke-PSBuildModuleSigning -Path .\Output\MyModule\1.0.0 -Certificate $cert + + Sign all .psd1, .psm1, and .ps1 files in the module output directory using a + certificate resolved automatically from the environment or certificate store. + .EXAMPLE + PS> $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'AB12CD...' + PS> Invoke-PSBuildModuleSigning -Path .\Output\MyModule\1.0.0 -Certificate $cert ` + -TimestampServer 'http://timestamp.sectigo.com' -Include '*.psd1','*.psm1' + + Sign only the manifest and root module using a specific certificate and a custom + timestamp server. + #> + [CmdletBinding()] + [OutputType([System.Management.Automation.Signature])] + param( + [parameter(Mandatory)] + [ValidateScript({ + if (-not (Test-Path -Path $_ -PathType Container)) { + throw ($LocalizedData.PathArgumentMustBeAFolder) + } + $true + })] + [string]$Path, + + [parameter(Mandatory)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + + [string]$TimestampServer = 'http://timestamp.digicert.com', + + [ValidateSet('SHA256', 'SHA384', 'SHA512', 'SHA1')] + [string]$HashAlgorithm = 'SHA256', + + [string[]]$Include = @('*.psd1', '*.psm1', '*.ps1') + ) + + $files = Get-ChildItem -Path $Path -Recurse -Include $Include + Write-Verbose ($LocalizedData.SigningModuleFiles -f $files.Count, ($Include -join ', '), $Path) + + $sigParams = @{ + Certificate = $Certificate + TimestampServer = $TimestampServer + HashAlgorithm = $HashAlgorithm + } + + $files | Set-AuthenticodeSignature @sigParams +} diff --git a/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 b/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 new file mode 100644 index 0000000..d8915e6 --- /dev/null +++ b/PowerShellBuild/Public/New-PSBuildFileCatalog.ps1 @@ -0,0 +1,73 @@ +function New-PSBuildFileCatalog { + <# + .SYNOPSIS + Creates a Windows catalog (.cat) file for a PowerShell module. + .DESCRIPTION + Wraps New-FileCatalog to generate a catalog file that records cryptographic hashes of + all files in the module output directory. The catalog can later be signed with + Invoke-PSBuildModuleSigning (or Set-AuthenticodeSignature) to provide tamper detection + and a trust chain for the entire module. + + The recommended signing order is: + 1. Sign module files (*.psd1, *.psm1, *.ps1) with Invoke-PSBuildModuleSigning. + 2. Create the catalog with New-PSBuildFileCatalog (hashes already-signed files). + 3. Sign the catalog file with Invoke-PSBuildModuleSigning -Include '*.cat'. + + Catalog file creation requires Windows (New-FileCatalog is not available on Linux or macOS). + + Reference: https://p0w3rsh3ll.wordpress.com/2017/09/19/psgallery-and-catalog-files/ + .PARAMETER ModulePath + The directory whose contents will be hashed and recorded in the catalog. + Typically the module output directory (PSBPreference.Build.ModuleOutDir). + .PARAMETER CatalogFilePath + The full path (directory + filename) of the .cat file to create. + By convention this is '\.cat'. + .PARAMETER CatalogVersion + The catalog hash version. + 1 = SHA1, compatible with Windows 7 and Windows Server 2008 R2. + 2 = SHA2 (SHA-256), required for Windows 8 / Server 2012 and newer. Default: 2. + .OUTPUTS + System.IO.FileInfo + Returns the FileInfo object of the created catalog file. + .EXAMPLE + PS> New-PSBuildFileCatalog -ModulePath .\Output\MyModule\1.0.0 ` + -CatalogFilePath .\Output\MyModule\1.0.0\MyModule.cat + + Create a version-2 (SHA2) catalog for all files in the module output directory. + .EXAMPLE + PS> New-PSBuildFileCatalog -ModulePath .\Output\MyModule\1.0.0 ` + -CatalogFilePath .\Output\MyModule\1.0.0\MyModule.cat -CatalogVersion 1 + + Create a SHA1 (version 1) catalog for compatibility with Windows 7 / Server 2008 R2. + #> + [CmdletBinding()] + [OutputType([System.IO.FileInfo])] + param( + [parameter(Mandatory)] + [ValidateScript({ + if (-not (Test-Path -Path $_ -PathType Container)) { + throw ($LocalizedData.PathArgumentMustBeAFolder) + } + $true + })] + [string]$ModulePath, + + [parameter(Mandatory)] + [string]$CatalogFilePath, + + [ValidateRange(1, 2)] + [int]$CatalogVersion = 2 + ) + + Write-Verbose ($LocalizedData.CreatingFileCatalog -f $CatalogFilePath, $CatalogVersion) + + $catalogParams = @{ + Path = $ModulePath + CatalogFilePath = $CatalogFilePath + CatalogVersion = $CatalogVersion + } + + Microsoft.PowerShell.Security\New-FileCatalog @catalogParams + + Write-Verbose ($LocalizedData.FileCatalogCreated -f $CatalogFilePath) +} diff --git a/PowerShellBuild/Public/Publish-PSBuildModule.ps1 b/PowerShellBuild/Public/Publish-PSBuildModule.ps1 index 3603c29..dd4708d 100644 --- a/PowerShellBuild/Public/Publish-PSBuildModule.ps1 +++ b/PowerShellBuild/Public/Publish-PSBuildModule.ps1 @@ -27,18 +27,18 @@ function Publish-PSBuildModule { Publish version 0.1.0 of the module at path .\Output\0.1.0\MyModule to the PSGallery repository using an API key and a PowerShell credential. #> - [cmdletbinding(DefaultParameterSetName = 'ApiKey')] + [CmdletBinding(DefaultParameterSetName = 'ApiKey')] param( [parameter(Mandatory)] [ValidateScript({ - if (-not (Test-Path -Path $_ )) { - throw 'Folder does not exist' - } - if (-not (Test-Path -Path $_ -PathType Container)) { - throw 'The Path argument must be a folder. File paths are not allowed.' - } - $true - })] + if (-not (Test-Path -Path $_ )) { + throw ($LocalizedData.PathDoesNotExist -f $_) + } + if (-not (Test-Path -Path $_ -PathType Container)) { + throw $LocalizedData.PathArgumentMustBeAFolder + } + $true + })] [System.IO.FileInfo]$Path, [parameter(Mandatory)] @@ -50,10 +50,10 @@ function Publish-PSBuildModule { [Alias('ApiKey')] [string]$NuGetApiKey, - [pscredential]$Credential + [PSCredential]$Credential ) - Write-Verbose "Publishing version [$Version] to repository [$Repository]..." + Write-Verbose ($LocalizedData.PublishingVersionToRepository -f $Version, $Repository) $publishParams = @{ Path = $Path diff --git a/PowerShellBuild/Public/Test-PSBuildPester.ps1 b/PowerShellBuild/Public/Test-PSBuildPester.ps1 index f0034c9..3dbd4a9 100644 --- a/PowerShellBuild/Public/Test-PSBuildPester.ps1 +++ b/PowerShellBuild/Public/Test-PSBuildPester.ps1 @@ -8,6 +8,8 @@ function Test-PSBuildPester { Directory Pester tests to execute. .PARAMETER ModuleName Name of Module to test. + .PARAMETER ModuleManifest + Path to module manifest to import during test .PARAMETER OutputPath Output path to store Pester test results to. .PARAMETER OutputFormat @@ -18,23 +20,33 @@ function Test-PSBuildPester { Threshold required to pass code coverage test (.90 = 90%). .PARAMETER CodeCoverageFiles Array of files to validate code coverage for. + .PARAMETER CodeCoverageOutputFile + Output path (relative to Pester tests directory) to store code coverage results to. + .PARAMETER CodeCoverageOutputFileFormat + Code coverage result output format. Currently, only 'JaCoCo' is supported by Pester. .PARAMETER ImportModule Import module from OutDir prior to running Pester tests. + .PARAMETER SkipRemainingOnFailure + Skip remaining tests after failure for selected scope. Options are None, Run, Container and Block. Default: None. + .PARAMETER OutputVerbosity + The verbosity of output, options are None, Normal, Detailed and Diagnostic. Default is Detailed. .EXAMPLE - PS> Test-PSBuildPester -Path ./tests -ModuleName Mymodule -OutputPath ./out/testResults.xml + PS> Test-PSBuildPester -Path ./tests -ModuleName MyModule -OutputPath ./out/testResults.xml Run Pester tests in ./tests and save results to ./out/testResults.xml #> - [cmdletbinding()] + [CmdletBinding()] param( [parameter(Mandatory)] [string]$Path, [string]$ModuleName, + [string]$ModuleManifest, + [string]$OutputPath, - [string]$OutputFormat = 'NUnitXml', + [string]$OutputFormat = 'NUnit2.5', [switch]$CodeCoverage, @@ -42,7 +54,17 @@ function Test-PSBuildPester { [string[]]$CodeCoverageFiles = @(), - [switch]$ImportModule + [string]$CodeCoverageOutputFile = 'coverage.xml', + + [string]$CodeCoverageOutputFileFormat = 'JaCoCo', + + [switch]$ImportModule, + + [ValidateSet('None', 'Run', 'Container', 'Block')] + [string]$SkipRemainingOnFailure = 'None', + + [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] + [string]$OutputVerbosity = 'Detailed' ) if (-not (Get-Module -Name Pester)) { @@ -51,39 +73,68 @@ function Test-PSBuildPester { try { if ($ImportModule) { - $ModuleOutputManifest = Join-Path -Path $env:BHBuildOutput -ChildPath "$($ModuleName).psd1" - # Remove any previously imported project modules - Get-Module $ModuleName | Remove-Module -Force - # Import recently built project module from BHBuildOutput - Import-Module $ModuleOutputManifest -Force + if (-not (Test-Path $ModuleManifest)) { + Write-Error ($LocalizedData.UnableToFindModuleManifest -f $ModuleManifest) + } else { + # Remove any previously imported project modules and import from the output dir + Get-Module $ModuleName | Remove-Module -Force -ErrorAction SilentlyContinue + Import-Module $ModuleManifest -Force + } } Push-Location -LiteralPath $Path - $pesterParams = @{ - PassThru = $true - Verbose = $VerbosePreference - } - if (-not [string]::IsNullOrEmpty($OutputPath)) { - $pesterParams.OutputFile = $OutputPath - $pesterParams.OutputFormat = $OutputFormat - } - # To control the Pester code coverage, a boolean $CodeCoverageEnabled is used. + Import-Module Pester -MinimumVersion 5.0.0 + $configuration = [PesterConfiguration]::Default + $configuration.Output.Verbosity = $OutputVerbosity + $configuration.Run.PassThru = $true + $configuration.Run.SkipRemainingOnFailure = $SkipRemainingOnFailure + $configuration.TestResult.Enabled = -not [string]::IsNullOrEmpty($OutputPath) + $configuration.TestResult.OutputPath = $OutputPath + $configuration.TestResult.OutputFormat = $OutputFormat + if ($CodeCoverage.IsPresent) { - $pesterParams.CodeCoverage = $CodeCoverageFiles + $configuration.CodeCoverage.Enabled = $true + if ($CodeCoverageFiles.Count -gt 0) { + $configuration.CodeCoverage.Path = $CodeCoverageFiles + } + $configuration.CodeCoverage.OutputPath = $CodeCoverageOutputFile + $configuration.CodeCoverage.OutputFormat = $CodeCoverageOutputFileFormat } - $testResult = Invoke-Pester @pesterParams + $testResult = Invoke-Pester -Configuration $configuration -Verbose:$VerbosePreference if ($testResult.FailedCount -gt 0) { - throw 'One or more Pester tests failed' + throw $LocalizedData.PesterTestsFailed } if ($CodeCoverage.IsPresent) { - $testCoverage = [int]($testResult.CodeCoverage.NumberOfCommandsExecuted / $testResult.CodeCoverage.NumberOfCommandsAnalyzed) - 'Pester code coverage on specified files: {0:p}' -f $testCoverage - if ($testCoverage -lt $CodeCoverageThreshold) { - throw 'Code coverage is less than threshold of {0:p}' -f $CodeCoverageThreshold + Write-Host ("`n{0}:`n" -f $LocalizedData.CodeCoverage) -ForegroundColor Yellow + if (Test-Path $CodeCoverageOutputFile) { + $textInfo = (Get-Culture).TextInfo + [xml]$testCoverage = Get-Content $CodeCoverageOutputFile + $ccReport = $testCoverage.report.counter.ForEach({ + $total = [int]$_.missed + [int]$_.covered + $percent = [Math]::Truncate([int]$_.covered / $total) + [PSCustomObject]@{ + name = $textInfo.ToTitleCase($_.Type.ToLower()) + percent = $percent + } + }) + + $ccFailMsgs = @() + $ccReport.ForEach({ + '{0}: [{1}]: {2:p}' -f $LocalizedData.Type, $_.name, $_.percent + if ($_.percent -lt $CodeCoverageThreshold) { + $ccFailMsgs += ($LocalizedData.CodeCoverageLessThanThreshold -f $_.name, $_.percent, $CodeCoverageThreshold) + } + }) + Write-Host "`n" + $ccFailMsgs.Foreach({ + Write-Error $_ + }) + } else { + Write-Error ($LocalizedData.CodeCoverageCodeCoverageFileNotFound -f $CodeCoverageOutputFile) } } } finally { diff --git a/PowerShellBuild/Public/Test-PSBuildScriptAnalysis.ps1 b/PowerShellBuild/Public/Test-PSBuildScriptAnalysis.ps1 index c917d1c..9d47628 100644 --- a/PowerShellBuild/Public/Test-PSBuildScriptAnalysis.ps1 +++ b/PowerShellBuild/Public/Test-PSBuildScriptAnalysis.ps1 @@ -5,17 +5,17 @@ function Test-PSBuildScriptAnalysis { .DESCRIPTION Run PSScriptAnalyzer tests against a module. .PARAMETER Path - Path to PowerShell module directory to run ScriptAnalyser on. + Path to PowerShell module directory to run ScriptAnalyzer on. .PARAMETER SeverityThreshold - Fail ScriptAnalyser test if any issues are found with this threshold or higher. + Fail ScriptAnalyzer test if any issues are found with this threshold or higher. .PARAMETER SettingsPath - Path to ScriptAnalyser settings to use. + Path to ScriptAnalyzer settings to use. .EXAMPLE - PS> Test-PSBuildScriptAnalysis -Path ./Output/Mymodule/0.1.0 -SeverityThreshold Error + PS> Test-PSBuildScriptAnalysis -Path ./Output/MyModule/0.1.0 -SeverityThreshold Error - Run ScriptAnalyzer on built module in ./Output/Mymodule/0.1.0. Throw error if any errors are found. + Run ScriptAnalyzer on built module in ./Output/MyModule/0.1.0. Throw error if any errors are found. #> - [cmdletbinding()] + [CmdletBinding()] param( [parameter(Mandatory)] [string]$Path, @@ -26,15 +26,15 @@ function Test-PSBuildScriptAnalysis { [string]$SettingsPath ) - Write-Verbose "SeverityThreshold set to: $SeverityThreshold" + Write-Verbose ($LocalizedData.SeverityThresholdSetTo -f $SeverityThreshold) $analysisResult = Invoke-ScriptAnalyzer -Path $Path -Settings $SettingsPath -Recurse -Verbose:$VerbosePreference - $errors = ($analysisResult.where({$_Severity -eq 'Error'})).Count - $warnings = ($analysisResult.where({$_Severity -eq 'Warning'})).Count - $infos = ($analysisResult.where({$_Severity -eq 'Information'})).Count + $errors = ($analysisResult.where({ $_Severity -eq 'Error' })).Count + $warnings = ($analysisResult.where({ $_Severity -eq 'Warning' })).Count + $infos = ($analysisResult.where({ $_Severity -eq 'Information' })).Count if ($analysisResult) { - Write-Host 'PSScriptAnalyzer results:' -ForegroundColor Yellow + Write-Host $LocalizedData.PSScriptAnalyzerResults -ForegroundColor Yellow $analysisResult | Format-Table -AutoSize } @@ -44,22 +44,22 @@ function Test-PSBuildScriptAnalysis { } 'Error' { if ($errors -gt 0) { - throw 'One or more ScriptAnalyzer errors were found!' + throw $LocalizedData.ScriptAnalyzerErrors } } 'Warning' { if ($errors -gt 0 -or $warnings -gt 0) { - throw 'One or more ScriptAnalyzer warnings were found!' + throw $LocalizedData.ScriptAnalyzerWarnings } } 'Information' { if ($errors -gt 0 -or $warnings -gt 0 -or $infos -gt 0) { - throw 'One or more ScriptAnalyzer warnings were found!' + throw $LocalizedData.ScriptAnalyzerWarnings } } default { if ($analysisResult.Count -ne 0) { - throw 'One or more ScriptAnalyzer issues were found!' + throw $LocalizedData.ScriptAnalyzerIssues } } } diff --git a/PowerShellBuild/build.properties.ps1 b/PowerShellBuild/build.properties.ps1 index f499b78..2f1c0b0 100644 --- a/PowerShellBuild/build.properties.ps1 +++ b/PowerShellBuild/build.properties.ps1 @@ -1,62 +1,69 @@ +# spell-checker:ignore PSGALLERY BHPS MAML BuildHelpers\Set-BuildEnvironment -Force -$outDir = Join-Path -Path $env:BHProjectPath -ChildPath Output +$outDir = [IO.Path]::Combine($env:BHProjectPath, 'Output') $moduleVersion = (Import-PowerShellDataFile -Path $env:BHPSModuleManifest).ModuleVersion [ordered]@{ General = @{ # Root directory for the project - ProjectRoot = $env:BHProjectPath + ProjectRoot = $env:BHProjectPath # Root directory for the module - SrcRootDir = $env:BHPSModulePath + SrcRootDir = $env:BHPSModulePath # The name of the module. This should match the basename of the PSD1 file - ModuleName = $env:BHProjectName + ModuleName = $env:BHProjectName # Module version - ModuleVersion = $moduleVersion + ModuleVersion = $moduleVersion # Module manifest path ModuleManifestPath = $env:BHPSModuleManifest } - Build = @{ + Build = @{ - Dependencies = @('StageFiles', 'BuildHelp') + # "Dependencies" moved to TaskDependencies section # Output directory when building a module - OutDir = $outDir + OutDir = $outDir # Module output directory # This will be computed in 'Initialize-PSBuild' so we can allow the user to # override the top-level 'OutDir' above and compute the full path to the module internally - ModuleOutDir = $null + ModuleOutDir = $null # Controls whether to "compile" module into single PSM1 or not - CompileModule = $false + CompileModule = $false - # List of files to exclude from output directory - Exclude = @() + # List of directories that if CompileModule is $true, will be concatenated into the PSM1 + CompileDirectories = @('Enum', 'Classes', 'Private', 'Public') + + # List of directories that will always be copied "as is" to output directory + CopyDirectories = @() + + # List of files (regular expressions) to exclude from output directory + Exclude = @() } - Test = @{ + Test = @{ # Enable/disable Pester tests - Enabled = $true + Enabled = $true # Directory containing Pester tests - RootDir = Join-Path -Path $env:BHProjectPath -ChildPath tests + RootDir = [IO.Path]::Combine($env:BHProjectPath, 'tests') # Specifies an output file path to send to Invoke-Pester's -OutputFile parameter. - # This is typically used to write out test results so that they can be sent to a CI - # system like AppVeyor. - OutputFile = $null + # This is typically used to write out test results so that they can be sent to a CI system + # This path is relative to the directory containing Pester tests + OutputFile = [IO.Path]::Combine($env:BHProjectPath, 'testResults.xml') # Specifies the test output format to use when the TestOutputFile property is given # a path. This parameter is passed through to Invoke-Pester's -OutputFormat parameter. - OutputFormat = 'NUnitXml' + OutputFormat = 'NUnitXml' - ScriptAnalysis = @{ + ScriptAnalysis = @{ # Enable/disable use of PSScriptAnalyzer to perform script analysis - Enabled = $true + Enabled = $true # When PSScriptAnalyzer is enabled, control which severity level will generate a build failure. # Valid values are Error, Warning, Information and None. "None" will report errors but will not @@ -66,59 +73,146 @@ $moduleVersion = (Import-PowerShellDataFile -Path $env:BHPSModuleManifest).Modul FailBuildOnSeverityLevel = 'Error' # Path to the PSScriptAnalyzer settings file. - SettingsPath = Join-Path $PSScriptRoot -ChildPath ScriptAnalyzerSettings.psd1 + SettingsPath = [IO.Path]::Combine($PSScriptRoot, 'ScriptAnalyzerSettings.psd1') } # Import module from OutDir prior to running Pester tests. - ImportModule = $false + ImportModule = $false - CodeCoverage = @{ + CodeCoverage = @{ # Enable/disable Pester code coverage reporting. - Enabled = $false + Enabled = $false # Fail Pester code coverage test if below this threshold - Threshold = .75 + Threshold = .75 # CodeCoverageFiles specifies the files to perform code coverage analysis on. This property # acts as a direct input to the Pester -CodeCoverage parameter, so will support constructions - # like the ones found here: https://github.com/pester/Pester/wiki/Code-Coverage. - Files = @( - Join-Path -Path $env:BHPSModulePath -ChildPath '*.ps1' - Join-Path -Path $env:BHPSModulePath -ChildPath '*.psm1' - ) + # like the ones found here: https://pester.dev/docs/usage/code-coverage. + Files = @() + + # Path to write code coverage report to + OutputFile = [IO.Path]::Combine($env:BHProjectPath, 'codeCoverage.xml') + + # The code coverage output format to use + OutputFileFormat = 'JaCoCo' } + + # Skip remaining tests after failure for selected scope. Options are None, Run, Container and Block. Default: None. + SkipRemainingOnFailure = 'None' + + # Set verbosity of output. Options are None, Normal, Detailed and Diagnostic. Default: Detailed. + OutputVerbosity = 'Detailed' } - Help = @{ - # Path to updateable help CAB - UpdatableHelpOutDir = Join-Path -Path $outDir -ChildPath 'UpdatableHelp' + Help = @{ + # Path to updatable help CAB + UpdatableHelpOutDir = [IO.Path]::Combine($outDir, 'UpdatableHelp') # Default Locale used for help generation, defaults to en-US - DefaultLocale = (Get-UICulture).Name + # Get-UICulture doesn't return a name on Linux so default to en-US + DefaultLocale = if (-not (Get-UICulture).Name) { 'en-US' } else { (Get-UICulture).Name } # Convert project readme into the module about file ConvertReadMeToAboutHelp = $false } - Docs = @{ + Docs = @{ # Directory PlatyPS markdown documentation will be saved to - RootDir = Join-Path -Path $env:BHProjectPath -ChildPath 'docs' + RootDir = [IO.Path]::Combine($env:BHProjectPath, 'docs') + + # Whether to overwrite existing markdown files and use comment based help as the source of truth + Overwrite = $false + + # Whether to order parameters alphabetically by name in PARAMETERS section. + # Value passed to New-MarkdownHelp and Update-MarkdownHelp. + AlphabeticParamsOrder = $false + + # Exclude the parameters marked with `DontShow` in the parameter attribute from the help content. + # Value passed to New-MarkdownHelp and Update-MarkdownHelp. + ExcludeDontShow = $false + + # Indicates that the target document will use a full type name instead of a short name for parameters. + # Value passed to New-MarkdownHelp and Update-MarkdownHelp. + UseFullTypeName = $false } Publish = @{ # PowerShell repository name to publish modules to - PSRepository = 'PSGallery' + PSRepository = 'PSGallery' # API key to authenticate to PowerShell repository with - PSRepositoryApiKey = $env:PSGALLERY_API_KEY + PSRepositoryApiKey = $env:PSGALLERY_API_KEY # Credential to authenticate to PowerShell repository with PSRepositoryCredential = $null } + Sign = @{ + # Enable/disable Authenticode signing of module files. Must be $true for any + # signing or catalog tasks to execute. + Enabled = $false + + # Certificate source used to resolve the code-signing certificate. + # Valid values: + # Auto - Uses EnvVar if CertificateEnvVar is populated, otherwise falls back to Store. + # This is the recommended setting for pipelines that share a common psakeFile. + # Store - Selects the first valid, unexpired code-signing certificate with a private + # key from the Windows certificate store (CertStoreLocation). + # Thumbprint - Like Store, but selects a specific certificate by Thumbprint. + # EnvVar - Decodes a Base64-encoded PFX from the CertificateEnvVar environment + # variable. Common in GitHub Actions, Azure DevOps, and GitLab CI. + # PfxFile - Loads a PFX/P12 file from PfxFilePath with an optional PfxFilePassword. + CertificateSource = 'Auto' + + # Windows certificate store path searched by Store and Thumbprint sources. + CertStoreLocation = 'Cert:\CurrentUser\My' + + # Specific certificate thumbprint to select (Thumbprint source only). + Thumbprint = $null + + # Name of the environment variable that holds the Base64-encoded PFX certificate. + # Used by the EnvVar source and as the presence-detection key for Auto. + CertificateEnvVar = 'SIGNCERTIFICATE' + + # Name of the environment variable that holds the PFX password (EnvVar source). + CertificatePasswordEnvVar = 'CERTIFICATEPASSWORD' + + # File system path to a PFX/P12 certificate file (PfxFile source). + PfxFilePath = $null + + # Password for the PFX file as a SecureString (PfxFile source). + PfxFilePassword = $null + + # A pre-resolved [System.Security.Cryptography.X509Certificates.X509Certificate2] object. + # When set, CertificateSource is ignored and this certificate is used directly. + # Useful for Azure Key Vault, HSM, or other custom certificate providers. + Certificate = $null + + # When true and using the Store or Thumbprint sources, skip the + # certificate validity check that ensures the certificate is not expired + # and has a private key. This is not recommended for production use but + # can be useful in CI environments where certificates are frequently + # renewed and updated. + SkipCertificateValidation = $false + + # RFC 3161 timestamp server URI embedded in Authenticode signatures. + TimestampServer = 'http://timestamp.digicert.com' + + # Authenticode hash algorithm. Valid values: SHA256, SHA384, SHA512, SHA1. + HashAlgorithm = 'SHA256' + + # Glob patterns of files to sign in the module output directory. + FilesToSign = @('*.psd1', '*.psm1', '*.ps1') + + Catalog = @{ + # Enable/disable Windows catalog (.cat) file creation and signing. + # Requires Sign.Enabled = $true. + Enabled = $false + + # Catalog hash version. + # 1 = SHA1, compatible with Windows 7 and Windows Server 2008 R2. + # 2 = SHA2, required for Windows 8 / Server 2012 and newer. + Version = 2 + + # Catalog file name. Defaults to '.cat' when $null. + FileName = $null + } + } } - -# Enable/disable generation of a catalog (.cat) file for the module. -# [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] -# $catalogGenerationEnabled = $true - -# # Select the hash version to use for the catalog file: 1 for SHA1 (compat with Windows 7 and -# # Windows Server 2008 R2), 2 for SHA2 to support only newer Windows versions. -# [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] -# $catalogVersion = 2 diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 new file mode 100644 index 0000000..58aff5e --- /dev/null +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -0,0 +1,39 @@ +ConvertFrom-StringData @' +NoCommandsExported=No commands have been exported. Skipping markdown generation. +FailedToGenerateMarkdownHelp=Failed to generate markdown help. : {0} +AddingFileToPsm1=Adding [{0}] to PSM1 +MakeCabNotAvailable=MakeCab.exe is not available. Cannot create help cab. +DirectoryAlreadyExists=Directory already exists [{0}]. +PathLongerThan3Chars=Path [{0}] must be longer than 3 characters. +BuildSystemDetails=Build System Details: +BuildModule=Build Module: {0}:{1} +PowerShellVersion=PowerShell Version: {0} +EnvironmentVariables={0}Environment variables: +PublishingVersionToRepository=Publishing version [{0}] to repository [{1}]... +FolderDoesNotExist=Folder does not exist: {0} +PathArgumentMustBeAFolder=The Path argument must be a folder. File paths are not allowed. +UnableToFindModuleManifest=Unable to find module manifest [{0}]. Can't import module +PesterTestsFailed=One or more Pester tests failed +CodeCoverage=Code Coverage +Type=Type +CodeCoverageLessThanThreshold=Code coverage: [{0}] is [{1:p}], which is less than the threshold of [{2:p}] +CodeCoverageCodeCoverageFileNotFound=Code coverage file [{0}] not found. +SeverityThresholdSetTo=SeverityThreshold set to: {0} +PSScriptAnalyzerResults=PSScriptAnalyzer results: +ScriptAnalyzerErrors=One or more ScriptAnalyzer errors were found! +ScriptAnalyzerWarnings=One or more ScriptAnalyzer warnings were found! +ScriptAnalyzerIssues=One or more ScriptAnalyzer issues were found! +NoCertificateFound=No valid code signing certificate was found. Verify the configured CertificateSource and that a certificate with a private key is available. +CertificateResolvedFromStore=Resolved code signing certificate from store [{0}]: Subject=[{1}] +CertificateResolvedFromThumbprint=Resolved code signing certificate by thumbprint [{0}]: Subject=[{1}] +CertificateResolvedFromEnvVar=Resolved code signing certificate from environment variable [{0}] +CertificateResolvedFromPfxFile=Resolved code signing certificate from PFX file [{0}] +SigningModuleFiles=Signing [{0}] file(s) matching [{1}] in [{2}]... +CreatingFileCatalog=Creating file catalog [{0}] (version {1})... +FileCatalogCreated=File catalog created: [{0}] +CertificateSourceAutoResolved=CertificateSource is 'Auto'. Resolved to '{0}'. +CertificateMissingPrivateKey=The resolved certificate does not have an accessible private key. Code signing requires a certificate with a private key. Subject=[{0}] +CertificateExpired=The resolved certificate has expired (NotAfter: {0}). Code signing requires a valid, unexpired certificate. Subject=[{1}] +CertificateMissingCodeSigningEku=The resolved certificate does not have the Code Signing Enhanced Key Usage (EKU: 1.3.6.1.5.5.7.3.3). Subject=[{0}] +CertificateSourceStoreNotSupported=CertificateSource 'Store' is only supported on Windows platforms. +'@ diff --git a/PowerShellBuild/psakeFile.ps1 b/PowerShellBuild/psakeFile.ps1 index 66b871a..cff79f9 100644 --- a/PowerShellBuild/psakeFile.ps1 +++ b/PowerShellBuild/psakeFile.ps1 @@ -1,9 +1,16 @@ - +# spell-checker:ignore Reqs # Load in build settings Remove-Variable -Name PSBPreference -Scope Script -Force -ErrorAction Ignore -Set-Variable -Name PSBPreference -Option ReadOnly -Scope Script -Value (. (Join-Path -Path $PSScriptRoot -ChildPath build.properties.ps1)) +Set-Variable -Name PSBPreference -Option ReadOnly -Scope Script -Value (. ([IO.Path]::Combine($PSScriptRoot, 'build.properties.ps1'))) -properties {} +Properties { + $importLocalizedDataSplat = @{ + BindingVariable = 'LocalizedData' + FileName = 'Messages.psd1' + ErrorAction = 'SilentlyContinue' + } + Import-LocalizedData @importLocalizedDataSplat +} FormatTaskName { param($taskName) @@ -11,26 +18,76 @@ FormatTaskName { Write-Host $taskName.ToUpper() -ForegroundColor Blue } +#region Task Dependencies +if ($null -eq $PSBCleanDependency) { + $PSBCleanDependency = @('Init') +} +if ($null -eq $PSBStageFilesDependency) { + $PSBStageFilesDependency = @('Clean') +} +if ($null -eq $PSBBuildDependency) { + $PSBBuildDependency = @('StageFiles', 'BuildHelp') +} +if ($null -eq $PSBAnalyzeDependency) { + $PSBAnalyzeDependency = @('Build') +} +if ($null -eq $PSBPesterDependency) { + $PSBPesterDependency = @('Build') +} +if ($null -eq $PSBTestDependency) { + $PSBTestDependency = @('Pester', 'Analyze') +} +if ($null -eq $PSBBuildHelpDependency) { + $PSBBuildHelpDependency = @('GenerateMarkdown', 'GenerateMAML') +} +if ($null -eq $PSBGenerateMarkdownDependency) { + $PSBGenerateMarkdownDependency = @('StageFiles') +} +if ($null -eq $PSBGenerateMAMLDependency) { + $PSBGenerateMAMLDependency = @('GenerateMarkdown') +} +if ($null -eq $PSBGenerateUpdatableHelpDependency) { + $PSBGenerateUpdatableHelpDependency = @('BuildHelp') +} +if ($null -eq $PSBPublishDependency) { + $PSBPublishDependency = @('Test') +} +if ($null -eq $PSBSignModuleDependency) { + $PSBSignModuleDependency = @('Build') +} +if ($null -eq $PSBBuildCatalogDependency) { + $PSBBuildCatalogDependency = @('SignModule') +} +if ($null -eq $PSBSignCatalogDependency) { + $PSBSignCatalogDependency = @('BuildCatalog') +} +if ($null -eq $PSBSignDependency) { + $PSBSignDependency = @('SignCatalog') +} +#endregion Task Dependencies + # This psake file is meant to be referenced from another # Can't have two 'default' tasks # Task default -depends Test -task Init { - Initialize-PSBuild -UseBuildHelpers -BuildEnvironment $PSBPreference -} -description 'Initialize build environment variables' +Task Init { + Initialize-PSBuild -UseBuildHelpers -BuildEnvironment $PSBPreference -Verbose:($VerbosePreference -eq 'Continue') +} -Description 'Initialize build environment variables' -task Clean -depends Init { - Clear-PSBuildOutputFolder -Path $PSBPreference.Build.ModuleOutDir -} -description 'Clears module output directory' +Task Clean -Depends $PSBCleanDependency { + Clear-PSBuildOutputFolder -Path $PSBPreference.Build.ModuleOutDir -Verbose:($VerbosePreference -eq 'Continue') +} -Description 'Clears module output directory' -task StageFiles -depends Clean { +Task StageFiles -Depends $PSBStageFilesDependency { $buildParams = @{ - Path = $PSBPreference.General.SrcRootDir - ModuleName = $PSBPreference.General.ModuleName - DestinationPath = $PSBPreference.Build.ModuleOutDir - Exclude = $PSBPreference.Build.Exclude - Compile = $PSBPreference.Build.CompileModule - Culture = $PSBPreference.Help.DefaultLocale + Path = $PSBPreference.General.SrcRootDir + ModuleName = $PSBPreference.General.ModuleName + DestinationPath = $PSBPreference.Build.ModuleOutDir + Exclude = $PSBPreference.Build.Exclude + Compile = $PSBPreference.Build.CompileModule + CompileDirectories = $PSBPreference.Build.CompileDirectories + CopyDirectories = $PSBPreference.Build.CopyDirectories + Culture = $PSBPreference.Help.DefaultLocale } if ($PSBPreference.Help.ConvertReadMeToAboutHelp) { @@ -48,10 +105,10 @@ task StageFiles -depends Clean { } } - Build-PSBuildModule @buildParams -} -description 'Builds module based on source directory' + Build-PSBuildModule @buildParams -Verbose:($VerbosePreference -eq 'Continue') +} -Description 'Builds module based on source directory' -task Build -depends $PSBPreference.Build.Dependencies -description 'Builds module and generate help documentation' +Task Build -Depends $PSBBuildDependency -Description 'Builds module and generate help documentation' $analyzePreReqs = { $result = $true @@ -65,14 +122,14 @@ $analyzePreReqs = { } $result } -task Analyze -depends Build -precondition $analyzePreReqs { +Task Analyze -Depends $PSBAnalyzeDependency -PreCondition $analyzePreReqs { $analyzeParams = @{ Path = $PSBPreference.Build.ModuleOutDir SeverityThreshold = $PSBPreference.Test.ScriptAnalysis.FailBuildOnSeverityLevel SettingsPath = $PSBPreference.Test.ScriptAnalysis.SettingsPath } - Test-PSBuildScriptAnalysis @analyzeParams -} -description 'Execute PSScriptAnalyzer tests' + Test-PSBuildScriptAnalysis @analyzeParams -Verbose:($VerbosePreference -eq 'Continue') +} -Description 'Execute PSScriptAnalyzer tests' $pesterPreReqs = { $result = $true @@ -90,24 +147,30 @@ $pesterPreReqs = { } return $result } -task Pester -depends Build -precondition $pesterPreReqs { +Task Pester -Depends $PSBPesterDependency -PreCondition $pesterPreReqs { $pesterParams = @{ - Path = $PSBPreference.Test.RootDir - ModuleName = $PSBPreference.General.ModuleName - OutputPath = $PSBPreference.Test.OutputFile - OutputFormat = $PSBPreference.Test.OutputFormat - CodeCoverage = $PSBPreference.Test.CodeCoverage.Enabled - CodeCoverageThreshold = $PSBPreference.Test.CodeCoverage.Threshold - CodeCoverageFiles = $PSBPreference.Test.CodeCoverage.Files - ImportModule = $PSBPreference.Test.ImportModule + Path = $PSBPreference.Test.RootDir + ModuleName = $PSBPreference.General.ModuleName + ModuleManifest = Join-Path $PSBPreference.Build.ModuleOutDir "$($PSBPreference.General.ModuleName).psd1" + OutputPath = $PSBPreference.Test.OutputFile + OutputFormat = $PSBPreference.Test.OutputFormat + CodeCoverage = $PSBPreference.Test.CodeCoverage.Enabled + CodeCoverageThreshold = $PSBPreference.Test.CodeCoverage.Threshold + CodeCoverageFiles = $PSBPreference.Test.CodeCoverage.Files + CodeCoverageOutputFile = $PSBPreference.Test.CodeCoverage.OutputFile + CodeCoverageOutputFileFormat = $PSBPreference.Test.CodeCoverage.OutputFileFormat + ImportModule = $PSBPreference.Test.ImportModule + SkipRemainingOnFailure = $PSBPreference.Test.SkipRemainingOnFailure + OutputVerbosity = $PSBPreference.Test.OutputVerbosity + Verbose = $VerbosePreference -eq 'Continue' } Test-PSBuildPester @pesterParams -} -description 'Execute Pester tests' +} -Description 'Execute Pester tests' -task Test -depends Pester, Analyze { -} -description 'Execute Pester and ScriptAnalyzer tests' +Task Test -Depends $PSBTestDependency { +} -Description 'Execute Pester and ScriptAnalyzer tests' -task BuildHelp -depends GenerateMarkdown, GenerateMAML {} -description 'Builds help documentation' +Task BuildHelp -Depends $PSBBuildHelpDependency {} -Description 'Builds help documentation' $genMarkdownPreReqs = { $result = $true @@ -117,15 +180,20 @@ $genMarkdownPreReqs = { } $result } -task GenerateMarkdown -depends StageFiles -precondition $genMarkdownPreReqs { +Task GenerateMarkdown -Depends $PSBGenerateMarkdownDependency -PreCondition $genMarkdownPreReqs { $buildMDParams = @{ - ModulePath = $PSBPreference.Build.ModuleOutDir - ModuleName = $PSBPreference.General.ModuleName - DocsPath = $PSBPreference.Docs.RootDir - Locale = $PSBPreference.Help.DefaultLocale + ModulePath = $PSBPreference.Build.ModuleOutDir + ModuleName = $PSBPreference.General.ModuleName + DocsPath = $PSBPreference.Docs.RootDir + Locale = $PSBPreference.Help.DefaultLocale + Overwrite = $PSBPreference.Docs.Overwrite + AlphabeticParamsOrder = $PSBPreference.Docs.AlphabeticParamsOrder + ExcludeDontShow = $PSBPreference.Docs.ExcludeDontShow + UseFullTypeName = $PSBPreference.Docs.UseFullTypeName + Verbose = $VerbosePreference -eq 'Continue' } Build-PSBuildMarkdown @buildMDParams -} -description 'Generates PlatyPS markdown files from module help' +} -Description 'Generates PlatyPS markdown files from module help' $genHelpFilesPreReqs = { $result = $true @@ -135,9 +203,9 @@ $genHelpFilesPreReqs = { } $result } -task GenerateMAML -depends GenerateMarkdown -precondition $genHelpFilesPreReqs { - Build-PSBuildMAMLHelp -Path $PSBPreference.Docs.RootDir -DestinationPath $PSBPreference.Build.ModuleOutDir -} -description 'Generates MAML-based help from PlatyPS markdown files' +Task GenerateMAML -Depends $PSBGenerateMAMLDependency -PreCondition $genHelpFilesPreReqs { + Build-PSBuildMAMLHelp -Path $PSBPreference.Docs.RootDir -DestinationPath $PSBPreference.Build.ModuleOutDir -Verbose:($VerbosePreference -eq 'Continue') +} -Description 'Generates MAML-based help from PlatyPS markdown files' $genUpdatableHelpPreReqs = { $result = $true @@ -147,12 +215,12 @@ $genUpdatableHelpPreReqs = { } $result } -task GenerateUpdatableHelp -depends BuildHelp -precondition $genUpdatableHelpPreReqs { - Build-PSBuildUpdatableHelp -DocsPath $PSBPreference.Docs.RootDir -OutputPath $PSBPreference.Help.UpdatableHelpOutDir -} -description 'Create updatable help .cab file based on PlatyPS markdown help' +Task GenerateUpdatableHelp -Depends $PSBGenerateUpdatableHelpDependency -PreCondition $genUpdatableHelpPreReqs { + Build-PSBuildUpdatableHelp -DocsPath $PSBPreference.Docs.RootDir -OutputPath $PSBPreference.Help.UpdatableHelpOutDir -Verbose:($VerbosePreference -eq 'Continue') +} -Description 'Create updatable help .cab file based on PlatyPS markdown help' -task Publish -depends Test { - Assert -conditionToCheck ($PSBPreference.Publish.PSRepositoryApiKey -or $PSBPreference.Publish.PSRepositoryCredential) -failureMessage "API key or credential not defined to authenticate with [$($PSBPreference.Publish.PSRepository)] with." +Task Publish -Depends $PSBPublishDependency { + Assert -ConditionToCheck ($PSBPreference.Publish.PSRepositoryApiKey -or $PSBPreference.Publish.PSRepositoryCredential) -FailureMessage "API key or credential not defined to authenticate with [$($PSBPreference.Publish.PSRepository)] with." $publishParams = @{ Path = $PSBPreference.Build.ModuleOutDir @@ -169,9 +237,146 @@ task Publish -depends Test { } Publish-PSBuildModule @publishParams -} -description 'Publish module to the defined PowerShell repository' +} -Description 'Publish module to the defined PowerShell repository' + +$signModulePreReqs = { + $result = $true + if (-not $PSBPreference.Sign.Enabled) { + Write-Warning 'Module signing is not enabled.' + $result = $false + } + if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { + Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.' + $result = $false + } + $result +} +Task SignModule -Depends $PSBSignModuleDependency -PreCondition $signModulePreReqs { + $certParams = @{ + CertificateSource = $PSBPreference.Sign.CertificateSource + CertStoreLocation = $PSBPreference.Sign.CertStoreLocation + CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar + CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar + SkipValidation = $PSBPreference.Sign.SkipCertificateValidation + Verbose = $VerbosePreference -eq 'Continue' + } + if ($PSBPreference.Sign.Thumbprint) { + $certParams.Thumbprint = $PSBPreference.Sign.Thumbprint + } + if ($PSBPreference.Sign.PfxFilePath) { + $certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath + } + if ($PSBPreference.Sign.PfxFilePassword) { + $certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword + } + + $certificate = if ($PSBPreference.Sign.Certificate) { + $PSBPreference.Sign.Certificate + } else { + Get-PSBuildCertificate @certParams + } + + Assert ($null -ne $certificate) $LocalizedData.NoCertificateFound + + $signingParams = @{ + Path = $PSBPreference.Build.ModuleOutDir + Certificate = $certificate + TimestampServer = $PSBPreference.Sign.TimestampServer + HashAlgorithm = $PSBPreference.Sign.HashAlgorithm + Include = $PSBPreference.Sign.FilesToSign + Verbose = $VerbosePreference -eq 'Continue' + } + Invoke-PSBuildModuleSigning @signingParams +} -Description 'Signs module files (*.psd1, *.psm1, *.ps1) with an Authenticode signature' + +$buildCatalogPreReqs = { + $result = $true + if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { + Write-Warning 'Catalog generation is not enabled.' + $result = $false + } + if (-not (Get-Command -Name 'New-FileCatalog' -ErrorAction Ignore)) { + Write-Warning 'New-FileCatalog is not available. Catalog generation requires Windows.' + $result = $false + } + $result +} +Task BuildCatalog -Depends $PSBBuildCatalogDependency -PreCondition $buildCatalogPreReqs { + $catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) { + $PSBPreference.Sign.Catalog.FileName + } else { + "$($PSBPreference.General.ModuleName).cat" + } + $catalogFilePath = Join-Path -Path $PSBPreference.Build.ModuleOutDir -ChildPath $catalogFileName + + $catalogParams = @{ + ModulePath = $PSBPreference.Build.ModuleOutDir + CatalogFilePath = $catalogFilePath + CatalogVersion = $PSBPreference.Sign.Catalog.Version + Verbose = $VerbosePreference -eq 'Continue' + } + New-PSBuildFileCatalog @catalogParams +} -Description 'Creates a Windows catalog (.cat) file for the built module' + +$signCatalogPreReqs = { + $result = $true + if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) { + Write-Warning 'Catalog signing is not enabled.' + $result = $false + } + if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) { + Write-Warning 'Set-AuthenticodeSignature is not available. Catalog signing requires Windows.' + $result = $false + } + $result +} +Task SignCatalog -Depends $PSBSignCatalogDependency -PreCondition $signCatalogPreReqs { + $certParams = @{ + CertificateSource = $PSBPreference.Sign.CertificateSource + CertStoreLocation = $PSBPreference.Sign.CertStoreLocation + CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar + CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar + SkipValidation = $PSBPreference.Sign.SkipCertificateValidation + Verbose = $VerbosePreference -eq 'Continue' + } + if ($PSBPreference.Sign.Thumbprint) { + $certParams.Thumbprint = $PSBPreference.Sign.Thumbprint + } + if ($PSBPreference.Sign.PfxFilePath) { + $certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath + } + if ($PSBPreference.Sign.PfxFilePassword) { + $certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword + } + + $certificate = if ($PSBPreference.Sign.Certificate) { + $PSBPreference.Sign.Certificate + } else { + Get-PSBuildCertificate @certParams + } + + Assert ($null -ne $certificate) $LocalizedData.NoCertificateFound + + $catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) { + $PSBPreference.Sign.Catalog.FileName + } else { + "$($PSBPreference.General.ModuleName).cat" + } + + $signingParams = @{ + Path = $PSBPreference.Build.ModuleOutDir + Certificate = $certificate + TimestampServer = $PSBPreference.Sign.TimestampServer + HashAlgorithm = $PSBPreference.Sign.HashAlgorithm + Include = @($catalogFileName) + Verbose = $VerbosePreference -eq 'Continue' + } + Invoke-PSBuildModuleSigning @signingParams +} -Description 'Signs the module catalog (.cat) file with an Authenticode signature' + +Task Sign -Depends $PSBSignDependency {} -Description 'Signs module files and catalog (meta task)' -task ? -description 'Lists the available tasks' { +Task ? -Description 'Lists the available tasks' { 'Available tasks:' $psake.context.Peek().Tasks.Keys | Sort-Object } diff --git a/README.md b/README.md index cf069b6..05c2e42 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,29 @@ # PowerShellBuild -| AppVeyor | GitHub Actions | PS Gallery | License -|----------|----------------|------------|---------| -[![AppVeyor Build Status][appveyor-badge]][appveyor-build] | [![GitHub Actions Status][github-actions-badge]][github-actions-build] | [![PowerShell Gallery][psgallery-badge]][psgallery] | [![License][license-badge]][license] +| GitHub Actions | PS Gallery | License | +|-------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------|--------------------------------------| +| [![GitHub Actions Status][github-actions-badge]][github-actions-build] [![GitHub Actions Status][github-actions-badge-publish]][github-actions-build] | [![PowerShell Gallery][psgallery-badge]][psgallery] [![PowerShell Gallery][psgallery-version]][psgallery] [![PowerShell Gallery][psgallery-platforms]][psgallery] | [![License][license-badge]][license] | -This project aims to provide common [psake](https://github.com/psake/psake) and [Invoke-Build](https://github.com/nightroman/Invoke-Build) tasks for building, testing, and publishing PowerShell modules. +This project aims to provide common [psake](https://github.com/psake/psake) and +[Invoke-Build](https://github.com/nightroman/Invoke-Build) tasks for building, +testing, and publishing PowerShell modules. -Using these shared tasks reduces the boilerplate scaffolding needed in most PowerShell module projects and help enforce a consistent module structure. -This consistency ultimately helps the community in building high-quality PowerShell modules. +Using these shared tasks reduces the boilerplate scaffolding needed in most +PowerShell module projects and help enforce a consistent module structure. This +consistency ultimately helps the community in building high-quality PowerShell +modules. -> If using [psake](https://github.com/psake/psake) as your task runner, version `4.8.0` or greater is required to make use of shared tasks distributed in separate modules. -> To install psake `4.8.0` you can run: +> If using [psake](https://github.com/psake/psake) as your task runner, version +> `4.8.0` or greater is required to make use of shared tasks distributed in +> separate modules. To install psake `4.8.0` you can run: ```powershell Install-Module -Name psake -RequiredVersion 4.8.0 -Repository PSGallery ``` -> For [Invoke-Build](https://github.com/nightroman/Invoke-Build), see the [how to dot source tasks using PowerShell aliases](https://github.com/nightroman/Invoke-Build/blob/master/Tasks/Import/README.md#example-2-import-from-a-module-with-tasks) example. +> For [Invoke-Build](https://github.com/nightroman/Invoke-Build), see the +> [how to dot source tasks using PowerShell aliases](https://github.com/nightroman/Invoke-Build/blob/master/Tasks/Import/README.md#example-2-import-from-a-module-with-tasks) +> example.

Logo @@ -24,95 +31,139 @@ Install-Module -Name psake -RequiredVersion 4.8.0 -Repository PSGallery ## Status - Work in progress -> This project is a **work in progress** and may change significantly before reaching stability based on feedback from the community. -> **Please do not base critical processes on this project** until it has been further refined. +> This project is a **work in progress** and may change significantly before +> reaching stability based on feedback from the community. **Please do not base +> critical processes on this project** until it has been further refined. ## Tasks -**PowerShellBuild** is a PowerShell module that provides helper functions to handle the common build, test, and release steps typically found in PowerShell module projects. -These steps are exposed as a set of [psake](https://github.com/psake/psake) tasks found in [psakeFile.ps1](./PowerShellBuild/psakeFile.ps1) in the root of the module, and as PowerShell aliases which you can dot source if using [Invoke-Build](https://github.com/nightroman/Invoke-Build). -In psake `v4.8.0`, a feature was added to reference shared psake tasks distributed within PowerShell modules. -This allows a set of tasks to be versioned, distributed, and called by other projects. +**PowerShellBuild** is a PowerShell module that provides helper functions to +handle the common build, test, and release steps typically found in PowerShell +module projects. These steps are exposed as a set of +[psake](https://github.com/psake/psake) tasks found in +[psakeFile.ps1](./PowerShellBuild/psakeFile.ps1) in the root of the module, and +as PowerShell aliases which you can dot source if using +[Invoke-Build](https://github.com/nightroman/Invoke-Build). In psake `v4.8.0`, a +feature was added to reference shared psake tasks distributed within PowerShell +modules. This allows a set of tasks to be versioned, distributed, and called by +other projects. ### Primary Tasks -These primary tasks are the main tasks you'll typically call as part of PowerShell module development. +These primary tasks are the main tasks you'll typically call as part of +PowerShell module development. -| Name | Dependencies | Description | -| --------------------- | --------------------- | ----------- | -| Init | _none_ | Initialize psake and task variables -| Clean | init | Clean output directory -| Build | StageFiles, BuildHelp | Clean and build module in output directory -| Analyze | Build | Run PSScriptAnalyzer tests -| Pester | Build | Run Pester tests -| Test | Analyze, Pester | Run combined tests -| Publish | Test | Publish module to defined PowerShell repository +| Name | Dependencies | Description | +|---------|-----------------------|-------------------------------------------------| +| Init | _none_ | Initialize psake and task variables | +| Clean | init | Clean output directory | +| Build | StageFiles, BuildHelp | Clean and build module in output directory | +| Analyze | Build | Run PSScriptAnalyzer tests | +| Pester | Build | Run Pester tests | +| Test | Analyze, Pester | Run combined tests | +| Publish | Test | Publish module to defined PowerShell repository | ### Secondary Tasks -These secondary tasks are called as dependencies from the primary tasks but may also be called directly. +These secondary tasks are called as dependencies from the primary tasks but may +also be called directly. -| Name | Dependencies | Description | -| --------------------- | -------------------------------| ----------- | -| BuildHelp | GenerateMarkdown, GenerateMAML | Build all help files -| StageFiles | Clean | Build module in output directory -| GenerateMarkdown | StageFiles | Build markdown-based help -| GenerateMAML | GenerateMarkdown | Build MAML help -| GenerateUpdatableHelp | BuildHelp | Build updatable help cab +| Name | Dependencies | Description | +|-----------------------|--------------------------------|----------------------------------| +| BuildHelp | GenerateMarkdown, GenerateMAML | Build all help files | +| StageFiles | Clean | Build module in output directory | +| GenerateMarkdown | StageFiles | Build markdown-based help | +| GenerateMAML | GenerateMarkdown | Build MAML help | +| GenerateUpdatableHelp | BuildHelp | Build updatable help cab | ## Task customization -The psake and Invoke-Build tasks can be customized by overriding the values contained in the `$PSBPreference` hashtable. defined in the psake file. -These settings govern if certain tasks are executed or set default paths used to build and test the module. -You can override these in either psake or Invoke-Build to match your environment. - -| Setting | Default value | Description | -|---------|---------------|-------------| -| $PSBPreference.General.ProjectRoot | $env:BHProjectPath | Root directory for the project -| $PSBPreference.General.SrcRootDir | $env:BHPSModulePath | Root directory for the module -| $PSBPreference.General.ModuleName | $env:BHProjectName | The name of the module. This should match the basename of the PSD1 file -| $PSBPreference.General.ModuleVersion | \ | The version of the module -| $PSBPreference.General.ModuleManifestPath | $env:BHPSModuleManifest | Path to the module manifest (PSD1) -| $PSBPreference.Build.OutDir | $projectRoot/Output | Output directory when building the module -| $PSBPreference.Build.Dependencies | 'StageFiles, 'BuildHelp' | Default task dependencies for the `Build` task -| $PSBPreference.Build.ModuleOutDir | $outDir/$moduleName/$moduleVersion | `For internal use only. Do not overwrite. Use '$PSBPreference.Build.OutDir' to set output directory` -| $PSBPreference.Build.CompileModule | $false | Controls whether to "compile" module into single PSM1 or not -| $PSBPreference.Build.CompileHeader | | String that appears at the top of your compiled PSM1 file -| $PSBPreference.Build.CompileFooter | | String that appears at the bottom of your compiled PSM1 file -| $PSBPreference.Build.CompileScriptHeader | | String that appears in your compiled PSM1 file before each added script -| $PSBPreference.Build.CompileScriptFooter | | String that appears in your compiled PSM1 file after each added script -| $PSBPreference.Build.Exclude | | Array of files to exclude when building module. If `$PSBPreference.Build.CompileModule` is `$true` this will be an array of regular expressions to match fiels to exclude -| $PSBPreference.Test.Enabled | $true | Enable/disable Pester tests -| $PSBPreference.Test.RootDir | $projectRoot/tests | Directory containing Pester tests -| $PSBPreference.Test.OutputFile | $null | Output file path Pester will save test results to -| $PSBPreference.Test.OutputFormat | NUnitXml | Test output format to use when saving Pester test results -| $PSBPreference.Test.ScriptAnalysis.Enabled | $true | Enable/disable use of PSScriptAnalyzer to perform script analysis -| $PSBPreference.Test.ScriptAnalysis.FailBuildOnSeverityLevel | Error | PSScriptAnalyzer threshold to fail the build on -| $PSBPreference.Test.ScriptAnalysis.SettingsPath | ./ScriptAnalyzerSettings.psd1 | Path to the PSScriptAnalyzer settings file -| $PSBPreference.Test.CodeCoverage.Enabled | $false | Enable/disable Pester code coverage reporting -| $PSBPreference.Test.CodeCoverage.Threshold | .75 | Fail Pester code coverage test if below this threshold -| $PSBPreference.Test.CodeCoverage.Files | *.ps1, *.psm1 | Files to perform code coverage analysis on -| $PSBPreference.Test.ImportModule | $false | Import module from output directory prior to running Pester tests -| $PSBPreference.Help.UpdatableHelpOutDir | $OutDir/UpdatableHelp | Output directory to store update module help (CAB) -| $PSBPreference.Help.DefaultLocale | (Get-UICulture).Name | Default locale used for help generation -| $PSBPreference.Help.ConvertReadMeToAboutHelp | $false | Convert project readme into the module about file -| $PSBPreference.Docs.RootDir | $projectRoot/docs | Directory PlatyPS markdown documentation will be saved to -| $PSBPreference.Publish.PSRepository | PSGallery | PowerShell repository name to publish -| $PSBPreference.Publish.PSRepositoryApiKey | $env:PSGALLERY_API_KEY | API key to authenticate to PowerShell repository with -| $PSBPreference.Publish.PSRepositoryCredential | $null | Credential to authenticate to PowerShell repository with. Overrides `$psRepositoryApiKey` if defined +The psake and Invoke-Build tasks can be customized by overriding the values +contained in the `$PSBPreference` hashtable. defined in the psake file. These +settings govern if certain tasks are executed or set default paths used to build +and test the module. You can override these in either psake or Invoke-Build to +match your environment. + +| Setting | Default value | Description | +|-------------------------------------------------------------|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| $PSBPreference.General.ProjectRoot | `$env:BHProjectPath` | Root directory for the project | +| $PSBPreference.General.SrcRootDir | `$env:BHPSModulePath` | Root directory for the module | +| $PSBPreference.General.ModuleName | `$env:BHProjectName` | The name of the module. This should match the basename of the PSD1 file | +| $PSBPreference.General.ModuleVersion | `\` | The version of the module | +| $PSBPreference.General.ModuleManifestPath | `$env:BHPSModuleManifest` | Path to the module manifest (PSD1) | +| $PSBPreference.Build.OutDir | `$projectRoot/Output` | Output directory when building the module | +| $PSBPreference.Build.Dependencies | 'StageFiles, 'BuildHelp' | Default task dependencies for the `Build` task | +| $PSBPreference.Build.ModuleOutDir | `$outDir/$moduleName/$moduleVersion` | `For internal use only. Do not overwrite. Use '$PSBPreference.Build.OutDir' to set output directory` | +| $PSBPreference.Build.CompileModule | `$false` | Controls whether to "compile" module into single PSM1 or not | +| $PSBPreference.Build.CompileDirectories | `@('Enum', 'Classes', 'Private', 'Public')` | List of directories to "compile" into monolithic PSM1. Only valid when `$PSBPreference.Build.CompileModule` is `$true`. | +| $PSBPreference.Build.CopyDirectories | `@()` | List of directories to copy "as-is" to built module | +| $PSBPreference.Build.CompileHeader | `` | String that appears at the top of your compiled PSM1 file | +| $PSBPreference.Build.CompileFooter | `` | String that appears at the bottom of your compiled PSM1 file | +| $PSBPreference.Build.CompileScriptHeader | `` | String that appears in your compiled PSM1 file before each added script | +| $PSBPreference.Build.CompileScriptFooter | `` | String that appears in your compiled PSM1 file after each added script | +| $PSBPreference.Build.Exclude | `` | Array of files (regular expressions) to exclude when building module | +| $PSBPreference.Test.Enabled | `$true` | Enable/disable Pester tests | +| $PSBPreference.Test.RootDir | `$projectRoot/tests` | Directory containing Pester tests | +| $PSBPreference.Test.OutputFile | `$null` | Output file path Pester will save test results to | +| $PSBPreference.Test.OutputFormat | `NUnitXml` | Test output format to use when saving Pester test results | +| $PSBPreference.Test.ScriptAnalysis.Enabled | `$true` | Enable/disable use of PSScriptAnalyzer to perform script analysis | +| $PSBPreference.Test.ScriptAnalysis.FailBuildOnSeverityLevel | `Error` | PSScriptAnalyzer threshold to fail the build on | +| $PSBPreference.Test.ScriptAnalysis.SettingsPath | `./ScriptAnalyzerSettings.psd1` | Path to the PSScriptAnalyzer settings file | +| $PSBPreference.Test.CodeCoverage.Enabled | `$false` | Enable/disable Pester code coverage reporting | +| $PSBPreference.Test.CodeCoverage.Threshold | `.75` | Fail Pester code coverage test if below this threshold | +| $PSBPreference.Test.CodeCoverage.Files | `*.ps1, *.psm1` | Files to perform code coverage analysis on | +| $PSBPreference.Test.CodeCoverage.OutputFile | `coverage.xml` | Output file path (relative to Pester test directory) where Pester will save code coverage results to | +| $PSBPreference.Test.CodeCoverage.OutputFileFormat | `$null` | Test output format to use when saving Pester code coverage results | +| $PSBPreference.Test.ImportModule | `$false` | Import module from output directory prior to running Pester tests | +| $PSBPreference.Test.SkipRemainingOnFailure | `None` | Skip remaining tests after failure for selected scope. Options are None, Run, Container and Block. | +| $PSBPreference.Test.OutputVerbosity | `Detailed` | Set verbosity of output. Options are None, Normal, Detailed and Diagnostic. | +| $PSBPreference.Help.UpdatableHelpOutDir | `$OutDir/UpdatableHelp` | Output directory to store update module help (CAB) | +| $PSBPreference.Help.DefaultLocale | `(Get-UICulture).Name` | Default locale used for help generation | +| $PSBPreference.Help.ConvertReadMeToAboutHelp | `$false` | Convert project readme into the module about file | +| $PSBPreference.Docs.RootDir | `$projectRoot/docs` | Directory PlatyPS markdown documentation will be saved to | +| $PSBPreference.Docs.Overwrite | `$false` | Overwrite the markdown files in the docs folder using the comment based help as the source of truth. | +| $PSBPreference.Docs.AlphabeticParamsOrder | `$false` | Order parameters alphabetically by name in PARAMETERS section. There are 5 exceptions: -Confirm, -WhatIf, -IncludeTotalCount, -Skip, and -First parameters will be the last. | +| $PSBPreference.Docs.ExcludeDontShow | `$false` | Exclude the parameters marked with `DontShow` in the parameter attribute from the help content. | +| $PSBPreference.Docs.UseFullTypeName | `$false` | Indicates that the target document will use a full type name instead of a short name for parameters. | +| $PSBPreference.Publish.PSRepository | `PSGallery` | PowerShell repository name to publish | +| $PSBPreference.Publish.PSRepositoryApiKey | `$env:PSGALLERY_API_KEY` | API key to authenticate to PowerShell repository with | +| $PSBPreference.Publish.PSRepositoryCredential | `$null` | Credential to authenticate to PowerShell repository with. Overrides `$psRepositoryApiKey` if defined | + +## Modifying Task Dependencies + +To change which tasks depend on each other, set the variables below in your +`psakeFile.ps1`. Unlike `$PSBPreference` settings, these variables should be set +outside the `properties` block, before you reference any PowerShellBuild tasks. + +| Setting | Default value | Description | +|-------------------------------------|------------------------------------|----------------------------------------------------| +| $PSBCleanDependency | 'Init' | Tasks the 'Clean' task depends on. | +| $PSBStageFilesDependency | 'Clean' | Tasks the 'StageFiles' task depends on. | +| $PSBBuildDependency | 'StageFiles', 'BuildHelp' | Tasks the 'Build' task depends on. | +| $PSBAnalyzeDependency | 'Build' | Tasks the 'Analyze' task depends on. | +| $PSBPesterDependency | 'Build' | Tasks the 'Pester' task depends on. | +| $PSBTestDependency | 'Pester', 'Analyze' | Tasks the 'Test' task depends on. | +| $PSBBuildHelpDependency | 'GenerateMarkdown', 'GenerateMAML' | Tasks the 'BuildHelp' task depends on. | +| $PSBGenerateMarkdownDependency | 'StageFiles' | Tasks the 'GenerateMarkdown' task depends on. | +| $PSBGenerateMAMLDependency | 'GenerateMarkdown' | Tasks the 'GenerateMAML' task depends on. | +| $PSBGenerateUpdatableHelpDependency | 'BuildHelp' | Tasks the 'GenerateUpdatableHelp' task depends on. | +| $PSBPublishDependency | 'Test' | Tasks the 'Publish' task depends on. | ## Examples ### psake -The example below is a psake file you might use in your PowerShell module. -When psake executes this file, it will recognize that tasks are being referenced from a separate module and automatically load them. -You can run these tasks just as if they were included directly in your task file. +The example below is a psake file you might use in your PowerShell module. When +psake executes this file, it will recognize that tasks are being referenced from +a separate module and automatically load them. You can run these tasks just as +if they were included directly in your task file. -Notice that the task file contained in `MyModule` only references the `Build` task supplied from `PowerShellBuild`. -When executed, the dependent tasks `Init`, `Clear`, and `StageFiles` also contained in `PowerShellBuild` are executed as well. +Notice that the task file contained in `MyModule` only references the `Build` +task supplied from `PowerShellBuild`. When executed, the dependent tasks `Init`, +`Clear`, and `StageFiles` also contained in `PowerShellBuild` are executed as +well. -###### psakeBuild.ps1 +#### psakeBuild.ps1 ```powershell properties { @@ -131,10 +182,14 @@ task Build -FromModule PowerShellBuild -Version '0.1.0' ### Invoke-Build -The example below is an [Invoke-Build](https://github.com/nightroman/Invoke-Build) task file that imports the `PowerShellBuild` module which contains the shared tasks and then dot sources the Invoke-Build task files that are referenced by the PowerShell alias `PowerShellBuild.IB.Tasks`. -Additionally, certain settings that control how the build tasks operate are overwritten after the tasks have been imported. +The example below is an +[Invoke-Build](https://github.com/nightroman/Invoke-Build) task file that +imports the `PowerShellBuild` module which contains the shared tasks and then +dot sources the Invoke-Build task files that are referenced by the PowerShell +alias `PowerShellBuild.IB.Tasks`. Additionally, certain settings that control +how the build tasks operate are overwritten after the tasks have been imported. -###### .build.ps1 +#### .build.ps1 ```powershell Import-Module PowerShellBuild @@ -147,11 +202,12 @@ $PSBPreference.Test.CodeCoverage.Enabled = $false ![Example](./media/ib_example.png) -[appveyor-badge]: https://ci.appveyor.com/api/projects/status/3iq5efmgmepcl8dx?svg=true -[appveyor-build]: https://ci.appveyor.com/project/devblackops/powershellbuild -[github-actions-badge]: https://github.com/psake/PowerShellBuild/workflows/CI/badge.svg +[github-actions-badge]: https://github.com/psake/PowerShellBuild/actions/workflows/test.yml/badge.svg +[github-actions-badge-publish]: https://github.com/psake/PowerShellBuild/actions/workflows/publish.yaml/badge.svg?event=release [github-actions-build]: https://github.com/psake/PowerShellBuild/actions -[psgallery-badge]: https://img.shields.io/powershellgallery/dt/powershellbuild.svg +[psgallery-badge]: https://img.shields.io/powershellgallery/dt/powershellbuild +[psgallery-version]: https://img.shields.io/powershellgallery/v/ChocoLogParse?label=version +[psgallery-platforms]: https://img.shields.io/powershellgallery/p/ChocoLogParse [psgallery]: https://www.powershellgallery.com/packages/PowerShellBuild [license-badge]: https://img.shields.io/github/license/psake/PowerShellBuild.svg -[license]: https://raw.githubusercontent.com/psake/PowerShellBuild/master/LICENSE +[license]: https://raw.githubusercontent.com/psake/PowerShellBuild/main/LICENSE diff --git a/aim.config.json b/aim.config.json new file mode 100644 index 0000000..620e754 --- /dev/null +++ b/aim.config.json @@ -0,0 +1,31 @@ +{ + "version": "latest", + "modules": { + "include": [ + "agent-workflow", + "shorthand", + "git-workflow", + "testing", + "powershell", + "markdown", + "readme", + "github-cli", + "releases", + "contributing", + "update", + "repository-specific" + ], + "exclude": [] + }, + "externalSources": { + "enabled": true, + "repositories": [ + { + "name": "awesome-copilot", + "url": "https://github.com/github/awesome-copilot", + "path": "instructions", + "description": "Community-contributed instructions from GitHub's awesome-copilot repository" + } + ] + } +} diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index ca70a85..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: 0.1.0.{build} -image: Visual Studio 2017 -skip_commits: - message: /updated readme.*|update readme.*s/ -build: off - -test_script: - - ps: ./build.ps1 -Task Test -Bootstrap -Verbose diff --git a/build.ps1 b/build.ps1 index a09f89a..8529de6 100644 --- a/build.ps1 +++ b/build.ps1 @@ -2,6 +2,23 @@ param( # Build task(s) to execute [parameter(ParameterSetName = 'task', position = 0)] + [ArgumentCompleter( { + param($Command, $Parameter, $WordToComplete, $CommandAst, $FakeBoundParams) + $psakeFile = './psakeFile.ps1' + switch ($Parameter) { + 'Task' { + if ([string]::IsNullOrEmpty($WordToComplete)) { + Get-PSakeScriptTasks -buildFile $psakeFile | Select-Object -ExpandProperty Name + } else { + Get-PSakeScriptTasks -buildFile $psakeFile | + Where-Object { $_.Name -match $WordToComplete } | + Select-Object -ExpandProperty Name + } + } + default { + } + } + })] [string[]]$Task = 'default', # Bootstrap dependencies @@ -9,17 +26,19 @@ param( # List available build tasks [parameter(ParameterSetName = 'Help')] - [switch]$Help + [switch]$Help, + + [pscredential]$PSGalleryApiKey ) $ErrorActionPreference = 'Stop' # Bootstrap dependencies if ($Bootstrap.IsPresent) { - Get-PackageProvider -Name Nuget -ForceBootstrap | Out-Null + PackageManagement\Get-PackageProvider -Name Nuget -ForceBootstrap | Out-Null Set-PSRepository -Name PSGallery -InstallationPolicy Trusted if (-not (Get-Module -Name PSDepend -ListAvailable)) { - Install-module -Name PSDepend -Repository PSGallery + Install-Module -Name PSDepend -Repository PSGallery } Import-Module -Name PSDepend -Verbose:$false Invoke-PSDepend -Path './requirements.psd1' -Install -Import -Force -WarningAction SilentlyContinue @@ -28,11 +47,14 @@ if ($Bootstrap.IsPresent) { # Execute psake task(s) $psakeFile = './psakeFile.ps1' if ($PSCmdlet.ParameterSetName -eq 'Help') { - Get-PSakeScriptTasks -buildFile $psakeFile | + Get-PSakeScriptTasks -buildFile $psakeFile | Format-Table -Property Name, Description, Alias, DependsOn } else { Set-BuildEnvironment -Force - - Invoke-psake -buildFile $psakeFile -taskList $Task -nologo + $parameters = @{} + if ($PSGalleryApiKey) { + $parameters['galleryApiKey'] = $PSGalleryApiKey + } + Invoke-psake -buildFile $psakeFile -taskList $Task -nologo -parameters $parameters exit ( [int]( -not $psake.build_success ) ) } diff --git a/build.settings.ps1 b/build.settings.ps1 index 32607b2..f631b9d 100644 --- a/build.settings.ps1 +++ b/build.settings.ps1 @@ -1,13 +1,13 @@ $projectRoot = if ($ENV:BHProjectPath) { $ENV:BHProjectPath } else { $PSScriptRoot } $moduleName = $env:BHProjectName $moduleVersion = (Import-PowerShellDataFile -Path $env:BHPSModuleManifest).ModuleVersion -$outDir = Join-Path -Path $projectRoot -ChildPath Output +$outDir = [IO.Path]::Combine($projectRoot, 'Output') $moduleOutDir = "$outDir/$moduleName/$moduleVersion" @{ ProjectRoot = $projectRoot ProjectName = $env:BHProjectName SUT = $env:BHModulePath - Tests = Get-ChildItem -Path (Join-Path -Path $projectRoot -ChildPath tests) -Filter '*.tests.ps1' + Tests = Get-ChildItem -Path ([IO.Path]::Combine($projectRoot, 'tests')) -Filter '*.tests.ps1' OutputDir = $outDir ModuleOutDir = $moduleOutDir ManifestPath = $env:BHPSModuleManifest diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..a8959d9 --- /dev/null +++ b/cspell.json @@ -0,0 +1,18 @@ +{ + "version": "0.2", + "ignorePaths": [], + "dictionaryDefinitions": [], + "dictionaries": [ + "powershell", + "csharp", + "json", + "xml", + "markdown" + ], + "words": [], + "ignoreWords": [ + "psake", + "MAML" + ], + "import": [] +} diff --git a/instructions/agent-workflow.instructions.md b/instructions/agent-workflow.instructions.md new file mode 100644 index 0000000..efc3f66 --- /dev/null +++ b/instructions/agent-workflow.instructions.md @@ -0,0 +1,96 @@ +--- +applyTo: '**/*' +description: 'Mandatory pre-flight protocol for AI agents' +--- + +# Agent Workflow Instructions + +## Purpose + +This file defines the recommended workflow that AI agents should follow when working in +repositories using AIM. It ensures agents understand the context and guidelines before +starting work. + +## Pre-Flight Protocol + +**Before starting any task, AI agents should:** + +### 1. Identify Task Type + +Analyze the user's request and identify all areas it touches. Common patterns: + +- Code development (specific languages or frameworks) +- Documentation (Markdown files, README files) +- Git operations (commits, branches, PRs) +- Testing and quality assurance +- Security considerations +- Repository-specific customizations + +### 2. Consider Applicable Instructions + +Review the instruction files listed in the repository's `AGENTS.md` to understand: + +- Language-specific coding standards +- Framework conventions +- Documentation requirements +- Git workflow expectations +- Security best practices + +### 3. Implement with Compliance + +Execute your task following the guidelines from the applicable instruction sections. + +## Best Practices + +### Read Before Writing + +- Always read existing code before modifying it +- Understand the project's patterns and conventions +- Check for existing implementations before creating new ones + +### Confirm Understanding + +When starting complex tasks, briefly confirm your understanding: + +> "Based on the instructions, I'll follow [specific guidelines]. Here's my approach..." + +This builds trust and catches misunderstandings early. + +### Avoid Over-Engineering + +- Only make changes that are directly requested +- Keep solutions simple and focused +- Don't add features, refactoring, or improvements beyond what was asked + +### Security First + +- Never introduce security vulnerabilities +- Be careful with user input validation +- Avoid hardcoding secrets or credentials +- Follow the security guidelines in this document + +## When in Doubt + +1. **Ask for clarification** - Better to ask than implement incorrectly +2. **Check existing code** - Follow established patterns in the codebase +3. **Keep it simple** - The simplest solution that works is usually best + +## Post-Task Protocol + +### Before Committing + +1. **Run tests** - Ensure all tests pass before committing +2. **Check repository-specific requirements** - Review `repository-specific.instructions.md` for + any post-task requirements such as: + - Release processes (version bumps, changelogs, tags) + - Commit message conventions beyond standard guidelines + - Required reviewers or approval workflows + - Documentation updates + +Following repository-specific requirements ensures consistency with the project's established +workflows and prevents incomplete changes from being committed. + +## Custom Instructions + +If this repository has a custom instructions section, those guidelines take precedence for +repository-specific conventions and may override or supplement the general instructions above. diff --git a/instructions/contributing.instructions.md b/instructions/contributing.instructions.md new file mode 100644 index 0000000..3c03a57 --- /dev/null +++ b/instructions/contributing.instructions.md @@ -0,0 +1,157 @@ +--- +applyTo: '**/*' +description: 'Guidelines for contributing improvements back to the upstream AIM repository' +--- + +# Contributing Instructions for AI Agents + +When users want to improve, fix, or extend the AI agent instructions, this guide helps agents +facilitate contributions back to the upstream AIM repository. + +## When to Contribute Upstream vs. Modify Locally + +### Contribute Upstream (Submit a PR) + +- Fixing errors or typos in instruction files +- Clarifying confusing or ambiguous instructions +- Adding missing best practices that benefit all users +- Creating new instruction modules for languages, frameworks, or tools +- Improving examples or adding helpful code snippets + +### Modify Locally Only + +- Organization-specific conventions or standards +- Project-specific customizations +- Internal tooling or proprietary workflows +- Content that references internal systems or URLs + +**Local changes belong in `repository-specific.instructions.md`** - this file is never synced from upstream. + +## Agent-Assisted Contribution Workflow + +When a user wants to contribute to upstream, guide them through these steps: + +### 1. Fork the Repository + +```bash +gh repo fork tablackburn/ai-agent-instruction-modules --clone +cd ai-agent-instruction-modules +``` + +### 2. Create a Feature Branch + +```bash +git checkout -b feature/descriptive-branch-name +``` + +Use descriptive branch names: + +- `feature/add-python-module` - New module +- `fix/powershell-typo` - Bug fix +- `docs/clarify-update-procedure` - Documentation improvement + +### 3. Make Changes + +Follow existing patterns in the repository: + +**For new instruction files:** + +- Place in `instruction-templates/` folder +- Use `.instructions.md` extension +- Include required YAML frontmatter + +**For existing files:** + +- Preserve the file's structure and style +- Make minimal, focused changes +- Don't introduce unrelated modifications + +### 4. Validate Changes + +```powershell +Invoke-Pester -Path .\tests\ +``` + +Ensure all tests pass before committing. + +### 5. Commit with Conventional Commits + +```bash +git commit -m "feat: Add Python type hints module" +``` + +Prefixes: + +- `feat:` - New feature or module +- `fix:` - Bug fix or correction +- `docs:` - Documentation only +- `refactor:` - Code restructuring without behavior change + +### 6. Push and Create Pull Request + +```bash +git push origin feature/descriptive-branch-name +gh pr create --title "feat: Add Python type hints module" --body "Description of changes" +``` + +## Module Requirements + +All instruction files must include YAML frontmatter: + +```yaml +--- +applyTo: '**/*.py' +description: 'Brief description of what this module covers' +--- +``` + +**Frontmatter fields:** + +- `applyTo` - Glob pattern for applicable files (e.g., `'**/*'`, `'**/*.py'`, `'**/README.md'`) +- `description` - One-line description of the module's purpose + +**Content guidelines:** + +- Keep instructions generic and universally applicable +- Use placeholder examples (``, ``, `example.com`) +- Avoid organization-specific references +- Include practical code examples where helpful +- Follow markdown conventions from `markdown.instructions.md` + +## Pull Request Guidelines + +**Title:** Use conventional commit format (e.g., `feat: Add Python module`) + +**Description should include:** + +- Summary of changes (1-3 bullet points) +- Motivation or problem being solved +- Any breaking changes or migration notes + +**Example PR body:** + +```markdown +## Summary + +- Add Python type hints and docstring guidelines +- Include examples for common patterns +- Reference PEP 484 and PEP 257 standards + +## Motivation + +Python developers need consistent guidance on type annotations and documentation strings. +``` + +## After Submission + +- Respond to review feedback promptly +- Make requested changes in additional commits +- Once merged, downstream repositories can sync using `update.instructions.md` + +## Questions or Discussion + +For questions about contributing, open an issue on the upstream repository: + +```bash +gh issue create --repo tablackburn/ai-agent-instruction-modules --title "Question: Your topic" --body "Your question here" +``` diff --git a/instructions/git-workflow.instructions.md b/instructions/git-workflow.instructions.md new file mode 100644 index 0000000..9c3d96c --- /dev/null +++ b/instructions/git-workflow.instructions.md @@ -0,0 +1,280 @@ +--- +applyTo: '**/*' +description: 'Git workflow conventions including branching, commits, and pull requests' +--- + +# Git Workflow Instructions + +Guidelines for consistent Git usage across repositories. + +## Working on Branches + +**Agents must always work on branches, never directly on main.** + +Before starting any work: + +1. Create a branch from `main` using the naming conventions below +2. Make changes in small, logical commits +3. Push the branch and create a pull request +4. Wait for CI checks and address any review feedback +5. Report status and wait for instructions before merging + +This ensures all changes go through review and CI validation before reaching the main branch. + +## Branch Naming + +Use descriptive, lowercase branch names with hyphens. + +### Basic Format + +```text +/ +``` + +### Format with Ticket Numbers + +When using project management tools, include the ticket identifier: + +```text +/- +``` + +### Branch Types + +| Prefix | Purpose | Example | +| ----------- | ------------------------------------ | ------------------------------------ | +| `feature/` | New functionality | `feature/user-authentication` | +| `bugfix/` | Bug fixes | `bugfix/login-validation-error` | +| `hotfix/` | Urgent production patches | `hotfix/security-vulnerability` | +| `release/` | Release preparation | `release/v1.2.0` | +| `docs/` | Documentation only | `docs/api-documentation` | +| `refactor/` | Code restructuring | `refactor/database-queries` | +| `test/` | Adding or updating tests | `test/payment-integration` | +| `chore/` | Maintenance tasks | `chore/update-dependencies` | + +### Examples with Ticket Numbers + +```text +feature/PROJ-123-add-user-authentication +bugfix/PROJ-456-fix-login-validation +hotfix/PROJ-789-patch-security-issue +``` + +### Best Practices + +- **Be descriptive**: Names should reflect the branch's purpose or task +- **Be concise**: Keep names brief but meaningful +- **Be consistent**: Follow the same conventions across the team +- **Use lowercase**: Avoid mixed case for cross-platform compatibility +- **Use hyphens**: Separate words with hyphens, not underscores or spaces + +### Technical Constraints + +Avoid the following in branch names: + +- Dots at the start of the name +- Trailing slashes +- Reserved Git names (`HEAD`, `FETCH_HEAD`) +- Spaces or special characters (except hyphens and forward slashes) + +### Avoid + +- Overly long names +- Generic names like `fix`, `update`, `changes` +- Names without context or purpose + +## Commit Messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/) format: + +```text +: + +[optional body] + +[optional footer] +``` + +**Types:** + +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation changes +- `style:` - Formatting (no code change) +- `refactor:` - Code restructuring +- `test:` - Adding/updating tests +- `chore:` - Maintenance tasks + +**Guidelines:** + +- Use imperative mood ("Add feature" not "Added feature") +- Keep first line under 72 characters +- Capitalize first letter after type +- No period at end of subject line +- Separate subject from body with blank line + +**Good examples:** + +```text +feat: Add user authentication flow +fix: Resolve null reference in payment processing +docs: Update API endpoint documentation +refactor: Extract validation logic to separate module +``` + +**Avoid:** + +```text +Fixed stuff +WIP +updates +asdfasdf +``` + +## Pull Request Guidelines + +### Before Creating a PR + +1. Ensure your branch is up to date with the base branch +2. Run tests locally and verify they pass +3. Review your own changes first +4. Remove debugging code and console logs + +### PR Title + +Use the same format as commit messages: + +```text +feat: Add user authentication flow +``` + +### PR Description + +Include: + +- **Summary** - What changed and why (1-3 bullet points) +- **Test plan** - How to verify the changes work +- **Breaking changes** - Note any breaking changes + +**Template:** + +```markdown +## Summary + +- Added user login and logout functionality +- Integrated with OAuth2 provider +- Added session management + +## Test Plan + +- [ ] Login with valid credentials succeeds +- [ ] Login with invalid credentials shows error +- [ ] Logout clears session + +## Breaking Changes + +None +``` + +### PR Size + +- Keep PRs focused and small when possible +- Large changes should be split into logical commits +- If a PR is too large, consider breaking it into smaller PRs + +### After Creating a PR + +1. **Monitor CI**: Wait for CI checks to complete and verify they pass +2. **Check for comments**: Review the PR for any feedback or requested changes +3. **Address feedback**: Make additional commits to address review comments +4. **Report status**: Report the PR status to the user and wait for instructions before merging + +## Branching Strategy + +### Default Branch + +- Use `main` as the default branch name for new repositories +- `main` is the industry standard and preferred for inclusive terminology +- When working with existing repositories using `master`, follow the repository's convention +- Consider migrating legacy repositories from `master` to `main` when practical + +### Main Branch + +- `main` is the production-ready branch +- Should always be in a deployable state +- Direct commits to main should be avoided + +### Feature Branches + +1. Create feature branch from `main` +2. Make changes in small, logical commits +3. Push branch and create PR +4. After review and approval, merge to `main` +5. Delete feature branch after merge + +### Keeping Branches Updated + +```bash +# Update your feature branch with latest main +git fetch origin +git rebase origin/main +``` + +Prefer rebase for feature branches to maintain clean history. + +## Merge Strategy + +### Squash and Merge (Recommended for feature branches) + +- Combines all commits into one clean commit +- Keeps main branch history clean +- Use when feature branch has many small/WIP commits + +### Merge Commit + +- Preserves full commit history +- Use for significant features where history is valuable +- Use for release branches + +### Rebase and Merge + +- Applies commits linearly without merge commit +- Use when commits are already clean and logical + +## Git Safety + +### Before Force Pushing + +- Never force push to `main` or shared branches +- Only force push to your own feature branches +- Always communicate with team before force pushing shared branches + +### Avoiding Common Issues + +- Pull before pushing to avoid conflicts +- Don't commit sensitive data (secrets, credentials, API keys) +- Use `.gitignore` for build artifacts and dependencies +- Review staged changes before committing + +## Useful Commands + +```bash +# View branch status +git status + +# View commit history +git log --oneline -10 + +# Amend last commit (before pushing) +git commit --amend + +# Stash changes temporarily +git stash +git stash pop + +# Undo last commit (keep changes) +git reset --soft HEAD~1 + +# View changes before committing +git diff --staged +``` diff --git a/instructions/github-cli.instructions.md b/instructions/github-cli.instructions.md new file mode 100644 index 0000000..347a024 --- /dev/null +++ b/instructions/github-cli.instructions.md @@ -0,0 +1,252 @@ +--- +applyTo: '**/*' +description: 'GitHub CLI usage guidelines and best practices (operational instructions for running gh commands, not file-specific)' +--- + +# GitHub CLI Guidelines + +Instructions for using GitHub CLI (`gh`) for repository operations. + +## Authentication + +Verify authentication before performing operations: + +```bash +# Check current authentication status +gh auth status + +# If not authenticated +gh auth login +``` + +## Repository Operations + +### Repository Discovery + +```bash +# List repositories in an organization +gh repo list --limit 100 + +# Get repository default branch (don't assume main) +gh api repos// --jq '.default_branch' + +# View repository details +gh repo view / +``` + +### Cloning and Forking + +```bash +# Clone a repository +gh repo clone / + +# Fork a repository +gh repo fork / --clone +``` + +## Issue Management + +### Creating Issues + +```bash +# Create a new issue +gh issue create --title "Issue Title" --body "Issue description" + +# Create with labels and assignee +gh issue create --title "Bug: Login fails" --body "Description" --label "bug" --assignee "@me" + +# Create interactively +gh issue create +``` + +### Viewing and Searching Issues + +```bash +# List open issues +gh issue list + +# View specific issue +gh issue view + +# Search issues +gh issue list --search "bug in:title" +``` + +### Issue Labels + +Common labels to use: + +- `bug` - Something isn't working +- `enhancement` - New feature or request +- `documentation` - Documentation improvements +- `question` - Further information requested +- `good first issue` - Good for newcomers + +## Pull Request Workflows + +### Creating Pull Requests + +```bash +# Create PR from current branch +gh pr create --title "PR Title" --body "Description" + +# Create draft PR +gh pr create --title "WIP: Feature" --body "Work in progress" --draft + +# Create PR with specific base branch +gh pr create --base develop --title "Feature" --body "Description" +``` + +### PR Review + +```bash +# List PRs awaiting review +gh pr list --search "review-requested:@me" + +# View PR details +gh pr view + +# Checkout PR locally +gh pr checkout + +# Approve PR +gh pr review --approve + +# Request changes +gh pr review --request-changes --body "Please fix..." +``` + +### Merging PRs + +```bash +# Merge PR +gh pr merge + +# Merge with squash +gh pr merge --squash + +# Merge with rebase +gh pr merge --rebase + +# Delete branch after merge +gh pr merge --delete-branch +``` + +## GitHub Actions + +### Workflow Management + +```bash +# List workflow runs +gh run list + +# View specific run +gh run view + +# Watch a running workflow +gh run watch + +# Re-run failed jobs +gh run rerun --failed +``` + +### Viewing Logs + +```bash +# View run logs +gh run view --log + +# View failed step logs +gh run view --log-failed +``` + +## Releases + +### Creating Releases + +Use `--notes-file` (write notes to a temporary file first) rather than `--notes` to avoid +escaping issues with backticks, backslashes, and quotes. For project releases, the rules in +`releases.instructions.md` take precedence over these examples. + +```bash +# Create release from tag (write notes to a file first to avoid escaping issues) +printf '## Highlights\n\n- Your release notes here\n' > release-notes.md +gh release create v1.0.0 --title "Version 1.0.0" --notes-file release-notes.md +rm release-notes.md + +# Create release with auto-generated notes +gh release create v1.0.0 --generate-notes + +# Create draft release +gh release create v1.0.0 --draft --title "Version 1.0.0" + +# Upload assets +gh release create v1.0.0 ./dist/*.zip --title "Version 1.0.0" +``` + +### Viewing Releases + +```bash +# List releases +gh release list + +# View latest release +gh release view --latest +``` + +## API Access + +### Direct API Calls + +```bash +# GET request +gh api repos// + +# POST request +gh api repos///issues --method POST -f title="Title" -f body="Body" + +# Use jq for filtering +gh api repos///pulls --jq '.[].title' +``` + +## Best Practices + +### Pre-Operation Validation + +```bash +# Verify you're in a git repository +gh repo view --json nameWithOwner --jq '.nameWithOwner' + +# Verify authentication +gh auth status +``` + +### Issue-to-Branch Workflow + +```bash +# Create issue and capture the issue number +ISSUE_NUM=$(gh issue create --title "Feature: New functionality" --body "Description" --json number --jq '.number') + +# Create feature branch using the captured issue number +git checkout -b "feature/issue-${ISSUE_NUM}-new-functionality" + +# Push and create PR +git push -u origin "feature/issue-${ISSUE_NUM}-new-functionality" +gh pr create --title "Feature: New functionality" --body "Closes #${ISSUE_NUM}" +``` + +### Common Flags + +- `--json` - Output as JSON +- `--jq` - Filter JSON output +- `--web` - Open in browser +- `--help` - Show help for any command + +## Environment Variables + +Useful environment variables: + +- `GH_TOKEN` - Authentication token +- `GH_HOST` - GitHub hostname (for enterprise) +- `GH_REPO` - Default repository +- `GH_EDITOR` - Editor for composing text diff --git a/instructions/markdown.instructions.md b/instructions/markdown.instructions.md new file mode 100644 index 0000000..430da58 --- /dev/null +++ b/instructions/markdown.instructions.md @@ -0,0 +1,123 @@ +--- +applyTo: '**/*.md' +description: 'Markdown formatting standards' +--- + +# Markdown Style Guidelines + +Consistent Markdown formatting for documentation files. + +## Blank Lines + +- Use single blank lines between sections and elements +- Never use multiple consecutive blank lines +- Headings, lists, and code blocks must have a blank line above and below + +## Headings + +- Use ATX style (`#`) not setext (underlines) +- Use consistent heading levels (don't skip levels) +- Start with a single H1 (`#`) for the document title +- Use sentence case for headings +- Include a space after `#` characters +- No trailing punctuation (colons, periods, etc.) +- Avoid duplicate heading text within the same document + +## Lists + +- Use `-` for unordered lists +- Use sequential numbering for ordered lists (`1.`, `2.`, `3.`, etc.) +- Use 2 spaces for nested list indentation + +```markdown +Text before list. + +- First item +- Second item + - Nested item + - Another nested item +- Third item + +Text after list. +``` + +## Code Blocks + +- Use backticks (`` ` ``) not tildes (`~`) for code fences +- Always specify language for fenced code blocks +- Ensure closing triple backticks are on their own line +- No trailing whitespace after closing backticks +- Code inside fenced blocks should follow the conventions of the relevant language's instruction + file (e.g., PowerShell snippets follow `powershell.instructions.md`) + +```javascript +// JavaScript code here +``` + +```python +# Python code here +``` + +```bash +# Shell code here +``` + +## Inline Formatting + +- Use `**bold**` for strong emphasis +- Use `*italic*` for light emphasis +- Use backticks for `code`, `filenames`, and `commands` +- Use backticks for keyboard shortcuts like `Ctrl+C` +- No spaces inside emphasis markers (`**text**` not `** text **`) +- No spaces inside backticks (`` `code` `` not `` ` code ` ``) +- Don't use bold/emphasis as a substitute for headings + +## Links + +- Use descriptive link text (not "click here") +- Use reference-style links for long URLs +- Use reference-style links when the same URL appears multiple times +- Links must have valid destinations (no empty hrefs) + +```markdown +See the [official documentation][docs] for more details. +The [documentation][docs] covers advanced topics. + +[docs]: https://example.com/documentation +``` + +## Images + +- Always include alt text for accessibility +- Use descriptive alt text that conveys the image content + +```markdown +![Diagram showing data flow between components](./images/data-flow.png) +``` + +## Line Length + +- Wrap prose at 80-100 characters when practical +- Don't wrap tables - maintain table formatting +- Don't wrap URLs or code blocks + +## File Structure + +- End all files with exactly one newline character +- No trailing whitespace on any lines +- Use spaces, not hard tabs +- Use UTF-8 encoding +- Avoid inline HTML when markdown alternatives exist + +## Tables + +- Align columns for readability in source +- Use header row separators +- Keep tables simple when possible + +```markdown +| Column 1 | Column 2 | Column 3 | +|----------|----------|----------| +| Value 1 | Value 2 | Value 3 | +| Value 4 | Value 5 | Value 6 | +``` diff --git a/instructions/powershell.instructions.md b/instructions/powershell.instructions.md new file mode 100644 index 0000000..8493861 --- /dev/null +++ b/instructions/powershell.instructions.md @@ -0,0 +1,515 @@ +--- +applyTo: '**/*.ps1,**/*.psm1,**/*.psd1' +description: 'PowerShell coding standards and best practices' +--- + +# PowerShell Style Guidelines + +Style rules for PowerShell code based on Microsoft guidelines and community standards. + +## Common Mistakes to Avoid + +**IMPORTANT**: These are frequent violations that MUST be avoided: + +1. **Plural nouns in function names** - ALWAYS use singular nouns regardless of how many items the + function returns. Use `Get-User` not `Get-Users`, `Get-Item` not `Get-Items`. + +## Function Structure + +1. Always start functions with `[CmdletBinding()]` attribute +2. Always include explicit `param()` block +3. Use `process {}` block when accepting pipeline input +4. For system-modifying cmdlets, use `[CmdletBinding(SupportsShouldProcess)]` +5. Document output types with `[OutputType([TypeName])]` attribute +6. Include comment-based help for all functions +7. Do not define nested functions inside other functions; define helper functions at module or + script scope + +```powershell +# Bad - nested function +function Get-Data { + [CmdletBinding()] + param() + + function Format-Result { + param($Value) + # Helper logic + } + + $result = Get-RawData + Format-Result -Value $result +} + +# Good - separate functions at module/script scope +function Format-Result { + [CmdletBinding()] + [OutputType([psobject])] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [psobject] + $Value + ) + # Helper logic +} + + +function Get-Data { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Name + ) + + # Implementation +} + +# Function with pipeline input +function Get-PipelineInput { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(ValueFromPipeline)] + [ValidateNotNull()] + [string] + $InputData + ) + + process { + # Process each pipeline item + } +} +``` + +## Type Accelerators + +Prefer type accelerators over full .NET type names: + +- `[string]`, `[int]`, `[bool]`, `[array]`, `[hashtable]` +- `[PSCustomObject]`, `[PSCredential]`, `[datetime]`, `[regex]` + +```powershell +# Good - type accelerators in parameter declarations +function Get-Setting { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] + $Configuration + ) +} + +# Avoid - full .NET type names +function Get-Setting { + [CmdletBinding()] + [OutputType([System.Management.Automation.PSCustomObject])] + param( + [Parameter(Mandatory)] + [System.Collections.Hashtable] + $Configuration + ) +} +``` + +## Naming Conventions + +1. Use approved PowerShell verbs only (verify with `Get-Verb`) +2. Use singular nouns for function names (`Get-Item` not `Get-Items`) +3. Use PascalCase for function names and parameters +4. Use camelCase for local variables (`$userName`, `$itemCount`) +5. Use descriptive variable names that indicate purpose +6. Use full cmdlet names, never aliases (`Get-Process` not `gps`) + +```powershell +# Good - descriptive variable names +$backupPath = 'C:\Backups' +$backupFiles = Get-ChildItem -Path $backupPath -Filter '*.bak' +$activeUsers = Get-ADUser -Filter { Enabled -eq $true } + +# Bad - generic variable names +$files = Get-ChildItem -Path $backupPath -Filter '*.bak' +$users = Get-ADUser -Filter { Enabled -eq $true } +``` + +### Path vs Directory Naming + +Use the appropriate suffix to indicate what the variable holds: + +- Use `Path` for any path string (file or folder) +- Reserve `Directory` for directory objects (e.g., `[System.IO.DirectoryInfo]`) or bare folder names + +```powershell +# Good - Path suffix for path strings +$configurationPath = Join-Path -Path $PSScriptRoot -ChildPath 'config.json' +$outputPath = Join-Path -Path $PSScriptRoot -ChildPath 'results' +$backupPath = 'C:\Backups' + +# Good - Directory suffix for a directory object +$logDirectory = [System.IO.DirectoryInfo]::new('C:\Logs') + +# Bad - Directory suffix on a path string +$outputDirectory = 'C:\App\results' +``` + +## Parameters + +1. Use full parameter names in scripts and functions +2. Always use quotes around string parameter values +3. Include validation on every parameter +4. Place each component on its own line + +```powershell +# Good - string parameter values are quoted +Get-Process -Name 'powershell' +Get-ChildItem -Path 'C:\Program Files' -Filter '*.txt' + +# Bad - bare string parameter values +Get-Process -Name powershell +Get-ChildItem -Path C:\Program Files -Filter *.txt +``` + +```powershell +function Get-UserData { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $UserName, + + [Parameter()] + [ValidateRange(1, 100)] + [int] + $MaxResults = 10, + + [Parameter(ValueFromPipeline)] + [ValidateNotNull()] + [string[]] + $ComputerName + ) +} +``` + +## Formatting + +1. Opening brace `{` at end of line, closing brace `}` on new line +2. Use 4 spaces per indentation level +3. Maximum line length: 115 characters +4. Use splatting for long parameter lists +5. Two blank lines before function definitions +6. One blank line at end of file + +```powershell +function Test-Code { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateRange(1, 100)] + [int] + $Value + ) + + if ($Value -gt 10) { + Write-Output 'Greater' + } + elseif ($Value -eq 10) { + Write-Output 'Equal' + } + else { + Write-Output 'Lesser' + } +} + +# Good - splatting for readability +$invokeRestMethodParameters = @{ + Uri = 'https://api.example.com/endpoint' + Method = 'Post' + Headers = $headers + Body = $body +} +Invoke-RestMethod @invokeRestMethodParameters +``` + +## Line Continuation + +1. Do not use backtick (`` ` ``) line continuation +2. Do not use semicolons (`;`) to chain multiple statements on one line +3. Prefer splatting (`@copyItemParameters`) for long parameter lists +4. Use natural continuation inside `()`, `@{}`, or `@()` when grouping expressions or collections +5. Place each hashtable element on its own line in multi-line hashtables +6. Pipelines continue without backticks when the line ends with `|` + +```powershell +# Good - splatting for long parameter lists +$copyItemParameters = @{ + Path = $sourcePath + Destination = $destinationPath + Recurse = $true + Force = $true +} +Copy-Item @copyItemParameters + +# Good - pipeline continues across lines +Get-ChildItem -Path $sourceDirectory -Recurse | + Where-Object { $_.Length -gt 1MB } | + Sort-Object -Property 'Length' -Descending + +# Good - natural continuation inside parentheses +$summaryMessage = ( + "Processed $successCount of $totalCount records. " + + "Skipped $skipCount records. " + + "Encountered $errorCount errors." +) + +# Good - for-loop semicolons are syntactic, not statement chaining +for ($i = 0; $i -lt 10; $i++) { + Write-Output -InputObject $i +} + +# Good - hashtable with each element on its own line +$webRequestOptions = @{ + Name = 'Value' + Size = 100 +} + +# Bad - backtick line continuation +Copy-Item -Path $sourcePath ` + -Destination $destinationPath ` + -Recurse ` + -Force + +# Bad - semicolons chaining statements +Import-Module -Name 'PSReadLine'; Set-PSReadLineOption -EditMode 'Emacs' + +# Bad - hashtable elements chained with semicolons on one line +$webRequestOptions = @{ Name = 'Value'; Size = 100 } +``` + +## Paths and File System + +1. Use `$PSScriptRoot` for script-relative paths +2. Use `$Env:UserProfile` or `$HOME` instead of `~` +3. Use `Join-Path` to construct paths + +```powershell +# Good +$configurationPath = Join-Path -Path $PSScriptRoot -ChildPath 'config.json' +$documentsPath = Join-Path -Path $Env:UserProfile -ChildPath 'Documents' + +# Bad +$configurationPath = '.\config.json' +$documentsPath = '~\Documents' +``` + +## Error Handling + +1. Use `-ErrorAction 'Stop'` for cmdlets within try/catch +2. Immediately copy `$_` in catch blocks before other commands + +```powershell +$filePath = 'C:\Data\settings.json' +try { + Get-Item -Path $filePath -ErrorAction 'Stop' +} +catch { + $errorRecord = $_ # Capture immediately + Write-Error "Failed: $($errorRecord.Exception.Message)" +} +``` + +## Credential Handling + +1. Use `[PSCredential]` for credential parameters, never `[string]` for passwords +2. Make credentials optional when the function can run without them +3. Use `[System.Management.Automation.Credential()]` attribute for flexibility + +```powershell +function Connect-Service { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] + $Server, + + [Parameter()] + [ValidateNotNull()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential = [System.Management.Automation.PSCredential]::Empty + ) + + # Check if credentials were provided + if ($Credential -eq [System.Management.Automation.PSCredential]::Empty) { + # Use current user context + } + else { + # Use provided credentials + } +} +``` + +## Output + +1. Write objects to pipeline immediately, don't batch into arrays +2. Use `Write-Verbose` for detailed operation information +3. Use `Write-Warning` for potential issues + +```powershell +# Good - immediate output +foreach ($item in $collection) { + $result = Format-Item -InputObject $item + $result # Output immediately +} + +# Bad - batching +$results = @() +foreach ($item in $collection) { + $results += Format-Item -InputObject $item +} +$results +``` + +## Documentation + +All functions must include comment-based help: + +```powershell +function Get-UserData { + <# + .SYNOPSIS + Brief one-line description. + + .DESCRIPTION + Detailed description of behavior. + + .PARAMETER UserName + Description of the parameter. + + .EXAMPLE + Get-UserData -UserName 'jsmith' + + Retrieves data for user jsmith. + + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $UserName + ) + + # Implementation +} +``` + +## Quotes + +1. Use single quotes for string literals +2. Use double quotes only when variable expansion is needed +3. Quote hashtable keys only when necessary (hyphens, spaces) + +```powershell +# Good +$headers = @{ + Authorization = "Bearer $token" # Needs expansion + 'User-Agent' = 'PowerShell' # Key has hyphen +} + +$branchName = "feature/issue-$issueNumber" +$title = 'Static string' +``` + +## Spacing + +1. Spaces around all operators: `$x = 1 + 2` +2. Spaces around comparison operators: `$value -eq 10` +3. Space after commas and semicolons +4. No trailing spaces + +## Build Systems + +When a repository uses a build system (psake, Invoke-Build, etc.), use the build system's tasks for +operations like testing, building, publishing, and deployment rather than running commands directly +or creating separate scripts. Check for common build files: + +- `psakefile.ps1` or `psake.ps1` (psake) +- `*.build.ps1` (Invoke-Build) +- `build.ps1` (general build script) + +```powershell +# Good - use the build system +Invoke-psake -taskList Test +Invoke-Build -Task Test + +# Avoid - bypassing the build system +Invoke-Pester -Path .\tests\ +``` + +## Static Analysis + +PSScriptAnalyzer warnings indicate real issues. Fix the underlying problem rather than suppressing warnings. + +### Warnings to Always Fix + +These warnings represent naming and style violations that should be corrected: + +- **PSUseSingularNouns** - Rename function to use singular noun (`Get-Item` not `Get-Items`) +- **PSUseApprovedVerbs** - Use an approved verb from `Get-Verb` +- **PSAvoidUsingCmdletAliases** - Replace alias with full cmdlet name +- **PSAvoidUsingWriteHost** - Use `Write-Output`, `Write-Verbose`, or `Write-Information` + +```powershell +# Bad - suppressing instead of fixing +function Get-Items { # PSUseSingularNouns warning + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] + [CmdletBinding()] + param() + # Returns multiple items +} + +# Good - fix the naming +function Get-Item { + [CmdletBinding()] + param() + # Returns zero, one, or more items (singular noun is correct regardless) +} +``` + +### Suppression Requirements + +When suppression is genuinely necessary (rare), include a justification: + +1. Use `SuppressMessageAttribute` with the `Justification` parameter +2. Explain why the warning cannot be resolved +3. Reference external constraints if applicable + +```powershell +# Acceptable - justified suppression for API compatibility +function Get-AWSItems { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseSingularNouns', + '', + Justification = 'Matches AWS SDK naming convention for consistency with existing tooling' + )] + [CmdletBinding()] + param() +} +``` + +### Never Suppress Without Justification + +Suppressions without justification are not acceptable: + +```powershell +# Never do this +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] +``` diff --git a/instructions/readme.instructions.md b/instructions/readme.instructions.md new file mode 100644 index 0000000..5b9ea49 --- /dev/null +++ b/instructions/readme.instructions.md @@ -0,0 +1,31 @@ +--- +applyTo: '**/README.md' +description: 'README maintenance guidelines' +--- + +# README Instructions + +## Purpose + +Guidelines and requirements for maintaining `README.md` files in repositories using these instructions. + +## Requirements + +- The `README.md` must always reflect the current repository structure, naming conventions, and + setup instructions. +- Whenever you make changes to files, instructions, or workflows, review and update the + `README.md` to ensure: + - All file and directory names are accurate and up to date + - All setup and usage instructions match the latest practices and workflows + - The repository structure diagram is correct + - Any new or deprecated features are documented +- Use clear, concise language and follow Markdown best practices (see `markdown.instructions.md`). +- Place repository-specific or maintainer-only notes in clearly marked sections. +- Reference the CHANGELOG.md file for a summary of recent changes. + +## Best Practices + +- When making changes to instructions, templates, or workflows, update the `README.md` as part of + your change process. +- Include a checklist item for README review in pull requests and release notes. +- Ensure consistency between the `README.md`, instruction files, and actual repository contents. diff --git a/instructions/releases.instructions.md b/instructions/releases.instructions.md new file mode 100644 index 0000000..17aa7c7 --- /dev/null +++ b/instructions/releases.instructions.md @@ -0,0 +1,85 @@ +--- +applyTo: '**/*' +description: 'Release management guidelines for AI agents' +--- + +# Release Instructions for AI Agents + +When creating releases, AI agents must follow these guidelines to ensure proper release +management and avoid formatting issues. + +These instructions are self-contained for release processes but reference +repository-specific.instructions.md for additional requirements. + +## Creating Releases + +Always use `--notes-file` instead of `--notes` when creating GitHub releases to avoid escaping +issues with special characters (backticks, backslashes, quotes, etc.): + +```powershell +# Create a temporary file with release notes +$releaseNotes = @" +## Added + +- Your changes here + +## Changed + +- Your changes here + +## Fixed + +- Your changes here +"@ + +$releaseNotes | Out-File -FilePath "release-notes.md" -Encoding utf8 +gh release create v0.x.y --title "v0.x.y - Release Title" --notes-file release-notes.md +Remove-Item "release-notes.md" +``` + +## Release Notes Format + +Release notes should follow the [Keep a Changelog](https://keepachangelog.com/) format: + +- Use standard sections: Added, Changed, Deprecated, Removed, Fixed, Security +- Write clear, user-focused descriptions +- Reference issue numbers or PRs where relevant +- Use present tense for changes ("Add feature" not "Added feature" in the section items) +- Keep descriptions concise but informative + +## Version Numbering + +Follow [Semantic Versioning](https://semver.org/): + +- **MAJOR** version (x.0.0): Breaking changes or incompatible API changes +- **MINOR** version (0.x.0): New features in a backward-compatible manner +- **PATCH** version (0.0.x): Backward-compatible bug fixes + +## Pre-Release Checklist + +Follow `git-workflow.instructions.md` for branching and PR workflow. The steps below are +release-specific: + +1. **Verify current release state**: Run `gh release list --limit 5` to check the most recent + releases. Compare the latest released version against the version in CHANGELOG.md. If they + match, the changelog version needs to be incremented. If the changelog is already ahead, use + that version. NEVER release a version that already exists. +2. **Check repository-specific instructions**: Review `repository-specific.instructions.md` for + any additional release requirements specific to this repository +3. **Update CHANGELOG.md**: Add new version section with all changes +4. **Update version numbers**: Bump version in relevant files as needed +5. **Update changelog links**: Add comparison link for the new version at the bottom of + CHANGELOG.md (e.g., `[0.2.0]: https://github.com/owner/repo/compare/v0.1.0...v0.2.0`) +6. **Run tests**: Ensure all tests pass +7. **Commit changes**: Commit all version updates +8. **Create PR and wait for merge**: Follow the PR workflow in `git-workflow.instructions.md` +9. **Create release**: After PR is merged, use `gh release create` with `--notes-file` + +## Post-Release + +After creating a release: + +1. Verify the release appears correctly on GitHub +2. Check that release notes display properly (no formatting issues) +3. Confirm download links work if applicable +4. Notify team members if this is a significant release diff --git a/instructions/repository-specific.instructions.md b/instructions/repository-specific.instructions.md new file mode 100644 index 0000000..f805c82 --- /dev/null +++ b/instructions/repository-specific.instructions.md @@ -0,0 +1,388 @@ +--- +applyTo: '**/*' +description: 'Repository-specific instructions for the PowerShellBuild module' +--- + +# Repository-Specific Instructions + +These instructions cover the parts of PowerShellBuild that an agent cannot infer from the +standard AIM modules (`powershell`, `git-workflow`, `testing`, etc.). Read those first; this +file only adds repo-specific concepts. + +## Repository Context + +**PowerShellBuild** is a PowerShell module that provides standardized build, test, and publish +tasks for other PowerShell module projects. It supports two task-runner frameworks: + +- **psake** (4.9.0+) +- **Invoke-Build** (5.8.1+) + +- Current version: **0.8.1** (see `PowerShellBuild/PowerShellBuild.psd1`) +- `PowerShellVersion` in the manifest is currently `'3.0'` — almost certainly wrong; under + review in the v1.0.0 roadmap (psake/PowerShellBuild#120) +- Cross-platform: Windows, Linux, macOS (CI matrix in `.github/workflows/test.yml`) +- The module is **psake/PowerShellBuild** on PSGallery and GitHub; maintained by the psake org + +## Repository Layout + +```text +PowerShellBuild/ +├── Build/Convert-PSAke.ps1 # Dev utility: converts psake tasks to Invoke-Build (not shipped) +├── PowerShellBuild/ # THE MODULE SOURCE (system under test) +│ ├── Public/ # 12 exported functions +│ ├── Private/ # Internal helpers +│ ├── en-US/Messages.psd1 # Localized strings (Import-LocalizedData) +│ ├── PowerShellBuild.psd1 # Manifest +│ ├── PowerShellBuild.psm1 # Dot-sources Public/ and Private/ +│ ├── ScriptAnalyzerSettings.psd1 +│ ├── build.properties.ps1 # $PSBPreference (canonical config hashtable) +│ ├── psakeFile.ps1 # Tasks consumers import +│ └── IB.tasks.ps1 # Invoke-Build entry (aliased as PowerShellBuild.IB.Tasks) +├── tests/ # Pester 5+ tests +│ └── TestModule/ # Sample module exercised by the test suite +├── build.ps1 # Main build entry point for THIS repo +├── build.settings.ps1 # Build settings for THIS repo's own psake build +├── psakeFile.ps1 # psake tasks for building THIS repo (simpler than the inner one) +└── requirements.psd1 # PSDepend manifest +``` + +**The two `psakeFile.ps1` files serve different purposes:** + +- Root `psakeFile.ps1` → builds *this* repo +- `PowerShellBuild/psakeFile.ps1` → what consumers import to build *their* repo + +## Key Concepts + +### `$PSBPreference` — the central configuration object + +All build behavior is controlled through a single ordered hashtable named `$PSBPreference`, +defined in `PowerShellBuild/build.properties.ps1`. The variable name is fixed — never rename +it or recreate it under a different name. + +It is set as a **read-only script-scoped variable** when `psakeFile.ps1` is loaded. To modify +it, set values *before* loading the task file, or use `Set-Variable -Force`. + +Sections: + +| Section | Purpose | +| -------------- | ------------------------------------------------------------------------------------------------------------------ | +| `General` | ProjectRoot, SrcRootDir, ModuleName, ModuleVersion, ModuleManifestPath | +| `Build` | OutDir, ModuleOutDir, CompileModule, CompileDirectories, CopyDirectories, Exclude | +| `Test` | Enabled, RootDir, OutputFile/Format, ScriptAnalysis, CodeCoverage, ImportModule, etc. | +| `Help` | UpdatableHelpOutDir, DefaultLocale, ConvertReadMeToAboutHelp | +| `Docs` | RootDir, Overwrite, AlphabeticParamsOrder, ExcludeDontShow, UseFullTypeName | +| `Publish` | PSRepository, PSRepositoryApiKey, PSRepositoryCredential | +| `Sign` | Enabled, CertificateSource, CertStoreLocation, Thumbprint, EnvVar/PfxFile sources, TimestampServer, HashAlgorithm, FilesToSign, Catalog | +| `Sign.Catalog` | Enabled, Version, FileName | + +### Module compilation modes + +`$PSBPreference.Build.CompileModule` controls how the module is staged to the output directory: + +- `$false` (default) — files copied as-is, preserving the `Public/`/`Private/` structure +- `$true` — all `.ps1` files from `CompileDirectories` (default: `Enum`, `Classes`, `Private`, + `Public`) are concatenated into a single `.psm1`. Optional `CompileHeader`, `CompileFooter`, + `CompileScriptHeader`, and `CompileScriptFooter` strings can be injected. + +### Task dependency variables + +Task dependencies in `PowerShellBuild/psakeFile.ps1` are defined via variables checked with +`if ($null -eq ...)`. This lets consumers override dependencies *before* importing the tasks +file: + +```powershell +# Example: insert a custom task before Pester runs +$PSBPesterDependency = @('Build', 'MyCustomTask') +``` + +Variables (pattern: `$PSB{TaskName}Dependency`): + +| Variable | Default | +| ------------------------------------- | -------------------------------- | +| `$PSBCleanDependency` | `@('Init')` | +| `$PSBStageFilesDependency` | `@('Clean')` | +| `$PSBBuildDependency` | `@('StageFiles', 'BuildHelp')` | +| `$PSBAnalyzeDependency` | `@('Build')` | +| `$PSBPesterDependency` | `@('Build')` | +| `$PSBTestDependency` | `@('Pester', 'Analyze')` | +| `$PSBBuildHelpDependency` | `@('GenerateMarkdown', 'GenerateMAML')` | +| `$PSBGenerateMarkdownDependency` | `@('StageFiles')` | +| `$PSBGenerateMAMLDependency` | `@('GenerateMarkdown')` | +| `$PSBGenerateUpdatableHelpDependency` | `@('BuildHelp')` | +| `$PSBPublishDependency` | `@('Test')` | +| `$PSBSignModuleDependency` | `@('Build')` | +| `$PSBBuildCatalogDependency` | `@('SignModule')` | +| `$PSBSignCatalogDependency` | `@('BuildCatalog')` | +| `$PSBSignDependency` | `@('SignCatalog')` | + +## Public API (Exported Functions) + +12 exported functions in `PowerShellBuild/Public/` (all follow the `Verb-PSBuildNoun` naming +pattern — keep new public functions consistent with this): + +| Function | Description | +| ------------------------------ | ---------------------------------------------------------------------- | +| `Initialize-PSBuild` | Sets up BuildHelpers env vars, displays build info | +| `Build-PSBuildModule` | Copies/compiles module source to output directory | +| `Clear-PSBuildOutputFolder` | Safely removes the build output directory | +| `Build-PSBuildMarkdown` | Generates PlatyPS Markdown docs from module help | +| `Build-PSBuildMAMLHelp` | Converts PlatyPS Markdown to MAML XML help files | +| `Build-PSBuildUpdatableHelp` | Creates a `.cab` file for updatable help | +| `Test-PSBuildPester` | Runs Pester tests with configurable output and coverage | +| `Test-PSBuildScriptAnalysis` | Runs PSScriptAnalyzer with a configurable severity threshold | +| `Publish-PSBuildModule` | Publishes the built module to a PowerShell repository | +| `Get-PSBuildCertificate` | Resolves an Authenticode signing certificate | +| `Invoke-PSBuildModuleSigning` | Signs module files with an Authenticode certificate | +| `New-PSBuildFileCatalog` | Generates a `.cat` file catalog for the module | + +Private helper: `Remove-ExcludedItem` — filters file system items by regex patterns during builds. + +### Invoke-Build alias + +The module exports an alias `PowerShellBuild.IB.Tasks` that points to `IB.tasks.ps1`, enabling +the Invoke-Build dot-source pattern: + +```powershell +# In a consumer's .build.ps1 for Invoke-Build +Import-Module PowerShellBuild +. PowerShellBuild.IB.Tasks +``` + +## Build Workflows + +### Building this repo + +The repo uses its own psake build. Main entry point is `./build.ps1`. **Run with PowerShell 7+ +(`pwsh`).** + +```powershell +# First time in a fresh env — installs deps via PSDepend +./build.ps1 -Bootstrap + +# Specific tasks +./build.ps1 -Task Build +./build.ps1 -Task Test +./build.ps1 -Task Analyze +./build.ps1 -Task Pester + +# List available tasks +./build.ps1 -Help + +# Publish to PSGallery (requires API-key credential) +./build.ps1 -Task Publish -PSGalleryApiKey $cred +``` + +### Repo-level tasks (root `psakeFile.ps1`) + +| Task | Depends On | Description | +| --------- | ---------------------- | ------------------------------------------ | +| `default` | Test | Default task | +| `Init` | — | Initialize build env (shows `BH*` env vars)| +| `Clean` | Init | Remove output directory | +| `Build` | Init, Clean | Copy module source to output | +| `Analyze` | Build | Run PSScriptAnalyzer | +| `Pester` | Build | Run Pester tests | +| `Test` | Init, Analyze, Pester | Run all tests | +| `Publish` | Test | Publish to PSGallery | + +### Module-level tasks (consumer-facing `PowerShellBuild/psakeFile.ps1`) + +These are the tasks consumer modules get when they import PowerShellBuild: + +| Task | Description | +| ----------------------- | -------------------------------------------- | +| `Init` | Initialize build env variables | +| `Clean` | Clear module output directory | +| `StageFiles` | Copy/compile source to output | +| `Build` | StageFiles + BuildHelp | +| `Analyze` | PSScriptAnalyzer | +| `Pester` | Pester tests | +| `Test` | Pester + Analyze | +| `GenerateMarkdown` | PlatyPS Markdown from help | +| `GenerateMAML` | MAML XML from Markdown | +| `BuildHelp` | GenerateMarkdown + GenerateMAML | +| `GenerateUpdatableHelp` | CAB file for updatable help | +| `Publish` | Publish to repository | +| `SignModule` | Authenticode-sign module files (`*.psd1`, `*.psm1`, `*.ps1`) | +| `BuildCatalog` | Create Windows catalog (`.cat`) for the built module | +| `SignCatalog` | Authenticode-sign the module catalog file | +| `Sign` | Meta task — runs the full signing chain | + +Tasks with prerequisite modules (`Analyze`, `Pester`, `GenerateMarkdown`, `GenerateMAML`, +`GenerateUpdatableHelp`) check that required modules are installed; they skip gracefully +with a warning if the module is missing. + +The signing tasks (`SignModule`, `BuildCatalog`, `SignCatalog`) have similar preconditions: +they skip when `$PSBPreference.Sign.Enabled` is `$false` (catalog tasks also require +`$PSBPreference.Sign.Catalog.Enabled = $true`) or when the required Windows-only cmdlets +(`Set-AuthenticodeSignature`, `New-FileCatalog`) are not available — so signing safely +no-ops on non-Windows. + +## Dependencies + +Defined in `requirements.psd1`, installed via **PSDepend** when `./build.ps1 -Bootstrap` runs: + +| Module | Version | +| ---------------- | -------- | +| BuildHelpers | 2.0.16 | +| Pester | ≥ 5.6.1 | +| psake | 4.9.0 | +| PSScriptAnalyzer | 1.24.0 | +| InvokeBuild | 5.8.1 | +| platyPS | 0.14.2 | + +## Testing + +Tests live in `tests/` and use **Pester 5+** syntax. + +- Always build the module before running Pester directly — running against source can produce + incorrect results. Prefer `./build.ps1 -Task Test` over a raw `Invoke-Pester` call. +- `tests/TestModule/` is a complete example module used to exercise PowerShellBuild's tasks. + It has its own `build.ps1`, `psakeFile.ps1`, `.build.ps1` (Invoke-Build), and Pester tests. + +| Test file | Tests | +| ---------------------- | ----------------------------------------------------------------------- | +| `build.tests.ps1` | Module compilation, file staging, exclusion, header/footer injection | +| `Help.tests.ps1` | Help documentation completeness | +| `IBTasks.tests.ps1` | Invoke-Build task definitions | +| `Manifest.tests.ps1` | Module manifest validity | +| `Meta.tests.ps1` | Script analysis, best practices across module source | + +## CI / CD (GitHub Actions) + +### Test workflow (`.github/workflows/test.yml`) + +- Triggers: push to default branch, pull requests, manual dispatch +- Matrix: `ubuntu-latest`, `windows-latest`, `macOS-latest` +- Command: `./build.ps1 -Task Test -Bootstrap` +- Supports a `DEBUG` runner flag for verbose output + +### Publish workflow (`.github/workflows/publish.yaml`) + +- Triggers: manual dispatch, GitHub release published +- Runs on: `ubuntu-latest` +- Reads `PSGALLERY_API_KEY` secret, converts to `PSCredential`, runs + `./build.ps1 -Task Publish -PSGalleryApiKey $cred -Bootstrap` + +## Repo-Specific Conventions + +These supplement `powershell.instructions.md` and `git-workflow.instructions.md` — they +don't replace them. + +- **Function naming**: public functions follow `Verb-PSBuildNoun` (e.g., `Build-PSBuildModule`, + `Test-PSBuildPester`). Always use an approved verb. +- **Config variable**: always `$PSBPreference`. Never rename or recreate it. +- **Task dependency vars**: `$PSB{TaskName}Dependency` (e.g., `$PSBPesterDependency`). +- **Localization**: user-facing strings live in `PowerShellBuild/en-US/Messages.psd1` and load + via `Import-LocalizedData`. Add new strings there rather than hardcoding messages in + function bodies. Use UTF-8 with BOM (standard for PowerShell data files). +- **Script analysis**: PSScriptAnalyzer config is `PowerShellBuild/ScriptAnalyzerSettings.psd1`. + Default severity threshold for build failure is `Error`. Warnings are reported but don't + fail the build. +- **Spell-checker ignores**: inline comments — `# spell-checker:ignore MAML PSGALLERY`. + +## How Consumers Use This Module + +### With psake + +```powershell +# In consumer's psakeFile.ps1 +properties { + # These settings overwrite values supplied from the PowerShellBuild + # module and govern how those tasks are executed + $PSBPreference.Test.ScriptAnalysisEnabled = $false + $PSBPreference.Test.CodeCoverage.Enabled = $true +} + +task default -depends Build + +task Build -FromModule PowerShellBuild -Version '0.1.0' +``` + +### With Invoke-Build + +```powershell +# In consumer's .build.ps1 +Import-Module PowerShellBuild +. PowerShellBuild.IB.Tasks + +# Override configuration after dot-sourcing +$PSBPreference.Build.CompileModule = $false +``` + +## Common Development Tasks + +### Adding a new public function + +1. Create the file under `PowerShellBuild/Public/NewFunction.ps1` +2. Use the `Verb-PSBuildNoun` naming pattern +3. Add any user-facing strings to `PowerShellBuild/en-US/Messages.psd1` +4. Add the function name to `FunctionsToExport` in `PowerShellBuild.psd1` +5. No edit to `PowerShellBuild.psm1` needed — it dot-sources all files in `Public/` automatically + +### Adding a new build task + +1. Add the task to `PowerShellBuild/psakeFile.ps1` +2. Define a corresponding `$PSB{TaskName}Dependency` variable with an `if ($null -eq ...)` guard +3. If the task requires a new module, update `PowerShellBuild.psd1` and `requirements.psd1` + +### Updating module version + +1. Edit `ModuleVersion` in `PowerShellBuild/PowerShellBuild.psd1` +2. Add a `CHANGELOG.md` entry (see `releases.instructions.md` for format) + +## Environment Variables (set by BuildHelpers) + +`Initialize-PSBuild` calls `BuildHelpers\Set-BuildEnvironment`, which populates: + +| Variable | Value | +| ------------------------- | ---------------------------------------------------- | +| `$env:BHProjectPath` | Repository root directory | +| `$env:BHProjectName` | Module name (from directory structure) | +| `$env:BHPSModulePath` | Path to module source directory | +| `$env:BHPSModuleManifest` | Path to `.psd1` manifest | +| `$env:BHModulePath` | Same as `BHPSModulePath` | +| `$env:BHBuildSystem` | Detected CI system (e.g., `GitHubActions`, `Unknown`)| +| `$env:BHBranchName` | Current git branch | +| `$env:BHCommitMessage` | Latest git commit message | + +## Output Directory Structure + +After a successful build: + +```text +Output/ +└── PowerShellBuild/ + └── 0.8.0/ + ├── Public/ # (when CompileModule = $false) + ├── Private/ + ├── en-US/ + ├── PowerShellBuild.psd1 + ├── PowerShellBuild.psm1 + └── ScriptAnalyzerSettings.psd1 +``` + +When `CompileModule = $true`, all `.ps1` files are merged into the single `.psm1` and the +`Public/`/`Private/` directories are not copied to output. + +`Output/` is in `.gitignore` and excluded from VS Code search (`.vscode/settings.json`). + +## v1.0.0 Roadmap + +The v1.0.0 release is actively being planned in **psake/PowerShellBuild#120**. Locked-in +decisions include: PRs directly to `main`, `1.0.0-preview.N` prereleases after each phase, +hard cut + migration guide (no deprecation cycle), psake 5.x in scope. Phase-by-phase +breakdown lives in the tracking issue. + +Migration guide path (created in Phase 1): `docs/migration/v0.8-to-v1.0.md`. + +## Notes for AI Agents + +- **First-time setup**: always run `./build.ps1 -Bootstrap` in a fresh environment to install + dependencies via PSDepend. +- **`$PSBPreference` is read-only at script scope** once `psakeFile.ps1` is loaded. To modify + it, set values before loading the task file, or use `Set-Variable -Force`. +- **Tests need the module built first** — running Pester directly against source can produce + incorrect results. Use `./build.ps1 -Task Test` rather than raw `Invoke-Pester` unless the + module is already built and imported. +- `Build/Convert-PSAke.ps1` is a developer convenience tool, not part of the published module. diff --git a/instructions/shorthand.instructions.md b/instructions/shorthand.instructions.md new file mode 100644 index 0000000..de68961 --- /dev/null +++ b/instructions/shorthand.instructions.md @@ -0,0 +1,66 @@ +--- +applyTo: '**/*' +description: 'Guidelines for avoiding shorthand and abbreviations in all code and documentation.' +--- + +# Shorthand Guidelines + +## Avoid Shorthand and Abbreviations + +To maximize clarity, maintainability, and consistency across all code and documentation, always +use full, descriptive words instead of shorthand or abbreviations. + +- **Do not use**: `Params`, `Props`, `Config`, `Info`, `Temp`, `Env`, `Obj`, `Val`, `Ref`, + `Err`, `Msg`, etc. +- **Do use**: `Parameters`, `Properties`, `Configuration`, `Information`, `Temporary`, + `Environment`, `Object`, `Value`, `Reference`, `Error`, `Message`, etc. + +### Rationale + +- Shorthand and abbreviations can be ambiguous and reduce code readability. +- Full words make intent clear for all contributors and AI agents. +- Consistent naming improves searchability and onboarding for new team members. + +### Examples + +| Avoid | Prefer | +| ------ | ---------------------------- | +| Params | Parameters | +| Props | Properties | +| Config | Configuration | +| Info | Information | +| Temp | Temporary | +| Env | Environment | +| Obj | Object | +| Val | Value | +| Ref | Reference | +| Err | Error | +| Msg | Message | +| Conn | Connection / Connections | +| Dir | Directory | +| Cmd | Command | +| Svc | Service | +| Cfg | Configuration | +| Tmp | Temporary | +| Usr | User | +| Grp | Group | +| Ctx | Context | +| Auth | Authentication / Authorize | +| Util | Utility / Utilities | +| Init | Initialize / Initialization | +| Req | Request / Requirement | +| Resp | Response | +| ObjRef | Object Reference | +| Num | Number | + +### Additional Guidance + +- Never use abbreviations in parameter, property, or variable names unless they are + industry-standard and unambiguous (e.g., `ID`, `URL`). +- Use the singular or plural form of a word as appropriate for the context (e.g., use + `Connection` for a single item, `Connections` for collections or lists). +- If a project already uses a specific abbreviation as a standard, document it clearly in the + relevant instruction file. +- If new abbreviations are introduced in the future, document them here and avoid their use + unless absolutely necessary and unambiguous. +- This rule applies to all code, documentation, commit messages, and user-facing text. diff --git a/instructions/testing.instructions.md b/instructions/testing.instructions.md new file mode 100644 index 0000000..4753984 --- /dev/null +++ b/instructions/testing.instructions.md @@ -0,0 +1,339 @@ +--- +applyTo: '**/*' +description: 'Test writing best practices and conventions' +--- + +# Testing Instructions + +Language-agnostic guidelines for writing effective tests. + +## Discovering Existing Test Tooling + +Before creating scripts for test-related tasks (running tests, gathering coverage, generating reports): + +1. **Check for build systems** - Look for `Makefile`, `build.ps1`, `package.json` scripts, `tox.ini`, + `pyproject.toml`, or similar build configuration files +2. **Search README and CI configs** - Existing commands are often documented or visible in CI workflows +3. **Ask the user** - If unsure whether tooling exists, ask before creating anything new + +**Never create new scripts when existing build tooling already handles the task.** + +## Test Structure + +### Arrange-Act-Assert (AAA) + +Structure each test in three clear sections: + +```javascript +// Arrange - Set up test data and preconditions +// Act - Execute the code being tested +// Assert - Verify the expected outcome +``` + +**Example:** + +```javascript +// Arrange +user = createTestUser(name: "Alice", role: "admin") + +// Act +result = user.hasPermission("delete") + +// Assert +expect(result).toBe(true) +``` + +### Given-When-Then (BDD Style) + +Alternative structure for behavior-focused tests: + +```javascript +// Given - Initial context +// When - Action occurs +// Then - Expected outcome +``` + +## Naming Conventions + +### Test Names Should Describe Behavior + +**Pattern:** `__` + +**Good examples:** + +```text +calculateTotal_withEmptyCart_returnsZero +userLogin_withInvalidPassword_throwsAuthError +emailValidator_withValidEmail_returnsTrue +``` + +**Avoid:** + +```text +test1 +testCalculate +itWorks +``` + +### Test File Naming + +Place test files alongside source files or in a dedicated test directory: + +```text +src/ + calculator.js + calculator.test.js # Adjacent to source + +tests/ + calculator.test.js # Or in test directory +``` + +Common extensions: + +- `.test.js`, `.test.ts` +- `.spec.js`, `.spec.ts` +- `_test.go` +- `Test.cs` +- `.Tests.ps1` + +## Test Types + +### Unit Tests + +- Test individual functions or methods in isolation +- Mock external dependencies +- Fast execution (milliseconds) +- High coverage of edge cases + +### Integration Tests + +- Test interaction between components +- May use real databases or services +- Slower than unit tests +- Focus on component boundaries + +### End-to-End Tests + +- Test complete user workflows +- Use real browser/UI automation +- Slowest to execute +- Cover critical user paths + +## Best Practices + +### One Assertion Per Concept + +Each test should verify one logical concept: + +**Good:** + +```text +test_addItem_increasesCartCount +test_addItem_updatesCartTotal +``` + +**Avoid:** + +```text +test_addItem_doesEverything // Tests multiple things +``` + +### Test Independence + +- Each test should run independently +- Don't rely on test execution order +- Clean up test data after each test +- Use fresh fixtures for each test + +### Avoid Test Interdependence + +**Bad:** + +```javascript +test1_createUser() // Creates user +test2_loginUser() // Assumes user exists from test1 +``` + +**Good:** + +```javascript +test_loginUser() { + user = createTestUser() // Each test creates its own data + // ... test logic +} +``` + +### Use Descriptive Assertions + +**Good:** + +```javascript +expect(user.isActive).toBe(true) +expect(result).toContain("success") +expect(list).toHaveLength(3) +``` + +**Avoid:** + +```javascript +expect(x).toBe(true) // What is x? +assert(result) // What should result be? +``` + +## Test Data + +### Use Meaningful Test Data + +**Good:** + +```javascript +email = "valid.user@example.com" +invalidEmail = "not-an-email" +``` + +**Avoid:** + +```javascript +email = "test" +x = "asdf" +``` + +### Use Factories or Builders + +Create helper functions for test data: + +```javascript +function createTestUser(overrides = {}) { + return { + id: generateId(), + name: "Test User", + email: "test@example.com", + role: "user", + ...overrides + } +} + +// Usage +adminUser = createTestUser({ role: "admin" }) +``` + +### Edge Cases to Consider + +- Empty inputs (null, undefined, empty string, empty array) +- Boundary values (0, -1, max int, min int) +- Invalid inputs (wrong type, malformed data) +- Large inputs (performance edge cases) +- Special characters and unicode +- Concurrent access (race conditions) + +## Mocking and Stubbing + +### When to Mock + +- External services (APIs, databases) +- Time-dependent operations +- Random number generation +- File system operations +- Network requests + +### When Not to Mock + +- Simple value objects +- Pure functions with no side effects +- The code you're actually testing + +### Mock Guidelines + +- Only mock what you need +- Verify mock interactions when behavior matters +- Reset mocks between tests +- Prefer dependency injection for easier mocking + +## Test Coverage + +### Focus on Critical Paths + +Prioritize testing: + +1. Business-critical functionality +2. Error handling and edge cases +3. Security-sensitive code +4. Complex algorithms + +### Coverage Goals + +- Aim for meaningful coverage, not 100% +- High coverage doesn't guarantee quality +- Focus on testing behavior, not implementation details + +## Bug Fix Testing + +### Test-First Bug Fixing + +When fixing a bug, always follow this workflow: + +1. **Write a failing test first** - Create at least one test that reproduces the bug +2. **Verify the test fails** - Confirm the test fails for the expected reason +3. **Fix the bug** - Implement the minimal fix to make the test pass +4. **Verify all tests pass** - Ensure both the new test and existing tests pass + +**Example workflow:** + +```text +# 1. Create test that exposes the bug +test_calculateDiscount_withZeroQuantity_returnsZero() + # This test fails because of the bug + +# 2. Run tests - confirm failure +> npm test +FAIL: calculateDiscount returns NaN instead of 0 + +# 3. Fix the bug in the source code + +# 4. Run tests - confirm fix +> npm test +PASS: All tests passing +``` + +### Why Test-First Matters + +- **Proves the bug exists** - The failing test documents the exact issue +- **Prevents regressions** - The test ensures the bug won't return +- **Validates the fix** - You know the fix works when the test passes +- **Documents behavior** - Future developers understand the expected behavior + +### Bug Test Naming + +Name bug-related tests to indicate the scenario being fixed: + +```text +calculateTotal_withNullItems_returnsZeroInsteadOfCrashing +parseDate_withLeapYear_handlesFebruary29Correctly +userAuth_withExpiredToken_returnsUnauthorizedNotServerError +``` + +## Running Tests + +### Before Committing + +- Run related tests locally +- Ensure all tests pass +- Add tests for new functionality +- Update tests for changed behavior + +### Continuous Integration + +- Tests should run on every PR +- Failed tests should block merging +- Keep test suite fast (parallelize when possible) + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------- | ------------------ | ----------------------------- | +| Testing implementation | Brittle tests | Test behavior/outcomes | +| Flaky tests | Unreliable CI | Fix timing/ordering issues | +| Slow tests | Developer friction | Optimize or parallelize | +| No assertions | False confidence | Always verify outcomes | +| Commented-out tests | Hidden failures | Delete or fix tests | +| Test data in production | Security risk | Use separate test environment | diff --git a/instructions/update.instructions.md b/instructions/update.instructions.md new file mode 100644 index 0000000..623ed3c --- /dev/null +++ b/instructions/update.instructions.md @@ -0,0 +1,193 @@ +--- +applyTo: '**/*' +description: 'Procedures for updating AI agent instructions from the centralized repository' +--- + +# Update Instructions for AI Agents + +These instructions are self-contained for update procedures but assume familiarity with Git. +For general workflow guidance, see agent-workflow.instructions.md. + +## Configuration Schema + +Repositories control AIM behavior through `aim.config.json` in the repository root: + +```json +{ + "version": "latest", + "modules": { + "include": ["agent-workflow", "powershell", "markdown"], + "exclude": [] + }, + "externalSources": { + "enabled": true, + "repositories": [ + { + "name": "awesome-copilot", + "url": "https://github.com/github/awesome-copilot", + "path": "instructions", + "description": "Community-contributed instructions from GitHub" + } + ] + } +} +``` + +**Configuration fields:** + +- `version` - Target AIM version: `"latest"` or specific version (e.g., `"0.8.0"`) +- `modules.include` - List of modules to include (without `.instructions.md` extension) +- `modules.exclude` - List of modules to exclude (takes precedence over include) +- `externalSources.enabled` - Enable fetching from external repositories +- `externalSources.repositories` - List of external instruction sources + +## Update Procedure + +When updating AI agent instructions in a repository that uses AIM, AI agents should: + +### 1. Read Configuration + +- Check if `aim.config.json` exists in the repository root +- If it exists, read all configuration fields +- If it doesn't exist, use defaults: version=latest, all modules, externalSources disabled + +### 2. Clone the Centralized Repository + +- Clone: `git clone https://github.com/tablackburn/ai-agent-instruction-modules.git` +- If targeting a specific version (not "latest"), checkout that tag: `git checkout v0.8.0` +- Use `AGENTS.template.md` from the cloned repository, NOT `AGENTS.md` +- The file `AGENTS.md` in the centralized repository is that repository's own implementation +- The file `AGENTS.template.md` is the template for downstream repositories + +### 3. Summarize Changes + +- Read the current version from the downstream repository's `AGENTS.md` header + (e.g., "Template Version: 0.7.0") +- Read `CHANGELOG.md` from the cloned upstream repository +- Extract all version sections between the current version and the target version +- Provide the user with a brief summary of what has changed, noting any breaking changes +- If the current version equals the target version, inform the user they are already up to date + +### 4. Determine Modules to Sync + +Based on `aim.config.json`: + +- If `modules.include` is specified, only sync those modules +- If `modules.exclude` is specified, exclude those from the sync +- Core modules (`agent-workflow`, `update`) should always be included unless explicitly excluded +- `repository-specific.instructions.md` is NEVER copied from upstream + +### 5. Sync Instruction Files + +For each instruction file in the upstream `instruction-templates/` folder: + +1. Check if the module should be synced based on configuration +2. Check if the file already exists in the downstream `instructions/` folder +3. **If the file exists, ask the user:** + - "File X already exists. Overwrite with upstream version? (yes/no/diff)" + - If "diff", show the differences between local and upstream versions + - Only overwrite if the user confirms +4. **If the file is new**, copy it without prompting + +### 6. Handle External Sources + +If `externalSources.enabled` is true and a needed language/framework instruction is not found in +AIM: + +1. Check each configured external repository in order +2. For awesome-copilot, look in the `instructions/` path for matching `.instructions.md` files +3. Download the instruction file and copy to the downstream `instructions/` folder +4. Inform the user which files were fetched from external sources + +**Example external fetch:** + +```text +Fetching python.instructions.md from github/awesome-copilot... +Fetching react.instructions.md from github/awesome-copilot... +``` + +### 7. Update AGENTS.md + +- Replace the HTML comment block at the top (the comment starting with `