GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub

We rely on GitHub Cost Centers to split the GitHub bill across the different entities that make up our company. What resource is tied to which cost center is managed in the GitHub Enterprise Settings.

GitHub Billing - Let Organization and Repository admins set the Cost Center in GitHub
Photo by Umesh Soni / Unsplash

We rely on GitHub Cost Centers to split the GitHub bill across the different entities that make up our company. What resource is tied to which cost center is managed in the GitHub Enterprise Settings and for now this can only be done be a user with Enterprise Owner or Billing Administrator permissions.

I'd been searching for a way to allow Organization Owners and Repository Administrators to set the Cost Center on a repository themselves without having to involve one of these highly privileged accounts. We found a solution using GitHub Actions and Repository Custom Properties.

Since my last blog post on Cost Centers, a few small improvements were released by GitHub:

  1. There is now an API to associate a Repository with a Cost Center.
  2. It's now possible to associate a Repository to a different Cost Center than the Organization that owns it.

These two improvements allow us to solve this problem.

GitHub’s new Billing - Assigning Cost Centers in Bulk
I’ve recently set up the internal cost centers to split our GitHub enterprise bill according to cost centers defined in GitHub. Cost Centers are part of GitHub’s new Billing experience which is currently rolling out to GitHub Enterprise customers and which is also coming to GitHub for Teams.

Creating the Custom Property

GitHub has a feature called Custom Properties for repositories which allows you to link arbitrary data to a repo. You can define these properties at the Enterprise, Organization and Repository level.

Since our cost centers are defined at the Enterprise level, I chose to create this custom property at the same level. To add a new custom property navigate to your enterprise, select the Policies tab and then drill down into Repository and then Custom Properties. Choose New Property to create your new property:

Navigate to the Custom Properties section and click new property

Give the property a clear Name and Description. Since our list of cost centers is clearly defined, I've chosen the Type single-select to allow our users to pick a cost center from a predefined list.

Give the property a unique name and set the type to single select

Next, add the list of cost centers you want people to be able to select from as options. I've also included an option inherit which I'll explain later:

Add the required cost-center options.

And finally make the field required and set the default value to inherit.

Set the permission to allow users with properties permission to edit this field, make it required and set the default value.

Now when you create a new repository or navigate to the settings of an existing repository, you can set the cost-center custom field:

Users are required to select a cost center when creating a new repository.

And existing repositories will now show the custom property in the repository settings:

The reason for Inherit

In the above example we added a special value inherit, when this value is set, we don't actually set any cost center on the repository and this causes all costs generated by this repository to automatically flow to the cost center associated to the Organization. If the Organization isn't associated to a cost center, the costs will flow to the Enterprise.

Making it all work using GitHub Actions

Creating the Custom Property itself doesn't do anything by itself. In order to actually associate the Repository to the Cost Center, we still need to update the configuration in the GitHub Enterprise Billing & Licensing section:

Billing Administrators can associate Organizations, Repositories and Users to Cost Centers

Without additional automation each repository needs to be manually added to a cost center by editing the Cost Center and adding the Repository. This is a tedious manual process.

Instead, to update the Cost Center configuration I've created a GitHub Actions workflow that runs once a day and compares the Cost Center configuration against the values of the Custom Properties and performs updates to the Cost Center configuration if needed.

The basic workflow is quite simple:

  • Query the current Cost Center configuration
  • Query all Organizations in our GitHub Enterprise
  • Query all Repositories in each GitHub Organization
  • Query the Custom Properties for each Repository
  • Update the Cost Center configuration in case it doesn't match the Custom Property

The workflow relies on the GitHub CLI t0 query the data.

$costCenters = (invoke-gh -fromJson -- api /enterprises/$enterprise/settings/billing/cost-centers).costCenters

$orgs = get-allorganizations $enterprise | % { $_.login }

update-repocostcenters

The update-repocostcenters function checks which updates are needed:

function update-repocostcenters {
    Write-Output "Updating Costcenters for repositories in $enterprise." 
    
    foreach ($org in $orgs) {
    
        Write-Output "Processing organization $org"
        $repos = invoke-gh -slurp api /orgs/$org/repos --paginate --jq '.[] | { name: .name, full_name: .full_name }' | ConvertFrom-Json
        
        foreach ($repo in $repos) { 
        
            $customProperties = invoke-gh api /repos/$($repo.full_name)/properties/values --jq '.' | ConvertFrom-Json
            $repoCostCenterProperty = $customProperties | Where-Object { $_.property_name -eq "cost-center" } | Select-Object -ExpandProperty value -ErrorAction Ignore
            $repo | Add-Member -NotePropertyName cost_center -NotePropertyValue $repoCostCenterProperty -Force
            
            $currentCostCenter = $costCenters | ? { $_.resources | ? { $_.type -eq "Repo" -and $_.name -eq $repo.full_name } }
            
            if ( $repo.cost_center -eq "inherit" ) {
                $repo.cost_center = $null
            }

            $targetCostCenter = $null

            if ($repo.cost_center) {
                $targetCostCenter = $costCenters | ? { $_.name -eq $repo.cost_center }
                if (-not $targetCostCenter) {
                    Write-Warning "Costcenter for repository $($repo.full_name) not found."
                }
            }

            if ($null -eq $currentCostCenter) {
                if ($null -ne $targetCostCenter) {
                    Write-Output "Updating costcenter for repository $($repo.full_name) from unassigned to $($targetCostCenter.name)."
                    Update-CostCenterResources -Handles $repo.full_name -ResourceType "Repo" -Action "Add" -CostCenter $targetCostCenter -Enterprise $enterprise
                }
                else {
                    Write-Verbose "Repository $($repo.full_name) does not have a cost center assigned."
                }
            }
            else {
                if ($null -eq $targetCostCenter) {
                    Write-Verbose "Repository $($repo.full_name) does not have a cost center assigned."
                    Write-Output "Updating costcenter for repository $($repo.full_name) from $($currentCostCenter.name) to unassigned."
                    Update-CostCenterResources -Handles $repo.full_name -ResourceType "Repo" -Action "Delete" -CostCenter $currentCostCenter -Enterprise $enterprise
                }
                elseif ($currentCostCenter.id -ne $targetCostCenter.id) {
                    Write-Output "Updating costcenter for repository $($repo.full_name) from $($currentCostCenter.name) to $($targetCostCenter.name)."
                    Update-CostCenterResources -Handles $repo.full_name -ResourceType "Repo" -Action "Delete" -CostCenter $currentCostCenter -Enterprise $enterprise
                    Update-CostCenterResources -Handles $repo.full_name -ResourceType "Repo" -Action "Add" -CostCenter $targetCostCenter -Enterprise $enterprise
                }
            }
        }
    }
}

Unfortunately, you can't simply update the associated Cost Center in a single call, when changing the current value, an add plus delete is required.

I shared the Update-CostCenterResources function in a previous blogpost. GitHub added the ability to set Organizations and Repositories recently, so here is the updated version:

function Update-CostCenterResources {
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Handles,

        [ValidateSet('User', 'Repo', 'Org')]
        [string]$ResourceType = "User",

        [Parameter(Mandatory = $true)]
        [ValidateSet('Add', 'Delete')]
        [string]$Action,
        
        [Parameter(Mandatory = $true)]
        $CostCenter,
        
        [Parameter(Mandatory = $true)]
        [string]$Enterprise
    )

    switch ($Action) {
        'Add' {
            $method = 'POST'
            $Handles = $Handles | Where-Object { 
                $handle = $_
                return (($costCenter.resources | ? { $_.type -eq $ResourceType } | ? { $_.name -eq $handle }).Count -eq 0)
            }
        }
        'Delete' {
            $method = 'DELETE'
            $Handles = $Handles | Where-Object { 
                $handle = $_
                return (($costCenter.resources | ? { $_.type -eq $ResourceType } | ? { $_.name -eq $handle }).Count -gt 0)
            }
        }
    }

    # Call fails when processing too many users at once. Thus batching the calls...
    $count = 0
    do {
        $batch = $Handles | Select-Object -Skip $count -First 30
        $count += $batch.Count

        if ($batch.Count -gt 0) {
            switch ($ResourceType) {
                'User' { 
                    $body = @{
                        users = [string[]]$batch
                    }
                }
                'Org' { 
                    $body = @{
                        organizations = [string[]]$batch
                    }
                }
                'Repo' { 
                    $body = @{
                        repositories = [string[]]$batch
                    }
                }
            }
            
            
            $_ = ($body | ConvertTo-Json) | gh api --method $method /enterprises/$Enterprise/settings/billing/cost-centers/$($CostCenter.id)/resource --input -
        }
    } while ($batch.Count -gt 0)
}

All of this runs from a simple GitHub workflow which has access to a Personal Access token with the required permissions to access the Cost Center information as well as sufficient permissions to query all Repositories in all Organizations in the enterprise.

name: Set Costcenters

on:
  schedule:
    - cron: "5 4 * * *"

jobs:
  update:
    permissions: 
      contents: read
      
    runs-on: ubuntu-latest
    
    steps:      
    - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
      with:
        fetch-depth: 0
        
    - name: Assign Costcenters
      run: |
         .\assign-costcenters.ps1
      env:
        GH_TOKEN: ${{ secrets.GH_TOKEN }}
      shell: pwsh 

You can find the whole script here:

Update Repository Cost Centers based on Custom Properties
Update Repository Cost Centers based on Custom Properties - update-repocostcenters.ps1