This is the fifth post in a multi-part post that describes using a GitHub Action and Terraform to deploy infrastructure in Azure. In this post I will create a GitHub Action that will run the Unicorn CI/CD pipeline.

Adding the GitHub Action

In GitHub, click on the Actions tab. GitHub has a lot of templates to get you started quickly. I figured I would use the Terraform template. However, after looking at the template, it didn’t do anything that I actually wanted. For example, it stores the .tfstate file in the Terraform Cloud and uses an API token to connect to it. I want to store my .tfstate file in Azure blob storage and connect to it with an Azure security principal using OAuth2. So I decided to start from scratch.

GitHub actions are a YAML file in a specific folder: ”{repo-root}/.github/workflows”. I created that folder structure with:

Next, I added a file to the workflows folder named main.yml and added some scaffolding for my GitHub Action.

The name of the Action is “Deploy Unicorn”. It runs whenever someone pushes to the main branch. It has a single job (so it runs on a single runner). It has two steps. First, it checks out the repository. Second, it runs the Linux command echo and prints “Hello, world!” to the log.

Branching Strategy

My use case is to implement a GitHub Action that supports the GitHub Flow branching strategy. The GitHub Flow is a simple branching strategy designed for very small teams. Basically, the main branch contains the current production code. Developers create feature branches to implement features and commit to the published upstream branch as they develop their features. Perhaps they test locally using VS Code or Visual Studio. When their feature is complete, they create a pull-request to merge from their feature branch to the main branch. This gives the dev lead an opportunity to review and comment on their code. When the pull-request is approved/complete, a GitHub Action runs that deploys the code to the production environment. To achieve this, the following things are required:

  • GitHub Action YAML is in ./.github/workflows directory
  • GitHub Branch Protection Ruleset with the “Require a pull request before merging” rule is active (this prevents people from pushing directly to main)
  • GitHub Action Trigger is:

Branch Rule Sets

GitHub supports rules, called Rulesets, that protect branches from a variety of misbehavior and bad practices. I do not want developers by-passing the code-review (pull-request) process and pushing their code directly to main and by extension, production. So, I’m going to create a branch Ruleset to protect the main branch.

Creating the branch Ruleset in GitHub

Click Settings, then under Code and Automation, click Rulesets. I gave my new ruleset a name, set it to Active, set the Target branch to default (the main branch) and checked the “Require a pull request before merging” checkbox. I require at least one approval. Finally, click Create.

Now, when I try to push directly to main, I get an error.

Error shown when you attempt to bypass the Ruleset

Pushing directly to the main branch is blocked by the Ruleset. Since I have created a Ruleset, I had to stash my changes, create a feature branch and pop the stashed files.

Refactoring the Terraform template

In order to run the Terraform template from the GitHub Action, we need to refactor it. The refactor involves the following steps.

  • Create the production GitHub Environment
  • Create secrets (for the security principal) in the production environment
  • Create variables (about where the state file is) in the production environment
  • Refactor main.yml to read the variables and secrets and set environment variables on the runner
  • Refactor unicorn.tf to remove the hard-coded state file information

Create the production GitHub environment

In the post about creating the security principal, I mentioned that the Unicorn project would use an GitHub Environment named “production”. This was related to the Subject of the Federated Credential. The Subject Identifier of the security principal is “repo:<github-organization-name>/unicorn:environment:production. So the environment name must be “production”. It’s time to create that environment. In GitHub, click Settings, then under Code and automation, click Environments and finally, the New environment button.

On the Environments/Add form, set the Name field to production and click the Configure environment button.

Create secrets (for the security principal) in the production environment

In the environment secrets, we need to create three secrets.

  • AZURE_CLIENT_ID
  • AZURE_SUBSCRIPTION_ID
  • AZURE_TENANT_ID

The values for these secrets can be found in the Azure portal. To find the subscription id, click on the search bar at the top of the portal (G+/) and start typing subscription. It should come right up. Click the Subscriptions icon and select your subscription from the list.

The client id and tenant id can be found in Microsoft Entra Id in App Registrations. Search for the security principals name (unicorn) under All Applications.

Once you have collected all the values, set the secrets in GitHub. Click Add environment secret.

The Add environment secret button in GitHub

Enter the secret name, value and click the Add secret button.

After the three environment secrets have been added, we will need add Environment variables.

Create variables in the production environment

In the unicorn.tf template, there is a block named “backend” that defines where the state file is located. We don’t want those values hard-coded, we want them defined in Environment variables. Use the Add environment variable button to add four variables. The values should be copied from the unicorn.tf Terraform template.

  • AZURE_STATEFILE_STORAGE_ACCOUNT_NAME
  • AZURE_STATEFILE_CONTAINER_NAME
  • AZURE_STATEFILE_STORAGE_ACCOUNT_RESOURCE_GROUP_NAME
  • AZURE_STATEFILE_KEY

Refactor main.yml to read the variables and secrets and set environment variables on the runner

Now that we have the secrets and variables set up in GitHub, refactor main.yml. Information about how to access the .tfstate file will be set as environment variables on the GitHub Action Runner.

When the GitHub Action Runner authenticates, it sets an envirionment variable with the value of the authentication token. To do that, it has to be granted permission. In main.yml, below the on: statement, add a permissions block.

Next, under the permissions block, use the env: keyword to set environment variables on the runner with values from the environment secrets and variables in GitHub.

What is going on here?

  • The env: statement is used to set environment variables on the GitHub Action Runner
  • The following lines set individual environment variables
  • ${{ env.AZURE_STATEFILE_CONTAINER_NAME }} uses the GitHub Repository Variable value
  • ${{ secrets.AZURE_CLIENT_ID }} uses the GitHub Repository Secret

The end result is that environment variables will be set on the runner that will later be consumed by terraform init command.

Before we can run terraform init, we need to set up Terraform on the runner. This can be done with the hashicorp/setup-terraform@v1 module. Below the steps: statement, under the actions/checkout@v4 statement, add the following block to main.yml.

Finally, we can run the Terraform init command. Add the following command to initialize the Terraform environment on the runner.

Now, what is going on here?

  • The run: is used to run a command in the shell
  • Call terraform init, the \ is the GitHub Actions YAML line continuation character
  • The following lines set parameters in the “backend-config” for Terraform and describe where to find the statefile; backend-config may be called repeatedly and the configurations are added, not replaced.

What isn’t clear is how terraform init authenticates to Azure. terraform init uses ARM_USE_AZUREAD=true to know that it is going to authenticate to Azure AD (Microsoft Entra ID) and ARM_USE_OIDC=true to know to use the OpenID Connect protocol. OpenId Connect requires the client id, subscription id and tenant id which are set with the ARM_CLIENT_ID, ARM_SUBSCRIPTION_ID and ARM_TENANT_ID environment variables. The environment variables must have those names or the terraform init statement will not work.

The entire main.yml file now looks like this.

Refactor unicorn.tf to remove the hard-coded state file information

The elements of the backend block can be removed from unicorn.tf, but leave the empty block there or you will get an error. unicorn.tf now looks like this.

Finally the changes are committed to the feature branch and published to the repo (not shown).

Over in GitHub, on the Code tab, I clicked Compare & pull request.

On the Comparing changes page, click Create pull request.

On the Open a pull request page, click Create pull request.

On the pull request page, click Merge pull request and Confirm merge (not shown).

Finally, the pull request screen showing a successful merge.

At this point, the Action ran and connected to the state file successfully.

This was a long post, and it covered a lot of ground. It covered:

  • Creating a barebones GitHub Action
  • Described the branching strategy
  • Creating the branch Ruleset
  • Refactoring the Terraform template
  • Creating the GitHub Environment
  • Creating GitHub Secrets and Variables
  • Refactoring main.yml to read the GitHub Secrets and Variables
  • Refactoring the Terraform template to remove hard-coded state file information
  • Running the GitHub Action

In the next post, we’ll look at adding the infrastructure that will host the service.