🔥Let’s Do DevOps: Auto-Disable Inactive GitHub Copilot Licenses! 🚀
This blog series focuses on presenting complex DevOps projects as simple and approachable via plain language and lots of pictures. You can…
This blog series focuses on presenting complex DevOps projects as simple and approachable via plain language and lots of pictures. You can do it!
Hey all!
I’ve been diving head-first into generative AI wherever I can, and one place we’ve done that is to enable GitHub Copilot for a bunch of folks to test out. This is a proof of concept, where we have different folks from different backgrounds trying it out, and seeing if they like it, and if it’s useful.
Some folks love it (Including me)! Other folks never had time to test it. However, the licensing costs to enable their seat for CoPilot ($19/month!) continue getting billed regardless. Which is weird, because they never used it.
The data for who is active in CoPilot in our GitHub Org is available on the web portal, so it’s easy enough to go in there and deactivate any users who haven’t been active recently — but why do it by hand?
So I wrote an Action that can read all the users that have licenses installed in an Org, and then check when they were last active, and disable those that haven’t been active beyond a threshold (30 days by default).
If you want to just skip ahead to using it, scroll to the bottom of this page to find the Action in the marketplace, ready for you to use ✨now✨.
Let’s talk about how it works!
Action Code — Global
Let’s walk through the action. You can find the source here.
First, we define the name and description — this is for the humans. Then we define the inputs
. The github-org
is the name of your Org, it’s generally the name in your URL at: https://github.com/org-name-is-here/repo-name-is-here.
The github-pat
is a PAT (Personal Access Token) from any user authorized to do stuff in your Org. The user should
be an automation user, but feel free to use a PAT for your personal user. The PAT will need two interesting things:
If your Org uses SSO, you need to authorize the PAT for the Org you are targeting.
Second, the PAT will need to be granted the following rights — manage_billing:copilot. Else you’ll get a 404 error for each request.
Last, the max-days-inactive
. Feel free to leave the default of 30 days if that fits your use case.
name: Remove CoPilot from Inactive Users | |
description: I check all Org CoPilot users and remove them if they are inactive for too long | |
inputs: | |
github-org: | |
description: Name of the GitHub Organization | |
required: true | |
github-pat: | |
description: PAT used for authenticting to Org | |
required: true | |
max-days-inactive: | |
description: Max days of inactivity before user considered inactive | |
required: true | |
default: '30' |
Then we start the Composite
action by mapping the input vars to local vars.
### | |
### Set vars | |
### | |
max_days_inactive="${{ inputs.max-days-inactive }}" | |
gh_org="${{ inputs.github-org }}" | |
GITHUB_TOKEN="${{ inputs.github-pat }}" |
Next we need to establish some baseline dates. The date strings from the date
command and the github API are in different formats, and comparing dates even in the same format is a challenge! The easiest way to beat this challenge is to use Unix time/epoch time (ref). This is the number of seconds since Jan 1, 1970 in UTC timezone. Those values are much easier to deal with.
So first we get the number of seconds to the current date (line 1), then we calculate the number of seconds that represent the inactivity time, which in the default case is 30 days (2,592,000 seconds!) on line 2. And then on line 3 we find the number of seconds that represent the “cutoff” time, the number of seconds from “today # of seconds” minus “30 days ago # of seconds”.
### | |
### Date | |
### | |
current_date_in_epoc=$(date +%s) | |
number_of_seconds_in_a_month=$((60 * 60 * 24 * $max_days_inactive)) | |
date_one_month_ago=$(($current_date_in_epoc - $number_of_seconds_in_a_month)) |
Then we start to introduce some reusable functions. First, since we’re going to be iterating over lots of users, we want to be able to check our API token wallet — this is how github controls how many changes you can initiate as a single user. You get 5k tokens per hour, and these requests only use 1 token for the check, plus 1 token for any changes, so really a cheap script to run. Regardless, we check just in case.
If we run low on tokens (less than 100 remain), we wait for a minute and check again, forever. Eventually our token wallet is refilled, and we continue.
### | |
### Functions | |
### | |
# Hold until rate-limit success | |
hold_until_rate_limit_success() { | |
# Loop forever | |
while true; do | |
# Any call to AWS returns rate limits in the response headers | |
API_RATE_LIMIT_UNITS_REMAINING=$(curl -sv \ | |
-H "Accept: application/vnd.github+json" \ | |
-H "Authorization: Bearer $GITHUB_TOKEN" \ | |
-H "X-GitHub-Api-Version: 2022-11-28" \ | |
https://api.github.com/repos/$gh_org/ActionPRValidate_AnyJobRun/autolinks 2>&1 1>/dev/null \ | |
| grep -E '< x-ratelimit-remaining' \ | |
| cut -d ' ' -f 3 \ | |
| xargs \ | |
| tr -d '\r') | |
# If API rate-limiting is hit, sleep for 1 minute | |
# Rounded parenthesis are used to trigger arithmetic expansion, which compares more than the first numeric digit (bash is weird) | |
if (( "$API_RATE_LIMIT_UNITS_REMAINING" < 100 )); then | |
echo "ℹ️ We have less than 100 GitHub API rate-limit tokens left ($API_RATE_LIMIT_UNITS_REMAINING), sleeping for 1 minute" | |
sleep 60 | |
# If API rate-limiting shows remaining units, break out of loop and function | |
else | |
echo "💥 Rate limit checked, we have "$API_RATE_LIMIT_UNITS_REMAINING" core tokens remaining so we are continuing" | |
break | |
fi | |
done | |
} |
There are a few different use cases when we want to remove a user from CoPilot, so we establish a reusable function. First, we do a curl
to the API endpoint that removes the user (line 4) and trap the response. If you’re building something similar, take special note of the double quote escapes on line 10 — those are annoying finnicky.
Then on line 13 we check the curl response to see if there are seats cancelled in the response. If it’s there, we note this worked, and if not, we print an error, print the whole curl response, and continue on.
# Remove user from copilot | |
remove_user_from_copilot() { | |
REMOVE_USER_FROM_COPILOT=$(curl -sL \ | |
-X DELETE \ | |
-H "Accept: application/vnd.github+json" \ | |
-H "Authorization: Bearer $GITHUB_TOKEN" \ | |
-H "X-GitHub-Api-Version: 2022-11-28" \ | |
https://api.github.com/orgs/$gh_org/copilot/billing/selected_users \ | |
-d "{\"selected_usernames\":[\"$copilot_user\"]}") | |
# If response contains json key seats_cancelled, it worked | |
if [[ $REMOVE_USER_FROM_COPILOT == *"seats_cancelled"* ]]; then | |
echo "✅ User $copilot_user removed from CoPilot" | |
else | |
echo "❌ Failed to remove user $copilot_user from CoPilot, please investigate:" | |
echo "$REMOVE_USER_FROM_COPILOT" | |
fi | |
} |
We’re still in the global run (outside the user loop), and we need to build the stuff we’re going to loop over. On line 2, we find all (well, at least the first 100 — if you have more than 100 you’ll need to build some pagination) the users that are assigned a copilot seat in the org, as well as lots of data about them.
Then on line 10, we filter the response just for the usernames that are active, and we’ll use this to iterate over.
# Get all the copilot user data | |
copilot_all_user_data=$(curl -s \ | |
-H "Accept: application/vnd.github+json" \ | |
-H "Authorization: Bearer $GITHUB_TOKEN" \ | |
-H "X-GitHub-Api-Version: 2022-11-28" \ | |
https://api.github.com/orgs/$gh_org/copilot/billing/seats?per_page=100 2>&1) | |
# Get all users that are added to CoPilot | |
copilot_all_users=$(echo "$copilot_all_user_data" | jq -r '.seats[].assignee.login') |
Action Code — User Loop
Now that we’re ready for the user loop, let’s do it. we establish a while
loop over the copilot_all_users
var. First we call our token wallet checker on line 8, then on line 11, we print the user we’re looking into.
# Iterate through all users, check their last active date | |
while IFS=$'\n' read -r copilot_user; do | |
# Print divider | |
echo "****************************************" | |
# Check rate limit blockers, hold if token bucket too low | |
hold_until_rate_limit_success | |
# Print the user we're looking at | |
echo "🔍 Looking into $copilot_user" |
Next we filter the copilot_all_user_data json block for just the user we want to zoom in on, on line 2. We’ll use this data block for all further lookups in this loop — it makes lookups way easier.
Then on line 5 we check if the pending_cancellation_date
attribute is set. For users which are still active (and not marked for cancellation) this is null
. So we check to see if the user is already marked for cancellation. If they are, we jump to the next user and exit this iteration of the loop — we’ve already marked the user for cancellation, no need to look further.
# Filter for user's data block | |
user_data=$(echo "$copilot_all_user_data" | jq -r ".seats[] | select(.assignee.login==\"$copilot_user\")") | |
# Check if already cancellation set | |
pending_cancellation_date=$(echo "$user_data" | jq -r '.pending_cancellation_date') | |
# If cancellation date null, print hi | |
if [ "$pending_cancellation_date" == "null" ]; then | |
echo "No pending cancellation date" | |
else | |
echo "User is already scheduled for deactivation, skipping, user license will be disabled: $pending_cancellation_date" | |
continue | |
fi |
Next up, let’s get the created_at_date. We don’t to mark users who were just created as cancelled because they haven’t activated their Copilot yet. So we’ll give anyone who’s just had their copilot seat assigned the same duration as the cancel timeline — by default, 30 days, before we check if they’re using the tool.
Tod o that, we get the created_at
attribute for the user. The timestamp looks like this: `2024–04–09T16:00:41–05:0`.
Then we do some branching logic on line 5 — primarily because I’m testing on a mac, which uses a different version of date
then the GNUtils
date that *nix systems use. If *nix, we use the date -d
command to convert our timestamp from the github API to epoc seconds.
# Get the created date of the user | |
created_at_date=$(echo "$user_data" | jq -r '.created_at') | |
echo "Created at date: $created_at_date" | |
# Convert the created date to epoc | |
# This uses branching logic because macs don't use GNUtils date | |
if [ -z "$local_testing" ]; then | |
created_date_in_epoc=$(date -d $created_at_date +"%s") | |
else | |
created_date_in_epoc=$(date -juf "%Y-%m-%dT%H:%M:%S" $created_at_date +%s 2>/dev/null) | |
fi |
We store a few interesting things — on line 2, the last editor that the user used. This tells us how the user is consuming copilot. It’s interesting, so I’m writing it down. Then on line 6, the last datestamp that the user was active.
We check if the last_active_date is null
, which means the user has never been active. If the user was created more than a month ago, and is still inactive, it’s time for the to go — we check on line 15, then then we call our remove_user_from_copilot act on line 17.
If they’re not active yet, but they were created less than a month ago, we give them the benefit fo the doubt, and skip them for now.
# Get the last editor of the user | |
last_editor=$(echo "$user_data" | jq -r '.last_activity_editor') | |
echo "Last editor: $last_editor" | |
# Get the last active date of the user | |
last_active_date=$(echo "$user_data" | jq -r '.last_activity_at') | |
echo "Last activity date at: $last_active_date" | |
# Check if last_active_date is null | |
if [ "$last_active_date" == "null" ]; then | |
echo "🔴 User $copilot_user has never been active" | |
# If created date more than a month ago, then user is inactive | |
if (( $created_date_in_epoc < $date_one_month_ago )); then | |
echo "🔴 User $copilot_user is inactive and was created more than a month ago, disabling user" | |
remove_user_from_copilot | |
else | |
echo "🟢 User $copilot_user is not active yet, but was created in the last month. Leaving active." | |
fi | |
continue | |
fi |
We convert the last_active date to epoc seconds so we can check to see if the user has been active in the last month. If no, we disable them (line 12), if yes, we report happy status that the user is using their expensive license.
On line 18, we exit the user while
loop.
# Convert the last active date to epoc | |
# This uses branching logic because macs don't use GNUtils date | |
if [ -z "$local_testing" ]; then | |
last_active_date_in_epoc=$(date -d $last_active_date +"%s") | |
else | |
last_active_date_in_epoc=$(date -juf "%Y-%m-%dT%H:%M:%S" $last_active_date +%s 2>/dev/null) | |
fi | |
# Check if the last active date epoc is less than a month ago | |
if (( $last_active_date_in_epoc < $date_one_month_ago )); then | |
echo "🔴 User $copilot_user is inactive for more than a month, disabling copilot for user" | |
remove_user_from_copilot | |
continue | |
else | |
echo "🟢 User $copilot_user is active in the last month" | |
fi | |
done <<< "$copilot_all_users" |
Calling the Action from Your Org
That’s it for the Action code itself — but you probably don’t care a lot about that, you just want to use it. So let’s talk about the Action you’ll need to create in your own Org to activate this code.
This file would go into a repo you own in the .github/workflows/cleanup_copilot_licenses.yml location.
On line 3, we define a few on
triggers, which is when this action will run. When you make a change to the master branch, line 5–7, or on a schedule at 5a UTC everyday, or on a workflow_dispatch
trigger, which means you click the “run action” button in the github web page.
On line 16, we set the action to run from the ubuntu-latest Runners — these are hosted by GitHub, and have everything we need for this to run.
Then on line 21, we call the Copilot License Cleanup Action we’ve walked through above. On line 25–27, we send the information this Action requires to run — the name of your Org, the PAT that lets it authenticate, and the number of max days inactive (this one is optional).
name: Cleanup CoPilot Licenses | |
on: | |
# Run automatically when master updated | |
push: | |
branches: | |
- master | |
# Run nightly at 5a UTC / 11p CT | |
schedule: | |
- cron: "0 5 * * *" | |
# Permit manual trigger | |
workflow_dispatch: | |
jobs: | |
cleanup_copilot_licenses: | |
runs-on: ubuntu-latest | |
steps: | |
- uses: actions/checkout@v3 | |
- name: Copilot License Cleanup | |
id: cleanup_copilot_licenses | |
uses: kymidd/CleanupCopilotLicenses@v1 | |
with: | |
github-org: "your-org-name" | |
github-pat: ${{ secrets.PAT_NAME_HERE }} | |
max-days-inactive: "30" |
And you’re done. Enjoy the savings!
Summary
In this write-up we talked about a public Action you can call which will disable those users in your Org which have either never used or no longer use the Copilot license they’re assigned.
This will help you save $19/user/month, so the savings is potentially a lot!
The code is fully open-source under an MIT license, so if you have any features to add, please feel free to open a PR. Thanks all!
Good luck out there!
kyler