| Comments

Well it was all about AI at Microsoft Build this year for sure…lots of great discussions and demos around GitHub Copilot, OpenAI, Intelligent Apps, etc.  I’ve been heavily relying on GitHub Copilot recently as I’ve been spending more time in writing VS Code extensions and I’m not as familiar with TypeScript.  Having that AI assistant with me *in the editor* has been amazing.

One of the sessions at Build was the keynote from Scott Guthrie where VP of Product, Amanda Silver, demonstrated building an OpenAI plugin for ChatGPT.  You can watch that demo starting at this timestamp as it was a part of the “Next generation AI for developers with the Microsoft Cloud” overall keynote.  It takes a simple API about products from the very famous Contoso outlet and exposes an API about products.  Amanda then created a plugin using Python and showed the workflow of getting this to work in ChatGPT.  So after a little prompting on Twitter and some change of weekend plans, I wanted to see what it would take to do this using ASP.NET Core API development.  Turns out it is pretty simple, so let’s dig in!

Working with ChatGPT plugins

A plugin in this case help connect the famous ChatGPT experience to third-party applications (APIs).  From the documentation:

These plugins enable ChatGPT to interact with APIs defined by developers, enhancing ChatGPT's capabilities and allowing it to perform a wide range of actions. For example, here is the Savvy Trader ChatGPT plugin in action where I can ask it investment questions and it becomes the responsible source for providing the data/answers to my natural language inquiry:

Screenshot of the Savvy Trader ChatGPT plugin

A basic plugin is a definition of a manifest that describe how ChatGPT should interact with the third-party API.  It’s a contract between ChatGPT, the plugin, and the API specification, using OpenAPI.  That’s it simply.  Could your existing APIs ‘just work’ as a plugin API? That’s something you’d have to consider before just randomly exposing your whole API surface area to ChatGPT. It makes more sense to be intentional about it and deliver a set of APIs that are meaningful to the AI model to look and receive a response.  With that said, we’ll keep on the demo/simple path for now.

For now the ChatGPT plugins require two sides: a ChatGPT Plus subscription to use them (plugins now available to all Plus subscribers) and to develop you need to be on the approved list, for which you must join the waitlist to develop/deploy a plugin (as of the date of this writing).

Writing the API

Now the cool thing for .NET developers, namely ASP.NET Core developers is writing your API doesn’t require anything new for you to learn…it’s just your code.  Can it be enhanced with more? Absolutely, but as you’ll see here, we are literally keeping it simple.  For ours we’ll start with the simple ASP.NET Core Web API template in Visual Studio (or `dotnet new webapi –use-minimal-apis`).  This gives us the simple starting point for our API.  We’re going to follow the same sample as Amanda’s so you can delete all the weather forecast sample information in Program.cs.  We’re going to add in some sample fake data (products.json) which we’ll load as our ‘data source’ for the API for now.  We’ll load that up first:

// get some fake data
List<Product> products = JsonSerializer.Deserialize<List<Product>>(File.ReadAllText("./Data/products.json"));

Observe that I have a Product class to deserialize into, which is pretty simple class that maps to the sample data…not terribly important for this reading.

Now we want to have our OpenAPI definition crafted a little, so we’re going to modify the Swagger definition a bit.  The template already includes Swashbuckle package to help us generate the OpenAPI specification needed…we just need to provide it with a bit of information.  I’m going to modify this to provide the title/description a bit better (otherwise by default it uses a set of project names you probably don’t want).

builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo() { Title = "Contoso Product Search", Version = "v1", Description = "Search through Contoso's wide range of outdoor and recreational products." });
});

Now we’ll add an API for products to query our data and expose that to OpenAPI definition:

app.MapGet("/products", (string? query = null) =>
{
    if (query != null) { 
        return products?.Where(p => p.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || 
        p.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || 
        p.Category.Contains(query, StringComparison.OrdinalIgnoreCase) ); 
    }

    return products;
})
.WithName("GetProducts")
.WithDescription("Get a list of products")
.WithOpenApi();

That’s it.  You can see the highlighted lines where we further annotate the endpoint for the OpenAPI specification. Now we have our API working and it will produce an OpenAPI spec by default at {host}/swagger/v1/swagger.yaml for us.  Note that you can further modify this location if you want providing a different route template in the Swagger config.

Now let’s move on to exposing this for ChatGPT plugins!

Exposing the API to ChatGPT

Plugins are enabled in ChatGPT by first providing a manifest that informs ChatGPT about what the plugin is, where the API definitions are, etc.  This is requested at a manifest located at {yourdomain}/.well-known/ai-plugin.json.  This is a well-known location and it is looking for a response that conforms to the schema.  Here are some advanced scenarios for authentication for a plugin, but we’ll keep it simple and expose this for all with no auth needed.  Details about the plugin manifest can be found here: ai-plugin.json manifest definition.  It’s a pretty simple file.  You probably will need a logo for your plugin of course – maybe use AI to generate that for you ;-).

There are a few ways you can expose this.  You can simply add a wwwroot folder, enable static files and drop the file in wwwroot\.well-known\ai-plugin.json.  To do that in your API project create the wwwroot folder, then create the .well-known folder (with the ‘.’) and put your ai-plugin.json file in that location.  If you go this approach you’ll want to ensure in your Program.cs you enable static files:

app.UseStaticFiles();

After you have all this in place you’ll need to enable CORS policy so that the ChatGPT can access your API correctly.  First you will need to enable CORS (line 1 in your builder) and then configure a policy for the ChatGPT domain (line 6 in the app):

builder.Services.AddCors();

...


app.UseCors(policy => policy
    .WithOrigins("https://chat.openai.com")
    .AllowAnyMethod()
    .AllowAnyHeader());

Now our API will be callable form the ChatGPT app.

Using Middleware to configure the manifest

As mentioned the static files approach for exposing the manifest is the simplest…but that’s no fun right?  We are developers!!! As I was looking at this myself, I put together a piece of ASP.NET middleware to help me configure it.  You can use the static files approach (in fact you’ll have to do that with your logo if hosting at the same place as your API) for sure, but just in case here’s a middleware approach that I put together.  First you’ll install the package TimHeuer.OpenAIPluginMiddleware from NuGet.  Once you’ve done that now you’ll add the service and tell the pipeline to use it.  First add it to the services of the builder (line 1) and then tell the app to use the middleware (line 15):

builder.Services.AddAiPluginGen(options =>
{
    options.NameForHuman = "Contoso Product Search";
    options.NameForModel = "contosoproducts";
    options.LegalInfoUrl = "https://www.microsoft.com/en-us/legal/";
    options.ContactEmail = "[email protected]";
    options.LogoUrl = "/logo.png";
    options.DescriptionForHuman = "Search through Contoso's wide range of outdoor and recreational products.";
    options.DescriptionForModel = "Plugin for searching through Contoso's outdoor and recreational products. Use it whenever a user asks about products or activities related to camping, hiking, climbing or camping.";
    options.ApiDefinition = new Api() { RelativeUrl = "/swagger/v1/swagger.yaml" };
});

...

app.UseAiPluginGen();

This might be overkill, but now your API will respond to /.well-known/ai-plugin.json automatically without having to use the static files manifest approach.  This comes in handy for any dynamic configuration of your manifest (and was the reason I created it).

Putting it together

With all this in place, now we go to ChatGPT (remember, need a Plus subscription) and add our plugin.  Since ChatGPT is a public site and we haven’t deployed our app yet to anywhere, we need to be able to have ChatGPT call it.  Visual Studio Dev Tunnels to the rescue!  If you haven’t heard about these yet, it is the fastest and most convenient way to get a public tunnel to your dev machine right from within Visual Studio!  In fact, this scenario is exactly what Dev Tunnels are for!  In our project we’ll create a tunnel first, and make it available to everyone (ChatGPT needs public access).  In VS first create a tunnel, you can do that easily from the ‘start’ button of your API in the toolbar:

Create a Dev Tunnel in Visual Studio

and then configure the options:

Dev Tunnel configuration screen

More details on these options are available at the documentation for Dev Tunnels, but these are the options I’m choosing.  Now once I have that the tunnel will be activated and when I run the project from within Visual Studio, it will launch under the Dev Tunnel proxy:

Screenshot of app running behind a public Dev Tunnel

You can see my app running, responding to the /.well-known/ai-plugin.json request and serving it from a public URL.  Now let’s make it known to ChatGPT…

First navigate to https://chat.openai.com and ensure you choose the GPT-4 approach then plugins:

Screenshot of the GPT-4 option on ChatGPT

Once there you will see the option to specify plugins in the drop-down and then navigate to the plugin store:

Plugin Store link

Click that and choose ‘Develop your own plugin’ where you will be asked to put in a URL.  This is where your manifest will respond to (just need the root URL).  Again, because this needs to be public, Visual Studio Dev Tunnels will help you! I put in the URL to my dev tunnel and click next through the process (because this is development you’ll see a few things about warnings etc):

Develop your own plugin

After that your plugin will be enabled and now I can issue a query to it and watch it work!  Because I’m using Visual Studio Dev Tunnels I can also set a breakpoint in my C# code and see it happening live, inspect, etc:

Breakpoint during debugging hit

A very fast way to debug my plugin before I’m fully ready for deployment!

Sample code

And now you have it.  Now you could actually deploy your plugin to Azure Container Apps for scale and you are ready to let everyone get recommendations on backpacks and hiking shoes from Contoso!  I’ve put all of this together (including some Azure deployment infrastructure scripts) in this sample repo: timheuer/openai-plugin-aspnetcore.  This uses the middleware that I created for the manifest.  That repo is located at timheuer/openai-plugin-middleware and I’d love to hear comments on the usefulness here. There is some added code in that repo that dynamically changes some of the routes to handle the Dev Tunnel proxy URL for development.

Hope this helps see the end to end of a very simple plugin using ASP.NET Core, Visual Studio, and ChatGPT with plugins!

| Comments

Okay, so I won’t quit my day job in favor of trying to come up with a witty title for a blog post.  But this is one thing that I’m proud to see our team deliver: one of the fastest ways to get your ASP.NET app to a container service on Azure (or elsewhere) without having to know what containers are or learn new things.  No really!

Cloud native

Well if you operate in the modern web world you’ve heard this term ‘cloud native’ before. And everyone has an opinion on what it means. I’m not here to pick sides and I think it means a lot of different things. One commonality it seems that most can agree on is that one aspect is of deploying a service to the cloud as ‘cloud native’ is to leverage containers.  If you aren’t familiar with containers, go read here: What is a container? It’s a good primer on what they are technically but also some benefits. Once you educate yourself you’ll be able to declare yourself worthy to nod your head in cloud native conversations and every once in a while throw out comments like “Yeah, containers will help here for us.” or something like that. Instantly you will be seen as smart and an authority and the accolades will start pouring in.  But then you may actually have to do something about it in your job/app. Hey don’t blame me, you brought this on yourself with those arrogant comments! Have no fear, Visual Studio is here to help!

Creating and deploying a container

If you haven’t spent time working with containers, you will be likely introduced to new concepts like Docker, Dockerfile, compose, and perhaps even YAML. In creating a container, you typically need to have a definition of what your container is, and generally this will be a Dockerfile.  A typical Docker file for a .NET Web API looks like this:

#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.

FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["CommerceApi.csproj", "."]
RUN dotnet restore "./CommerceApi.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "CommerceApi.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "CommerceApi.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "CommerceApi.dll"]

You can see a few concepts here that you’d have to understand and that’s not the purpose of this post. You’d then need to use Docker to build this container image and also to ‘push’ it to a container registry like Azure Container Registry (ACR). For a developer this would mean you’d likely have Docker Desktop installed that brings these set of tools to you locally to execute within your developer workflow.  As you develop your solution, you’ll have to keep your Dockerfile updated if it involves more projects, version changes, path changes, etc. But what if you just have a simple service, you’ve heard about containers and you just want to get it to a container service as fast as possible and simple.  Well, in Visual Studio we have you covered.

Publish

Yeah yeah, ‘friends don’t let friends…’ – c’mon let people be (more on that later). In VS we have a great set of tools to help you rapidly get your code to various deployment endpoints. Since containers are ‘the thing’ lately as of this writing we want to help you remove concepts and get their fast as well…in partnership with Azure.  Azure has a new service launched last year called Azure Container Apps (ACA), a managed container environment that helps you scale your app. It’s a great way to get started in container deployments easily and have manageability and scale.  Let me show you how we help you get to ACA quickly, from your beloved IDE, with no need for a Dockerfile or other tools.  You’ll start with your ASP.NET Web project and start from the Publish flow (yep, right-click publish).  From their choose Azure and notice Azure Container Apps right there for you:

Visual Studio Publish dialog

After selecting that Visual Studio (VS) will help you either select existing resources that your infrastructure team helped setup for you or, if you’d like and have access to create them, create new Azure resources all from within VS easily without having to go to the portal.  You can then select your ACA instance:

Visual Studio Publish dialog with Azure

And then the container registry for your image:

Visual Studio Publish dialog with Azure

Now you’ll be presented with an option on how to build the container. Notice two options because we’re nice:

Publish with .NET SDK selection

If you still have a Dockerfile and want to go that route (read below) we enable that for you as well. But the first option is leveraging the .NET SDK that you already have (using the publish targets for the SDK). Selecting this option will be the ‘fast path’ to your publishing adventure.

Then click finish and you’re done, you now have a profile ready to push a container image to a registry (ACR), then to a container app service (ACA) without having to create a Docker file, learn a new concept or have other tools.  Click publish and you’ll see the completed results and you will now be able to strut back into your manager’s office/cube/open space bean bag and say Hey boss, our service is all containerized and in the cloud ready to scale…where’s my promo?

Publish summary page

VS has helped with millions of cloud deployments every month whether they be to VMs, PaaS services, Web Deploy to on-metal cloud-hosted machines, and now easily to container services like ACA.  It’s very helpful and fast, especially for those dev/test scenarios as you iterate on your app with others.

Leveraging continuous integration and deployment (CI/CD)

But Tim, friends don’t let friends right-click publish! Pfft, again I say, do what makes you happy and productive.  But also, I agree ;-).  Seriously though I’ve become a believer in CI/CD for EVERYTHING I do now, no matter the size of project. It just raises the confidence of repeatable builds and creates an environment of collaboration better for other things. And here’s the good thing, VS is going to help you bootstrap your efforts here easily as well – EVEN WITH CONTAINERS! Remember that step where we selected the SDK to build our container? Well if your VS project is within a GitHub repository (free for most cases these days, you should use it!), we’ll offer to generate an Actions workflow, which is GitHub’s CI/CD system:

Publish using CI/CD

In choosing a CI/CD workflow, the CI system (in this case GitHub Actions) needs to know some more information: where to deploy, some credentials to use for deployment, etc. The cool thing is even in CI, Visual Studio will help you do all of this setup including retrieving and setting these values as secrets on your repo! Selecting this option would result in this summary for you:

GitHub Actions summary page

And the resulting workflow in an Actions YAML file in your project:

name: Build and deploy .NET application to container app commerceapp
on:
  push:
    branches:
    - main
env:
  CONTAINER_APP_CONTAINER_NAME: commerceapi
  CONTAINER_APP_NAME: commerceapp
  CONTAINER_APP_RESOURCE_GROUP_NAME: container-apps
  CONTAINER_REGISTRY_LOGIN_SERVER: XXXXXXXXXXXX.azurecr.io
  DOTNET_CORE_VERSION: 7.0.x
  PROJECT_NAME_FOR_DOCKER: commerceapi
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout to the branch
      uses: actions/checkout@v3
    - name: Setup .NET SDK
      uses: actions/[email protected]
      with:
        include-prerelease: True
        dotnet-version: ${{ env.DOTNET_CORE_VERSION }}
    - name: Log in to container registry
      uses: azure/docker-login@v1
      with:
        login-server: ${{ env.CONTAINER_REGISTRY_LOGIN_SERVER }}
        username: ${{ secrets.timacregistry_USERNAME_F84D }}
        password: ${{ secrets.timacregistry_PASSWORD_F84D }}
    - name: Build and push container image to registry
      run: dotnet publish -c Release -r linux-x64 -p:PublishProfile=DefaultContainer -p:ContainerImageTag=${{ github.sha }} --no-self-contained -p:ContainerRegistry=${{ env.CONTAINER_REGISTRY_LOGIN_SERVER }} -bl
    - name: Upload binlog for investigation
      uses: actions/upload-artifact@v3
      with:
        if-no-files-found: error
        name: binlog
        path: msbuild.binlog
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
    - name: Azure Login
      uses: azure/login@v1
      with:
        creds: ${{ secrets.commerceapp_SPN }}
    - name: Deploy to containerapp
      uses: azure/CLI@v1
      with:
        inlineScript: >
          az config set extension.use_dynamic_install=yes_without_prompt

          az containerapp registry set --name ${{ env.CONTAINER_APP_NAME }} --resource-group ${{ env.CONTAINER_APP_RESOURCE_GROUP_NAME }} --server ${{ env.CONTAINER_REGISTRY_LOGIN_SERVER }} --username ${{ secrets.timacregistry_USERNAME_F84D }} --password ${{ secrets.timacregistry_PASSWORD_F84D }}

          az containerapp update --name ${{ env.CONTAINER_APP_NAME }} --container-name ${{ env.CONTAINER_APP_CONTAINER_NAME }} --resource-group ${{ env.CONTAINER_APP_RESOURCE_GROUP_NAME }} --image ${{ env.CONTAINER_REGISTRY_LOGIN_SERVER }}/${{ env.PROJECT_NAME_FOR_DOCKER }}:${{ github.sha }}
    - name: logout
      run: >
        az logout

Boom! So now you CAN use right-click publish and still get started with CI/CD deploying to the cloud!  Strut right back into that office: Hey boss, I took the extra step and setup our initial CI/CD workflow for the container service so the team can just focus on coding and checking it in…gonna take the rest of the week off.

Cool, but I have advanced needs…

Now, now I know there will be always cases where your needs are different, this is too simple, etc. and YOU ARE RIGHT! There are limitations to this approach which we outlined in our initial support for the SDK container build capabilities.  Things like customizing your base container image, tag names, ports, etc. are all easily customizable in your project file as they feed into the build pipeline, so we have you covered on this type of customization. As your solution grows and your particular full microservices needs get more complex, you may outgrow this simplicity…we hope that means your app is hugely successful and profits are rolling in for your app! You’ll likely grow into the Dockerfile scenarios and that’s okay…you’ll have identified your needs and have already setup your starting CI/CD workflow that you can progressively also grow as needed. We will continue to listen and see about ways we can improve this capability as developers like you give us feedback!

Summary

Our goal in Visual Studio is to help you be productive with a range of tasks. Moving to ‘cloud native’ can be another thing that your team has to worry about and as you start your journey (or perhaps looking to simplify a bit) VS aims to be your partner there and continue to help you be productive in getting your code to the cloud quickly with as much friction removed from your normal workflow. Here’s a few links to read more in more corporate speak about these capabilities:

Thanks for reading!

| Comments

I’ve had a love/hate relationship with CI/CD for a long while ever since I remember it being a thing. In those early days the ‘tools’ were basically everyone’s homegrown scripts, batch files, random daemon hosts, etc. Calling something a workflow was a stretch. It was for that reason I just wasn’t a believer, it was just too ‘hard’ for the average dev. I, like many, would build from my machine and direct deploy or copy over to file shares (NOTE: LOTS of people still do this). Well the tools have gotten WAY better across the board from many different vendors and your options for great tools exist. I’ve been privileged to work with Damian Brady and Abel Wang to educate me on the ways of CI/CD a bit. I know Damian has a mantra about right-click publish, but that only made me want to make it simpler for devs.

NOTE: Did you know that for most projects in .NET working in VS you can use right-click Publish to generate a CI/CD workflow for you, further reducing the complexity?

Well, I’m a believer now and I make it part of my mission to improve the tool experience for .NET devs and also look to convince/advocate for .NET developers to use CI/CD even in the smallest of projects. I’ve honed my own workflows to now I truly just worry about development…releases just take care of themselves. It’s glorious and frees so much time. I go out of my way now when I see friend’s projects who are on GitHub but not using Actions, for example. Recently I was working with Mads Kristensen on some things and asked him if he’d consider using Actions. And in a few minutes I submitted a first PR to one of his projects showing how simple it was. I started from using my own `dotnet new workflow` tool as not all project types support the right-click Publish—>Actions work Visual Studio has done yet. This helps get started with the basics.

In a few back/forth with Mads he wanted to encapsulate more…the files were too busy for him LOL. Enter composite Actions (or technically composite run steps). This was my chance to look into these as I hadn’t really had a need yet. You should read the docs, but my lay explanation is that composite run steps enable you to basically templatize some of your steps into a single encapsulation…and VERY simply. 

Screenshot of GitHub Action YAML file

Let’s look at one example with Mads’ desires. Mads’ projects are usually Visual Studio extensibility projects and require a few things to build more than just the .NET SDK. In this particular instance Mads needed .NET SDK, NuGet, and MSBuild to be setup.  No problem, I started out with this, because duh, why not:

  # prior portion of jobs removed for brevity
  steps:
    - name: Setup dotnet
      uses: actions/[email protected]
      with:
        dotnet-version: 6.0.x

    - name: Setup MSBuild
      uses: microsoft/[email protected]

    - name: Setup NuGet
      uses: NuGet/[email protected]

But wanting less text, we discussed and I encapsulated these three in one single step using a new composite action. Creating a composite action is simple and enables you to deploy it in a few ways. First you can just keep these in your own repo itself without having to release anything, etc. This is helpful when yours are very repo-specific and nobody is sharing them across org/repos. Let’s look at the above and how we might encapsulate this. I still want to enable SDK version input to start so need an input parameter for that. So in the repo I’ll create two new folders in the .github/workflows folder, creating a new path called ./github/workflows/composite/bootstrap-dotnet and then place a new action.yaml file in that directory. My action.yaml file looks like this:

# yaml-language-server: $schema=https://json.schemastore.org/github-action.json
name: 'Setup .NET build dependencies'
description: 'Sets up the .NET dependencies of MSBuild, SDK, NuGet'
branding:
  icon: download
  color: purple
inputs:
  dotnet-version:
    description: 'What .NET SDK version to use'
    required: true
    default: 6.0.x
  sdk:
    description: 'Setup .NET SDK'
    required: false
    default: 'true'
  msbuild:
    description: 'Setup MSBuild'
    required: false
    default: 'true'
  nuget:
    description: 'Setup NuGet'
    required: false
    default: 'true'
runs:
  using: "composite"
  steps:
    - name: Setup dotnet
      if: inputs.sdk == 'true'
      uses: actions/[email protected]
      with:
        dotnet-version: ${{ inputs.dotnet-version }}

    - name: Setup MSBuild
      if: inputs.msbuild == 'true' && runner.os == 'Windows'
      uses: microsoft/[email protected]

    - name: Setup NuGet
      if: inputs.nuget == 'true'
      uses: NuGet/[email protected]

Let’s break it down. Composite actions still have the same setup as other custom actions enabling you to have branding/name/description/etc. as well as inputs as I’ve defined starting at line 6. I can then use these inputs in later steps (line 27/30). As you can see this action basically is a template for other steps that use other actions…simple!!! Now in the primary workflow for the project it looks like this:

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: "PR Build"

on: [pull_request]
      
jobs:
  build:
    name: Build 
    runs-on: windows-2022
      
    steps:
    - uses: actions/checkout@v2

    - name: Setup .NET build dependencies
      uses: ./.github/workflows/composite/bootstrap-dotnet
      with:
        nuget: 'false'

Notice the path to the workflow itself using the new folder structure (line 14). Now when this workflow runs it will bring this composite action in and also run it’s steps…beautiful. If the action is more generic and you want to move it out of the repo you can do that. In fact in this one we did just that and you can see it at timheuer/bootstrap-dotnet and be able to use it just like any other action in your setup. An example of changed like the above is as simple as:

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: "PR Build"

on: [pull_request]

jobs:
  build:
    name: Build 
    runs-on: windows-2022
      
    steps:
    - uses: actions/checkout@v2

    - name: Setup .NET build dependencies
      uses: timheuer/bootstrap-dotnet@v1
      with:
        nuget: 'false'

Done! What’s also great is because this still is a legit GitHub Action you can publish it on the marketplace for others to discover and use (hence the branding). Here is this one we just demonstrated above in the marketplace:

Screenshot of GitHub Action marketplace listing

So that’s a simple example of truly a template/merge of other existing actions. But can you use this method to create a custom action that just uses script for example, like PowerShell? YES! Let’s take another one of these examples that uploads the VSIX from our project to the Open VSIX gallery. Mads was using a PowerShell script that does his upload for him, so I’m copying that into a new composite action and making some inputs and then he can use it.  Here’s the full composite action:

# yaml-language-server: $schema=https://json.schemastore.org/github-action.json
name: 'Publish to OpenVSIX Gallery'
description: 'Publishes a Visual Studio extension (VSIX) to the OpenVSIX Gallery'
branding:
  icon: upload-cloud
  color: purple
inputs:
  readme:
    description: 'Path to readme file'
    required: false
    default: ''
  vsix-file:
    description: 'Path to VSIX file'
    requried: true
runs:
  using: "composite"
  steps:
    - name: Publish to Gallery
      id: publish_gallery
      shell: pwsh
      run: |
        $repo = ""
        $issueTracker = ""

        # If no readme URL was specified, default to "<branch_name>/README.md"
        if (-not "${{ inputs.readme }}") {
          $readmeUrl = "$Env:GITHUB_REF_NAME/README.md"
        } else {
          $readmeUrl = "${{ inputs.readme }}"
        }

        $repoUrl = "$Env:GITHUB_SERVER_URL/$Env:GITHUB_REPOSITORY/"

        [Reflection.Assembly]::LoadWithPartialName("System.Web") | Out-Null
        $repo = [System.Web.HttpUtility]::UrlEncode($repoUrl)
        $issueTracker = [System.Web.HttpUtility]::UrlEncode(($repoUrl + "issues/"))
        $readmeUrl = [System.Web.HttpUtility]::UrlEncode($readmeUrl)

        # $fileNames = (Get-ChildItem $filePath -Recurse -File)
        $vsixFile = "${{ inputs.vsix-file }}"
        $vsixUploadEndpoint = "https://www.vsixgallery.com/api/upload"

        [string]$url = ($vsixUploadEndpoint + "?repo=" + $repo + "&issuetracker=" + $issueTracker + "&readmeUrl=" + $readmeUrl)
        [byte[]]$bytes = [System.IO.File]::ReadAllBytes($vsixFile)
             
        try {
            $webclient = New-Object System.Net.WebClient
            $webclient.UploadFile($url, $vsixFile) | Out-Null
            'OK' | Write-Host -ForegroundColor Green
        }
        catch{
            'FAIL' | Write-Error
            $_.Exception.Response.Headers["x-error"] | Write-Error
        }

You can see it is mostly a PowerShell script and has the inputs (line 6). And here it is in use in a project:

# other steps removed for brevity in snippet
  publish:
    runs-on: ubuntu-latest
    steps:

      - uses: actions/checkout@v2

      - name: Download Package artifact
        uses: actions/download-artifact@v2
        with:
          name: RestClientVS.vsix

      - name: Upload to Open VSIX
        uses: timheuer/openvsixpublish@v1
        with:
          vsix-file: RestClientVS.vsix

Pretty cool when your custom action is a script like this and you don’t need to do any funky containers, or have a node app that just launches pwsh.exe or stuff like that. LOVE IT! Here’s the repo for this one to see more: timheuer/openvsixpublish.

This will definitely be the first approach I consider when needing other simple actions for my projects or others. The simplicity and flexibility in ‘templatizing’ some steps is really great!

Hope this helps!

| Comments

Well with the release of .NET 6, lots of excitement around the platform and me being the nerd I am with a love for cycling, it’s time to open up for another round of ordering for the highly exclusive limited edition .NET Cycling Kit (jersey and bib shorts).

.NET Cycling Kit

Last year I had created these using the .NET Foundation assets and in accordance with the brand guidelines for the .NET project (did you know we had a branding guidelines?! Me neither, but now you do).  Well, we’re opening it up for an exclusive another round (this is round 3) and probably the last (famous last words).

Here’s the details:

  • These are high-quality race-fit cycling kits…yes they are not cheap…nor is the quality.
  • These are race-fit, not ‘club fit’ so they are meant to fit tighter and in riding position
  • Sizing: Eliel Cycling Sizing Guide (for context I am 5’9” [175cm] and ~195lbs [88.5kg] and I prefer a Large top and Large bib shorts)
  • These are all 100% custom made-to-order – there is no ‘stock’ for immediate ship
  • For patience, upon store closing these will take 10-12 weeks of production – must be patient :-)
  • They are custom, sales final, no returns
  • They look awesome and are comfortable
  • You will be the envy of all cyclists who are .NET developers that didn’t get one
  • I make NO money on this
  • Microsoft makes no money on this
  • Microsoft has nothing to do with this
  • There is no telemetry collected on the kit

So how do you get one?  Simple, click here: .NET Cycling Kit (Round 3) – you order direct from the manufacturer in California and pay direct to them.  Please read the site and details clearly and as well for EU ordering.

If you have any questions about these, the best place to ask is ping me on Twitter @timheuer for questions that I may be able to answer on sizing or otherwise.  I would love to see more .NET cyclists with their kits in the wild worldwide

| Comments

Last night I got tweeted at asking me how one could halt a CI workflow in GitHub Actions on a condition.  This particular condition was if the code coverage tests failed a certain coverage threshold.  I’m not a regular user of code coverage tools like Coverlet but I went Googling for some answers and oddly did not find the obvious answer that was pointed out to me this morning.  Regardless the journey to discover an alternate means was interesting to me so I’ll share what I did that I feel is super hacky, but works and is a similar method I used for passing some version information in other workflows.

First, the simple solution for if you are using Coverlet and want to fail a build and thus a CI workflow is to use the MSBuild integration option and then you can simply use:

dotnet test /p:CollectCoverage=true /p:Threshold=80

I honestly felt embarrassed that I didn’t find this simple option, but oh well, it is there and is definitely the simplest option if you can use this option.  But there you have it.  When used in an Actions workflow if the threshold isn’t met, this will fail that step and you are done.

Picture of failed GitHub Actions step

Creating your condition to inspect

But let’s say you need to fail for a different reason or in this example here, you couldn’t use the MSBuild integration and instead are just using the VSTest integration with a collector.  Well, we’ll use this code coverage scenario as an example but the key step here is focusing on how to fail a step.  Your condition may be anything but I suspect it is usually based on some previous step’s output or value.  Well first, if you are relying on previous steps values, be sure you understand the power of using outputs.  This is I think the best way to kind of ‘set state’ of certain things in steps.  A step can do some things and either in the Action itself set an Output value, or in the workflow YAML you can do this as well using a shell command and calling the ::set-output method.  Let’s look at an example…first the initial step (again using our code coverage scenario):

- name: Test
  run: dotnet test XUnit.Coverlet.Collector/XUnit.Coverlet.Collector.csproj --collect:"XPlat Code Coverage"

This basically will produce an XML output ‘report’ that contains the values we want to extract.  Namely it’s in this snippet:

<?xml version="1.0" encoding="utf-8"?>
<coverage line-rate="0.85999999999" branch-rate="1" version="1.9" timestamp="1619804172" lines-covered="15" lines-valid="15" branches-covered="8" branches-valid="8">
  <sources>
    <source>D:\</source>
  </sources>
  <packages>
    <package name="Numbers" line-rate="1" branch-rate="1" complexity="8">
      <classes>

I want the line-rate value (line 2) in this XML to be my condition…so I’m going to create a new Actions step to extract the value by parsing the XML using a PowerShell cmdlet.  Once I have that I will set the value as the output of this step for later use:

- name: Get Line Rate from output
  id: get_line_rate
  shell: pwsh  
  run: |
    $covreport = get-childitem -Filter coverage.cobertura.xml -Recurse | Sort-Object -Descending -Property LastWriteTime -Top 1
    Write-Output $covreport.FullName
    [xml]$covxml = Get-Content -Path $covreport.FullName
    $lineRate = $covxml.coverage.'line-rate'
    Write-Output "::set-output name=lineRate::$lineRate"

As you can see in lines 2 and 9 I have set a specific ID for my step and then used the set-output method to write a value to an output of the step named ‘lineRate’ that can be later used.  So now let’s use it!

Evaluating your condition and failing the step manually

Now that we have our condition, we want to fail the run if the condition evaluates a certain way…in our case if the code coverage line rate isn’t meeting our threshold.  To do this we’re going to use a specific GitHub Action called actions/github-script which allows you to run some of the GitHub API directly in a script.  This is great as it allows us to use the core library which has a set of methods for success and failure!  Let’s take a look at how we combine the condition with the setting:

- name: Check coverage tolerance
  if: ${{ steps.get_line_rate.outputs.lineRate < 0.9 }}
  uses: actions/github-script@v3
  with:
    script: |
        core.setFailed('Coverage test below tolerance')

Okay, so we did a few things here.  First we are defining this step as executing the core.setFailed() method…that’s specifically what this step will do, that’s it…it will fail the run with a message we put in there.  *BUT* we have put a condition on the step itself using the if condition checking.  In line 6 we are executing the setFailed function with our custom message that will show in the runner log.  On line 2 we have set the condition for if this step even runs at all.  Notice we are using the ID of a previous step (get_line_rate) and the output parameter (lineRate) and then doing a quick math check.  If this condition is met, then this step will run.  If the condition is NOT met, this step will not run, but also doesn’t fail and the run can continue.  Observe that if the condition is met, our step will fail and the run fails:

Failed run based on condition

If the condition is NOT met the step is ignored, the run continues:

Condition not met

Boom, that’s it! 

Summary

This was just one scenario but the key here is if you need to manually control a fail condition or otherwise evaluate conditions, using the actions/github-script Action is a simple way to do a quick insertion to control your run based on a condition.  It’s quick and effective for some scenarios where your steps may not have natural success/fail exit codes that would otherwise fail your CI run.  What do you think? Is there a better/easier way that I missed when you don’t have clear exit codes?

Hope this helps someone!