Hello from the GitHub Actions: Core .NET SDK
The unofficial .NET equivalent of the official GitHub actions/toolkit @actions/core project.
GitHub Actions: Core .NET SDK
SDKs are great, but why do we need one for GitHub Actions? The short answer is, you don’t! You can write a GitHub Action in .NET without using one. I’ve written about this already, if you’re interested see the Tutorial: Create a GitHub Action with .NET. There are a few ways in which you can write a GitHub Action. You can do so using JavaScript, Docker, or as a composite action. One of the easiest ways was to use JavaScript, as the GitHub team maintains an open-source SDK (or actions/toolkit
) written in TypeScript. Their toolkit is comprised of a set of packages to make creating actions easier.
I wanted to make it easier for .NET developers to write GitHub Actions, so I created an unofficial .NET equivalent of the official GitHub actions/toolkit @actions/core
and @actions/github
projects.
Why should I care about GitHub Actions?
If you’re unfamiliar with GitHub Actions, the TL;DR; is that they allow you to automate tasks such as building, testing, packaging, and deploying your code. If you want a deeper look beyond that definition, see GitHub Docs: Actions. Just know that GitHub Actions are a powerful tool, and the team has done an amazing job of making them easy to use.
You should care about GitHub Actions because they are a great way to automate by writing workflows. A GitHub Action is then consumed by a repositories workflow, which is a set of instructions that are executed when a specific event occurs. For example, you can create a workflow that runs when a pull request is created, or when a new release is published. You can also create a workflow that runs on a schedule, such as every day at 3:00 AM. Workflows are defined in a YAML file, and you can create as many workflows as you want. You can also create a workflow that is triggered by another workflow. For example, you can create a workflow that runs when a pull request is created, and then create another workflow that runs when that workflow completes. The possibilities are endless!
Inspiration from actions/toolkit
The actions/toolkit
specifies the standard I/O, and it defines specific environment variables that are used within a GitHub Action workflow. If you’re familiar with any of my other open-source projects, you’ll likely be aware of my appreciation for what 🤓 TypeScript has done for the web. Moreover, you know that I’m all about that C# 💜.
I’ve been working on various other projects that leverage TypeScript in the world of .NET. There are certainly parallel efforts in this regard, for more information, see Blazorators. As this relates to the actions/toolkit
specifically, let’s start by having a look at the possible inputs and outputs, and the formats in which they can be set.
Commanding the workflow
GitHub Action workflows rely heavily on environment variables. The following are the environment variables that are used to interact with the workflow:
GITHUB_ACTION
: The name of the action.GITHUB_OUTPUT
: A file path to a file containing any custom data that the workflow cares to write.GITHUB_PATH
: A file path to a file containing environment variables to add to thePATH
environment variable for all subsequent steps in the current job.GITHUB_STATE
: Custom user data that is persisted across workflow jobs and steps.GITHUB_TOKEN
: The token used to authenticate with GitHub. This value is provided by GitHub, and contextually available to make whateverpermissions
the workflow has available to it. This value is always written to the log in a masked format.
These are but a few of the environment variables that are available to a GitHub Action workflow. For a complete list, see GitHub Docs: Environment variables.
Outputs
The workflow can write outputs to a file, and even custom state. In the case of output, files are written to the file path from the GITHUB_OUTPUT
environment variable. The output in this scenario would be a JSON file that contains anything we want to serialize to and from. We’re not limited by that, it is trivial in C# with the System.IO
for reading and writing JSON, and either System.Text.Json
or Newtonsoft.Json
for JSON serialization of C# objects. In addition to files, any arbitrary string can be written to the log. The log is written to the standard output stream and is available to the workflow. Certain logged messages receive special treatment and are parsed by the workflow as commands. The following are the special messages that are parsed by the workflow:
Command name | Description |
---|---|
add-mask |
Registers a secret which will get masked from logs. |
add-path |
Prepends the input path to the PATH (for this action and future actions). |
echo |
Enables or disables the echoing of commands into stdout for the rest of the step. |
debug |
Writes debug message to user log. |
error |
Adds an error issue. |
warning |
Adds a warning issue. |
notice |
Adds a notice issue. |
group |
Begin an output group. |
endgroup |
End the output group. |
If you’re seeing these commands and wondering where the save-state
and set-output
commands are, they have been deprecated. For more information, see GitHub Actions: Deprecating save-state
and set-output
commands. Now that we’re familiarizing ourselves with the commands, let’s have a look at the @actions/core
package to see how it implements commanding the workflow.
TypeScript Command
object
This code is from the @actions/core
project, and it’s TypeScript code that represents a command. The command.ts file exposes a few functions to issue commands. Read through the contents of the actions/toolkit/blob/main/packages/core/src/command.ts file:
import * as os from 'os'
import {toCommandValue} from './utils'
export interface CommandProperties {
[key: string]: any
}
/**
* Commands
*
* Command Format:
* ::name key=value,key=value::message
*
* Examples:
* ::warning::This is the message
* ::set-env name=MY_VAR::some value
*/
export function issueCommand(
command: string,
properties: CommandProperties,
message: any
): void {
const cmd = new Command(command, properties, message)
process.stdout.write(cmd.toString() + os.EOL)
}
export function issue(name: string, message = ''): void {
issueCommand(name, {}, message)
}
const CMD_STRING = '::'
class Command {
private readonly command: string
private readonly message: string
private readonly properties: CommandProperties
constructor(command: string, properties: CommandProperties, message: string) {
if (!command) {
command = 'missing.command'
}
this.command = command
this.properties = properties
this.message = message
}
toString(): string {
let cmdStr = CMD_STRING + this.command
if (this.properties && Object.keys(this.properties).length > 0) {
cmdStr += ' '
let first = true
for (const key in this.properties) {
if (this.properties.hasOwnProperty(key)) {
const val = this.properties[key]
if (val) {
if (first) {
first = false
} else {
cmdStr += ','
}
cmdStr += `${key}=${escapeProperty(val)}`
}
}
}
}
cmdStr += `${CMD_STRING}${escapeData(this.message)}`
return cmdStr
}
}
function escapeData(s: any): string {
return toCommandValue(s)
.replace(/%/g, '%25')
.replace(/\r/g, '%0D')
.replace(/\n/g, '%0A')
}
function escapeProperty(s: any): string {
return toCommandValue(s)
.replace(/%/g, '%25')
.replace(/\r/g, '%0D')
.replace(/\n/g, '%0A')
.replace(/:/g, '%3A')
.replace(/,/g, '%2C')
}
A few things to observe about the preceding code:
- The
issueCommand
function is one of the two functions that are exported. It’s used to issue a command to the standard output stream. - The
issue
function is a convenience function that issues a command with noproperties
parameter. - The
Command
class is used to represent a command. It has atoString
method that returns the string representation of the command. - The
escapeData
andescapeProperty
functions are used to escape themessage
andproperties
of the command, respectively.
This is standard TypeScript code, but how might we want to implement this in C#? Let’s take a look at the Command
class in Microsoft.GitHub.Actions
project:
C# Command
object
While inspired, I decided this could be implemented in C#, and with a few minor tweaks — it was. Let’s start by discussing what this would look like in C#. The following is the C# implementation of the Command
object, this corresponds to the Microsoft.GitHub.Actions/Workflows/Command.cs:
namespace Microsoft.GitHub.Actions.Workflows;
/// <summary>
/// Command format:
/// <c>::name key=value,key=value::message</c>
/// </summary>
/// <example>
/// <list type="bullet">
/// <item><c>::warning::This is the message</c></item>
/// <item><c>::set-env name=MY_VAR::some value</c></item>
/// </list>
/// </example>
internal readonly record struct Command<T>(
string? CommandName = "missing.command",
T? Message = default,
IReadOnlyDictionary<string, string>? CommandProperties = default)
{
const string CMD_STRING = "::";
internal bool Conventional =>
CommandNames.IsConventional(CommandName);
/// <summary>
/// The string representation of the workflow command, i.e.;
/// <code>::name key=value,key=value::message</code>.
/// </summary>
public override string ToString()
{
StringBuilder builder = new($"{CMD_STRING}{CommandName}");
if (CommandProperties?.Any() ?? false)
{
builder.Append(' ');
foreach (var (isNotFirst, key, value)
in CommandProperties.Select(
(kvp, index) => (index is > 0, kvp.Key, kvp.Value)))
{
if (isNotFirst)
{
builder.Append(',');
}
builder.Append($"{key}={EscapeProperty(value)}");
}
}
builder.Append($"{CMD_STRING}{EscapeData(Message)}");
return builder.ToString();
}
static string EscapeProperty<TSource>(TSource? value) =>
value.ToCommandValue()
.Replace("%", "%25")
.Replace("\r", "%0D")
.Replace("\n", "%0A")
.Replace(":", "%3A")
.Replace(",", "%2C");
static string EscapeData<TSource>(TSource? value) =>
value.ToCommandValue()
.Replace("%", "%25")
.Replace("\r", "%0D")
.Replace("\n", "%0A");
}
I wanted the Command
object to be a readonly record struct
for immutability, value-based comparison semantics, deconstruction, and performance (it’s lightweight). The Command
object has a CommandName
property, a Message
property, and a CommandProperties
property. The CommandName
property is a string
that represents the name of the command, and it defaults to "missing.command"
. The Message
property is a generic type that represents the message of the command. The CommandProperties
property is a IReadOnlyDictionary<string, string>
that represents the properties of the command.
Command format
Workflow commands are written to the standard output stream. For a command with a name of name
, properties of { "key1" = "value", "key2" = "value" }
, and a message of message
, it’s formatted as follows when issued to the standard output stream:
::name key1=value,key2=value::message
Another example is when writing a debug message to the log:
::debug::This is a debug message
One final example is the set-env
command:
::set-env name=ENVIRONMENT_VARIABLE::value
All of these commands are delimited with the ::
characters. The command name is the first token after the first ::
delimiter. The command properties are the second token after the first ::
delimiter, following the first space character. The command properties are key/value pairs expressed with the =
character and delimited by the ,
character. The command message is optional. Any given Command
instance results in the appropriate command being written to the standard output stream.
Installing the NuGet package 📦
Welcome to the [Microsoft.GitHub.Actions] .NET SDK. This SDK is used to create GitHub Actions in .NET. The SDK is a thin wrapper around the .NET implementation of the GitHub Actions a select few packages from the @actions/toolkit
.
Microsoft.GitHub[.*]
namespace for the package. I’m not trying to mislead anyone, but I do want to make it clear that this is not an official product.
You’ll need to install the GitHub Actions: Core .NET SDK NuGet package to use the .NET APIs. The package is available on NuGet.org. The following is the command to install the package:
Adding the package reference
Either add the package reference to your project file:
<PackageReference Include="GitHub.Actions.Core" Version="0.0.1" />
Or use the dotnet add package
.NET CLI command:
dotnet add package GitHub.Actions.Core
Registering the services for consumption
The SDK is dependency injection ready, meaning that you can call the AddGitHubActions
extension method on the IServiceCollection
interface to register the services for consumption. The following is an example of how to register the services, and an example of how to consume the ICoreService
:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.GitHub;
using Microsoft.GitHub.Actions.Extensions;
using Microsoft.GitHub.Actions.Services;
using var provider = new ServiceCollection()
.AddGitHubActions()
.BuildServiceProvider();
var core = provider.GetRequiredService<ICoreService>();
try
{
// "who-to-greet" input defined in action metadata file
var nameToGreet = core.GetInput("who-to-greet");
core.Info($"Hello {nameToGreet}!");
await core.SetOutputAsync("time", DateTime.UtcNow.ToString("o"));
// Get the JSON webhook payload for the event that triggered the workflow
var payload = JsonSerializer.Serialize(Context.Current.Payload);
core.Info($"The event payload: {payload}");
}
catch (Exception ex)
{
core.SetFailed(ex.ToString());
}
Compare this to the following JavaScript code in the index.js file from the Creating a JavaScript action: Writing the action code article, which would achieve the same thing with the TypeScript toolkit:
const core = require('@actions/core');
const github = require('@actions/github');
try {
// `who-to-greet` input defined in action metadata file
const nameToGreet = core.getInput('who-to-greet');
console.log(`Hello ${nameToGreet}!`);
const time = (new Date()).toTimeString();
core.setOutput("time", time);
// Get the JSON webhook payload for the event that triggered the workflow
const payload = JSON.stringify(github.context.payload, undefined, 2)
console.log(`The event payload: ${payload}`);
} catch (error) {
core.setFailed(error.message);
}
Issuing commands
Instead of being able to export
any custom data structures, and corresponding functions, the Command
object is internal readonly record struct
that can be used to issue commands. The following is the C# implementation of the issueCommand
and issue
functionality:
namespace Microsoft.GitHub.Actions.Commands;
internal interface ICommandIssuer
{
void IssueCommand<T>(
string commandName,
IReadOnlyDictionary<string, string>? properties = default,
T? message = default);
void Issue<T>(string commandName, T? message = default);
}
I’m a big fan of triple-slash comments, and as a consumer of many various APIs written by different authors, I’ve enjoyed learning APIs with good and meaningful triple-slash comments. I say all of this to say, that I’ve omitted them from the code pasted here. If you want to see them, check them out on GitHub. The ICommandIssuer
interface has two methods, IssueCommand
, and Issue
. Consider the implementation that’s defined in the following DefaultCommandIssuer
class:
namespace Microsoft.GitHub.Actions.Commands;
internal sealed class DefaultCommandIssuer : ICommandIssuer
{
private readonly IConsole _console;
public DefaultCommandIssuer(IConsole console) => _console = console;
public void Issue<T>(string commandName, T? message = default) =>
IssueCommand(commandName, null, message);
public void IssueCommand<T>(
string commandName,
IReadOnlyDictionary<string, string>? properties = default,
T? message = default)
{
var cmd = new Command<T>(
commandName, message, properties);
if (cmd is not { Conventional: true })
{
_console.WriteLine("Issuing unconventional command.");
}
var commandMessage = cmd.ToString();
_console.WriteLine(commandMessage);
}
}
Some commands require interactions with the file system. This functionality is defined as file commands.
File commands
In addition to standard commands, file commands are also supported. File commands write to a file instead of the standard output stream. A file command is free to write any value to the file. The following are file-based commands:
add-path
: Writes the value to the file path from theGITHUB_PATH
environment variable.save-state
: Writes the value to the file path from theGITHUB_STATE
environment variable.set-env
: Writes the value to the file path from theGITHUB_ENV
environment variable.set-output
: Writes the value to the file path from theGITHUB_OUTPUT
environment variable.
The corresponding .NET APIs that are used to issue file commands are:
ICoreService.AddPathAsync
: Issues anadd-path
command.ICoreService.ExportVariableAsync
: Issues theset-env
command.ICoreService.SaveStateAsync
: Issues thesave-state
command.ICoreService.SetOutputAsync
: Issues theset-output
command.
The IFileCommandIssuer
interface
namespace Microsoft.GitHub.Actions.Commands;
internal interface IFileCommandIssuer
{
ValueTask IssueFileCommandAsync<TValue>(string commandSuffix, TValue message);
string PrepareKeyValueMessage<TValue>(string key, TValue value);
}
The IFileCommandIssuer
interface is used to issue file-based commands. The DefaultFileCommandIssuer
class implements this interface:
namespace Microsoft.GitHub.Actions.Commands;
internal sealed class DefaultFileCommandIssuer : IFileCommandIssuer
{
private readonly Func<string, string, ValueTask> _writeLineTask;
public DefaultFileCommandIssuer(
Func<string, string, ValueTask> writeLineTask) =>
_writeLineTask = writeLineTask.ThrowIfNull();
ValueTask IFileCommandIssuer.IssueFileCommandAsync<TValue>(
string commandSuffix, TValue message)
{
var filePath = GetEnvironmentVariable($"{GITHUB_}{commandSuffix}");
if (string.IsNullOrWhiteSpace(filePath))
{
throw new Exception(
"Unable to find environment variable for file " +
$"command suffix '{commandSuffix} ({GITHUB_}{commandSuffix})'.");
}
if (File.Exists(filePath) is false)
{
throw new Exception(
$"Missing file at path: '{filePath}' " +
$"for file command '{commandSuffix}'.");
}
return _writeLineTask.Invoke(filePath, message.ToCommandValue());
}
string IFileCommandIssuer.PrepareKeyValueMessage<TValue>(
string key, TValue value)
{
var delimiter = $"ghadelimiter_{Guid.NewGuid()}";
var convertedValue = value.ToCommandValue();
// These should realistically never happen, but just in case someone finds a
// way to exploit uuid generation let's not allow keys or values that contain
// the delimiter.
if (key.Contains(
delimiter, StringComparison.OrdinalIgnoreCase))
{
throw new Exception(
$"Unexpected input: name should not contain the delimiter {delimiter}");
}
if (convertedValue.Contains(delimiter, StringComparison.OrdinalIgnoreCase))
{
throw new Exception(
$"Unexpected input: value should not contain the delimiter {delimiter}");
}
return $"{key}<<{delimiter}{NewLine}{convertedValue}{NewLine}{delimiter}";
}
}
This code is derived from the TypeScript implementation of the core/src/file-command.ts file:
import * as fs from 'fs'
import * as os from 'os'
import {v4 as uuidv4} from 'uuid'
import {toCommandValue} from './utils'
export function issueFileCommand(
command: string, message: any): void {
const filePath = process.env[`GITHUB_${command}`]
if (!filePath) {
throw new Error(
`Unable to find environment variable ` +
`for file command ${command}`
)
}
if (!fs.existsSync(filePath)) {
throw new Error(`Missing file at path: ${filePath}`)
}
fs.appendFileSync(
filePath, `${toCommandValue(message)}${os.EOL}`, {
encoding: 'utf8'
})
}
export function prepareKeyValueMessage(
key: string, value: any): string {
const delimiter = `ghadelimiter_${uuidv4()}`
const convertedValue = toCommandValue(value)
// These should realistically never happen, but just in case someone finds a
// way to exploit uuid generation let's not allow keys or values that contain
// the delimiter.
if (key.includes(delimiter)) {
throw new Error(
`Unexpected input: name should not ` +
`contain the delimiter "${delimiter}"`
)
}
if (convertedValue.includes(delimiter)) {
throw new Error(
`Unexpected input: value should not ` +
`contain the delimiter "${delimiter}"`
)
}
return `${key}<<${delimiter}${os.EOL}${convertedValue}${os.EOL}${delimiter}`
}
The C# implementation matches the TypeScript implementation, except for the use of ValueTask
instead of void
for the IssueFileCommandAsync
method. All of the command-issuing functionality is internal to the ICoreService
implementation.
The SDK continues to evolve ⚒️
More than 90% of the functionality of the packages/src/core.ts
and packages/github/src/github.ts
files have been implemented in the SDK.
I’m not going to paste more than 100 lines for multiple files, this blog post isn’t the place for that. If you’re interested in seeing the rest of the SDK, you can find it on GitHub. The SDK is still evolving, and the following features are still in development:
- I want to create a GitHub repository template that can be used to create a new containerized .NET GitHub Action.
- I want to continue working on adding missing functionality to the SDK.
- I want to implement the other packages that exist in the actions/toolkit.
The future is bright ☀️
I’m always open to collaborating with developer community members, 🙌🏽 who are interested in helping this code live on to help others. I do, however, agree with the wise sentiment of Kelsey Hightower in the following Tweet:
But I’m willing to try to make it work. I encourage you to explore the code and be sure to see the sample use cases.
Share this post
Sponsor
Twitter
Facebook
Reddit
LinkedIn
StumbleUpon
Email