Jekyll2026-07-20T04:15:44+01:00https://milkyware.github.io/feed.xmlCloud AdventuresMy problems and solutions to cloud integration, containerisation and devopsCameron CowenCreating a GitHub Copilot CLI Plugin2026-06-01T00:00:00+01:002026-06-01T00:00:00+01:00https://milkyware.github.io/creating-a-github-copilot-cli-plugin<![CDATA[

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.

What Are Copilot CLI Plugins?

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.

The Anatomy of a Plugin

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

My Approach: Conversational Prompting

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:

  1. 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.

  2. 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.

  3. 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.

The Marketplace Manifest

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.

Keeping Versions in Sync

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:

  1. 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.

  2. 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]
    

Quick Start

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 Records
  • generate-hld — Guided workflow for generating High-Level Design documents
  • generate-architecture-md — Guided workflow for generating ARCHITECTURE.md documentation

Each skill will walk you through a structured question sequence and produce a markdown document ready for version control.

Sample Repo

All of the code and configuration covered in this post is available in the repository below.

milkyware/awesome-ai - GitHub

Wrapping Up

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.

]]>Cameron Cowen<![CDATA[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.]]>Adding Release Please2026-01-05T00:00:00+00:002026-01-05T00:00:00+00:00https://milkyware.github.io/adding-release-please<![CDATA[

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.

Introducing Release Please

Release Please is a Google project that monitors the commits of a GitHub repository and automates:

  • CHANGELOG generation
  • Creation of GitHub releases
  • Calculation of version numbers

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.

image1

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.

image2

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.

Preparing the Repo

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.

image3

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.

image4

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.

Configuring Release Please

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-version argument.

Automating Releases

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.

image1

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?

Validating PR Titles

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. image5

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.

Sample Repo

I’ve prepared a small sample repo with my setup of Release Please.

milkyware/blog-release-please - GitHub

Wrapping Up

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.

]]>
Cameron Cowen<![CDATA[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.]]>
Automating Dependency Updates2025-09-01T00:00:00+01:002025-09-01T00:00:00+01:00https://milkyware.github.io/automating-dependency-updates<![CDATA[

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.

Getting Started with Dependabot

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:

  1. Security Updates – Scanning and proposing updates for vulnerable dependencies
  2. Version Updates – General purpose dependency updates

Let’s start by looking at security updates.

Dependabot 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.

image1

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.

image2

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.

image3

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.

Enabling by Default

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.

image4

Enabling this feature by default will help ensure that all your future projects remain secure with no additional setup effort required.

Configuring Version Updates

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.

Bonus: Auto-Merge Dependabot PRs

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.

Introducing Renovate

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.

Recreating Dependabot in Renovate

Like GitHub’s Dependabot, Renovate also provide a great demo repo detailing:

  1. Installing the GitHub app
  2. Onboarding a repository
  3. Customising Renovate
  4. Reviewing PRs
  5. Interacting with the dashboard

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.

image5

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:

  • The config:base presets have been replaced with config:recommended
  • Custom labels have been added using template values to categorise PRs
  • Security updates are labelled with security
  • The FluentAssertions v8 packages have been ignored

Once again, with minimal additional configuration, we’ve been able to add significant value to the dependency updates raised.

Automating Merging Updates

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 style
  • minimumReleaseAge adds a check that an update is at least 14 days old as recommended by Renovate
  • separateMajorMinor has been enabled to ensure separate PRs are raised for major and minor updates
  • A custom packageRule has been added to override the default minimumReleaseAge for fast updating packages
  • Lastly, the security: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.

image6

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.

Wrapping Up

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.

]]>
Cameron Cowen<![CDATA[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.]]>
Adopting Bicep Linting2025-08-04T00:00:00+01:002025-08-04T00:00:00+01:00https://milkyware.github.io/azure/adopting-bicep-linting<![CDATA[

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!

What Does Linting Look Like?

The VS Code Bicep extension offers great IntelliSense for developing Bicep templates, giving clear warnings in the code.

image1

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.

Replacing ARM-TTK in my Pipelines

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.

Developing a SARIF Converter

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.

milkyware/sarif-converter - GitHub

Next, let’s integrate my new tool with my existing pipeline template.

Integrating the Tool into the Pipelines

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.

The End Result

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 &quot;Microsoft.Storage/storageAccounts/tableServices/tables@2025-01-01&quot; 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.

image2

Wrapping Up

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.

]]>
Cameron Cowen<![CDATA[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.]]>
Migrating ASP.NET Core to OpenTelemetry2025-04-07T00:00:00+01:002025-04-07T00:00:00+01:00https://milkyware.github.io/.net/migrating-aspnet-core-to-opentelemetry<![CDATA[

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.

The Current App Insights Integration

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.

image1

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.

What is OpenTelemetry?

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.

image2

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.

Migrating to OpenTelemetry with App Insights

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:

  1. Remove the Microsoft.ApplicationInsights.AspNetCore package from projects.
  2. Remove the builder.Services.AddApplicationInsightsTelemetry() integration
  3. Remove references to App Insights components and clean the solution

With 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:

image3

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?

Including Scopes

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.

image4

Enriching the Logs

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.

image5

The logs created in Azure Monitor should now look similar to above with the LogLevel, CategoryName and OriginalFormat included.

Sample Project

As always, the samples in this post are taken from the sample project I’ve prepared.

milkyware/blog-migrate-aspnetcore-appinsights-to-otel - GitHub

Wrapping Up

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.

]]>
Cameron Cowen<![CDATA[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.]]>
ASP.NET Core Health Checks2025-03-03T00:00:00+00:002025-03-03T00:00:00+00:00https://milkyware.github.io/.net/aspnet-core-health-checks<![CDATA[

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.

What is ASP.NET Health Checks?

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:

  • Detect failures in individual components and dependencies
  • Automate alerting on unhealthy applications
  • Allow load balancers and orchestrators to automate restarting or diverting traffic (good ol’ turn it off and on again)

Getting Started with Health Checks

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.

Creating a Health Check

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.

Sample Project

All of the samples in this post are included in the below sample project.

milkyware/blog-aspnet-core-health-checks - GitHub

Special Mention

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.

Xabaril/AspNetCore.Diagnostics.HealthChecks - GitHub

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.

Wrapping Up

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.

]]>
Cameron Cowen<![CDATA[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.]]>
Exploring GitHub Pull Requests Automation2025-02-03T00:00:00+00:002025-02-03T00:00:00+00:00https://milkyware.github.io/automation/exploring-github-pull-request-automation<![CDATA[

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.

Automating GitHub

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.

Assigning Reviewers

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.

Tagging

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:

  • A private helper function LabelPR has been added to upsert labels
  • Regex is used to extract the value provided for the release in the pull request body and added as a label
  • Branches conforming to the branchType/name format have the prefix extracted and added as a tag
name: 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.

image1

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.

Wrapping Up

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.

]]>
Cameron Cowen<![CDATA[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.]]>
Scaling CI Testing with an Azure Pipeline for Monorepos2025-01-06T00:00:00+00:002025-01-06T00:00:00+00:00https://milkyware.github.io/devops/scaling-ci-testing-with-an-azure-pipeline-for-monorepos<![CDATA[

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.

The Pipeline Template

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

Running the Pipeline Template

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.

image2

The test results are then uploaded to the Azure Pipeline for easy reviewing.

image3.

Key Features of the Pipeline

Dynamic Test Discovery

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.

image1

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.

Modular Configuration

A number of parameters are available to customise the usage of the pipeline to fit different project structures and team requirements:

  • comparisonBranch: The base branch for evaluating changes.
  • comparisonDepth: Determines how deeply the directory structure is analyzed for changes.
  • buildConfiguration: Specifies the build configuration (e.g., Debug or Release).
  • dotnetVersions: Lists .NET versions required to run the tests.
  • excludedTestProjects: Allows exclusion of specific test projects, such as integration tests.

As the script is written entirely with PowerShell, additional script changes can be easily made.

Intelligent Test Identification

The pipeline uses the following heuristics to identify test projects:

  • Checks if the IsTestProject property is defined in the .csproj file.
  • Verifies if testing frameworks like MSTest, NUnit, or xUnit are referenced in the project file.

This logic ensures that only genuine test projects are included in the test run.

Exclusion Handling

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.

Multi-Version .NET Support

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 Result Publishing

Test results are collected and published in the VSTest format, as is standard practice in CI pipelines, integrating seamlessly with Azure DevOps’ reporting tools.

What are the Benefits?

As touched on already, there are a number of benefits:

  • As the monorepo grows with new apps, providing the folder structured is adhered to, the test projects will automatically be evaluated and run if considered relevant, without the need to define a new CI pipeline.
  • With the pipeline being templated and parametrised, it can easily be included in or referenced from various repos for great reuse.
  • By targeting only impacted tests, the pipeline reduces compute costs and accelerates feedback loops, enabling faster delivery cycles and more efficient use of build agent resources.
  • The pipeline makes use of PowerShell to perform the evaluation of the affected test. This script makes heavy use of standard PowerShell logging practices and integrates with the Azure Pipeline debug flag to toggle enhanced logging output to easily identity what tests have been selected.
  • With the pipeline being entirely written in PowerShell, should some changes or extra logic be needed, this can be easily incorporated into the pipeline.

Conclusion

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!

]]>
Cameron Cowen<![CDATA[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.]]>
My Approach to Logging2024-12-02T00:00:00+00:002024-12-02T00:00:00+00:00https://milkyware.github.io/.net/my-approach-to-logging<![CDATA[

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.

Why Log?

There are many reasons to add logging to your code, 4 key reasons are:

  1. Debugging and Troubleshooting: Captures events that can help pinpoint issues and understand what led to them
  2. Monitoring Application Health: Offers real-time insight into the state of an application and enables proactive monitoring of errors, crashes, and unusual patterns
  3. Audit and Compliance: Provides a history of key activities which can be useful for security, auditing, and compliance purposes
  4. Performance Optimization: Logging execution times and performance metrics, bottlenecks can be identified and resolved

Using ILogger

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:

  • Information logs are used at the start and end of public methods
  • Debug logs are using during public 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 APIs
  • Error logs are used in catch blocks for unhandled exceptions with the full exception being passed to the log, as well as a message to give context   - Although not shown, I’ve often used Information or Warning for handled exceptions
  • Trace logs are used to record key data/state in the code

As mentioned, there are several logging levels available by default, the levels available are:

  1. Critical
  2. Error
  3. Warning
  4. Information
  5. Debug
  6. Trace

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.

Structured 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.

image1

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.

Using Scopes for Contextual Logging

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.

image2

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.

Wrapping Up

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:

  • How using a variety of logging levels helps granularly dial up and down logging
  • How structured logging improves our ability to query and analyse logs
  • How logging scopes can create correlating sessions of logs which can be inherited in nested logic.

I hope this has been useful and thanks for reading.

]]>
Cameron Cowen<![CDATA[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.]]>
Replacing BizTalk Business Rules Engine2024-11-04T00:00:00+00:002024-11-04T00:00:00+00:00https://milkyware.github.io/integration/replacing-biztalk-business-rules-engine<![CDATA[

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.

image1

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.

What is the Microsoft Rules Engine?

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:

  • JSON-based rules definition
  • Multiple input support
  • Dynamic object input support
  • C# Expression support
  • Extending expression via custom class/type injection
  • Scoped parameters
  • Post-rule execution actions
  • Standalone expression evaluator

Installing

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

Using the Rules Engine

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:

  • Writing Expressions
  • Defining Workflows
  • Creating Rule Stores
  • Creating Custom Actions

Writing Expressions

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.

Defining 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.

Defining Post-Rule Actions

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.

Using the JSON Schema

One really useful feature of the rules engine is that the Workflow class also has an associated JSON schema.

image2

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.

Creating Rule Stores

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.

image3

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.

Creating Custom Actions

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:

  1. Register the custom action type with the service collection (along with any other necessary dependencies)
  2. Register a reference tuple of the name of the action (referenced in workflow definitions) and a delegate to retrieve an action instance from DI
  3. As part of registering the rules engine with DI, retrieve the collection of tuples and map to a dictionary to configure 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.

Sample Project

I’ve put together the below sample project to demonstrate the concepts and features we’ve discussed through this post.

milkyware/blog-rules-engine - GitHub

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.

Wrapping Up

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.

]]>
Cameron Cowen<![CDATA[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.]]>