I’ve been using the GitHub Copilot CLI for a while now and had been building up a small collection of markdown prompt files for generating architectural documentation — prompts for creating Architecture Decision Records (ADRs) and High-Level Design (HLD) documents. I was sharing these with others through a basic git repo, but the workflow involved copying and pasting prompts with no clear versioning or discoverability. When I discovered the plugin system, it was clear this was a much better approach — packaging prompts as installable skills that anyone on the Copilot CLI can invoke directly.
In this post, I’ll walk through what Copilot CLI plugins are, the anatomy of a plugin, the approach I use to develop skills through conversational prompting, and how I’ve automated the marketplace.
A Copilot CLI plugin is a directory-based package that contains a manifest file (plugin.json) and one or more skill definitions (SKILL.md files). Skills are structured markdown prompts that the Copilot AI interprets at runtime — there are no binaries, no compiled code, and no runtime dependencies. The entire plugin is just text.
When a user installs a plugin, the Copilot CLI reads the SKILL.md files and presents them as slash-commands:
copilot
/architecture-docs:generate-adr
/architecture-docs:generate-hld
Plugins are discovered through a marketplace manifest — a marketplace.json file that lives in .github/plugin/ — which the Copilot CLI reads when you register a source repository.
Every plugin starts with a plugin.json manifest:
{
"name": "architecture-docs",
"description": "Generate Architecture Decision Records (ADRs) and High-Level Design (HLD) documents with structured, gated workflows.",
"version": "0.0.3",
"author": {
"name": "MilkyWare"
},
"license": "MIT",
"skills": [
"./skills/generate-adr",
"./skills/generate-hld",
"./skills/generate-architecture-md"
]
}
The manifest declares the plugin name, version, author, and a list of skill paths. Each skill path points to a directory containing a SKILL.md file:
---
name: generate-adr
description: Generate an Architecture Decision Record (ADR) with guided questions,
structured decision drivers, and formal documentation.
---
# Generate Architecture Decision Record (ADR)
You are an expert software architect helping to write an Architecture Decision Record (ADR) in Markdown.
Your task:
1. Ask the user a series of questions, one section at a time.
2. Wait for the user's response before moving to the next section.
3. Use their answers to generate the final output.
## Writing Rules
- Use UK English spelling, grammar, and terminology only.
- Do not hallucinate or fabricate any information.
- ... (additional rules)
The YAML frontmatter gives the skill its name and description (visible in the CLI). The body is the system prompt — it defines the AI’s role, the workflow it should follow, writing rules, and the output format. At its core, a skill is just a structured markdown prompt that the Copilot AI interprets at runtime.
For my documentation prompts, I’ve designed them to function as smart document templates — they use a gated question sequence where the AI asks questions section-by-section, waits for the user’s response before proceeding, and only generates the final output once all required information has been gathered. This prevents hallucination and ensures the output is grounded in real context, but that’s a design choice specific to these prompts rather than a constraint of the skill format itself.
plugins/architecture-docs/
├── plugin.json
├── README.md
├── version.txt
├── CHANGELOG.md
└── skills/
├── generate-adr/
│ └── SKILL.md
├── generate-hld/
│ └── SKILL.md
└── generate-architecture-md/
└── SKILL.md
When developing a new skill, I don’t start by writing a SKILL.md from scratch. Instead, I follow a three-phase process using the AI itself:
Plan — I have a conversation to define the workflow, the sections or steps involved, the rules the AI should follow, and the expected output format. I iterate on this plan until it feels complete.
Implement — Once the plan is solid, I ask the AI to implement it into working components — prompts, templates, and any supporting files. I refine the output through further conversation until it produces the results I want.
Structured Prompt — Finally, I ask the AI to produce a standalone, self-contained prompt capturing everything we discussed. This is the reusable asset I can keep in version control and share with others.
The interesting part comes next. Taking that structured prompt, I can then ask the AI to build a Copilot CLI skill from it — generating the SKILL.md, plugin.json, and marketplace wiring in one go. The AI transforms the conversational prompt into the formal SKILL.md structure with frontmatter, process rules, and the gated question sequence, all without me needing to learn the exact format upfront.
This conversational prompting approach lets me rapidly prototype complex gated workflows and package them as plugins with minimal ceremony.
For a plugin to be discoverable, the repository needs a marketplace.json file at .github/plugin/marketplace.json:
{
"name": "awesome-ai",
"owner": {
"name": "MilkyWare"
},
"plugins": [
{
"name": "architecture-docs",
"description": "Generate Architecture Decision Records (ADRs) and High-Level Design (HLD) documents with structured, gated workflows.",
"version": "0.0.3",
"source": "./plugins/architecture-docs"
}
]
}
This is the entry point for the copilot /plugin marketplace add command. When a user registers the repository as a marketplace:
copilot /plugin marketplace add https://github.com/milkyware/awesome-ai
…the Copilot CLI reads marketplace.json to discover available plugins and their versions. The version field is particularly important — it determines whether a user has the latest version installed.
With multiple plugins (and potentially more in the future), keeping marketplace.json versions in sync with each plugin.json manually would be error-prone. I’ve automated this with two pieces of CI/CD:
Release Please handles version bumps. I covered this in detail in my previous post, but the key detail here is the extra-files configuration in release-please-config.json:
{
"packages": {
"plugins/architecture-docs": {
"component": "architecture-docs",
"changelog-path": "CHANGELOG.md",
"extra-files": [
{
"type": "json",
"path": "plugin.json",
"jsonpath": "$.version"
}
]
}
}
}
This tells Release Please to update the $.version field in plugin.json whenever it calculates a new version — no manual editing needed.
A custom GitHub Action (generate-marketplace) scans all plugins/*/plugin.json files and regenerates marketplace.json with the current version from each plugin’s manifest. This runs as a workflow triggered whenever .release-please-manifest.json changes (i.e. after a release PR is merged), ensuring the marketplace always reflects the latest published versions.
flowchart LR
A[Merge to main] --> B[Release Please<br>creates release PR]
B --> C[Merge release PR]
C --> D[Release Please bumps<br/>$.version in plugin.json]
D --> E[Generate Marketplace<br>workflow triggered]
E --> F[marketplace.json<br>regenerated with<br>latest versions]
To use the plugin yourself:
# Register the marketplace
copilot /plugin marketplace add https://github.com/milkyware/awesome-ai
# Install the plugin
copilot /plugin install architecture-docs
# Use a skill
copilot
/architecture-docs:generate-adr
The plugin currently offers three skills:
generate-adr — Guided workflow for creating Architecture Decision Recordsgenerate-hld — Guided workflow for generating High-Level Design documentsgenerate-architecture-md — Guided workflow for generating ARCHITECTURE.md documentationEach skill will walk you through a structured question sequence and produce a markdown document ready for version control.
All of the code and configuration covered in this post is available in the repository below.
Copilot CLI plugins are a surprisingly lightweight way to package and share reusable AI workflows. The entire plugin is just markdown files and a JSON manifest — no build step, no runtime, no dependencies. Combined with a conversational prompting approach to iterating on prompts and letting the AI handle the SKILL.md format for you, it becomes very quick to go from an idea to a published plugin.
I’ve found the combination of structured prompts and gated question sequences produces consistently reliable outputs, and the marketplace automation means I can publish updates without any manual steps. I hope this has been useful and encourages you to experiment with creating your own plugins. As always, feel free to share your experiences or questions below.
]]>I’ve been using the Microsoft Graph SDK in various projects for a while now and recently noticed Release Please being used to automate its releases. Preparing releases and ensuring they’re accurate can be time-consuming, especially when commit messages follow different styles. Automating the release process and ensuring consistency is something I’ve wanted to explore for a while.
Release Please is a tool that automates the release process by generating changelogs, creating GitHub releases, and managing version numbers based on commit messages. In this post, I’ll introduce Release Please, explain how I’ve implemented it in my projects, and show how I’ve added validation to ensure pull request commits remain consistent and conform to Release Please conventions.
Release Please is a Google project that monitors the commits of a GitHub repository and automates:
To do this, Release Please looks for commit messages conforming to the Conventional Commits spec. This is a set of rules on how to structure messages to create an explicit git history and indicate the type of change in the project. The general structure of Conventional Commits is:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Often, just the top part of the message is needed. Generally, the most common types are fix: and feat:, corresponding to patch and minor version changes, respectively. In addition, suffixing a type with ! (e.g. feat!:) indicates a breaking change, which corresponds to a major version change.

Conventional Commits will then trigger Release Please to create a release Pull Request containing a summary of the changes made since the last release, along with calculating the next version number based on the types of changes made. As part of the release PR, a CHANGELOG.md will be created or updated, containing the same details as the PR description to create a single history of the changes.

Merging this PR then triggers a GitHub release to be created, along with the associated Git tag. So let’s have a look at how to set it up.
Before setting up Release Please, there are some prerequisites. First, it is recommended to use squash merges when merging PRs to maintain a linear Git history.

To ensure squash merges are enabled, go to General -> Pull Requests in the repo settings. Also, ensure that the default commit message is set to at least Pull request title so that the Conventional Commit can be set as the PR title, which we can validate later on.

Second, to allow Release Please to create and manage Release PRs, GitHub Actions need permission to create PRs. This can be enabled in the repo settings under Actions -> General -> Workflow permissions.
With the repo prepared, we can now configure Release Please. This involves creating two files: release-please-config.json (to configure Release Please) and .release-please-manifest.json (to track versions).
npm i release-please -g
Although these files can be created manually using samples (such as those from MS Graph), the release-please CLI makes this process easier. Let’s install the package.
$ghToken = "contents-pr-read-write-pat-token"
release-please bootstrap --token $ghToken `
--repo-url=username/repo-name `
--release-type=simple `
--bump-minor-pre-major=true `
--bump-patch-for-minor-pre-major=true
Once installed, we can then run the CLI. You’ll need a token to authorise the creation of the bootstrap PR. This could be from the GitHub CLI using gh auth token or a PAT token, which can be created under your GitHub account settings, with read/write access to Contents and Pull requests.
> Fetching .release-please-manifest.json from branch main
> Fetching release-please-config.json from branch main
√ Starting GitHub PR workflow...
√ Successfully found branch HEAD sha "6bf1cb2d415637b225b54cfb73a1f1bbb7a22567".
√ Successfully created branch at https://api.github.com/repos/milkyware/blog-release-please/git/refs/heads/release-please/bootstrap/default
√ Got the latest commit tree
√ Successfully created a tree with the desired changes with SHA 95f20137c7cdc3c31510182e16230a692180d94a
√ Successfully created commit. See commit at https://api.github.com/repos/milkyware/blog-release-please/git/commits/b1af4e0fbebd80fd67b0492beaec0f67518dd9b4
√ Updating reference heads/release-please/bootstrap/default to b1af4e0fbebd80fd67b0492beaec0f67518dd9b4
√ Successfully updated reference release-please/bootstrap/default to b1af4e0fbebd80fd67b0492beaec0f67518dd9b4
√ Successfully opened pull request available at url: https://api.github.com/repos/milkyware/blog-release-please/pulls/1.
√ Successfully opened pull request: 1.
{
headBranchName: 'release-please/bootstrap/default',
baseBranchName: 'main',
number: 1,
title: 'chore: bootstrap releases for path: .',
body: 'Configuring release-please for path: .',
files: [],
labels: []
}
The output of the command should look similar to above, with a pull request being created that initialises the config and manifest files.
{
"packages": {
".": {
"changelog-path": "CHANGELOG.md",
"release-type": "simple",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"draft": false,
"prerelease": false
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
The config file in the PR should look similar to above. Notice that the arguments specified in the command appear under the . object within packages. This is because Release Please supports both monorepo and polyrepo architectures, where . indicates that the component being versioned is the entire repo. In a monorepo setup, the --path argument can be used on the CLI to configure additional components in the same repo by specifying the path of the component, such as --path=lib/my-versioned-component. The JSON schema is also referenced to support intellisense when editing the config.
{
".": "0.0.0"
}
The manifest file should look similar to above. The key(s) should match those under packages in the config file (in our case, this is .). The version on the right tracks the current version for that component; typically, this should match the Git tags.
N.B. If the current version doesn’t have a corresponding tag, Release Please will fall back to 1.0.0. This is particularly important when working with 0.x.x versions, as without a tag matching the manifest, Release Please will jump to 1.0.0. If onboarding a repo without any tags, create the initial tag (e.g., 0.0.0) and update the manifest or use the
--initial-versionargument.
With the repo prepared and Release Please configured, we can now set up the automation. Release Please is available as a GitHub Action, so the automation just involves setting up a GitHub Workflow.
name: Release Please
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- uses: googleapis/release-please-action@v4
with:
token: $
This workflow is very similar to the one documented by the GitHub Action, with the additional inclusion of the actions/checkout@v6 step. This ensures that the config and manifest are available to the Release Please action.

As Conventional Commits are added to main via PRs, Release Please will automatically prepare the release PR like the one shown earlier. Once you’re happy with the release, you can merge it to trigger the actual GitHub release. However, how can you ensure that PRs do follow the Conventional Commits syntax?
As we set up the repo earlier to use squash merges and for PR commits to default to the PR title, we can validate that.
name: Validate PR Title
on:
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
jobs:
job:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Semantic PR Title Check
uses: amannn/action-semantic-pull-request@v6
id: semantic-pr
env:
GITHUB_TOKEN: $
There are a few GitHub Actions available that can validate a PR title against the Conventional Commit format. The one I’ve opted for is action-semantic-pull-request.

The basic setup for the action is really simple, but it has plenty of customisation available if needed. When the step runs, it will either complete successfully (indicating the title is valid) or throw an error similar to above.
- name: Label PR
if: always()
shell: pwsh
env:
GH_TOKEN: $
run: |
$InformationPreference = 'Continue'
if ($env:RUNNER_DEBUG)
{
$DebugPreference = 'Continue'
$VerbosePreference = 'Continue'
}
$label = "invalid-pr-title"
$prNumber = $
$errorMessage = @"
$
"@
Write-Verbose "errorMessage: $errorMessage"
if ([string]::IsNullOrEmpty($errorMessage))
{
gh pr edit $prNumber --remove-label $label --repo $ | Out-Null
Write-Information "PR title is valid. No label applied."
return
}
$labels = gh label list --repo $ --json name | ConvertFrom-Json | Select-Object -ExpandProperty name
$exists = $labels -contains $label
if (-not $exists)
{
gh label create $label --repo $ | Out-Null
}
gh pr edit $prNumber --add-label $label --repo $ | Out-Null
Write-Information "PR title is invalid. Applied label '$label'."
In addition, I’ve also added a custom script that will check the output of the title check step and add or remove an invalid-pr-title label to or from the PR, depending on whether the PR title is valid. This makes it clear from the PR overview screen if any PRs need attention.
I’ve prepared a small sample repo with my setup of Release Please.
In this post, we’ve explored how to automate releases using Release Please and integrate it into your workflow with GitHub Actions. By setting up Release Please, you can streamline the release process, reduce manual effort, and ensure consistency across your projects. We’ve looked at configuring the tool, preparing your repository, and automating releases with a simple workflow. I hope this guide has been helpful and encourages you to try out Release Please in your own projects. As always, feel free to share your experiences or questions in the comments below.
]]>Over the years, I’ve been involved with various software development projects. Once an application shifts into production, maintenance becomes essential—not just fixing bugs or shipping features, but ensuring the software remains secure, performant, and compatible with its evolving ecosystem.
One key aspect of maintenance is ensuring dependencies remain up-to-date. Outdated dependencies can miss bug fixes and improvements, and introduce security vulnerabilities. However, as applications grow or additional applications need support, the task of manually tracking and updating dependencies can become increasingly error-prone and time-consuming.
Automating dependency updates is therefore key to ensuring bug fixes, improvements, and security fixes are applied swiftly, while limiting the growth of manual involvement from developers. In this post, I’ll share how I set up Dependabot initially, before later adopting Renovate to handle my automation of updates.
GitHub is one of the largest and most popular source code platforms and comes with built-in support for automated dependency updates via Dependabot. Dependabot has support for numerous package ecosystems and works by scanning repositories for dependency updates, raising pull requests (PR) when updates are found.
Dependabot has two main features:
Let’s start by looking at security updates.
As mentioned, the security updates feature scans for vulnerable dependencies and raises PRs updating dependencies to a secure version.
You can enable the security updates feature in the Settings of a repository. In Security > Advanced Security, you’ll find the Dependabot section.

Enabling either Dependabot security updates or Grouped security updates will enable scanning the repo.
Any vulnerabilities that are found will be listed in the Security > Dependabot area of the repository, along with severity details and a brief description.

Notice that on the far right of these alerts is a reference to the same PR, #1. In this case, all of the alerts are addressed by the same PR. We can then review the details of the PR and merge it.

These PRs are subject to the same checks the repository has configured, such as peer reviews and automated CI builds, to ensure the code continues to work.
GitHub also offers the ability to enable this feature by default and configure it en masse for all repositories, either for a user or an organisation. This can be found in Settings > Code Security > Dependabot under the same two settings as in the repository: Dependabot security updates and Grouped security updates.

Enabling this feature by default will help ensure that all your future projects remain secure with no additional setup effort required.
While security updates are triggered by the discovery of vulnerabilities, version updates allow you to keep all your dependencies up-to-date as soon as new versions are released. With version updates enabled, Dependabot will automatically raise pull requests whenever it detects a new version of a dependency in your configured package ecosystems.
To enable this, you add a dependabot.yml file to your repository (typically under .github/dependabot.yml). Below is a sample configuration and a breakdown of its options:
version: 2
updates:
- package-ecosystem: nuget
directories:
- /apps/app1
- /apps/app2
schedule:
interval: daily
labels:
- nuget
ignore:
- dependency-name: FluentAssertions
versions:
- ">=8.0.0"
- package-ecosystem: terraform
directory: /terraform
schedule:
interval: daily
labels:
- terraform
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
labels:
- github-actions
Let’s review the key aspects:
package-ecosystem: Specifies the type of dependencies to monitor (e.g., nuget, npm, terraform, github-actions).directory or directories: The path(s) in your repo where the manifest or lock files are located. For some ecosystems, you can specify multiple directories.schedule: Controls how often Dependabot checks for updates. Common intervals are daily, weekly, or monthly.labels: Adds custom labels to PRs for easier filtering and triage.ignore: Lets you exclude specific dependencies or versions from automatic updates. In the example above, updates for FluentAssertions version 8.0.0 and above are ignored.This configuration allows you to tailor Dependabot to your team’s workflow. For example, you might want daily updates for critical dependencies, but only weekly updates for less critical ones. You can also combine security and version updates for comprehensive coverage.
For further reading, GitHub provides a sample repo for setting up both security and version updates.
So far, all of the PRs we’ve discussed for updating dependencies have required manual review and merging. While researching some GitHub repositories, I came across a GitHub Workflow that automatically marks non-major Dependabot update PRs as auto-merged.
name: Auto-merge dependabot updates
on:
pull_request:
branches: [ main ]
permissions:
pull-requests: write
contents: write
jobs:
dependabot-merge:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/[email protected]
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for Dependabot PRs
# Only if version bump is not a major version change
if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}}
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
The workflow is triggered by new PRs to main, checking that the PR has been raised by Dependabot. If raised by Dependabot, the metadata is retrieved to determine whether the package is a major change. Lastly, if not a major update, the GitHub CLI is used to set the PR to auto-merge (once all checks have passed) as well as to squash merge.
During a recent conversation with a colleague, I was introduced to Renovate as an alternative to Dependabot. Renovate is an open-source and highly customisable automated dependency update tool.
For the purposes of this post, I’m going to focus on configuring and running Renovate in GitHub, hosted by Mend.io. However, Renovate is multi-platform, including the ability to self-host.
Like GitHub’s Dependabot, Renovate also provide a great demo repo detailing:
By the end of the tutorial, you should end up with a renovate.json file similar to the one below:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
],
"prHourlyLimit": 3,
"packageRules": [
{
"matchUpdateTypes": ["major"],
"dependencyDashboardApproval": true
}
]
}
The renovate.json file is the equivalent of dependabot.yml and we can see that with very minimal configuration we can automate dependency updates.

In fact, in a sample repo with the basic config supplied by the initial Renovate onboarding PR, we can see update PRs raised for my out-of-date Bicep, Terraform, GitHub Actions and NuGet dependencies.
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"labels": [
"dependencies",
"",
""
],
"vulnerabilityAlerts": {
"addLabels": ["security"]
},
"packageRules": [
{
"groupName": "FluentAssertions Ignore",
"matchManagers": ["nuget"],
"matchPackageNames": ["FluentAssertions"],
"allowedVersions": "<8.0.0"
}
]
}
To replicate the Dependabot configuration we discussed earlier, I expanded the default configuration as shown above.
In this configuration:
config:base presets have been replaced with config:recommendedsecurityFluentAssertions v8 packages have been ignoredOnce again, with minimal additional configuration, we’ve been able to add significant value to the dependency updates raised.
I covered the auto-merging of Dependabot updates as a bonus, since it’s currently not natively supported. However, with Renovate, this functionality is natively available.
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":automergeMinor",
"security:openssf-scorecard"
],
"labels": [
"dependencies",
"",
""
],
"automergeStrategy": "squash",
"minimumReleaseAge": "14 days",
"separateMajorMinor": true,
"vulnerabilityAlerts": {
"addLabels": ["security"]
},
"packageRules": [
{
"groupName": "Alternate Release Age",
"matchPackageNames": ["some-fast-package"],
"minimumReleaseAge": "2 days"
}
]
}
Expanding on the Renovate configuration covered in the previous section, I’ve added a few extra settings:
:automergeMinor has been enabled to allow minor and patch updates to be auto-merged (once all checks pass)automergeStrategy has been set to squash as this is my preferred merge styleminimumReleaseAge adds a check that an update is at least 14 days old as recommended by RenovateseparateMajorMinor has been enabled to ensure separate PRs are raised for major and minor updatespackageRule has been added to override the default minimumReleaseAge for fast updating packagessecurity:openssf-scorecard preset has been added to include the OpenSSF scoring for a package to indicate the security posture of the updated package.Let’s look at a sample PR using this revised configuration.

Using the same Azure.Identity dependency as an example, we can see a lot of the details Dependabot included, with the addition of the OpenSSF score, a list of CVEs being addressed (in the case of security updates) and labels categorising the PR. In the Configuration section, we can also see that Automerge is enabled as the update is classed as minor.
Automating dependency updates, especially when combined with automated checks and CI pipelines, can significantly accelerate development and improve security and is an essential part of modern software development. By staying on top of updates, teams ensure that bug fixes and security patches are applied promptly, reducing manual effort and allowing developers to focus on building new features rather than maintenance.
Dependabot is a great tool for getting started with automated updates, thanks to its seamless integration with GitHub and straightforward configuration. It provides robust support for security and version updates, making it ideal for teams already using GitHub as their source code platform.
For those seeking more flexibility, Renovate is a highly customisable alternative with support for multiple platforms, including self-hosting options. Renovate offers advanced configuration, powerful grouping and scheduling features, and native support for auto-merging updates, making it suitable for complex or multi-repository environments.
By leveraging these tools, you can keep your dependencies up-to-date, improve your project’s security posture, and free up developer time for innovation and delivery.
]]>In one of my earlier posts, I covered using ARM-TTK via an Azure DevOps extension to analyse and test my Bicep modules automatically as part of my pipelines.
Since then, Microsoft now offers a 1st party solution for Bicep linting which aligns more directly with Bicep and opens the possibility for the linting to be easily adapted to other CI/CD platforms.
My goal was to replace using the DevOps Extension with the Bicep linter. For this post, I want to share my approach. As a sneak peek, this project turned out to be an opportunity to use a library I’ve wanted to try for a while—Spectre.Console!
The VS Code Bicep extension offers great IntelliSense for developing Bicep templates, giving clear warnings in the code.

However, the addition of the az bicep lint command, allows verifying Bicep templates against the same rules used in the IDE. Running the command against the Bicep in the earlier screenshot produces the results below:
> az bicep lint --file main.bicep
WARNING: D:\Git\milkyware\azure-bicep\.tmp\storageaccount.bicep(22,35) : Warning outputs-should-not-contain-secrets: Outputs should not contain secrets. Found possible secret: function 'listKeys' [https://aka.ms/bicep/linter-diagnostics#outputs-should-not-contain-secrets]
The default console shows the same violation as before, but the format doesn’t help from an automation perspective. To deal with that, there is the --diagnostics-format which currently supports Static Analysis Results Interchange Format (SARIF).
> az bicep lint --file main.bicep --diagnostics-format sarif
{
"$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.6.json",
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "bicep"
}
},
"results": [
{
"ruleId": "outputs-should-not-contain-secrets",
"message": {
"text": "Outputs should not contain secrets. Found possible secret: function 'listKeys' [https://aka.ms/bicep/linter-diagnostics#outputs-should-not-contain-secrets]"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "file:///D:/Git/.tmp/main.bicep"
},
"region": {
"startLine": 22,
"charOffset": 35
}
}
}
]
}
],
"columnKind": "utf16CodeUnits"
}
]
}
SARIF is a widely used, standardized format for sharing static code analysis results. Let’s take a look at how we can integrate that with a pipeline.
Previously, I’ve been using the Sam-Cogan.ARMTTKExtensionXPlatform DevOps Extension, which is a wrapper around ARM-TTK. This extension performs the code analysis and then outputs the results as NUnit 2, which can then be published to DevOps. When the results are published, any failures will cause the pipeline to fail, raising awareness of violations.
However, currently, the PublishTestResults@2 task doesn’t support publishing SARIF results, so the next step is to reformat the results.
In a few blog posts, I’d seen suggestions of using the sarif-junit npm package to convert the SARIF to JUnit, which is supported by the PublishTestResults@2 task. However, I found that the resulting format wasn’t quite what I wanted, so I decided to develop my own converter.
To do that, I decided this was a good opportunity to try out Spectre.Console. As a brief introduction, Spectre.Console is a library for building rich command-line apps with support for rendering ANSI widgets for displaying data.
var sarif = SarifLog.Load(@"path\to\file.sarif")
Microsoft provide a SARIF Nuget package for parsing and interacting with results data.
public interface ISarifConverter
{
public FormatType FormatType { get; }
Task<string> ConvertAsync(SarifLog sarif);
}
public class JUnitConverter(ILogger<JUnitConverter> logger) : ISarifConverter
{
private readonly ILogger<JUnitConverter> _logger = logger;
public FormatType FormatType => FormatType.JUnit;
public async Task<string> ConvertAsync(SarifLog sarif)
{
_logger.LogInformation("Converting SARIF to JUnit");
// Building JUnit XML according to schema
return xml;
}
}
I started by creating a simple converter interface and service that would use the SarifLog object to build the JUnit XML.
public enum FormatType
{
JUnit,
NUnit
}
public class ConvertSarifSettings : LoggingSettings
{
[CommandOption("-f|--format")]
[Description("Format to convert SARIF to. Allowed values: JUnit, NUnit")]
public FormatType FormatType { get; set; } = FormatType.JUnit;
[CommandOption("-i|--input-file")]
[Description("Path to the input SARIF file")]
public string? InputFile { get; set; }
[CommandOption("-o|--output-file")]
[Description("Path to output the converted file to. Outputs to stdout if not specified")]
public string? OutputFile { get; set; }
}
I then created a settings class to provide arguments which could be passed into my tool. Spectre.Console offers support for using attributes to indicate the corresponding command argument and documentation.
public class ConvertSarifCommand(ILogger<ConvertSarifCommand> logger, IAnsiConsole ansiConsole, IEnumerable<ISarifConverter> converters) : AsyncCommand<ConvertSarifSettings>
{
private readonly IAnsiConsole _ansiConsole = ansiConsole;
private readonly IEnumerable<ISarifConverter> _converters = converters;
private readonly ILogger<ConvertSarifCommand> _logger = logger;
public override async Task<int> ExecuteAsync(CommandContext context, ConvertSarifSettings settings)
{
var sarif = SarifLog.Load(settings.InputFile);
var converter = _converters.FirstOrDefault(c => c.FormatType == settings.FormatType);
if (converter == null)
{
_logger.LogError("Unsupported output type");
return 1;
}
var xml = await converter.ConvertAsync(sarif);
if (string.IsNullOrEmpty(settings.OutputFile))
{
_ansiConsole.Write(xml);
return 0;
}
var directory = Path.GetDirectoryName(settings.OutputFile);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllTextAsync(settings.OutputFile, xml);
return 0;
}
}
I then implemented an AsyncCommand<T> in my ConvertSarifCommand, using the values from the settings argument to control where to read and write files.
N.B. Notice that dependencies are injected into the command, ready for Dependency Injection (DI)
var services = new ServiceCollection();
// Register DI services
var registrar = new TypeRegistrar(services);
var app = new CommandApp<ConvertSarifCommand>(registrar);
app.Configure(configure =>
{
configure.SetApplicationName("milkyware-sarif-converter");
configure.UseAssemblyInformationalVersion();
configure.AddExample("-i", @"./test.sarif.json", "-f", "JUnit")
.AddExample("-i", @"./test.sarif.json", "-o", "./test.xml", "-f", "JUnit");
#if DEBUG
configure.PropagateExceptions();
configure.ValidateExamples();
#endif
});
app.Run(args);
Lastly, I set up the CommandApp to bring all of these components together. The CommandApp is similar to the HostBuilder in ASP.NET Core, allowing you to register services and dependencies before configuring the app. Spectre.Console provides some fantastic documentation for setting this up, so I won’t repeat it here.
To make this tool available to use in my pipelines, I’ve published it to NuGet as a dotnet tool.
dotnet tool install milkyware-sarif-converter -g
For more details on the tool, please refer to my repo.
Next, let’s integrate my new tool with my existing pipeline template.
To start getting access to the tool in my DevOps pipeline, I need to setup the .NET SDK in my job.
- task: UseDotNet@2
displayName: Install .Net Core
This makes the .NET CLI available, including the dotnet tool install command demonstrated earlier, which installs the milkyware-sarif-converter tool.
- task: PowerShell@2
displayName: Install SARIF Converter
inputs:
pwsh: true
targetType: inline
script: |
dotnet tool install -g milkyware-sarif-converter
With the converter tool installed, we can now use it alongside az bicep lint in an AzureCLI@2 task to lint a bicep file in a pipeline and output JUnit results to be published as test results.
Below is an abbreviated version of the AzureCLI@2 task linting and then converting to JUnit for publishing:
- task: AzureCLI@2
displayName: Scan Bicep
inputs:
azureSubscription: ${{parameters.azureSubscription}}
visibleAzLogin: false
useGlobalConfig: true
scriptType: pscore
scriptLocation: inlineScript
inlineScript: |
$InformationPreference = 'Continue'
if ($env:SYSTEM_DEBUG)
{
$DebugPreference = 'Continue'
$VerbosePreference = 'Continue'
}
# Workaround to fix AzureCLI task installing Bicep in wrong location
Write-Debug "Installing Bicep CLI"
az config set bicep.use_binary_from_path=false
az bicep install
$resultsDir = "${{variables.resultsDir}}"
$tempPath = $null
try {
$tempPath = [System.IO.Path]::GetTempFileName()
az bicep lint --file "${{parameters.bicepPath}}" --diagnostics-format sarif | Out-File -Path $tempPath -Encoding utf8
$resultsFile = "$($bicepFile.BaseName).junit.xml"
$resultsPath = Join-Path -Path $resultsDir -ChildPath $resultsFile
Write-Verbose "resultsPath=$resultsPath"
Write-Debug "Converting to JUnit"
milkyware-sarif-converter -i $tempPath -o $resultsPath
Write-Verbose (Get-Content -Path $resultsPath -Raw)
}
finally {
Remove-Item -Path $tempPath -ErrorAction Ignore
}
The PowerShell can be expanded to add support for linting multiple Bicep files in a directory to make the pipeline more flexible.
With the dotnet tool in place and the pipeline template update, we can now test a pipeline run. An example of the resulting JUnit result is below.
<testsuites tests="1" failures="1">
<testsuite>
<testcase name="Resource type "Microsoft.Storage/storageAccounts/tableServices/tables@2025-01-01" does not have types available. Bicep is unable to validate resource properties prior to deployment, but this will not block the resource from being deployed. [https://aka.ms/bicep/core-diagnostics#BCP081] - storageaccount.bicep:129:31" classname="BCP081">
<failure type="AssertionError" />
</testcase>
</testsuite>
</testsuites>
These results appear in the Azure Pipeline Tests tab in a familiar format, as shown below.

Adopting the az bicep lint command and integrating SARIF-based analysis into your CI/CD pipelines provides a modern, automated approach to validating Bicep templates. By converting SARIF output to JUnit format, you can leverage familiar Azure DevOps test reporting and ensure that issues are surfaced early in your deployment process.
This workflow not only streamlines template validation but also makes it easier to maintain code quality and compliance across teams. The use of Spectre.Console and a custom converter offers flexibility and extensibility for future enhancements.
]]>App Insights integration with ASP.NET Core has been a common feature of many applications since its release in 2016. However, with the rise of the open-standard OpenTelemetry and its wide adoption by multiple platforms and monitoring tools, Microsoft has been working on adopting OpenTelemetry as the telemetry middleware for ASP.NET Core.
As part of Microsoft recently starting to caution against using the legacy App Insights integration, I’ve started migrating my projects to use the OpenTelemetry integration. For this post, I want to share how I’ve gone about the migration as well as a couple of obstacles I encountered.
To set the scene, let’s have a quick look at how the legacy App Insights integration is set up and how it looks. Firstly, the App Insights Nuget package needs to be installed:
dotnet add package Microsoft.ApplicationInsights.AspNetCore
Once installed, the .AddApplicationInsightsTelemetry() extension is then added to register the logging provider:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplicationInsightsTelemetry();
// Add other services
var app = builder.Build();
await app.RunAsync();
Lastly, the App Insights connection string needs to be configured by configuring APPLICATIONINSIGHTS_CONNECTION_STRING.

Logging events using ILogger will then create logs in App Insights similar to above. The key thing to note is the inclusion of various values under customDimensions such as RequestPath and CategoryName which can help filter and group logs without needing to deconstruct a log message.
OpenTelemetry is an open-source observability framework which has been widely adopted by the industry. The framework is intended to function as a middleware in generating, collecting and exporting telemetry from solution components (including software and platform components) to observability tools.

So why use OpenTelemetry? Just as cloud computing has resulted in more decoupled and microservice-style solutions, OpenTelemetry addresses these needs through its open standard. This open standard therefore allows decoupling the generation and collection of telemetry from exporting that telemetry. The OpenTelemetry docs are incredibly detailed if you want to dig deeper.
So how can we migrate to using OpenTelemetry to integrate with App Insights? Microsoft do offer a guide for migration but lets start by summarising the removal steps:
Microsoft.ApplicationInsights.AspNetCore package from projects.builder.Services.AddApplicationInsightsTelemetry() integrationWith the legacy App Insights integration removed, we can now start to add the OpenTelemetry integration. Firstly, the ASP.NET Core OpenTelemetry packages need to be installed:
dotnet add package Azure.Monitor.OpenTelemetry.AspNetCore
The basic OpenTelemetry and App Insights can then be enabled by adding builder.Services.AddOpenTelemetry().UseAzureMonitor()
var builder = WebApplication.CreateBuilder(args);
var otelBuilder = builder.Services.AddOpenTelemetry();
if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
otelBuilder.UseAzureMonitor();
// Add other services
var app = builder.Build();
await app.RunAsync();
Above is a basic example of enabling the OpenTelemetry integration. However, the first difference with the legacy App Insights integration is that the connection string is now mandatory either by reusing the APPLICATIONINSIGHTS_CONNECTION_STRING environment variable or configuring in an options delegate in .UseAzureMonitor(). To maintain the previous optional configuration (for scenarios such as running locally where App Insights isn’t needed) we can check the environment variable is set before configuring the builder returned by .AddOpenTelemetry().
Let’s have a look at the logs this integration produces:

So, we can see the basic logged message and the values of the formatted message, however, the custom dimensions contain a lot less detail. So how can we get these details back?
One of the great features of the .NET Core ILogger is its scoping capability allowing contextual values to be attached to all logged events in a scope. However, after digging around in the Azure.Monitor.OpenTelemetry.AspNetCore packge I found that including the scoped values weren’t enabled by default:
var otelBuilder = services.AddOpenTelemetry()
.WithLogging(configureBuilder => { }, configureOptions =>
{
configureOptions.IncludeScopes = true;
configureOptions.IncludeFormattedMessage = true;
configureOptions.ParseStateValues = true;
})
There are a few ways to configure enabling including scopes, I’ve opted for the approach above of using the .WithLogging() extension on the OpenTelemetryBuilder to keep the configuration all under the IServiceCollection extension. Below, we can now see more customDimensions data included.

Two values that are still missing are CategoryName and OriginalFormat which I find can be useful for filtering logs specific to the namespace of your application and looking for logs using the message straight out of your code.
These values are available as properties on the OpenTelemetry LogRecord model, however, the Azure Monitor exporter populates the customDimensions using the LogRecord.Attributes property.
public class LogEnrichmentProcessor : BaseProcessor<LogRecord>
{
private const string CategoryNameKey = nameof(LogRecord.CategoryName);
private const string LogLevelKey = nameof(LogRecord.LogLevel);
private const string OriginalFormatKey = "OriginalFormat";
public override void OnEnd(LogRecord data)
{
var attributes = data.Attributes is not null
? new List<KeyValuePair<string, object?>>(data.Attributes)
: [];
if (data.Attributes is not null && data.Attributes.Any())
attributes.AddRange(data.Attributes);
if (!attributes.Any(a => a.Key == LogLevelKey))
attributes.Add(new(LogLevelKey, data.LogLevel.ToString()));
if (!attributes.Any(a => a.Key == CategoryNameKey) && !string.IsNullOrWhiteSpace(data.CategoryName))
attributes.Add(new(CategoryNameKey, data.CategoryName));
if (!attributes.Any(a => a.Key == OriginalFormatKey) && !string.IsNullOrWhiteSpace(data.Body))
attributes.Add(new(OriginalFormatKey, data.Body));
data.Attributes = attributes;
base.OnEnd(data);
}
}
OpenTelemetry offers the ability to enrich telemetry through processors. The documentation and samples for creating processors for LogRecord can be found here. Above is the processor I put together to enrich logs with LogLevel, CategoryName and OriginalFormat.
var otelBuilder = services.AddOpenTelemetry()
.WithLogging(configureBuilder =>
{
configureBuilder.AddProcessor<LogEnrichmentProcessor>();
}, configureOptions =>
{
configureOptions.IncludeScopes = true;
configureOptions.IncludeFormattedMessage = true;
configureOptions.ParseStateValues = true;
});
The processor can then be registered with our existing .WithLogging() setup.

The logs created in Azure Monitor should now look similar to above with the LogLevel, CategoryName and OriginalFormat included.
As always, the samples in this post are taken from the sample project I’ve prepared.
With the direction of travel from Microsoft being to adopt OpenTelemetry, I’ve wanted to share how I’ve migrated my projects to use OpenTelemetry. As a framework, OpenTelemetry is a fantastic tool that offers decoupling and flexibility such as swapping out exporters and, although not covered in this post, supports distributed tracing for developing observability in solutions using microservice components.
I’ve also highlighted some of the differences in functionality between the legacy App Insights integration and the default OpenTelemetry setup, but this can be configured to a similar level and retain support for any existing Azure Monitor KQL queries we may be using. I hope you find this useful and please feel free to try it out.
]]>Modern applications are often complex, integrating and depending on multiple backend components including databases, APIs and cloud infrastructure. Ensuring these applications remain reliable and available is vital especially as outages of any of these underlying components can impact the availability and performance of applications.
There are many tools available that all contribute to monitoring the health of an application such as logging practices and platform/infrastructure diagnostics. However, the tool I want to focus on for this post is the ASP.NET Core Health Checks framework.
Health probes are a common feature of container orchestrators and cloud PaaS where a simple endpoint is polled with the response used to indicate the health of an application. Typically HTTP 2XX codes indicate healthy with any other code indicating unhealthy.
The Health Checks library is an optional middleware service provided by ASP.NET to allow defining a pool of health checks which are exposed via an endpoint to integrate with health probes. The component approach of the library offers a standardised way to create granular health checks to:
Getting the basic health check service setup is as simple as a Nuget package and registering a couple of components in the Program.cs. The package can be installed with the command below:
dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks
Once the package is installed, the Program.cs needs to be updated like below:
using AspNetCoreHealthChecks;
using Microsoft.Extensions.Azure;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
await app.RunAsync();
The key additions are builder.Services.AddHealthChecks() and app.MapHealthChecks("/healthz") which register the core middleware and an endpoint to evaluate the health of the middleware respectively. With the basic functionality registered, we can now call the endpoint:
GET /healthz HTTP/1.1
Host: localhost:7176
HTTP/1.1 200 OK
Content-Type: text/plain
Healthy
The response should look similar to the above. As, by default, no health checks have been registered the response currently should always be Healthy with a 200 OK.
To start adding value to the health checks, we need to start developing components. To do this we use the IHealthCheck interface:
public class FakerHealthCheck(ILogger<FakerHealthCheck> logger, IFakerClient client) : IHealthCheck
{
private readonly IFakerClient _client = client;
private readonly ILogger<FakerHealthCheck> _logger = logger;
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
await _client.AddressesAsync();
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
_logger.LogError(ex, "FakerHealthCheck failed");
return HealthCheckResult.Unhealthy();
}
}
}
The interface requires the CheckHealthAsync() method to be implemented, returning a HealthCheckResult. The result can be Healthy, Degraded or Unhealthy. The component above checks that an API client can retrieve data, notice that the client and logger are injected using typical Dependency Injection practices meaning we can inject any of our services into health check components.
builder.Services.AddHealthChecks()
.AddCheck<FakerHealthCheck>("faker");
The registration of the custom health check can be done like above. The AddHealthChecks() extension uses the fluent builder pattern to register health checks with DI and the health checks middleware.
All of the samples in this post are included in the below sample project.
By default, the Health Checks framework is a bit of a blank canvas. However, a special mention is needed for the Xabaril/AspNetCore.Diagnostics.HealthChecks project.
The project contains a huge collection of ready-made community health check components! I’ve used these components in numerous projects as they make configuring a robust health check incredibly easy.
builder.Services.AddHealthChecks()
.AddSqlServer(Configuration["Data:ConnectionStrings:Sql"])
.AddRedis(Configuration["Data:ConnectionStrings:Redis"]);
The project also offers an extra UI component to visualise a history of health check events.
The Health Checks framework is a simple yet powerful way to monitor the health of your applications detect failures in dependencies quickly. The health probe endpoint can then integrate with various hosting environments, including cloud and container environments, to automate scaling and self-healing.
The framework can also easily be extended with custom health checks to monitor your own components and dependencies, but there are also community projects such as Xabaril/AspNetCore.Diagnostics.HealthChecks to help set up robust monitoring. Whether for a single app or a microservices architecture, health checks are essential for ensuring reliability and resilience.
]]>As development teams grow and projects become more complex, automating processes becomes critical to maintaining productivity and code quality. I’m a big fan of using Azure Pipelines to automate builds, testing, and general code quality. However, recently, I’ve started exploring GitHub Workflows to automate managing pull requests (PR) to make it easier to identify what they relate to.
In this post, I want to share my experience setting up some initial workflows to handle tagging PRs. Whether you’re new to GitHub Actions or looking for ideas to enhance your current CI/CD pipeline, this guide offers practical insights and tips to help you get started.
The majority of my CI/CD pipeline experience has centred around Azure DevOps and Azure Pipeline which I’ve used extensively for CI/CD pipelines in many projects. As I’ve started to use GitHub more, I’ve wanted to experiment with the GitHub CLI to easily integrate with the GitHub API to automate processes.
gh pr view 1 --repo owner/scratchpad
As the example the above command will retrieve and display details about PR #1 in the specified repo.
gh pr view 1 --repo owner/scratchpad --json id,title,body
In addition, the --json argument can be added to many commands to return a JSON object, allowing programmatic handling of the responses, such as using ConvertFrom-Json in PowerShell.
The first workflow I wanted to create was to automate assigning a configurable list of reviewers to PRs.
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$PRNumber,
[Parameter(Mandatory = $true)]
[string[]]$Reviewers
)
begin
{
$InformationPreference = 'Continue'
if ($env:RUNNER_DEBUG)
{
$DebugPreference = 'Continue'
$VerbosePreference = 'Continue'
}
}
process
{
Write-Debug "prNumber=$PRNumber"
Write-Debug "reviewers=$($reviewers | ConvertTo-Json -Compress)"
Write-Debug "Getting PR"
$pr = gh pr view $prNumber --json author | ConvertFrom-Json
$author = $pr.author.login
Write-Verbose "author=$author"
Write-Debug "Filtering out author"
$reviewers = $reviewers | Where-Object { $_ -ine $author }
$reviewersStr = $reviewers | ConvertTo-Json -Compress
Write-Verbose "reviewersStr=$reviewersStr"
Write-Debug "Getting current repo"
$repo = gh repo view --json name,owner | ConvertFrom-Json
foreach ($r in $reviewers)
{
Write-Debug "Assigning reviewer $r"
gh api --method POST `
-H "Accept: application/vnd.github+json" `
-H "X-GitHub-Api-Version: 2022-11-28" `
/repos/$($repo.owner.login)/$($repo.name)/pulls/$prNumber/requested_reviewers `
-f "reviewers[]=$r" `
--silent
}
Write-Information "Assigned reviewers"
}
The scripts takes parameters for a PR number and GitHub usernames to be assigned as reviewers. Using the GitHub CLI, the author of the repo is evaluated and removed from the list of reviewers with the remaining reviewers added using the gh api command.
N.B. The gh api command is currently used to add the reviewers due to a bug in the handling of workflow permissions.
name: Assign Reviewers
on:
pull_request:
types:
- opened
- ready_for_review
- reopened
jobs:
assign-reviewers:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Print Environment Variables
shell: pwsh
run: |
Get-ChildItem Env: | ForEach-Object {Write-Host "$($_.Name)=$($_.Value)"}
- name: Assign Reviewers
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
./scripts/AssignReviewers.ps1 `
-PRNumber "${{ github.event.number }}" `
-Reviewers "github-user1", "github-user2"
The above workflow is triggered when a pull request is opened (including re-opened and published after drafting) and executes the script passing in values for PRNumber and Reviewers. To authenticate the CLI used in the script, the GH_TOKEN environment variable is set using the $ workflow variable.
Adding the reviewers automatically results in greater awareness by triggering notifications and greater ownership of code reviews.
When dealing with a large number of PRs, it’s important to be able to quickly and easily identify what it relates to. To help with this I developed a workflow to start tagging PRs to categorise them.
<!-- Provide the release in the format vx.x.x.x -->
**Release:**
<!-- Other PR template details ...-->
Firstly I created a pull request template like above to capture certain details when the pull request is raised. This template is then placed at .github\pull_request_template.md.
[CmdletBinding()]
param (
[Parameter(Mandatory=$true)]
[string]$PRNumber,
[Parameter()]
[string[]]$SupportedBranchTypes = @("feature","hotfix","release")
)
begin {
$InformationPreference = 'Continue'
if ($env:RUNNER_DEBUG)
{
$DebugPreference = 'Continue'
$VerbosePreference = 'Continue'
}
}
process {
function LabelPR {
param (
[Parameter(Mandatory=$true)]
[string]$PRNumber,
[Parameter(Mandatory=$true)]
[string]$Label
)
process {
Write-Verbose "label=$Label"
Write-Debug "Getting labels"
$labels = gh label list --json name | ConvertFrom-Json | Select-Object -ExpandProperty name
Write-Debug "Check label exists"
$exists = $labels -contains $Label
if (-not $exists)
{
Write-Debug "Creating label"
gh label create $Label
}
Write-Information "Labelling PR with `"$Label`""
gh pr edit $PRNumber --add-label $Label | Out-Null
}
}
Write-Verbose "prNumber=$prNumber"
Write-Debug "Getting PR content"
$pr = gh pr view $prNumber --json id,body,state,baseRefName,headRefName,createdAt | ConvertFrom-Json
$regex = "(?<!<!--.*)(?<=\*{2}[Rr]elease:?\*{2}\s?)(v\d(\.\d){2,3})"
Write-Verbose "regex=$regex"
Write-Debug "Matching release number"
$match = $pr.body -match $regex
if ($match)
{
$releaseNumber = $Matches[0]
LabelPR -PRNumber $PRNumber -Label $releaseNumber
}
if ($SupportedBranchTypes)
{
Write-Debug "Splitting branch name"
$branchSplit = $pr.headRefName -split "/"
$branchType = $branchSplit[0].ToLower()
Write-Verbose "branchType=$branchType"
if ($branchType -in $SupportedBranchTypes)
{
LabelPR -PRNumber $PRNumber -Label $branchType
}
else
{
Write-Warning "Invalid branch type $branchType"
}
}
}
I’ve created a PowerShell script to process the pull request. There are a few key aspects to note:
LabelPR has been added to upsert labelsname: Label PR
on:
pull_request:
types:
- opened
- edited
- ready_for_review
- reopened
jobs:
job:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Print Environment Variables
shell: pwsh
run: |
Get-ChildItem Env: | ForEach-Object {Write-Host "$($_.Name)=$($_.Value)"}
- name: Label PR
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
./scripts/LabelPR.ps1 `
-PRNumber "${{ github.event.number }}"
Like the previous automation, the PowerShell script is attached to a workflow with pull request triggers.

The resulting tags can then be used to distinguish features from hotfixes and which requests belong to version X or version Y allowing easier prioritisation of PRs so that more urgent requests are reviewed first. This automation could be expanded further by adding more details to the pull request template and then extracting these as tags.
Automation is a powerful tool for reducing repetitive tasks and improving workflow efficiency, and GitHub Workflows has proven to be a versatile platform for managing pull requests. By automating processes like assigning reviewers and tagging PRs, you can streamline collaboration, enhance visibility, and prioritise tasks more effectively.
The workflows in this post are just the beginning. GitHub Actions and the GitHub CLI offers a wealth of possibilities to extend automation even further and building custom scripts tailored to your team’s needs and processes. Whether you’re new to GitHub automation or already familiar with it, experimenting with workflows like these can open up new opportunities to enhance your development process.
I’ve thoroughly enjoyed exploring GitHub Workflows and the CLI and plan to continue experimenting. I hope you’ve enjoyed this post and if you’ve tried something similar or have comments, please feel free to share below.
]]>Continuous Integration (CI) in monorepos often presents challenges as the codebase scales. Running all tests on every commit is inefficient, particularly for large repositories with multiple solutions and test projects, whereas defining a CI pipeline per solution is repetitive and doesn’t scale easily. To address this, I’ve implemented an Azure Pipeline that dynamically identifies and runs only the relevant test projects affected by code changes. This pipeline simplifies CI operations and scales efficiently as more test projects are added.
Below is the complete Azure Pipeline template. For the remainder of the post, I want to break down some of the pipeline’s key features and benefits.
parameters:
- name: comparisonBranch
displayName: Comparison Branch. The branch to compare against to evaluate changes. Defaults to main
type: string
default: main
- name: comparisonDepth
displayName: Comparison Depth. Compares the top x folders to evaluate test projects to rerun
type: number
default: 2
- name: buildConfiguration
displayName: Build Configuration
type: string
default: debug
- name: dotnetVersions
displayName: Array of .NET versions required to run the tests. This should include all possible versions
type: object
default:
- 8.0.x
- name: excludedTestProjects
displayName: Array of test projects to be excluded
type: object
default: []
steps:
- checkout: self
fetchDepth: 0
- template: PrintEnvironmentVariables.azure-pipelines.yml
- ${{each dv in parameters.dotnetVersions}}:
- task: UseDotNet@2
displayName: Install .NET ${{dv}}
inputs:
version: ${{dv}}
- task: PowerShell@2
displayName: Run affected tests
inputs:
pwsh: true
targetType: inline
informationPreference: continue
script: |
$InformationPreference = 'Continue'
if ($env:SYSTEM_DEBUG)
{
$DebugPreference = 'Continue'
$VerbosePreference = 'Continue'
}
$comparisonBranch = "${{parameters.comparisonBranch}}"
Write-Verbose "comparisonBranch=$comparisonBranch"
$currentDirPrefix = ".$([System.IO.Path]::DirectorySeparatorChar)"
Write-Verbose "currentDirPrefix=$currentDirPrefix"
Write-Debug "Getting changed files"
$fileChanges = git diff --name-only "origin/$comparisonBranch..." |
ForEach-Object {[System.IO.Path]::GetRelativePath(".", $_)}
Write-Information "Found $($fileChanges.Count) changed files"
Write-Verbose "fileChanges"
$fileChanges | Write-Verbose
$depthIndex = ${{parameters.comparisonDepth}} - 1
Write-Verbose "depthIndex=$depthIndex"
Write-Debug "Getting changed directories"
$dirChanges = $fileChanges | ForEach-Object {
$split = $_.Split([System.IO.Path]::DirectorySeparatorChar)
$dir = $split[0..$depthIndex] -join [System.IO.Path]::DirectorySeparatorChar
return $dir
} |
Select-Object -Unique
Write-Information "Found $($dirChanges.Count) changed directories"
Write-Verbose "dirChanges"
$dirChanges | Write-Verbose
$xpathIsTestProject = "/Project/PropertyGroup/IsTestProject/text()"
Write-Verbose "xpathIsTestProject=$xpathIsTestProject"
function IsTestProject ([string]$Path) {
$csproj = [xml](Get-Content -Path $Path)
$IsTestProject = $csproj.SelectSingleNode("/Project/PropertyGroup/IsTestProject/text()").Value
$HasTestSdk = $csproj.Project.ItemGroup.PackageReference.Include -icontains "Microsoft.NET.Test.Sdk"
$HasMSTest = $csproj.Project.ItemGroup.PackageReference.Include -icontains "Microsoft.NET.Test.Sdk"
$HasNUnit = $csproj.Project.ItemGroup.PackageReference.Include -icontains "NUnit"
$HasXUnit = $csproj.Project.ItemGroup.PackageReference.Include -icontains "XUnit"
return $IsTestProject -or $HasTestSdk -or $HasMSTest -or $HasNUnit -or $HasXUnit
}
Write-Debug "Getting all test projects"
$testProjects = Get-ChildItem -Include "*.csproj" -Recurse |
ForEach-Object {[System.IO.Path]::GetRelativePath(".", $_)} |
Where-Object {IsTestProject -Path $_}
Write-Verbose "allTestProjects"
$testProjects | Write-Verbose
function StartsWithAny ([string]$StringToCheck, [string[]] $Prefixes) {
foreach ($prefix in $Prefixes) {
if ($StringToCheck.StartsWith($prefix)) {
return $true
}
}
return $false
}
Write-Debug "Filtering test projects to changed directories"
$testProjects = $testProjects | Where-Object { StartsWithAny -StringToCheck $_ -Prefixes $dirChanges }
$excludedTestProjectsJson = '${{ convertToJson(parameters.excludedTestProjects) }}'
Write-Verbose "excludedTestProjectsJson=$excludedTestProjectsJson"
Write-Debug "Resolving excluded tests"
$excludedTestProjects = $excludedTestProjectsJson |
ConvertFrom-Json |
ForEach-Object {[System.IO.Path]::GetRelativePath(".", $_)} |
ForEach-Object {$_.Substring(2)}
Write-Verbose "excludedTestProjects"
$excludedTestProjects | Write-Verbose
Write-Debug "Removing excluded test projects"
$testProjects = $testProjects | Where-Object {$excludedTestProjects -notcontains $_}
Write-Verbose "testProjects"
$testProjects | Write-Verbose
Write-Information "Running $($testProjects.Count) test projects"
$testProjects | ForEach-Object {
Write-Information "Running tests in $_"
dotnet test $_ --configuration ${{parameters.buildConfiguration}} --collect "Code coverage" --logger trx --results-directory "$(Agent.TempDirectory)"
}
- task: PublishTestResults@2
displayName: Publish Test Results
inputs:
buildConfiguration: ${{parameters.buildConfiguration}}
testResultsFormat: VSTest
testResultsFiles: $(Agent.TempDirectory)/**/*.trx
Defining an Azure Pipeline which consumes the template would look similar to below. Note that the template is being referenced as a file, however, the template could just as easily be referenced from a remote repo as covered in this article.
name: $(Date:yy.MM.dd)$(Rev:.rr)
trigger:
batch: true
branches:
include:
- main
paths:
include:
- apps
extends:
template: templates/RunAffectedTests.azure-pipelines.yml
parameters:
dotnetVersions:
- 8.0.x
- 6.0.x
excludedTestProjects:
- apps/app2/tests/IntegrationTests/IntegrationTests.csproj
An example of the pipeline running has been included below. The logs of the pipeline detail which test projects have been run.

The test results are then uploaded to the Azure Pipeline for easy reviewing.
.
The pipeline identifies changes in a branch compared to a base branch (e.g., main) using the git diff command. It uses a configurable depth parameter to map changes to specific directories and locate affected test projects.

This is intended to work with a folder structure which adheres to convention across the monorepo. For example, with the default comparisonDepth parameter of 2 for the above folder structure, updating the file apps/app1/src/ConsoleApp1/Program.cs would run test projects under apps/app1. This approach ensures that only impacted tests are executed, saving test execution time and pipeline resources.
A number of parameters are available to customise the usage of the pipeline to fit different project structures and team requirements:
As the script is written entirely with PowerShell, additional script changes can be easily made.
The pipeline uses the following heuristics to identify test projects:
IsTestProject property is defined in the .csproj file.This logic ensures that only genuine test projects are included in the test run.
By specifying test projects to exclude, tests can be skipped that are irrelevant (such as scripted manual test or integration tests) to the current changes or temporarily disabled. This enhances control of what tests are run and reduces unnecessary processing.
The pipeline is capable of installing multiple .NET versions as specified in the dotnetVersions parameter, ensuring compatibility with various test projects in scenarios where a variety of .NET versions are used (e.g. during a migration to a new .NET version).
Test results are collected and published in the VSTest format, as is standard practice in CI pipelines, integrating seamlessly with Azure DevOps’ reporting tools.
As touched on already, there are a number of benefits:
This Azure Pipeline streamlines CI testing for monorepos by focusing only on what’s impacted, reducing unnecessary overhead, and keeping workflows efficient. It’s flexible, scalable, and integrates easily into existing processes, making it a powerful tool for managing complex repositories.
I hope this has been of use and give it a try. Happy coding!
]]>Logging is an essential part of software development that provides a record of events and activities within an application. It allows both developers and support teams to track an application’s behaviours, diagnose issues, monitor performance, and troubleshoot bugs. Detailed log data can give great visibility into how an application is running, which in turn makes it easier to maintain and improve. For this post, I want to share my personal approach to logging which I’ve found strikes a balance between having detailed logs without being unnecessarily chatty.
There are many reasons to add logging to your code, 4 key reasons are:
In C#, the ILogger interface is a standard feature of modern .NET applications. It enables flexible logging as well as seamless integration with dependency injection and is available in most project templates to start logging out-of-the-box. Here’s an example of how to inject ILogger and utilize different log levels in a service class:
using Microsoft.Extensions.Logging;
using System;
namespace LoggingExample
{
public class MyService
{
private readonly ILogger<MyService> _logger;
private readonly IThirdPartyLibrary _thirdPartyLibrary;
// Inject ILogger and other dependencies via constructor
public MyService(ILogger<MyService> logger, IThirdPartyLibrary thirdPartyLibrary)
{
_logger = logger;
_thirdPartyLibrary = thirdPartyLibrary;
}
// Example of using logging in a method
public void ProcessData()
{
// Log at the start of the method
_logger.LogInformation("ProcessData started.");
try
{
// Debug log before calling a third-party library
_logger.LogDebug("Calling third-party library method GetData.");
var data = _thirdPartyLibrary.GetData();
// Trace log to capture the value of 'data'
_logger.LogTrace("Data received from third-party library: {Data}", data);
// Process the data and log key variables
var processedData = ProcessReceivedData(data);
// Trace log to capture processed data state
_logger.LogTrace("Processed data: {ProcessedData}", processedData);
// Log at the end of the method
_logger.LogInformation("ProcessData completed successfully.");
}
catch (Exception ex)
{
// Error log with exception details
_logger.LogError(ex, "An error occurred in ProcessData.");
// Rethrow the exception to preserve the stack trace
throw;
}
}
private string ProcessReceivedData(object data)
{
// Example of processing the data (placeholder logic)
_logger.LogDebug("Processing received data.");
// Key variable (processedData) traced for debugging
string processedData = data.ToString() + "_processed";
_logger.LogTrace("Intermediate processed data: {ProcessedData}", processedData);
return processedData;
}
}
}
A few key features to highlight are:
public methodspublic methods as well as at the start of private methods. They are also used when calling out to external code, such as 3rd party libraries or APIsAs mentioned, there are several logging levels available by default, the levels available are:
Using a variety of log levels allows us to categorise different events into different levels of importance. When logs of all levels are presented together, we can see a detailed log of an application. These categories also allow us to dial up/down the log level and exclude less critical logs in different scenarios. For example, gathering logs of all levels would be particularly useful in a development setting, however, in production it may be better to limit the logs to information or warning to avoid overly chatty logs which could contain sensitive details or quickly balloon the storage for the logs.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"LoggingExample": "Trace",
// Other logging category log levels
}
},
// Other app settings
}
A typical ASP.NET Core application will often have a section in the appsettings.json similar to above. Rather than cover it here, Microsoft provide really detailed docs on how to configure logging. In short, configuring logging through appsettings or environment variables allows us to dial up and down logging.
Another feature of the default implementation of ILogger is structured logging. In the code example, you may have noticed logs such as
_logger.LogTrace("Processed data: {ProcessedData}", processedData);
Notice that rather than building a string or using string interpolation, a formatted string is provided as the message with a placeholder: {ProcessedData}. A value for the placeholder is then provided using the processedData variables in the params array after the message.

Above is a screenshot of the logs from the earlier example from Seq. Notice that the message contains the value of the processedData variable, but the placeholder is also stored separately.
select *
from stream
where ProcessedData = '13a2bc2e-e4a2-4d32-a331-c806e74b4a13_processed'
Structured logging allows us to query more easily based on the placeholders’ values. If multiple log events contain the same context property and value, they would all be returned together, further helping to analyse timelines and relationships in the logs.
N.B. Seq is a self-hosted structured logging server. Many other logging services also support this feature, including Azure App Insights and AWS CloudWatch.
One feature I’ve been making greater use of recently is logging scopes with ILogger. A scope enables logged events within a block to inherit shared context, making it easier to follow related events in the logs. This is particularly useful in tracing the flow of data through nested methods.
public void ProcessOrder(int orderId)
{
// Create a logging scope with contextual information
using (_logger.BeginScope(new Dictionary<string, object>
{
{ "OrderId", orderId }
}))
{
_logger.LogInformation("Starting order processing.");
try
{
// Example of scoped logging context
_logger.LogDebug("Loading order details.");
var orderDetails = LoadOrder(orderId);
_logger.LogDebug("Order details loaded: {OrderDetails}", orderDetails);
// Pass the order to fulfillment
FulfillOrder(orderDetails);
_logger.LogInformation("Order processing completed successfully.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while processing order {OrderId}", orderId);
throw;
}
}
}
With BeginScope, any log statements within the ProcessOrder method (and within methods it calls) will include OrderId in the context, regardless of if the placeholder is in the message.

Looking at the logs in Seq again, we can see the OrderId value in all of the events. This allows us to trace logs related to this specific order-processing session. This is especially useful in multi-threaded or high-volume environments where tracking specific workflows is critical.
Over the years I’ve discussed and shared approaches to logging with various developers which I’ve found greatly contributes to a consistent logging experience amongst a team and throughout an application which in turn improves monitoring and analytics. For this post, I’ve wanted to share the approach I typically take and a few features of logging in .NET to help get the most out of logging.
For this we’ve looked at:
I hope this has been useful and thanks for reading.
]]>Having developed on BizTalk Server for many years, I’ve used the Business Rules Engine (BRE) in numerous solutions via both orchestration shapes and the BREPipelineFramework.

The BRE offers fantastic flexibility in configuring basic business logic instead of redeploying the whole application. The configuration can then be quickly and easily deployed and/or rolled back.
However, with migrating away from BizTalk, I’ve needed to look at replacing the BRE. I opted for the Microsoft Rules Engine, and for this post, I want to share my experience with it. For disclosure, Microsoft offers some great documentation in both their wiki and GitHub Pages site.
N.B. Microsoft has made a direct port of the BRE to the Azure Logic Apps Rules Engine. Although this is more of a direct migration path, it’s tightly coupled to Logic Apps, retains the need for XML and is still in preview so I opted against this option.
The Microsoft Rules Engine is an open-source library nuget package for abstracting business rules and logic in a similar way to what is available in the BizTalk BRE. The key features of the library are:
With being a Nuget package, the installation is as simple as adding a package to your application/project like below:
dotnet add package RulesEngine
Being an open-source library, this offers great flexibility in how the rules engine can be used and hosted i.e. it can be used in any .NET app which supports .NET Standard 2.0
The rules engine revolves around the definition of workflows to represent business rules and logic. The rules engine and workflows offer many great features and extensibility, however, I’m going to focus on a few key aspects:
Before getting into defining workflows, we need to understand the expressions. Expressions are the foundation of the Microsoft Rules Engine to evaluate both rules and outputs. The core of the rules engine is the RuleExpressionParser.
var output = new RuleExpressionParser()
.Evaluate<string>("3 + input1", new RuleParameter[]
{
new RuleParameter("input1", 5)
}); // output equals 8
The RuleExpressionParser allows us to evaluate expressions outside of the rules engine. In the example above, the expression performs a simple addition with one value provided using a RuleParameter.
The syntax used by the expressions is Dynamic LINQ expressions which supports many of the same concepts and features of C# as it’s designed to be familiar to C# users.
[Fact]
public void SimpleParameterExpressionTest()
{
// Act
var actual = new RuleExpressionParser()
.Evaluate<string>("\"Hello \" + input1",
[
new RuleParameter("input1", "World")
]);
//Assert
actual.Should()
.Be("Hello World");
}
One use case I find the standalone RuleExpressionParser useful for is in unit testing expressions, particularly complex expressions, to help document expressions and highlight issues.
Now that we’ve looked at expressions, let’s look at how they’ve been used in workflows.
A workflow is a collection of rules which are executed against 1 or more inputs.
{
"WorkflowName": "SampleWorkflow",
"Rules": [
{
"RuleName": "GeneralGrevious",
"RuleExpressionType": "LambdaExpression",
"Expression": "input1 == \"Hello there\"",
"SuccessEvent": "General Kenobi"
},
{
"RuleName": "Droids",
"RuleExpressionType": "LambdaExpression",
"Expression": "input1 != \"Hello there\"",
"SuccessEvent": "These aren't the droids you're looking for"
}
]
}
The above workflow compares the input1 with a specified string in an expression. If the expression returns true, the associated SuccessEvent is raised. Notice that the string literals are escaped using \\"
var workflowJson = // workflow definition
var rulesEngine = new RulesEngine.RulesEngine([workflowJson]);
var results = await rulesEngine.ExecuteAllRulesAsync("SampleWorkflow", "Hello there");
var output = string.Empty;
results.OnSuccess(eventName => output = eventName); // Sets output to "General Kenobi"
The previous workflow can now be passed into the constructor of RulesEngine as an array to load the workflow(s). Under the hood, the JSON definition(s) is deserialized using Workflow instance(s). The rules engine is then executed with the name of the workflow specified and any inputs provided in a params array. The results are then evaluated and the .OnSuccess() delegate is used to extract the SuccessEvent.
In the previous workflow example we looked at defining a basic workflow with a success event which we can then access using the .OnSuccess() extension (the inverse be achieved with the .OnFail() extension).
{
"WorkflowName": "SampleWorkflow",
"Rules": [
{
"RuleName": "GeneralGrevious",
"RuleExpressionType": "LambdaExpression",
"Expression": "input1 == \"Hello there\"",
"Actions": {
"OnSuccess": {
"Name": "OutputExpression",
"Context": {
"Expression": "input1 + \" General Kenobi\""
}
},
}
}
// Additional rule definitions
]
}
However, the success/fail delegate extensions require the delegate to be defined in code. A more flexible alternative is to define actions for OnSuccess/OnFailure where we can use the inbuilt OutputExpression to define a LINQ expression, with access to the same inputs and C# tooling used in the defining rules, to execute on either success or failure. In the above example, if the input is Hello there the workflow returns a concatenated string.
One really useful feature of the rules engine is that the Workflow class also has an associated JSON schema.

In the above example of defining the SampleWorkflow, the $schema element is specified at the top of the file. In many code editors, including VS Code, this causes the editor to prompt with the intellisense of the members available/expected in the schema definition to help take the guesswork out of defining workflows.
In the examples so far, the workflow definitions have been passed directly to the RulesEngine constructor and are in turn executed. However, in a real-world scenario, these workflow definitions need to be persisted somewhere outside of the application.

Microsoft provides the above diagram to represent the recommended setup for using the RulesEngine. In essence, Microsoft provides the RulesEngine library, but we need to develop our own wrapper around the library as well as integration to the necessary rule stores.
public interface IRuleStore
{
Task<IEnumerable<Workflow>> GetWorkflowsAsync();
}
public class SampleRuleStore(/* Inject Dependencies */) : IRuleStore
{
public async Task<IEnumerable<Workflow>> GetWorkflowsAsync()
{
// Retrieve and deserialise workflows from storage
}
}
For the rule stores, I decided to create a simple interface to make use of dependency injection. Multiple implementations of IRuleStore can then be registered with DI e.g. builder.Services.AddTransient<IRuleStore, SampleRuleStore>().
public class RulesEngineWrapper
{
private readonly IEnumerable<IRuleStore> _ruleStores;
public RulesEngineWrapper(IEnumerable<IRuleStore> ruleStores /* Other dependencies */)
{
_ruleStores = ruleStores;
}
public async Task LoadWorkflowsAsync()
{
var workflows = new List<Workflow>();
foreach (var rs in _ruleStores)
{
workflows.AddRange(await rs.GetWorkflowsAsync())
}
// Load workflows into rules engine
}
// Other wrapper methods to execute rules
}
A collection of rule store implementations can then be injected (whether it be 1 or several), like in the sample wrapper service above, to allow greater flexibility in storing workflow definitions.
Another powerful feature of the Rules Engine is the ability to extend the workflows with custom actions.
public class OutputExpressionAction : ActionBase
{
private readonly RuleExpressionParser _ruleExpressionParser;
public OutputExpressionAction(RuleExpressionParser ruleExpressionParser)
{
_ruleExpressionParser = ruleExpressionParser;
}
public override ValueTask<object> Run(ActionContext context, RuleParameter[] ruleParameters)
{
var expression = context.GetContext<string>("expression");
return new ValueTask<object>(_ruleExpressionParser.Evaluate<object>(expression, ruleParameters));
}
}
The OutputExpression we used earlier is actually an inbuilt example of a custom action.
public class SampleAction : ActionBase
{
private const string ContextKeyName = "Name";
private readonly ILogger<SampleAction> _logger;
public SampleAction(ILogger<SampleAction> logger)
{
_logger = logger;
}
public override ValueTask<object> Run(ActionContext context, RuleParameter[] ruleParameters)
{
_logger.LogInformation("Executing sample action");
var name = context.GetContext<string>(ContextKeyName);
return new ValueTask<object>($"Hello {name}");
}
}
Defining a custom action is as simple as creating a new class and implementing the abstract class ActionBase. The above action looks for a value in the context called Name and uses it to format a string. Multiple context properties can be configured to act as arguments in more complex actions. Also, notice that ILogger is provided to the constructor, like with the rule stores, as we can combine this with dependency injection.
builder.Services.AddTransient<SampleAction>();
builder.Services.AddSingleton<Tuple<string, Func<ActionBase>>>(sp => new("SampleAction", () => sp.GetRequiredService<T>()));
builder.Services.AddTransient<IRulesEngine, RulesEngine>(sp => {
var customActions = sp.GetServices<Tuple<string, Func<ActionBase>>>();
var settings = new ReSettings()
{
CustomActions = customActions.ToDictionary(ca => ca.Item1, ca => ca.Item2)
};
var rulesEngine = new RulesEngine.RulesEngine(settings);
return rulesEngine;
})
To integrate the custom actions with dependency injection there is a little bit more involved, so let’s break down what the above is doing:
ReSettings.CustomActions in the engine{
"$schema": "https://raw.githubusercontent.com/microsoft/RulesEngine/main/schema/workflow-schema.json",
"WorkflowName": "SampleActionWorkflow",
"Rules": [
{
"RuleName": "Sample",
"RuleExpressionType": "LambdaExpression",
"Expression": "true",
"Actions": {
"OnSuccess": {
"Name": "SampleAction",
"Context": {
"Name": "Fred"
}
}
}
}
]
}
The custom action can then be referenced like above, notice that $.Rules[0].Actions.OnSuccess.Name element is set to SampleAction, the same as what is configured in the DI in the previous sample.
I’ve put together the below sample project to demonstrate the concepts and features we’ve discussed through this post.
The sample exposes the rules engine through a generic swagger documented WebAPI endpoint to make it easier to interact with.
N.B. Due to the generic post object body of the WebAPI endpoint, the request body is handled as a JsonNode which results in the expressions in the workflow using functions such as string() to handle typing correctly or indexers to access child members on a JSON object.
In addition, I’ve also added a basic builder pattern to improve the setup of the rules engine as well as some unit tests to demonstrate automating the testing of more complex expressions.
This has been a bit of a longer post and one I’ve been meaning to do for a while as, having used the BizTalk rules engine, having a configurable rules engine is an incredibly powerful tool to have at your disposal. In this post we’ve introduced some of the key features of the JSON-based Microsoft Rules Engine as well as how we can extend the library through rule stores and custom actions to create a solution comparable to the BizTalk Business Rules Engine. I hope you find this article useful and thanks for reading.
]]>