Effortlessly Configure GitHub Repositories for Azure Deployment via OIDC

Scripting your Azure-Ready GitHub Repository using Azure and GitHub CLI
Published on Monday, October 23, 2023

What if we could script the creation and configuration of a GitHub Repository so that it is ready to provision or deploy Azure resources from a GitHub Actions pipeline? We will do that in this article using the Azure CLI and GitHub CLI.

The Objective

The goal is to go from nothing to running a GitHub Actions workflow that authenticates to Azure using Open ID Connect (so without secret credentials) in a newly created GitHub repository.

The workflow we plan to run is as follows:

name: Run Azure Login with OIDC
on:
  workflow_dispatch:

permissions:
  id-token: write
  contents: read
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 'Az CLI login'
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: 'Run az commands'
        run: |
          az account show
          az group list

This workflow is an example coming from the GitHub documentation showing how to configure GitHub Actions workflow to access Azure resources protected by Microsoft Entra.

To run this workflow we will need to automate the configuration of these resources:

A diagram showing the interactions between Azure and GitHub.

💬 Looks familiar? That's the same diagram from my article about creating an Azure-Ready GitHub Repository using Pulumi. The purpose was the same but using Pulumi instead of CLI tools. If you prefer a declarative Infrastructure as Code approach using programming languages over CLI tools, you should definitively read it 😉

The Script

A word about the tools used

I will be using PowerShell which is cross-platform. However, if you prefer using a different shell, you will simply need to adjust some syntax (such as the environment variable declarations) to ensure compatibility.

To create and configure the Microsoft Entra ID resources, we will need the Azure CLI.

To create and configure the GitHub repository, we will need the GitHub CLI.

Create the repository on GitHub

Let's assume we are already in a new directory with the YAML workflow file .github\workflows\main.yml in it.

First, we can initialize the git repository.

git init
git add .
git commit -m "Intialize repository with the GitHub Actions workflow file"

Second, we can create the GitHub repository and push the git repository we just initialized in it.

$repositoryName = "MyAzureReadyRepository"
gh repo create $repositoryName --private --source=. --push

💡You can use the --public flag instead of the --private one if you want your GitHub repository to be public.

The repository's full name (containing the organization name) can be retrieved like this:

$repositoryFullName=$(gh repo view --json nameWithOwner -q ".nameWithOwner")

💡Passing the --json flag converts the output format to JSON which, combined with the --q flag can be handy for filtering or formatting a command output. More on that in the documentation

Create the Microsoft Entra ID resources

Later, we will need the subscription and the tenant identifiers. Let's retrieve them now and take this opportunity to check that we are logged in on the correct tenant with the correct subscription selected.

$subscriptionId=$(az account show --query "id" -o tsv)
$tenantId=$(az account show --query "tenantId" -o tsv)

💬 Similar to the GitHub CLI, the Azure CLI has a --query flag to filter a command output. There are also different output formats. The tsv (tab-separated values) one is useful for capturing a value in an environment variable. If you are not very familiar with the Azure CLI, you can check my article on the topic here

To create the app registration and its associated service principal, we can execute the following commands:

$appId=$(az ad app create --display-name "GitHub Action OIDC for ${repositoryFullName}" --query "appId" -o tsv)
$servicePrincipalId=$(az ad sp create --id $appId --query "id" -o tsv)

We can now assign the contributor role to the service principal on the subscription.

az role assignment create --role contributor --subscription $subscriptionId --assignee-object-id  $servicePrincipalId --assignee-principal-type ServicePrincipal --scope /subscriptions/$subscriptionId

Creating federated credentials is a bit more complex as one of the arguments needs to be an in-line JSON string.

$parametersJson = @{
    name = "FederatedIdentityForWorkshop"
    issuer = "https://token.actions.githubusercontent.com"
    subject = "repo:${repositoryFullName}:ref:refs/heads/main"
    description = "Deployments for ${repositoryFullName}"
    audiences = @(
        "api://AzureADTokenExchange"
    )
}

💡The subject property here specifies that the GitHub Actions workflow from the created repository is only authorized to authenticate to Azure when it runs on the main branch. Of course, there are other possible configurations, such as those involving pull requests or environments. Consult the documentation to learn more about these options.

To make this JSON string an inline string with escaped quotes that works for the Azure CLI, we have to transform the string using a command I found in this blog article.

$parameters = $($parametersJson | ConvertTo-Json -Depth 100 -Compress).Replace("`"", "\`"")

And finally, we can create the federated credentials.

az ad app federated-credential create --id $appId --parameters $parameters

Configure the GitHub Actions and run the workflow

For the OIDC authentication to function properly, we need to set 3 GitHub Actions Secrets (could also be GitHub Actions variables as there are not really secrets):

  1. The identifier of the Azure tenant

  2. The identifier of the Azure subscription

  3. The application identifier of the app registration

gh secret set AZURE_TENANT_ID --body $tenantId
gh secret set AZURE_SUBSCRIPTION_ID --body $subscriptionId
gh secret set AZURE_CLIENT_ID --body $appId

We can directly run the workflow from the GitHub CLI, and watch the run until it is completed.

gh workflow run main.yml
$runId=$(gh run list --workflow=main.yml --json databaseId -q ".[0].databaseId")
gh run watch $runId
Screenshot of the GitHub Actions workfow run

Full script

# Initialize git repository with current code
# You should have added the main.yml workflow file in the `.github\workflows` directory 
git init
git add .
git commit -m "Intialize repository with the GitHub Actions workflow file"

# Create a new remote private GitHub repository
$repositoryName = "MyAzureReadyRepository"
gh repo create $repositoryName --private --source=. --push

# Retrieve the repository full name (org/repo)
$repositoryFullName=$(gh repo view --json nameWithOwner -q ".nameWithOwner") 

# Retrieve the current subscription and current tenant identifiers 
$subscriptionId=$(az account show --query "id" -o tsv)
$tenantId=$(az account show --query "tenantId" -o tsv)

# Create an App Registration and its associated service principal
$appId=$(az ad app create --display-name "GitHub Action OIDC for ${repositoryFullName}" --query "appId" -o tsv)
$servicePrincipalId=$(az ad sp create --id $appId --query "id" -o tsv)

# Assign the contributor role to the service principal on the subscription
az role assignment create --role contributor --subscription $subscriptionId --assignee-object-id  $servicePrincipalId --assignee-principal-type ServicePrincipal --scope /subscriptions/$subscriptionId

# Prepare parameters for federated credentials
$parametersJson = @{
    name = "FederatedIdentityForWorkshop"
    issuer = "https://token.actions.githubusercontent.com"
    subject = "repo:${repositoryFullName}:ref:refs/heads/main"
    description = "Deployments for ${repositoryFullName}"
    audiences = @(
        "api://AzureADTokenExchange"
    )
}

# Change parameters to single line string with escaped quotes to make it work with Azure CLI
# https://medium.com/medialesson/use-dynamic-json-strings-with-azure-cli-commands-in-powershell-b191eccc8e9b
$parameters = $($parametersJson | ConvertTo-Json -Depth 100 -Compress).Replace("`"", "\`"")

# Create federated credentials
az ad app federated-credential create --id $appId --parameters $parameters

# Create GitHub secrets needed for the GitHub Actions
gh secret set AZURE_TENANT_ID --body $tenantId
gh secret set AZURE_SUBSCRIPTION_ID --body $subscriptionId
gh secret set AZURE_CLIENT_ID --body $appId

# Run workflow
gh workflow run main.yml
$runId=$(gh run list --workflow=main.yml --json databaseId -q ".[0].databaseId")
gh run watch $runId

# Open the repostory in the browser
gh repo view -w

Final Thoughts

I am very glad to have scripted the creation and configuration of a GitHub repository ready to deploy to Azure. Even if I had already done the same using Pulumi, having a small script can sometimes be more convenient than having a full IaC program. In my case, I needed to automate that for a workshop, so it was easier to give participants a script to execute.

However, I must admit that developing this script proved to be much more challenging than provisioning the same resources using Pulumi. I didn't expect it to take so much time: browsing the CLI documentation, finding the correct syntax, and understanding the cause of failures. In contrast, using the GitHub and Azure Pulumi providers in my TypeScript code turned out to be a much more enjoyable experience.

Nevertheless, I was pleased to be introduced to the GitHub CLI, which I hadn't explored extensively until now. While I found it very useful, a few things bothered me. Not all commands can be used with the --json and -q parameters, which is not very convenient for scripting. Commands that create things (repo, workflow runs) don't return the identifier of the thing they create. I wish GitHub CLI would be more similar to Azure CLI in these matters. I have no doubt these will be improved over time.

As for Azure CLI, I am still a big fan, although a bit disappointed to have struggled with the inline JSON string.

Keep learning, keep sharing.