From e81b63df26c917dd5c51fa038e16df37d4fcbfee Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 11 Mar 2026 14:40:54 -0300 Subject: [PATCH] feat(ci): add reusable actions budget guard workflow --- .github/scripts/actions-budget-guard.sh | 130 ++++++++++++++++++ .../tests/actions-budget-guard.test.sh | 106 ++++++++++++++ .../tests/fixtures/usage-below-warn.json | 10 ++ .../scripts/tests/fixtures/usage-degrade.json | 10 ++ .../tests/fixtures/usage-warn-only.json | 10 ++ .../reusable-actions-budget-guard.yml | 78 +++++++++++ .../workflows/test-actions-budget-guard.yml | 32 +++++ CHANGELOG.md | 10 ++ README.md | 3 + 9 files changed, 389 insertions(+) create mode 100755 .github/scripts/actions-budget-guard.sh create mode 100755 .github/scripts/tests/actions-budget-guard.test.sh create mode 100644 .github/scripts/tests/fixtures/usage-below-warn.json create mode 100644 .github/scripts/tests/fixtures/usage-degrade.json create mode 100644 .github/scripts/tests/fixtures/usage-warn-only.json create mode 100644 .github/workflows/reusable-actions-budget-guard.yml create mode 100644 .github/workflows/test-actions-budget-guard.yml diff --git a/.github/scripts/actions-budget-guard.sh b/.github/scripts/actions-budget-guard.sh new file mode 100755 index 0000000..6b45adc --- /dev/null +++ b/.github/scripts/actions-budget-guard.sh @@ -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" diff --git a/.github/scripts/tests/actions-budget-guard.test.sh b/.github/scripts/tests/actions-budget-guard.test.sh new file mode 100755 index 0000000..08cd5da --- /dev/null +++ b/.github/scripts/tests/actions-budget-guard.test.sh @@ -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" diff --git a/.github/scripts/tests/fixtures/usage-below-warn.json b/.github/scripts/tests/fixtures/usage-below-warn.json new file mode 100644 index 0000000..896cba0 --- /dev/null +++ b/.github/scripts/tests/fixtures/usage-below-warn.json @@ -0,0 +1,10 @@ +{ + "usageItems": [ + { + "date": "2026-03-01T00:00:00Z", + "product": "actions", + "unitType": "Minutes", + "quantity": 100 + } + ] +} diff --git a/.github/scripts/tests/fixtures/usage-degrade.json b/.github/scripts/tests/fixtures/usage-degrade.json new file mode 100644 index 0000000..7d9ac81 --- /dev/null +++ b/.github/scripts/tests/fixtures/usage-degrade.json @@ -0,0 +1,10 @@ +{ + "usageItems": [ + { + "date": "2026-03-01T00:00:00Z", + "product": "actions", + "unitType": "Minutes", + "quantity": 900 + } + ] +} diff --git a/.github/scripts/tests/fixtures/usage-warn-only.json b/.github/scripts/tests/fixtures/usage-warn-only.json new file mode 100644 index 0000000..e192253 --- /dev/null +++ b/.github/scripts/tests/fixtures/usage-warn-only.json @@ -0,0 +1,10 @@ +{ + "usageItems": [ + { + "date": "2026-03-01T00:00:00Z", + "product": "actions", + "unitType": "Minutes", + "quantity": 750 + } + ] +} diff --git a/.github/workflows/reusable-actions-budget-guard.yml b/.github/workflows/reusable-actions-budget-guard.yml new file mode 100644 index 0000000..4b44df5 --- /dev/null +++ b/.github/workflows/reusable-actions-budget-guard.yml @@ -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" diff --git a/.github/workflows/test-actions-budget-guard.yml b/.github/workflows/test-actions-budget-guard.yml new file mode 100644 index 0000000..d81a247 --- /dev/null +++ b/.github/workflows/test-actions-budget-guard.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9769ec9..9980913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9d56bde..6f6059a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ > Maintenance (2026-03-10): fixed `reusable-docker-build.yml` so Docker Hub > login guards do not use `secrets.*` directly in `if` expressions. +> +> Infrastructure (2026-03-11): added `reusable-actions-budget-guard.yml` for +> limit-aware GitHub Actions gating on new org/project bootstraps. ---