From f84f182ab389d3e6be5023bae12c897d58ec4c58 Mon Sep 17 00:00:00 2001 From: Really Him Date: Tue, 6 Jan 2026 19:07:29 -0500 Subject: [PATCH 1/5] Set package-ecosystem to 'npm' in dependabot config --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5f0889c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" From 8e47f398b95bb7c38668bd536b95e5e6366c7b63 Mon Sep 17 00:00:00 2001 From: Really Him Date: Mon, 19 Jan 2026 20:11:36 -0500 Subject: [PATCH 2/5] docs: update README --- LICENSE | 2 +- README.md | 40 +++++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/LICENSE b/LICENSE index dfa9038..238e9fb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Really Him +Copyright (c) 2026 Really Him Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index da10833..c8f7b7f 100644 --- a/README.md +++ b/README.md @@ -18,24 +18,27 @@ This action captures the rate-limit state at job start and compares it with the ## Usage +To use this action, just drop it in anywhere in your job - the pre- and post-job hooks will do all of the work. + ```yaml jobs: - track: + search: runs-on: ubuntu-latest - outputs: - usage: ${{ steps.usage.outputs.usage }} steps: - - uses: actions/checkout@v4 - - uses: hesreallyhim/github-api-usage-tracker@v1 - id: usage - - report: - runs-on: ubuntu-latest - needs: track - steps: - - run: echo "Core API used: ${{ needs.track.outputs.usage }}" + - name Checkout + uses: actions/checkout@v4 + - name Track Usage + uses: hesreallyhim/github-api-usage-tracker@v1 + - name: Query API + uses: actions/github-script@v6 + with: + script: | + const response = await ... + ... ``` +After your job completes, you'll get a nice summary: +
API Usage Tracking Flow
@@ -74,13 +77,20 @@ Example output: - The action uses pre and post job hooks to snapshot the rate limit, so you only need to use it in one step - the rest will be handled automatically. - Output is set in the post step, so it is only available after the job completes (use job outputs if needed). - Logs are emitted via `core.debug()`. Enable step debug logging to view them. +- GitHub's primary rate limits appear to use fixed windows with reset times anchored to the first observed usage of the token (per resource bucket), rather than calendar-aligned rolling windows.” + • GitHub’s primary rate limit for Actions using the GITHUB_TOKEN is 1,000 REST API requests per hour per repository (or 15,000 per hour per repository when accessing GitHub Enterprise Cloud resources). This limit is specific to the automatically generated GITHUB_TOKEN and is independent of the standard REST API limits for other token types. + Reference: GitHub Actions limits documentation — “The rate limit for GITHUB_TOKEN is 1,000 requests per hour per repository.” + https://docs.github.com/en/actions/reference/limits + • When a GitHub Actions workflow uses a different token (such as a personal access token or a GitHub App installation token), the workflow is subject to that token’s normal primary API rate limits, not the GITHUB_TOKEN Actions limit (e.g., 5,000 requests per hour for a PAT). + Reference: GitHub REST API rate limits documentation + https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api --- -## License -
-MIT © 2025 Really Him +## License + +MIT © 2026 Really Him
From f88ee3e8499895d79f52a4f05788654f1de02ac0 Mon Sep 17 00:00:00 2001 From: Really Him Date: Mon, 19 Jan 2026 21:47:35 -0500 Subject: [PATCH 3/5] fix: usage accounting across windows --- dist/post/index.js | 154 ++++++++++++++++++++++++++++++++++---- src/post-utils.js | 74 +++++++++++++++++- src/post.js | 80 ++++++++++++++++---- tests/post-utils.test.mjs | 86 ++++++++++++++++++++- 4 files changed, 363 insertions(+), 31 deletions(-) diff --git a/dist/post/index.js b/dist/post/index.js index 094e31a..3b3d6e8 100644 --- a/dist/post/index.js +++ b/dist/post/index.js @@ -27862,7 +27862,79 @@ function makeSummaryTable(resources) { return summaryTable; } -module.exports = { formatMs, makeSummaryTable }; +/** + * Computes usage stats for a single bucket using pre/post snapshots. + * + * @param {object} startingBucket - bucket from the pre snapshot. + * @param {object} endingBucket - bucket from the post snapshot. + * @param {number} endTimeSeconds - post snapshot time in seconds. + * @returns {object} usage details and validation status. + */ +function computeBucketUsage(startingBucket, endingBucket, endTimeSeconds) { + const result = { + valid: false, + used: 0, + remaining: undefined, + crossed_reset: false, + warnings: [] + }; + + if (!startingBucket || !endingBucket) { + result.reason = 'missing_bucket'; + return result; + } + + const startingRemaining = Number(startingBucket.remaining); + const endingRemaining = Number(endingBucket.remaining); + if (!Number.isFinite(startingRemaining) || !Number.isFinite(endingRemaining)) { + result.reason = 'invalid_remaining'; + return result; + } + + const startingLimit = Number(startingBucket.limit); + const endingLimit = Number(endingBucket.limit); + const resetPre = Number(startingBucket.reset); + const crossedReset = Number.isFinite(resetPre) && endTimeSeconds >= resetPre; + result.crossed_reset = crossedReset; + + let used; + if (crossedReset) { + if (!Number.isFinite(startingLimit) || !Number.isFinite(endingLimit)) { + result.reason = 'invalid_limit'; + return result; + } + if (startingLimit !== endingLimit) { + result.warnings.push('limit_changed_across_reset'); + } + used = startingLimit - startingRemaining + (endingLimit - endingRemaining); + } else { + if ( + Number.isFinite(startingLimit) && + Number.isFinite(endingLimit) && + startingLimit !== endingLimit + ) { + result.reason = 'limit_changed_without_reset'; + return result; + } + used = startingRemaining - endingRemaining; + if (used < 0) { + result.reason = 'remaining_increased_without_reset'; + return result; + } + } + + if (used < 0) { + result.reason = 'negative_usage'; + return result; + } + + result.valid = true; + result.used = used; + result.remaining = endingRemaining; + return result; +} + +module.exports = { formatMs, makeSummaryTable, computeBucketUsage }; /***/ }), @@ -27980,7 +28052,7 @@ const fs = __nccwpck_require__(9896); const path = __nccwpck_require__(6928); const { fetchRateLimit } = __nccwpck_require__(5042); const { log, parseBuckets } = __nccwpck_require__(9630); -const { formatMs, makeSummaryTable } = __nccwpck_require__(5828); +const { formatMs, makeSummaryTable, computeBucketUsage } = __nccwpck_require__(5828); /** * Writes JSON-stringified data to a file if a valid pathname is provided. @@ -28032,6 +28104,7 @@ async function run() { ); } const endTime = Date.now(); + const endTimeSeconds = Math.floor(endTime / 1000); const duration = hasStartTime ? endTime - startTime : null; log('[github-api-usage-tracker] Fetching final rate limits...'); @@ -28044,33 +28117,77 @@ async function run() { log(`[github-api-usage-tracker] ${JSON.stringify(endingResources, null, 2)}`); const data = {}; + const crossedBuckets = []; let totalUsed = 0; for (const bucket of buckets) { - const startingUsed = startingResources[bucket]?.used; - const endingUsed = endingResources[bucket]?.used; - if (startingUsed === undefined) { + const startingBucket = startingResources[bucket]; + const endingBucket = endingResources[bucket]; + if (!startingBucket) { core.warning( `[github-api-usage-tracker] Starting rate limit bucket "${bucket}" not found; skipping` ); continue; } - if (endingUsed === undefined) { + if (!endingBucket) { core.warning( `[github-api-usage-tracker] Ending rate limit bucket "${bucket}" not found; skipping` ); continue; } - let used = endingUsed - startingUsed; - if (used < 0) { + + const usage = computeBucketUsage(startingBucket, endingBucket, endTimeSeconds); + if (!usage.valid) { + switch (usage.reason) { + case 'invalid_remaining': + core.warning( + `[github-api-usage-tracker] Invalid remaining count for bucket "${bucket}"; skipping` + ); + break; + case 'invalid_limit': + core.warning( + `[github-api-usage-tracker] Invalid limit for bucket "${bucket}" during reset crossing; skipping` + ); + break; + case 'limit_changed_without_reset': + core.warning( + `[github-api-usage-tracker] Limit changed without reset for bucket "${bucket}"; skipping` + ); + break; + case 'remaining_increased_without_reset': + core.warning( + `[github-api-usage-tracker] Remaining increased without reset for bucket "${bucket}"; skipping` + ); + break; + case 'negative_usage': + core.warning( + `[github-api-usage-tracker] Negative usage for bucket "${bucket}" detected; skipping` + ); + break; + default: + core.warning( + `[github-api-usage-tracker] Invalid usage data for bucket "${bucket}"; skipping` + ); + break; + } + continue; + } + + if (usage.warnings.includes('limit_changed_across_reset')) { core.warning( - `[github-api-usage-tracker] Negative usage for bucket "${bucket}" detected; clamping to 0` + `[github-api-usage-tracker] Limit changed across reset for bucket "${bucket}"; results may reflect a token change` ); - used = 0; } - const remaining = endingResources[bucket].remaining; - data[bucket] = { used, remaining }; - totalUsed += used; + + data[bucket] = { + used: usage.used, + remaining: usage.remaining, + crossed_reset: usage.crossed_reset + }; + if (usage.crossed_reset) { + crossedBuckets.push(bucket); + } + totalUsed += usage.used; } // Set output @@ -28088,9 +28205,16 @@ async function run() { log( `[github-api-usage-tracker] Preparing summary table for ${Object.keys(data).length} bucket(s)` ); - core.summary + const summary = core.summary .addHeading('GitHub API Usage Tracker Summary') - .addTable(makeSummaryTable(data)) + .addTable(makeSummaryTable(data)); + if (crossedBuckets.length > 0) { + summary.addRaw( + `

Reset Window Crossed: Yes (${crossedBuckets.join(', ')})

`, + true + ); + } + summary .addRaw( `

Action Duration: ${ hasStartTime ? formatMs(duration) : 'Unknown (data missing)' diff --git a/src/post-utils.js b/src/post-utils.js index 63c43f8..82dd859 100644 --- a/src/post-utils.js +++ b/src/post-utils.js @@ -35,4 +35,76 @@ function makeSummaryTable(resources) { return summaryTable; } -module.exports = { formatMs, makeSummaryTable }; +/** + * Computes usage stats for a single bucket using pre/post snapshots. + * + * @param {object} startingBucket - bucket from the pre snapshot. + * @param {object} endingBucket - bucket from the post snapshot. + * @param {number} endTimeSeconds - post snapshot time in seconds. + * @returns {object} usage details and validation status. + */ +function computeBucketUsage(startingBucket, endingBucket, endTimeSeconds) { + const result = { + valid: false, + used: 0, + remaining: undefined, + crossed_reset: false, + warnings: [] + }; + + if (!startingBucket || !endingBucket) { + result.reason = 'missing_bucket'; + return result; + } + + const startingRemaining = Number(startingBucket.remaining); + const endingRemaining = Number(endingBucket.remaining); + if (!Number.isFinite(startingRemaining) || !Number.isFinite(endingRemaining)) { + result.reason = 'invalid_remaining'; + return result; + } + + const startingLimit = Number(startingBucket.limit); + const endingLimit = Number(endingBucket.limit); + const resetPre = Number(startingBucket.reset); + const crossedReset = Number.isFinite(resetPre) && endTimeSeconds >= resetPre; + result.crossed_reset = crossedReset; + + let used; + if (crossedReset) { + if (!Number.isFinite(startingLimit) || !Number.isFinite(endingLimit)) { + result.reason = 'invalid_limit'; + return result; + } + if (startingLimit !== endingLimit) { + result.warnings.push('limit_changed_across_reset'); + } + used = startingLimit - startingRemaining + (endingLimit - endingRemaining); + } else { + if ( + Number.isFinite(startingLimit) && + Number.isFinite(endingLimit) && + startingLimit !== endingLimit + ) { + result.reason = 'limit_changed_without_reset'; + return result; + } + used = startingRemaining - endingRemaining; + if (used < 0) { + result.reason = 'remaining_increased_without_reset'; + return result; + } + } + + if (used < 0) { + result.reason = 'negative_usage'; + return result; + } + + result.valid = true; + result.used = used; + result.remaining = endingRemaining; + return result; +} + +module.exports = { formatMs, makeSummaryTable, computeBucketUsage }; diff --git a/src/post.js b/src/post.js index 9eca844..39d8b0e 100644 --- a/src/post.js +++ b/src/post.js @@ -23,7 +23,7 @@ const fs = require('fs'); const path = require('path'); const { fetchRateLimit } = require('./rate-limit'); const { log, parseBuckets } = require('./log'); -const { formatMs, makeSummaryTable } = require('./post-utils'); +const { formatMs, makeSummaryTable, computeBucketUsage } = require('./post-utils'); /** * Writes JSON-stringified data to a file if a valid pathname is provided. @@ -75,6 +75,7 @@ async function run() { ); } const endTime = Date.now(); + const endTimeSeconds = Math.floor(endTime / 1000); const duration = hasStartTime ? endTime - startTime : null; log('[github-api-usage-tracker] Fetching final rate limits...'); @@ -87,33 +88,77 @@ async function run() { log(`[github-api-usage-tracker] ${JSON.stringify(endingResources, null, 2)}`); const data = {}; + const crossedBuckets = []; let totalUsed = 0; for (const bucket of buckets) { - const startingUsed = startingResources[bucket]?.used; - const endingUsed = endingResources[bucket]?.used; - if (startingUsed === undefined) { + const startingBucket = startingResources[bucket]; + const endingBucket = endingResources[bucket]; + if (!startingBucket) { core.warning( `[github-api-usage-tracker] Starting rate limit bucket "${bucket}" not found; skipping` ); continue; } - if (endingUsed === undefined) { + if (!endingBucket) { core.warning( `[github-api-usage-tracker] Ending rate limit bucket "${bucket}" not found; skipping` ); continue; } - let used = endingUsed - startingUsed; - if (used < 0) { + + const usage = computeBucketUsage(startingBucket, endingBucket, endTimeSeconds); + if (!usage.valid) { + switch (usage.reason) { + case 'invalid_remaining': + core.warning( + `[github-api-usage-tracker] Invalid remaining count for bucket "${bucket}"; skipping` + ); + break; + case 'invalid_limit': + core.warning( + `[github-api-usage-tracker] Invalid limit for bucket "${bucket}" during reset crossing; skipping` + ); + break; + case 'limit_changed_without_reset': + core.warning( + `[github-api-usage-tracker] Limit changed without reset for bucket "${bucket}"; skipping` + ); + break; + case 'remaining_increased_without_reset': + core.warning( + `[github-api-usage-tracker] Remaining increased without reset for bucket "${bucket}"; skipping` + ); + break; + case 'negative_usage': + core.warning( + `[github-api-usage-tracker] Negative usage for bucket "${bucket}" detected; skipping` + ); + break; + default: + core.warning( + `[github-api-usage-tracker] Invalid usage data for bucket "${bucket}"; skipping` + ); + break; + } + continue; + } + + if (usage.warnings.includes('limit_changed_across_reset')) { core.warning( - `[github-api-usage-tracker] Negative usage for bucket "${bucket}" detected; clamping to 0` + `[github-api-usage-tracker] Limit changed across reset for bucket "${bucket}"; results may reflect a token change` ); - used = 0; } - const remaining = endingResources[bucket].remaining; - data[bucket] = { used, remaining }; - totalUsed += used; + + data[bucket] = { + used: usage.used, + remaining: usage.remaining, + crossed_reset: usage.crossed_reset + }; + if (usage.crossed_reset) { + crossedBuckets.push(bucket); + } + totalUsed += usage.used; } // Set output @@ -131,9 +176,16 @@ async function run() { log( `[github-api-usage-tracker] Preparing summary table for ${Object.keys(data).length} bucket(s)` ); - core.summary + const summary = core.summary .addHeading('GitHub API Usage Tracker Summary') - .addTable(makeSummaryTable(data)) + .addTable(makeSummaryTable(data)); + if (crossedBuckets.length > 0) { + summary.addRaw( + `

Reset Window Crossed: Yes (${crossedBuckets.join(', ')})

`, + true + ); + } + summary .addRaw( `

Action Duration: ${ hasStartTime ? formatMs(duration) : 'Unknown (data missing)' diff --git a/tests/post-utils.test.mjs b/tests/post-utils.test.mjs index bd4ffcd..9395fb0 100644 --- a/tests/post-utils.test.mjs +++ b/tests/post-utils.test.mjs @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); -const { formatMs, makeSummaryTable } = require('../src/post-utils.js'); +const { formatMs, makeSummaryTable, computeBucketUsage } = require('../src/post-utils.js'); describe('post utils', () => { it('formats sub-minute durations in seconds', () => { @@ -37,3 +37,87 @@ describe('post utils', () => { ]); }); }); + +describe('computeBucketUsage', () => { + it('computes usage within the same window', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 900, reset: 1600 }, + { limit: 1000, remaining: 850 }, + 1200 + ); + + expect(result).toEqual({ + valid: true, + used: 50, + remaining: 850, + crossed_reset: false, + warnings: [] + }); + }); + + it('marks remaining increases without reset as invalid', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 800, reset: 1600 }, + { limit: 1000, remaining: 900 }, + 1200 + ); + + expect(result).toEqual({ + valid: false, + used: 0, + remaining: undefined, + crossed_reset: false, + warnings: [], + reason: 'remaining_increased_without_reset' + }); + }); + + it('computes usage when a reset window is crossed', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 700, reset: 1100 }, + { limit: 1000, remaining: 900 }, + 1300 + ); + + expect(result).toEqual({ + valid: true, + used: 400, + remaining: 900, + crossed_reset: true, + warnings: [] + }); + }); + + it('warns when limits change across a reset', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 600, reset: 1100 }, + { limit: 5000, remaining: 4700 }, + 1300 + ); + + expect(result).toEqual({ + valid: true, + used: 700, + remaining: 4700, + crossed_reset: true, + warnings: ['limit_changed_across_reset'] + }); + }); + + it('marks limit changes mid-window as invalid', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 950, reset: 1600 }, + { limit: 5000, remaining: 4900 }, + 1200 + ); + + expect(result).toEqual({ + valid: false, + used: 0, + remaining: undefined, + crossed_reset: false, + warnings: [], + reason: 'limit_changed_without_reset' + }); + }); +}); From a84710affd70eec493afe79e3f0da428b64470b1 Mon Sep 17 00:00:00 2001 From: Really Him Date: Tue, 20 Jan 2026 19:06:32 -0500 Subject: [PATCH 4/5] ci: add dependabot config --- .github/dependabot.yml | 6 +++--- .prettierignore | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5f0889c..d91a92a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ version: 2 updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: 'npm' # See documentation for possible values + directory: '/' # Location of package manifests schedule: - interval: "weekly" + interval: 'weekly' diff --git a/.prettierignore b/.prettierignore index 91a3983..e904c05 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ dist node_modules package-lock.json +__INTERNAL__ From c9b2595817c6294fba9e3101e066b1969b6ffb9d Mon Sep 17 00:00:00 2001 From: Really Him Date: Tue, 20 Jan 2026 18:56:43 -0500 Subject: [PATCH 5/5] ci: add release-please for release management --- .github/workflows/release-please.yml | 21 ++++++++++++++++++ .release-please-manifest.json | 3 +++ MAINTAINERS.md | 32 ++++++++++++++++++++++++++++ release-please-config.json | 10 +++++++++ 4 files changed, 66 insertions(+) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 MAINTAINERS.md create mode 100644 release-please-config.json diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..9b089d8 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,21 @@ +name: Release Please + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - name: Run Release Please + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..b870c5e --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.0.2" +} diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..6bb8d93 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,32 @@ +# Maintainers + +This file documents the release workflow for this repository. + +## Release automation (Release Please) + +- Workflow: `.github/workflows/release-please.yml` +- Trigger: pushes to `main` (or manual run via `workflow_dispatch`) +- Behavior: opens or updates a release PR and manages `CHANGELOG.md` +- Merge: merging the release PR creates the Git tag and GitHub Release + +## Release checklist + +1. Ensure commit messages follow conventional commits: + - `fix:` for patch releases + - `feat:` for minor releases + - `feat!:` or `BREAKING CHANGE:` for major releases +2. Verify `dist/` is up to date if any `src/` files changed since the last release: + - run `npm run bundle` + - if `dist/` changes, commit them to the release PR +3. Confirm CI is green on the release PR +4. Merge the release PR + +## After merge + +- Confirm the GitHub Release is published (not a draft) +- Verify the Marketplace listing reflects the new release tag/version + +## Notes + +- `CHANGELOG.md` is generated by Release Please; avoid manual edits. +- If no release PR is created, confirm there are new conventional commits since the last tag. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..09a32fa --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,10 @@ +{ + "packages": { + ".": { + "release-type": "simple", + "package-name": "github-api-usage-tracker", + "changelog-path": "CHANGELOG.md", + "include-v-in-tag": true + } + } +}