Dynamic GitHub Templates

Templates are useful way to bootstrap a repository, once I see a pattern in repositories, I will create a template version of the repository locally and use rysnc to copy files across to a newly created repository. This works when you’re the only one producing the repositories, but that’s almost never the case. I wanted to share my template with others.

I started by creating a template repository in GitHub, this works great for a simple template that contains dotfiles and GitHub workflows. It doesn’t retain history from template repository which is perfect (something GitLab used to do from memory, this might have changed though).

But I started to question whether we can make these dynamic. In order to try prove this out I started research the simplest of tasks “Could we update the title in the README.md of the new repository?”

You can loose yourself in conversations about platforms as a service (PaaS) and tooling that will support this dream. But I stumbled across a GitHub discussion with a helpful comment, that suggested using a GitHub workflow which runs on the create event. This got the cogs turning. And now I’m doing exactly what a consultant for a PaaS provider once said, “You guys will end up building your own first, everyone always does”.

The approach I decided on was a workflow that made the updates and creates a pull request with those updates, allowing the creator of that repo to review them. I could have made the commit straight from workflow to the default branch but I had some other plans which mean the PR made sense.

The on.create runs on the creation of new branches, in order to limit it to only running once for repository, we check the github.run_number is 1, this could be extended further to only run on default branches. The Update files step replaces the a placeholder in the README.md and removes the bootstrap workflow.

I also added on.workflow_dispatch, this allowed me to test the workflow in the template repository itself deleting the branch and pull request after the testing was complete.

The simplest version of the workflow looked like this:

name: Template Bootstrap

on:
  create:
  workflow_dispatch:

jobs:
  template:
    name: Template Bootstrap
    if: |
      (github.event_name == 'create' && github.run_number == 1) ||
      github.event_name == 'workflow_dispatch'      
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull_request: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.repository.default_branch }}

      - name: Update files
        env:
          GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
        run: |
          sed -i "1s/repo_name/${GITHUB_REPOSITORY_NAME}/g" README.md
          rm .github/workflows/template-bootstrap.yml          

      - name: Create Pull Request
        uses: peter-evans/create-pull-request@v6
        with:
          commit-message: "initial: customise template"
          branch: initial/template
          title: "initial: customise template"
          body: |
            Automated changes by [Workflow Run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).            

This worked perfectly and had the desired effect but the cogs continued to turn.

The template was for a terraform projects and I wanted to update the state backend. The problem is the state backend could point to different environments. So there was decision for the consumer of the template.

What I decided to do was use a review comments to make a suggestion allowing the consumer to choose which environment.

A review comment can only be made on a change so I needed to create a change to be able to make the suggestion. The starting point is a main.tf, with a commented out backend.

terraform {
  # backend "azurerm" {
  # }
}

This had multiple benefits we had a Terraform Workflow setup that would run on creation of the repository a backend pointing nowhere would mean the terraform.tfstate would get created but then forgotten about.

The Update files step was updated to uncomment the backend in main.tf, this meant that the Terraform Workflow now fails because it doesn’t know where to send the state. Which again is perfect because it means the reviewer needs to do something before accepting the changes.

- name: Update files
  env:
    GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
  run: |
    sed -i "1s/repo_name/${GITHUB_REPOSITORY_NAME}/g" README.md
    sed -i "2,3s/^  # /  /g" main.tf
    rm .github/workflows/template-bootstrap.yml    

In order to help the reviewer the workflow would suggest the available options for the environment. This was done using actions/github-script and github.rest.pulls.createReviewComment. The pull_request and pull_request_head_sha both provided as outputs to from peter-evans/create-pull-request.

- name: Comment terraform backend update
  uses: actions/github-script@v7
  env:
    pull_request: ${{ steps.pr.outputs.pull-request-number }}
    pull_request_head_sha: ${{ steps.pr.outputs.pull-request-head-sha }}
  with:
    github-token: ${{ steps.github-app-token.outputs.token }}
    script: |
      const { pull_request, pull_request_head_sha } = process.env;
      const output = `
      Review the suggestions below

      If this project is targeting \`Development\`, apply this:

      \`\`\`suggestion
        backend "azurerm" {
          tenant_id            = "00000000-0000-0000-0000-000000000000"
          subscription_id      = "00000000-0000-0000-0000-000000000000"
          resource_group_name  = "StorageAccount-ResourceGroup-Dev"
          storage_account_name = "terraformdev"
          container_name       = "tfstate"
          key                  = "${context.repo.repo}.tfstate"
        }
      \`\`\`

      If this project is targeting \`Production\`, apply this:

      \`\`\`suggestion
        backend "azurerm" {
          tenant_id            = "00000000-0000-0000-0000-000000000000"
          subscription_id      = "00000000-0000-0000-0000-000000000000"
          resource_group_name  = "StorageAccount-ResourceGroup-Prod"
          storage_account_name = "terraformprod"
          container_name       = "tfstate"
          key                  = "${context.repo.repo}.tfstate"
        }
      \`\`\`
      `
      github.rest.pulls.createReviewComment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        pull_number: pull_request,
        body: output,
        commit_id: pull_request_head_sha,
        path: "main.tf",
        start_line: 2,
        line: 3
      })      

The reviewer can then accept a suggestion which updates the backend, approve and merge. Then start writing the infrastructure code they intended.

The last part is very terraform specific but the concept applies to any changes that require a decision.