Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/scripts/actions-budget-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -euo pipefail

ORG="${ORG:-}"
MONTHLY_CAP_MINUTES="${MONTHLY_CAP_MINUTES:-}"
WARN_PCT="${WARN_PCT:-70}"
DEGRADE_PCT="${DEGRADE_PCT:-85}"
GITHUB_TOKEN_INPUT="${GITHUB_TOKEN_INPUT:-}"
MOCK_USAGE_JSON_FILE="${MOCK_USAGE_JSON_FILE:-}"
FORCE_API_FAILURE="${FORCE_API_FAILURE:-false}"
CURRENT_MONTH_UTC="$(date -u +%Y-%m)"

emit_output() {
local key="$1"
local value="$2"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "${key}=${value}" >> "$GITHUB_OUTPUT"
return
fi
echo "${key}=${value}"
}

emit_result() {
local usage_pct="$1"
local warn_mode="$2"
local degrade_mode="$3"
local summary="$4"
emit_output "usage_pct" "$usage_pct"
emit_output "warn_mode" "$warn_mode"
emit_output "degrade_mode" "$degrade_mode"
emit_output "summary" "$summary"
}

is_positive_number() {
[[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]]
}

safe_percentage() {
local used="$1"
local cap="$2"
awk -v used="$used" -v cap="$cap" 'BEGIN { if (cap <= 0) { print "0.00" } else { printf "%.2f", (used * 100) / cap } }'
}

is_ge() {
local left="$1"
local right="$2"
awk -v left="$left" -v right="$right" 'BEGIN { if (left + 0 >= right + 0) print "true"; else print "false" }'
}

if [ -z "$ORG" ]; then
emit_result "0.00" "false" "false" "budget-guard fail-open: missing org input"
exit 0
fi

if ! is_positive_number "$MONTHLY_CAP_MINUTES"; then
emit_result "0.00" "false" "false" "budget-guard fail-open: invalid monthly_cap_minutes"
exit 0
fi

if ! is_positive_number "$WARN_PCT"; then
WARN_PCT="70"
fi

if ! is_positive_number "$DEGRADE_PCT"; then
DEGRADE_PCT="85"
fi

if [ "$(is_ge "$WARN_PCT" "$DEGRADE_PCT")" = "true" ]; then
DEGRADE_PCT="$WARN_PCT"
fi

usage_json=""

if [ -n "$MOCK_USAGE_JSON_FILE" ]; then
if [ ! -f "$MOCK_USAGE_JSON_FILE" ]; then
emit_result "0.00" "false" "false" "budget-guard fail-open: missing mock usage file"
exit 0
fi
usage_json="$(cat "$MOCK_USAGE_JSON_FILE")"
elif [ "$FORCE_API_FAILURE" = "true" ]; then
emit_result "0.00" "false" "false" "budget-guard fail-open: forced API failure"
exit 0
elif [ -z "$GITHUB_TOKEN_INPUT" ]; then
emit_result "0.00" "false" "false" "budget-guard fail-open: missing github token"
exit 0
else
tmp_file="$(mktemp)"
http_code="$({
curl -sS \
-H "Authorization: Bearer ${GITHUB_TOKEN_INPUT}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-w "%{http_code}" \
"https://api.github.com/orgs/${ORG}/settings/billing/usage" \
-o "$tmp_file"
} || true)"

if [ "$http_code" != "200" ]; then
rm -f "$tmp_file"
emit_result "0.00" "false" "false" "budget-guard fail-open: billing usage API request failed (status=${http_code:-curl_error})"
exit 0
fi

usage_json="$(cat "$tmp_file")"
rm -f "$tmp_file"
fi

if ! echo "$usage_json" | jq -e . >/dev/null 2>&1; then
emit_result "0.00" "false" "false" "budget-guard fail-open: invalid billing usage payload"
exit 0
fi

used_minutes="$(echo "$usage_json" | jq -r --arg month "$CURRENT_MONTH_UTC" '[
.usageItems[]?
| select(.product == "actions")
| select(.unitType == "Minutes")
| select((.date // "") | startswith($month))
| (.quantity | tonumber)
] | add // 0')"

if ! is_positive_number "$used_minutes"; then
emit_result "0.00" "false" "false" "budget-guard fail-open: usage payload does not contain numeric minutes"
exit 0
fi

usage_pct="$(safe_percentage "$used_minutes" "$MONTHLY_CAP_MINUTES")"
warn_mode="$(is_ge "$usage_pct" "$WARN_PCT")"
degrade_mode="$(is_ge "$usage_pct" "$DEGRADE_PCT")"
summary="budget-guard ok: actions usage ${used_minutes}m/${MONTHLY_CAP_MINUTES}m (${usage_pct}%), warn=${warn_mode}, degrade=${degrade_mode}, month=${CURRENT_MONTH_UTC}"
emit_result "$usage_pct" "$warn_mode" "$degrade_mode" "$summary"
106 changes: 106 additions & 0 deletions .github/scripts/tests/actions-budget-guard.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
SCRIPT_PATH="$ROOT_DIR/.github/scripts/actions-budget-guard.sh"
FIXTURE_DIR="$ROOT_DIR/.github/scripts/tests/fixtures"

assert_equal() {
local expected="$1"
local actual="$2"
local label="$3"
if [ "$expected" != "$actual" ]; then
echo "Assertion failed for ${label}: expected=${expected} actual=${actual}" >&2
exit 1
fi
}

assert_contains() {
local needle="$1"
local haystack="$2"
local label="$3"
if [[ "$haystack" != *"$needle"* ]]; then
echo "Assertion failed for ${label}: expected summary to contain '${needle}'" >&2
exit 1
fi
}

prepare_fixture_for_current_month() {
local fixture="$1"
local target="$2"
local current_month
current_month="$(date -u +%Y-%m)"
jq --arg month "$current_month" '.usageItems |= map(.date = ($month + "-01T00:00:00Z"))' "$fixture" > "$target"
}

run_case() {
local fixture_file="$1"
local expected_pct="$2"
local expected_warn="$3"
local expected_degrade="$4"

local output_file
output_file="$(mktemp)"
local mock_file
mock_file="$(mktemp)"
trap 'rm -f "$output_file" "$mock_file"' RETURN

prepare_fixture_for_current_month "$fixture_file" "$mock_file"

ORG="Forge-Space" \
MONTHLY_CAP_MINUTES="1000" \
WARN_PCT="70" \
DEGRADE_PCT="85" \
MOCK_USAGE_JSON_FILE="$mock_file" \
GITHUB_OUTPUT="$output_file" \
"$SCRIPT_PATH"

local usage_pct warn_mode degrade_mode summary
usage_pct="$(grep '^usage_pct=' "$output_file" | cut -d= -f2-)"
warn_mode="$(grep '^warn_mode=' "$output_file" | cut -d= -f2-)"
degrade_mode="$(grep '^degrade_mode=' "$output_file" | cut -d= -f2-)"
summary="$(grep '^summary=' "$output_file" | cut -d= -f2-)"

assert_equal "$expected_pct" "$usage_pct" "usage_pct"
assert_equal "$expected_warn" "$warn_mode" "warn_mode"
assert_equal "$expected_degrade" "$degrade_mode" "degrade_mode"
assert_contains "budget-guard ok" "$summary" "summary"

rm -f "$output_file" "$mock_file"
trap - RETURN
}

run_failure_case() {
local output_file
output_file="$(mktemp)"
trap 'rm -f "$output_file"' RETURN

ORG="Forge-Space" \
MONTHLY_CAP_MINUTES="1000" \
WARN_PCT="70" \
DEGRADE_PCT="85" \
FORCE_API_FAILURE="true" \
GITHUB_OUTPUT="$output_file" \
"$SCRIPT_PATH"

local usage_pct warn_mode degrade_mode summary
usage_pct="$(grep '^usage_pct=' "$output_file" | cut -d= -f2-)"
warn_mode="$(grep '^warn_mode=' "$output_file" | cut -d= -f2-)"
degrade_mode="$(grep '^degrade_mode=' "$output_file" | cut -d= -f2-)"
summary="$(grep '^summary=' "$output_file" | cut -d= -f2-)"

assert_equal "0.00" "$usage_pct" "usage_pct (failure mode)"
assert_equal "false" "$warn_mode" "warn_mode (failure mode)"
assert_equal "false" "$degrade_mode" "degrade_mode (failure mode)"
assert_contains "fail-open" "$summary" "summary (failure mode)"

rm -f "$output_file"
trap - RETURN
}

run_case "$FIXTURE_DIR/usage-below-warn.json" "10.00" "false" "false"
run_case "$FIXTURE_DIR/usage-warn-only.json" "75.00" "true" "false"
run_case "$FIXTURE_DIR/usage-degrade.json" "90.00" "true" "true"
run_failure_case

echo "actions-budget-guard tests passed"
10 changes: 10 additions & 0 deletions .github/scripts/tests/fixtures/usage-below-warn.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"usageItems": [
{
"date": "2026-03-01T00:00:00Z",
"product": "actions",
"unitType": "Minutes",
"quantity": 100
}
]
}
10 changes: 10 additions & 0 deletions .github/scripts/tests/fixtures/usage-degrade.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"usageItems": [
{
"date": "2026-03-01T00:00:00Z",
"product": "actions",
"unitType": "Minutes",
"quantity": 900
}
]
}
10 changes: 10 additions & 0 deletions .github/scripts/tests/fixtures/usage-warn-only.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"usageItems": [
{
"date": "2026-03-01T00:00:00Z",
"product": "actions",
"unitType": "Minutes",
"quantity": 750
}
]
}
78 changes: 78 additions & 0 deletions .github/workflows/reusable-actions-budget-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Reusable Actions Budget Guard

on:
workflow_call:
inputs:
org:
description: Organization slug used for billing usage lookup
required: true
type: string
monthly_cap_minutes:
description: Monthly GitHub Actions minutes budget cap
required: true
type: string
warn_pct:
description: Warn threshold percentage
required: false
type: string
default: "70"
degrade_pct:
description: Degrade threshold percentage
required: false
type: string
default: "85"
secrets:
github_token:
required: false
outputs:
usage_pct:
description: Current-month usage percentage against monthly cap
value: ${{ jobs.guard.outputs.usage_pct }}
warn_mode:
description: Warn mode flag
value: ${{ jobs.guard.outputs.warn_mode }}
degrade_mode:
description: Degrade mode flag
value: ${{ jobs.guard.outputs.degrade_mode }}
summary:
description: Human-readable budget guard summary
value: ${{ jobs.guard.outputs.summary }}

permissions:
contents: read

jobs:
guard:
name: Actions Budget Guard
runs-on: ubuntu-latest
outputs:
usage_pct: ${{ steps.evaluate.outputs.usage_pct }}
warn_mode: ${{ steps.evaluate.outputs.warn_mode }}
degrade_mode: ${{ steps.evaluate.outputs.degrade_mode }}
summary: ${{ steps.evaluate.outputs.summary }}
steps:
- name: Resolve workflow source
id: source
run: |
echo "repo=${GITHUB_WORKFLOW_REF%%/.github/workflows/*}" >> "$GITHUB_OUTPUT"
echo "ref=${GITHUB_WORKFLOW_REF##*@}" >> "$GITHUB_OUTPUT"

- name: Checkout workflow source
uses: actions/checkout@v4
with:
repository: ${{ steps.source.outputs.repo }}
ref: ${{ steps.source.outputs.ref }}
path: .workflow-source

- name: Evaluate Actions usage against cap
id: evaluate
env:
ORG: ${{ inputs.org }}
MONTHLY_CAP_MINUTES: ${{ inputs.monthly_cap_minutes }}
WARN_PCT: ${{ inputs.warn_pct }}
DEGRADE_PCT: ${{ inputs.degrade_pct }}
GITHUB_TOKEN_INPUT: ${{ secrets.github_token }}
run: ./.workflow-source/.github/scripts/actions-budget-guard.sh

- name: Job summary
run: echo "${{ steps.evaluate.outputs.summary }}" >> "$GITHUB_STEP_SUMMARY"
32 changes: 32 additions & 0 deletions .github/workflows/test-actions-budget-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Test Actions Budget Guard

on:
pull_request:
paths:
- '.github/workflows/reusable-actions-budget-guard.yml'
- '.github/scripts/actions-budget-guard.sh'
- '.github/scripts/tests/**'
push:
branches: [main]
paths:
- '.github/workflows/reusable-actions-budget-guard.yml'
- '.github/scripts/actions-budget-guard.sh'
- '.github/scripts/tests/**'
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
guard-tests:
name: Budget Guard Script Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate shell syntax
run: |
bash -n .github/scripts/actions-budget-guard.sh
bash -n .github/scripts/tests/actions-budget-guard.test.sh
- name: Run guard tests
run: .github/scripts/tests/actions-budget-guard.test.sh
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `reusable-actions-budget-guard.yml`: new reusable workflow that reads
organization Actions usage and emits `usage_pct`, `warn_mode`, `degrade_mode`,
and `summary` outputs for limit-aware CI orchestration.
- `.github/scripts/actions-budget-guard.sh`: fail-open billing guard logic with
mockable payload support for deterministic test coverage.
- `test-actions-budget-guard.yml`: workflow-level shell test harness validating
below-warn, warn-only, degrade, and API-failure scenarios.

### Fixed

- `reusable-docker-build.yml`: replaced `secrets.*` usage in Docker Hub login
Expand Down
Loading
Loading