diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d91a92a --- /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' 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/.prettierignore b/.prettierignore index 91a3983..e904c05 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ dist node_modules package-lock.json +__INTERNAL__ 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/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/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/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
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/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 + } + } +} 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' + }); + }); +});