Pre-Releases & GitHub Actions for Visual Studio Code Extensions

Intro

This post is going to be a bit of a brain dump about developing my VS Code extension, branching strategy for pre-releases and releases and using GitHub actions to stitch it all together.

If you’re only here for the AL / Business Central content then you might want to give this one a miss. Then again, Microsoft are increasingly using GitHub for AL projects themselves (e.g. AL-Go for GitHub) – so it might be worth a look after all.

Objectives

What am I trying to achieve? I want to have a short turn around of:

  1. Have an idea for a new feature
  2. Implement the feature
  3. Test it and make it available for others to test
  4. Release

I use the extension pretty much every day at work so I am my own biggest customer. I want to write some new feature and start working with it in a pre-release myself to find any issues before I release it.

I also want to have a little fun with a side-project – learn a little typescript, practice some CI/CD, GitHub Actions and Application Insights. If anyone else finds the extension useful as well then that’s a bonus.

Overview

This is my workflow. I want to get the feature into the pre-release version of the extension on the marketplace quickly. That way I will get the new pre-release myself from the marketplace and use it in my daily work. I’ll make any fixes or improvements in updates to the pre-release before merging the code to the release version and publishing to the marketplace.

GitHub Actions

The GitHub actions definition is fairly self-explanatory. The yaml is bellow, or here if you prefer. Run whenever some code is pushed. Build, test, package with npm and vsce. Run the PowerShell tests with Pester. Upload the built extension as an artifact. If the pre-release branch is being built then use vsce to publish to the marketplace with the --pre-release switch.

The actions definition in the master branch is similar but publishes to the marketplace without the --pre-release switch.

name: CI

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
  pull_request:
    branches: [ master ]
  workflow_dispatch:

jobs:
  build:
    runs-on: windows-latest

    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v2

      - name: npm install, build and test
        run: |
          npm install
          npm run build
          npm test
      - name: package with vsce
        run: |
          npm install -g vsce
          vsce package
      - name: run pester tests
        shell: pwsh
        run: |
          Set-PSRepository psgallery -InstallationPolicy Trusted
          Install-Module Pester
          Install-Module bccontainerhelper
          gci *ALTestRunner.psm1 -Recurse | % {$_.FullName; Import-Module $_.FullName}
          Invoke-Pester
      - name: Upload a Build Artifact
        uses: actions/upload-artifact@v2.1.4
        with:
          name: AL Test Runner
          path: ./*.vsix

      - name: Publish to marketplace
        if: github.ref == 'refs/heads/pre-release'
        run: |
          vsce publish -p ${{ secrets.VSCE_PAT }} --pre-release

The personal access token for my Visual Studio account (used to publish to the marketplace) is stored in a repository secret.

Repository secrets

You can create and update these from the settings for the repository. You can read more about creating the personal access token and the option for publishing extensions to the marketplace here: https://code.visualstudio.com/api/working-with-extensions/publishing-extension

Conclusions

It is rewarding to make some changes to the extension, push them to GitHub and then 10-15 minutes later be able to use them in a new version of the extension which has been automatically published, downloaded and installed. It allows you to publish more frequently and with more confidence.

JSON References

TL;DR

JSON types reference their value in memory, not the actual value. The below is snipped from https://docs.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/jsonobject/jsonobject-data-type

Be careful making JSON types equal to one another. When you do that you copy the reference, not the value. This caught me out.

Example 1

I’m implementing an interface which accepts a JsonObject parameter expecting that you will assign a value which will be used later on. The interface doesn’t require that the JsonObject is passed with var. In fact, it requires that it isn’t. If you include var the compiler will complain that you haven’t implemented all of the interface methods. Something like the JsonExample action in the below code.

“That’s never going to work, the parameter needs to be passed with var” I thought. Better still, just have method return a JsonObject type. However, the interface probably pre-dates complex return types so we’ll let that go. Although, I think you could still return JSON types even before complex return types were introduced…but let it go.

pageextension 50100 "Customer List" extends "Customer List"
{
    actions
    {
        addlast(processing)
        {
            action(JsonExample)
            {
                ApplicationArea = All;

                trigger OnAction()
                var
                    JsonExample: Codeunit "Json Example";
                    Object: JsonObject;
                    Result: Text;
                begin
                    JsonExample.CalcJson(Object);
                    Object.WriteTo(Result);
                    Message(Result);
                end;
            }
            action(JsonExample2)
            {
                ApplicationArea = All;

                trigger OnAction()
                var
                    JsonExample: Codeunit "Json Example";
                    Object: JsonObject;
                    Result: Text;
                begin
                    JsonExample.CalcJson2(Object);
                    Object.WriteTo(Result);
                    Message(Result);
                end;
            }
            action(JsonExample3)
            {
                ApplicationArea = All;

                trigger OnAction()
                var
                    JsonExample: Codeunit "Json Example";
                    Object: JsonObject;
                    Result: Text;
                begin
                    JsonExample.CalcJson3(Object);
                    Object.WriteTo(Result);
                    Message(Result);
                end;
            }
        }
    }
}

codeunit 50100 "Json Example"
{
    procedure CalcJson(Object: JsonObject)
    begin
        Object.Add('aKindOf', 'magic');
    end;

    procedure CalcJson2(Object: JsonObject)
    var
        CalcJson: Codeunit "Calc. Json";
    begin
        Object := CalcJson.CalcJson();
    end;

    procedure CalcJson3(Object: JsonObject)
    var
        CalcJson: Codeunit "Calc. Json";
        JSON: Text;
    begin
        CalcJson.CalcJson().WriteTo(JSON);
        Object.ReadFrom(JSON);
    end;
}

codeunit 50101 "Calc. Json"
{
    procedure CalcJson() Result: JsonObject
    var
        Boys: JsonObject;
    begin
        Boys.Add('backInTown', true);
        Result.Add('boys', Boys);
    end;
}

I was surprised that it did work. Call JsonExample and you get:

{"aKindOf":"magic"}

That’s because even without the var keyword the JsonObject variable holds a refence to the object rather than the value itself, so it still exists after CalcJson() has finished executing.

Example 2

OK, great. I went on to create a separate codeunit to handle the creation of the JsonObject. I wanted to add some error handling and separate the boilerplate of the interface implementation from the business logic.

I wrote something like CalcJson2(). My tests started failing. It seemed that the JsonObject was empty. That puzzled me for a while. What had I done wrong? I think this is the problem.

  1. The JsonObject referenced by the Result variable in codeunit 50101 is created and has the properties added
  2. This reference goes out of scope once CalcJson has finished executing and its value is lost/garbage collected/however it works in Business Central
  3. The JsonObject referenced by the Object parameter is made equal to the first i.e. now points to the first JsonObject in memory – but that value has already gone
  4. As the result the second JsonObject is empty when it is handed back to the calling code

Example 3

Instead of making the JSON types equal to one another explicitly copy the value of one to the other. Like this:

procedure CalcJson3(Object: JsonObject)
var
    CalcJson: Codeunit "Calc. Json";
    JSON: Text;
begin
    CalcJson.CalcJson().WriteTo(JSON);
    Object.ReadFrom(JSON);
end;

In this case writing the value of one to text and then reading it back in to the other. It looks a bit weird, but it works. JsonObject also has a Clone method.

Tip: Get Current Callstack with a Collectible Error

The Code

codeunit 50104 "Get Callstack"
{
    SingleInstance = true;

    [ErrorBehavior(ErrorBehavior::Collect)]
    procedure GetCallstack() Callstack: Text
    var
        LF: Char;
    begin
        LF := 10;
        Error(ErrorInfo.Create('', true));
        Callstack := GetCollectedErrors(true).Get(1).Callstack;
        exit(Callstack.Substring(Callstack.IndexOf(Format(LF)) + 1));
    end;
}

Yea, but…why?

I dunno, I was just curious whether it was possible. And, it is 🧐 Any sensible applications are probably going to be do with error handling or reporting.

You may be tempted to have your code respond differently depending on the context in which it has been called and read the callstack for that purpose. That’s not a train you want to ride though. I’ve tried, it stops at some pretty weird stations.

One advantage of this approach over using a TryFunction (as below) is that the debugger doesn’t break on collectible errors. It can sometimes be frustrating stepping through errors that are always caught to get to the code that you actually want to debug.

procedure LessGoodGetCallstack(): Text
begin
    ThrowError();
    exit(GetLastErrorCallstack());
end;

[TryFunction]
procedure ThrowError()
begin
    Error('');
end;

Part 4: (Slightly) More Elegant Error Handling in Business Central

Collectible Errors

In part 3 we had a look at the new platform feature, collectible errors. Long story short: it makes scenarios where you want to collect and display multiple errors together (e.g. checking journal lines) much easier to code and read, no messing around with if Codeunit.Run then, read the post if you’re interested.

The only thing that let the team down was the horrible user interface to display the errors that have been collected. Shame, but at least the API has been designed with extensibility in mind and we can handle it ourselves. I’m using the same example as in the docs (but with a little added finesse).

GetCollectedErrors

There is a new global method, GetCollectedErrors. This will return a list containing the ErrorInfo objects which have been collected in the current transaction. That gives us all the detail that we need to display the errors in a user-friendlier way.

Show Errors Codeunit

I’ve created a new codeunit to accept the list of errors and show the standard Error Messages page. Of course, you can do whatever you want with this. Create a new table and page to display the errors if you like. For now, the Error Messages page does everything that I need.

See the commit with the changes here if you prefer: https://github.com/jimmymcp/error-message-mgt/commit/89b421db69656b3e2717a0b307975320c3d5ddef

codeunit 50103 "Show Errors"
{
    SingleInstance = true;

    procedure ShowErrors(Errors: List of [ErrorInfo]; Context: Variant)
    var
        TempErrorMsg: Record "Error Message" temporary;
        DataTypeMgt: Codeunit "Data Type Management";
        ErrorInfo: ErrorInfo;
        RecRef: RecordRef;
    begin
        if Errors.Count() = 0 then
            exit;

        foreach ErrorInfo in Errors do begin
            TempErrorMsg.LogDetailedMessage(ErrorInfo.RecordId, ErrorInfo.FieldNo, TempErrorMsg."Message Type"::Error, ErrorInfo.Message, ErrorInfo.DetailedMessage, '');
            if DataTypeMgt.GetRecordRef(Context, RecRef) then
                TempErrorMsg.Validate("Context Record ID", RecRef.RecordId());
            TempErrorMsg.SetErrorCallStack(ErrorInfo.Callstack);
            TempErrorMsg.Modify();
        end;

        Page.Run(Page::"Error Messages", TempErrorMsg);
        Error('');
    end;
}

A couple of notes on the above:

  • There is a SetContext method on the error message record which I’m not using. For reasons I’m not clear about that method sets the context fields on a global variable in the Error Message table, not on the record instance itself
  • Calling the Log methods on the Error Messages table captures the current callstack*, I’m overwriting that with the callstack on the Error Info object
  • The Error(”); on the final line ensures that code execution is stopped and the transaction rolled back. An error like this (outside of If Codeunit.Run then) cannot be collected

I’ve added the last line to the CheckLines method (the method which is collecting the errors):

[ErrorBehavior(ErrorBehavior::Collect)]
local procedure CheckLines(VideoCallBatch: Record "Video Call Batch")
var
    VideoJnlLine: Record "Video Journal Line";
    ShowErrors: Codeunit "Show Errors";
begin
    //check lines code
    ShowErrors.ShowErrors(GetCollectedErrors(), VideoCallBatch.RecordId());
end;

The result is something like this. The Error Messages page does the rest, drilling down to show the callstack, adding an action to open the related record, displaying the name of the field name related to the error.

Error Messages page

Check the docs for more info. The error info API includes IsCollectingErrors(), HasCollectedErrors() and ClearCollectedErrors().

*incidentally, with an ugly hack like this (https://tecman.typepad.com/software_answers/2016/07/context-of-events-in-microsoft-dynamics-nav.html) 😉 I’m not sure whether to be pleased that I wasn’t missing a better way to do it back then or disappointed that there still isn’t a better way to do it 6 years later.**

**Actually, maybe this could be improved with a collectible error?***

leonardo dicaprio dreaming GIF
***thinking about that for too long…

Tip: List-Commits

function List-Commits {
  cd 'C:\Git'
  $Commits = @()
  Get-ChildItem . -Directory | % {
    cd "$_"
    if (Test-Path (Join-Path (Get-Location) '.git')) {
      $Commits += git log --all --format="$($_)~%h~%ai~%s~%an" | ConvertFrom-Csv -Delimiter '~' -Header ('Project,Hash,Date,Message,Author'.Split(','))
    }
    cd ..
  }
  $Commits | ? Author -EQ "$(git config --get user.name)" | sort Date -Descending | Out-GridView -Title 'Commits'
}

This function iterates through Git repositories under the same parent folder (C:\Git in my case), builds a list of all the commits that you’ve authored (i.e. that match your user.name in Git config) and displays them in descending date order in a grid view.

Change the path in the second line to suit, or just remove it to have it search for repositories under the current directory.

Sample output in a PowerShell grid view

I use it to remind myself what I’ve been working on over the last few days. Mostly for fun and only occasionally because I’m late filling in my timesheets… 🙄