Skip to content

Commit eac605f

Browse files
authored
Merge pull request #16 from fergusmacd/feature/exception
Raise exception when minutes go too low
2 parents 1ffd17c + c50e7ae commit eac605f

5 files changed

Lines changed: 147 additions & 63 deletions

File tree

.github/workflows/test-action.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ jobs:
1818
organisation: ${{secrets.ORGANISATION}}
1919
gitHubAPIKey: ${{secrets.GITHUBAPIKEY}} # default token in GitHub Workflow
2020
loglevel: debug
21+
raisealarmremainingminutes: 100
22+

README.md

Lines changed: 83 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=fergusmacd_github-action-usage&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=fergusmacd_github-action-usage)
22

3-
# GitHub Actions Billable Usage Audit
3+
# GitHub Actions Usage Audit
44

55
This GitHub Action can:
66

7-
- print out action billable usage per organization repo
8-
- print out action billable usage per organization repo and workflow
7+
- fails when remaining minutes drop below a defined number
8+
- print out monthly action minutes budget
9+
- print out action usage per organization repo
10+
- print out action usage per organization repo and workflow
911
- show totals for both of the above
1012
- print out the number of remaining days in the billing cycle
1113
- be run locally with Docker or python
@@ -22,65 +24,83 @@ has been reached, as we found out, the workflows will just stop running thereby
2224
delivery pipeline. If the billing user is on holiday, this is pretty disastrous. Better to be forewarned and so extra
2325
credits can be added by the billing owner.
2426

25-
However, the billable minutes total is hidden away in the admin section and so repo owners cannot even see GHA usage.
26-
Even if they could, the usage CSV that GitHub sends out contains too much information making it hard to isolate heavy
27-
usage workflows and repos.
27+
However, the usage minutes total is hidden away in the admin section and so repo owners cannot even see GHA usage. Even
28+
if they could, the usage CSV that GitHub sends out contains too much information making it hard to isolate heavy usage
29+
workflows and repos.
2830

2931
Furthermore, MacOS usage is charged at 10x, Windows 2x the rate of Ubuntu machines. This means that a 7 second action
30-
runtime will be billed as 1 minute on Ubuntu, 10 minutes on MacOS, and 2 minutes on Windows. MacOS builds can take 20-30
31-
minutes and costs can soon build up. It turned out, this was the cause of our high usage. Top tip: don't build MacOS
32-
machines in GitHub.
32+
runtime will be consume 1 minute of the allowance on Ubuntu, 10 minutes on MacOS, and 2 minutes on Windows. MacOS builds
33+
can take 20-30 minutes and the free minutes soon dry up. It turned out, this was the cause of our high usage. Top tip:
34+
don't build MacOS machines in GitHub.
3335

3436
## What the Action Does
3537

3638
So I wrote this action to address the problems above, in the following way:
3739

38-
- give clear visibility of GitHub Action billing usage to all users
40+
- fails the workflow if the minutes remaining drops below 100, or a user defined value meaning a notification will be
41+
sent out to watchers
42+
- show remaining minutes left in billing period
43+
- give clear visibility of GitHub Action usage to all users
3944
- show total usage per repo
4045
- show total usage per repo and workflow
4146
- show usage by machine type, i.e. Ubuntu, MacOS and Windows
42-
- show remaining days in the billing period
4347

4448
To do this, the GHA prints out two tables:
4549

46-
- total billable usage per repo
47-
- billable usage per repo and workflow
50+
- total usage per repo
51+
- usage per repo and workflow
4852

4953
in the prettyprint formatted ASCII tables like this:
5054

5155
```
52-
+-------------------------------+--------+-------+---------+
53-
| Repo Name | Ubuntu | MacOS | Windows |
54-
+-------------------------------+--------+-------+---------+
55-
| aws-infra | 0 | 0 | 0 |
56-
| cicd-images | 0 | 0 | 0 |
57-
| terraform-github-repository | 0 | 0 | 0 |
58-
| --------- | ---- | ---- | ---- |
59-
| Total Costs | 30 | 0 | 0 |
60-
| --------- | ---- | ---- | ---- |
61-
+-------------------------------+--------+-------+---------+
62-
63-
+-------------------------------+---------------------+--------+-------+---------+
64-
| Repo Name | Workflow | Ubuntu | MacOS | Windows |
65-
+-------------------------------+---------------------+--------+-------+---------+
66-
| aws-infra | automerge.yml | 0 | 0 | 0 |
67-
| | close-stale-prs.yml | 0 | 0 | 0 |
68-
| | enforce-labels.yml | 0 | 0 | 0 |
69-
| | labeler.yml | 0 | 0 | 0 |
70-
| | release.yml | 0 | 0 | 0 |
71-
| | setup-terraform.yml | 0 | 0 | 0 |
72-
| -------- | -------- | ----- | ----- | ----- |
73-
| -------- | -------- | ----- | ----- | ----- |
74-
| github-audit | automerge.yml | 0 | 0 | 0 |
75-
| | close-stale-prs.yml | 15 | 0 | 0 |
76-
| | enforce-labels.yml | 0 | 0 | 0 |
77-
| | labeler.yml | 0 | 0 | 0 |
78-
| | release.yml | 0 | 0 | 0 |
79-
| | setup-terraform.yml | 0 | 0 | 0 |
80-
| -------- | -------- | ----- | ----- | ----- |
81-
| terraform-github-repository | No workflows | | | |
82-
| -------- | -------- | ----- | ----- | ----- |
83-
+-------------------------------+---------------------+--------+-------+---------+
56+
+--------------------------------+--------+-------+---------+
57+
| Repo Name | Ubuntu | MacOS | Windows |
58+
+------------------------------- +--------+-------+---------+
59+
| aws-infra | 0 | 0 | 0 |
60+
| cicd-images | 12 | 0 | 0 |
61+
| terraform-github-repository | 39 | 0 | 0 |
62+
| --------- | ---- | ---- | ---- |
63+
| Usage Minutes 2022-06-13 13:59 | 51 | 0 | 0 |
64+
| --------- | ---- | ---- | ---- |
65+
| Stats From GitHub | | | |
66+
| Monthly Allowance: 2000 | | | |
67+
| Usage Minutes: 51 | 51 | 0 | 0 |
68+
| Remaining Minutes: 1949 | | | |
69+
| Alarm Triggered at: 150 | | | |
70+
| Paid Minutes: 0 | | | |
71+
| Days Left in Cycle: 13 | | | |
72+
+--------------------------------+--------+-------+---------+
73+
74+
+--------------------------------+---------------------+--------+-------+---------+
75+
| Repo Name | Workflow | Ubuntu | MacOS | Windows |
76+
+--------------------------------+---------------------+--------+-------+---------+
77+
| aws-infra | automerge.yml | 0 | 0 | 0 |
78+
| | close-stale-prs.yml | 0 | 0 | 0 |
79+
| | enforce-labels.yml | 0 | 0 | 0 |
80+
| | labeler.yml | 0 | 0 | 0 |
81+
| | release.yml | 0 | 0 | 0 |
82+
| | setup-terraform.yml | 0 | 0 | 0 |
83+
| -------- | -------- | ----- | ----- | ----- |
84+
| -------- | -------- | ----- | ----- | ----- |
85+
| github-audit | automerge.yml | 0 | 0 | 0 |
86+
| | close-stale-prs.yml | 15 | 0 | 0 |
87+
| | enforce-labels.yml | 0 | 0 | 0 |
88+
| | labeler.yml | 0 | 0 | 0 |
89+
| | release.yml | 0 | 0 | 0 |
90+
| | setup-terraform.yml | 0 | 0 | 0 |
91+
| -------- | -------- | ----- | ----- | ----- |
92+
| terraform-github-repository | No workflows | | | |
93+
| -------- | -------- | ----- | ----- | ----- |
94+
| Usage Minutes 2022-06-13 13:59 | | 15 | 0 | 0 |
95+
| -------- | -------- | ----- | ----- | ----- |
96+
| Stats From GitHub | | | | |
97+
| Monthly Allowance: 2000 | | | | |
98+
| Usage Minutes: 15 | | 15 | 0 | 0 |
99+
| Remaining Minutes: 1985 | | | | |
100+
| Alarm Triggered at: 150 | | | | |
101+
| Paid Minutes: 0 | | | | |
102+
| Days Left in Cycle: 13 | | | | |
103+
+--------------------------------+---------------------+--------+-------+---------+
84104
```
85105

86106
## How Does it Work?
@@ -94,11 +114,15 @@ calls [GitHub List Organisational Repos API](https://docs.github.com/en/rest/rep
94114
. For repository workflows, it
95115
calls [GitHub List Repository Workflow API](https://docs.github.com/en/rest/actions/workflows#list-repository-workflows)
96116
. For workflow usage, it
97-
calls [GitHub Get Workflow Usage API](https://docs.github.com/en/rest/actions/workflows#get-workflow-usage). Finally for
98-
days left in the billing cycle , it
99-
calls [GitHub Get shared storage billing for an organization API](https://docs.github.com/en/rest/billing#get-shared-storage-billing-for-an-organization)
117+
calls [GitHub Get Workflow Usage API](https://docs.github.com/en/rest/actions/workflows#get-workflow-usage).
118+
119+
For days left in the billing cycle , it
120+
calls [GitHub Get Actions billing for an organization API](https://docs.github.com/en/rest/billing#get-shared-storage-billing-for-an-organization)
100121
.
101122

123+
Finally for monthly allowance, paid minutes and what GitHub think has been used it
124+
calls [GitHub Get shared storage billing for an organization API](https://docs.github.com/en/rest/billing#get-github-actions-billing-for-an-organization)
125+
102126
## Prerequisites to Run as an GH Action
103127

104128
- an organisation or repo secret called `ORGANISATION` with the value of your organisation
@@ -112,31 +136,33 @@ calls [GitHub Get shared storage billing for an organization API](https://docs.g
112136
Create a file called `gha-audit.yml` in your `workflows` directory, paste the following as the contents and you are good
113137
to
114138
go. [GHA best practices](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions)
115-
recommend using a commit SHA, rather than a version. The example below runs on a schedule at 3AM every day.
139+
recommend using a commit SHA, rather than a version. The example below runs on a schedule at 3AM every day. This way
140+
when the remaining allowance drops below the threshold (100 or user defined) a notification will be triggered.
116141

117142
```
118-
name: GHA Billable Audit
143+
name: GHA USAGE Audit
119144
on:
120145
schedule:
121146
- cron: "0 3 * * *" # Runs at 03:00 AM (UTC) every day
122147
jobs:
123-
gha-billable-minutes-report:
148+
gha-usage-minutes-report:
124149
runs-on: ubuntu-latest
125150
steps:
126-
- name: GitHub Actions Billable Usage Audit
151+
- name: GitHub Actions Usage Audit
127152
uses: fergusmacd/github-action-usage@daff7e5517914546a1e39fcc22f476e1471853f6 # use a commit SHA
128153
# pass user input as arguments
129154
with:
130155
organisation: ${{secrets.ORGANISATION}}
131156
gitHubAPIKey: ${{secrets.GITHUBAPIKEY}} # default token in GitHub Workflow
132157
loglevel: error # not required, change to debug if misbehaving
158+
raisealarmremainingminutes: 100 # not required, defaults to 100
133159
```
134160

135161
### Running Locally
136162

137163
The docker file and python script can both be run locally in the following ways.
138164

139-
### Running with Python
165+
#### Running with Python
140166

141167
For python, from the python directory:
142168

@@ -147,12 +173,13 @@ pip install -r requirements.txt
147173
export INPUT_LOGLEVEL=debug|info|warning|error
148174
export INPUT_ORGANISATION="myorg"
149175
export INPUT_GITHUBAPIKEY="***"
176+
export INPUT_RAISEALARMREMAININGMINUTES="150"
150177

151178
# from python directory you can run
152179
python main.py
153180
```
154181

155-
### Running with Docker
182+
#### Running with Docker
156183

157184
For Docker, run from the root directory:
158185

@@ -163,7 +190,8 @@ docker build -t gha-billable-usage .
163190
export INPUT_LOGLEVEL=debug|info|warning|error
164191
export INPUT_ORGANISATION="myorg"
165192
export INPUT_GITHUBAPIKEY="***"
166-
docker run -v $PWD:/app/results -e INPUT_LOGLEVEL=${INPUT_LOGLEVEL} -e INPUT_ORGANISATION=${INPUT_ORGANISATION} -e INPUT_GITHUBAPIKEY=${INPUT_GITHUBAPIKEY} -it gha-billable-usage
193+
export INPUT_RAISEALARMREMAININGMINUTES="150"
194+
docker run -v $PWD:/app/results -e INPUT_RAISEALARMREMAININGMINUTES=${INPUT_RAISEALARMREMAININGMINUTES} -e INPUT_LOGLEVEL=${INPUT_LOGLEVEL} -e INPUT_ORGANISATION=${INPUT_ORGANISATION} -e INPUT_GITHUBAPIKEY=${INPUT_GITHUBAPIKEY} -it gha-billable-usage
167195
```
168196

169197
## Common Errors

action.yaml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
name: "GitHub Actions Billable Usage Audit"
2-
description: "Creates tables of billable GitHub Action usage by repo and workflow"
1+
name: "GitHub Actions Usage Audit"
2+
description: "Creates tables of GitHub Action usage by repo and workflow"
33
author: "Fergus MacDermot"
44
inputs:
55
organisation:
@@ -13,9 +13,16 @@ inputs:
1313
info will give logging from the python code. warning and error return any exceptions"
1414
required: false
1515
default: "warning"
16+
raisealarmremainingminutes:
17+
description: "The number of remaining minutes below which the workflow will fail, and so notification will
18+
be sent. For example if the monthly allowance is 2000, and 1901 minutes are used, then teh workflow will
19+
fail based on the default of 100 minutes remaining"
20+
required: false
21+
default: "100"
22+
1623
outputs:
1724
warnings:
18-
description: "prettyprint formatted tables with billable minutes by repo and workflow"
25+
description: "prettyprint formatted tables with usage minutes by repo and workflow"
1926
runs:
2027
using: "docker"
2128
image: "Dockerfile"

python/ghorg.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,14 @@ def getremainingdaysinbillingperiod(org):
5858
logger.debug(f'Data from org: {json_data}')
5959

6060
return json_data["days_left_in_billing_cycle"]
61+
62+
63+
def gettotalghausage(org):
64+
api_url = 'https://api.github.com/orgs/{}/settings/billing/actions'.format(org)
65+
66+
logger.info(f'Data from api_url: {api_url}')
67+
response = requests.get(api_url, headers=headers)
68+
json_data = json.loads(response.text)
69+
70+
logger.debug(f'Data from org: {json_data}')
71+
return json_data

python/main.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66

77
from customlogger import getlogger
88
from ghaworkflows import getrepoworkflows
9-
from ghorg import getreposfromorganisation, getremainingdaysinbillingperiod
9+
from ghorg import getreposfromorganisation, getremainingdaysinbillingperiod, gettotalghausage
10+
11+
12+
class RemainingMinutesThresholdError(Exception):
13+
"""Error thrown when the remaining minutes threshold has been breached"""
14+
pass
1015

1116

1217
class RepoData:
@@ -84,20 +89,51 @@ def main():
8489

8590
workflow_table.add_row(["--------", "--------", "-----", "-----", "-----"])
8691

92+
# get what GH thinks our usage is
93+
monthly_usage_dic = gettotalghausage(org)
94+
monthly_usage_breakdown_dic = monthly_usage_dic["minutes_used_breakdown"]
95+
included_minutes = monthly_usage_dic["included_minutes"]
96+
total_minutes_used = monthly_usage_dic["total_minutes_used"]
97+
total_paid_minutes_used = monthly_usage_dic["total_paid_minutes_used"]
98+
raise_alarm_remaining_minutes = os.environ['INPUT_RAISEALARMREMAININGMINUTES']
99+
remaining_minutes = included_minutes - total_minutes_used
100+
87101
summary_table.add_row(["---------", "----", "----", "----"])
88102
summary_table.add_row(
89-
["Billable Minutes " + datetime.now().strftime(datetime_format), total_costs["UBUNTU"],
103+
["Usage Minutes " + datetime.now().strftime(datetime_format), total_costs["UBUNTU"],
90104
total_costs["MACOS"],
91105
total_costs["WINDOWS"]])
92106
summary_table.add_row(["---------", "----", "----", "----"])
93-
summary_table.add_row(["Days left in cycle: " + str(billing_days_left), "", "", ""])
94-
workflow_table.add_row(["Billable Minutes " + datetime.now().strftime(datetime_format), "",
107+
summary_table.add_row(["Stats From GitHub", "", "", ""])
108+
summary_table.add_row(["Monthly Allowance: " + str(included_minutes), "", "", ""])
109+
summary_table.add_row(["Usage Minutes: " + str(total_minutes_used),
110+
monthly_usage_breakdown_dic["UBUNTU"], monthly_usage_breakdown_dic["MACOS"],
111+
monthly_usage_breakdown_dic["WINDOWS"]])
112+
summary_table.add_row(["Remaining Minutes: " + str(remaining_minutes), "", "", ""])
113+
summary_table.add_row(["Alarm Triggered at: " + raise_alarm_remaining_minutes, "", "", ""])
114+
summary_table.add_row(["Paid Minutes: " + str(total_paid_minutes_used), "", "", ""])
115+
summary_table.add_row(["Days Left in Cycle: " + str(billing_days_left), "", "", ""])
116+
workflow_table.add_row(["Usage Minutes " + datetime.now().strftime(datetime_format), "",
95117
validate_total_costs["UBUNTU"], validate_total_costs["MACOS"],
96118
validate_total_costs["WINDOWS"]])
97119
workflow_table.add_row(["--------", "--------", "-----", "-----", "-----"])
98-
workflow_table.add_row(["Days left in cycle: " + str(billing_days_left), "", "", "", ""])
120+
workflow_table.add_row(["Stats From GitHub", "", "", "", ""])
121+
workflow_table.add_row(["Monthly Allowance: " + str(included_minutes), "", "", "", ""])
122+
workflow_table.add_row(["Usage Minutes: " + str(total_minutes_used), "",
123+
monthly_usage_breakdown_dic["UBUNTU"], monthly_usage_breakdown_dic["MACOS"],
124+
monthly_usage_breakdown_dic["WINDOWS"]])
125+
workflow_table.add_row(["Remaining Minutes: " + str(remaining_minutes), "", "", "", ""])
126+
workflow_table.add_row(["Alarm Triggered at: " + raise_alarm_remaining_minutes, "", "", "", ""])
127+
workflow_table.add_row(["Paid Minutes: " + str(total_paid_minutes_used), "", "", "", ""])
128+
129+
workflow_table.add_row(["Days Left in Cycle: " + str(billing_days_left), "", "", "", ""])
99130
print(summary_table)
100131
print(workflow_table)
132+
# we should throw an error if we are running out of minutes as a warning
133+
# minutes buffer is how low the minutes should get before failing and raising an alarm
134+
if remaining_minutes < int(raise_alarm_remaining_minutes):
135+
raise RemainingMinutesThresholdError(
136+
f'Your organisation is running short on minutes, you have {raise_alarm_remaining_minutes} left')
101137

102138

103139
if __name__ == "__main__":

0 commit comments

Comments
 (0)