-
Notifications
You must be signed in to change notification settings - Fork 457
Action size: Add a PR check that comments on significant repo size changes #3910
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
4fc0f3e
Add a PR check that comments on significant repo size changes
henrymercer 6f8805e
Default setup env vars: Restrict results to `src`
henrymercer bcffb2b
Unify checks into a single job
henrymercer 5a80681
Address review comments
henrymercer b5b50d6
Merge branch 'main' into henrymercer/repo-size-diff-check
henrymercer 9b6438e
Tweak workflow
henrymercer 15a712b
Address review comments
henrymercer 2c8faa5
Pass comment body file directly
henrymercer a14f75e
Address review comments
henrymercer f3f52bf
Revert `getErrorMessage` import
henrymercer 72ac23c
Update excluded required check list
henrymercer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| name: Check repo size | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize, reopened] | ||
|
|
||
| defaults: | ||
| run: | ||
| shell: bash | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| check-repo-size: | ||
| name: Check repo size | ||
| runs-on: ubuntu-slim | ||
|
henrymercer marked this conversation as resolved.
Outdated
|
||
| # PRs from forks (and Dependabot, which behaves like a fork) get a | ||
| # read-only GITHUB_TOKEN that can't post comments, so the job would only | ||
| # ever fail. Skip them. | ||
| if: >- | ||
| github.event.pull_request.head.repo.full_name == github.repository && | ||
| github.triggering_actor != 'dependabot[bot]' | ||
|
henrymercer marked this conversation as resolved.
Outdated
|
||
| timeout-minutes: 10 | ||
|
|
||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| # Need full history so we have both the PR merge commit (HEAD) and | ||
| # the base ref locally for `git archive` to work against either. | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v6 | ||
| with: | ||
| node-version: 24 | ||
| cache: 'npm' | ||
|
|
||
| - name: Install pr-checks dependencies | ||
| working-directory: pr-checks | ||
| run: npm ci | ||
|
|
||
| - name: Check repo size | ||
| working-directory: pr-checks | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| BASE_REF: ${{ github.event.pull_request.base.ref }} | ||
|
henrymercer marked this conversation as resolved.
Outdated
|
||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| GITHUB_REPOSITORY: ${{ github.repository }} | ||
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | ||
| run: npx tsx check-repo-size.ts | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,334 @@ | ||
| import * as assert from "node:assert/strict"; | ||
|
henrymercer marked this conversation as resolved.
|
||
| import { execFileSync } from "node:child_process"; | ||
| import { randomBytes } from "node:crypto"; | ||
| import * as fs from "node:fs"; | ||
| import * as os from "node:os"; | ||
| import * as path from "node:path"; | ||
| import { afterEach, beforeEach, describe, it } from "node:test"; | ||
|
|
||
| import { getOctokit } from "@actions/github"; | ||
| import * as sinon from "sinon"; | ||
|
|
||
| import { | ||
| COMMENT_MARKER, | ||
| buildCommentBody, | ||
| formatBytes, | ||
| formatPercent, | ||
| isDeltaSignificant, | ||
| measureArchiveSize, | ||
| upsertSizeComment, | ||
| } from "./check-repo-size"; | ||
|
|
||
| describe("formatBytes", async () => { | ||
| const cases: Array<[number, boolean, string]> = [ | ||
| // Unsigned: bytes / KiB / MiB boundaries. | ||
| [0, false, "0 B"], | ||
| [1, false, "1 B"], | ||
| [1023, false, "1023 B"], | ||
| [1024, false, "1.00 KiB"], | ||
| [2048, false, "2.00 KiB"], | ||
| [1024 * 1024 - 1, false, "1024.00 KiB"], | ||
| [1024 * 1024, false, "1.00 MiB"], | ||
| [2.5 * 1024 * 1024, false, "2.50 MiB"], | ||
| // Negative values always use a leading minus. | ||
| [-512, false, "-512 B"], | ||
| [-2048, false, "-2.00 KiB"], | ||
| [-2 * 1024 * 1024, false, "-2.00 MiB"], | ||
| // signed=true prepends a + to non-negative values. | ||
| [0, true, "+0 B"], | ||
| [512, true, "+512 B"], | ||
| [2048, true, "+2.00 KiB"], | ||
| [-512, true, "-512 B"], | ||
| ]; | ||
| for (const [bytes, signed, expected] of cases) { | ||
| await it(`formats ${bytes} (signed=${signed}) as ${expected}`, () => { | ||
| assert.equal(formatBytes(bytes, signed), expected); | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| describe("formatPercent", async () => { | ||
| await it("formats positive fractions with a leading +", () => { | ||
| assert.equal(formatPercent(0.1), "+10.00%"); | ||
| assert.equal(formatPercent(0.0123), "+1.23%"); | ||
| }); | ||
|
|
||
| await it("formats negative fractions with a leading -", () => { | ||
| assert.equal(formatPercent(-0.1), "-10.00%"); | ||
| }); | ||
|
|
||
| await it("formats zero without a sign", () => { | ||
| assert.equal(formatPercent(0), "0.00%"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("isDeltaSignificant", async () => { | ||
| const cases: Array<[number, number, number, boolean]> = [ | ||
| // At and above threshold (both signs). | ||
| [100, 1000, 0.1, true], | ||
| [101, 1000, 0.1, true], | ||
| [-100, 1000, 0.1, true], | ||
| // Below threshold (both signs, plus exact zero). | ||
| [99, 1000, 0.1, false], | ||
| [-99, 1000, 0.1, false], | ||
| [0, 1000, 0.1, false], | ||
| ]; | ||
| for (const [delta, base, fraction, expected] of cases) { | ||
| await it(`returns ${expected} for delta=${delta}, base=${base}, fraction=${fraction}`, () => { | ||
| assert.equal(isDeltaSignificant(delta, base, fraction), expected); | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| describe("buildCommentBody", async () => { | ||
| await it("includes the marker, the base/PR/delta rows, and the run URL", () => { | ||
| const body = buildCommentBody({ | ||
| baseRef: "main", | ||
| baseSize: 2_000_000, | ||
| prSize: 2_300_000, | ||
| runUrl: "https://example.test/run", | ||
| }); | ||
|
|
||
| assert.match(body, new RegExp(`^${escapeRegExp(COMMENT_MARKER)}`)); | ||
| assert.match(body, /Base \(`main`\) \| 1\.91 MiB \(2000000 bytes\)/); | ||
| assert.match(body, /This PR \| 2\.19 MiB \(2300000 bytes\)/); | ||
| assert.match( | ||
| body, | ||
| /\*\*Delta\*\* \| \*\*\+292\.97 KiB \(\+300000 bytes, \+15\.00%\)\*\*/, | ||
| ); | ||
| assert.match(body, /\[workflow run\]\(https:\/\/example\.test\/run\)/); | ||
| }); | ||
|
|
||
| await it("formats negative deltas with a leading minus and omits the run URL when missing", () => { | ||
| const body = buildCommentBody({ | ||
| baseRef: "main", | ||
| baseSize: 2_000_000, | ||
| prSize: 1_800_000, | ||
| }); | ||
| assert.match( | ||
| body, | ||
| /\*\*Delta\*\* \| \*\*-195\.31 KiB \(-200000 bytes, -10\.00%\)\*\*/, | ||
| ); | ||
| assert.doesNotMatch(body, /workflow run/); | ||
| }); | ||
| }); | ||
|
|
||
| let repoDir: string; | ||
|
|
||
| beforeEach(() => { | ||
| repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "check-repo-size-test-")); | ||
| execFileSync("git", ["init", "--initial-branch=main", "-q"], { | ||
| cwd: repoDir, | ||
| }); | ||
| execFileSync("git", ["config", "user.email", "test@example.test"], { | ||
| cwd: repoDir, | ||
| }); | ||
| execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); | ||
| execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| fs.rmSync(repoDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| function commit(name: string, content: string, message: string) { | ||
| fs.writeFileSync(path.join(repoDir, name), content); | ||
| execFileSync("git", ["add", name], { cwd: repoDir }); | ||
| execFileSync("git", ["commit", "-q", "-m", message], { cwd: repoDir }); | ||
| } | ||
|
|
||
| describe("measureArchiveSize", async () => { | ||
| await it("returns a positive byte count for a non-empty repo", async () => { | ||
| commit("a.txt", "hello world\n", "first"); | ||
| const size = await measureArchiveSize("HEAD", repoDir); | ||
| assert.ok(size > 0, `expected size > 0, got ${size}`); | ||
| }); | ||
|
|
||
| await it("returns the same size on repeated runs (deterministic)", async () => { | ||
| commit("a.txt", "hello world\n", "first"); | ||
| const a = await measureArchiveSize("HEAD", repoDir); | ||
| const b = await measureArchiveSize("HEAD", repoDir); | ||
| assert.equal(a, b); | ||
| }); | ||
|
|
||
| await it("returns a larger size when more content is added", async () => { | ||
| commit("a.txt", "hello world\n", "first"); | ||
| const small = await measureArchiveSize("HEAD", repoDir); | ||
|
|
||
| // Use random bytes so the new content is incompressible and the archive | ||
| // is guaranteed to grow even after gzip. | ||
| commit("b.bin", randomBytes(8192).toString("base64"), "second"); | ||
| const big = await measureArchiveSize("HEAD", repoDir); | ||
| assert.ok( | ||
| big > small, | ||
| `expected ${big} > ${small} after adding more content`, | ||
| ); | ||
| }); | ||
|
|
||
| await it("ignores untracked files (e.g. node_modules)", async () => { | ||
| commit("a.txt", "hello\n", "first"); | ||
| commit(".gitignore", "node_modules/\n", "ignore node_modules"); | ||
| const sizeBefore = await measureArchiveSize("HEAD", repoDir); | ||
|
|
||
| fs.mkdirSync(path.join(repoDir, "node_modules")); | ||
| fs.writeFileSync( | ||
| path.join(repoDir, "node_modules", "huge.bin"), | ||
| "x".repeat(1_000_000), | ||
| ); | ||
|
|
||
| const sizeAfter = await measureArchiveSize("HEAD", repoDir); | ||
| assert.equal( | ||
| sizeAfter, | ||
| sizeBefore, | ||
| "untracked node_modules should not affect the archive size", | ||
| ); | ||
| }); | ||
|
|
||
| await it("rejects when the ref does not exist", async () => { | ||
| commit("a.txt", "hello\n", "first"); | ||
| await assert.rejects( | ||
| () => measureArchiveSize("does-not-exist", repoDir), | ||
| /git archive does-not-exist exited with code/, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("upsertSizeComment", async () => { | ||
| const owner = "test-owner"; | ||
| const repo = "test-repo"; | ||
| const prNumber = 42; | ||
|
|
||
| let octokit: ReturnType<typeof getOctokit>; | ||
|
|
||
| beforeEach(() => { | ||
| octokit = getOctokit("test-token"); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| sinon.restore(); | ||
| }); | ||
|
|
||
| function stubExistingComments(comments: Array<{ id: number; body: string }>) { | ||
| // upsertSizeComment calls `octokit.paginate(octokit.rest.issues.listComments, ...)`, | ||
| // so stubbing `paginate` directly mocks the listing without depending on how | ||
| // paginate walks Octokit's response (link headers etc.). | ||
| return sinon.stub(octokit, "paginate").resolves(comments); | ||
| } | ||
|
|
||
| await it("creates a new comment when none exists and the delta is significant", async () => { | ||
| stubExistingComments([]); | ||
| const createStub = sinon | ||
| .stub(octokit.rest.issues, "createComment") | ||
| .resolves({ data: { id: 999 } } as never); | ||
|
|
||
| const result = await upsertSizeComment({ | ||
| octokit, | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| body: `${COMMENT_MARKER}\nhello`, | ||
| delta: 200, | ||
| baseSize: 1000, | ||
| }); | ||
|
|
||
| assert.deepEqual(result, { action: "created", commentId: 999 }); | ||
| sinon.assert.calledOnce(createStub); | ||
| const createArgs = createStub.firstCall.args[0]!; | ||
| assert.equal(createArgs.owner, owner); | ||
| assert.equal(createArgs.repo, repo); | ||
| assert.equal(createArgs.issue_number, prNumber); | ||
| assert.ok(createArgs.body.includes(COMMENT_MARKER)); | ||
| }); | ||
|
|
||
| await it("creates a new comment for a significant size decrease", async () => { | ||
| // Shrinkage matters too: it might indicate accidentally deleted tracked | ||
| // files. The full pipeline (not just isDeltaSignificant) needs to post on | ||
| // negative deltas. | ||
| stubExistingComments([]); | ||
| const createStub = sinon | ||
| .stub(octokit.rest.issues, "createComment") | ||
| .resolves({ data: { id: 999 } } as never); | ||
|
|
||
| const result = await upsertSizeComment({ | ||
| octokit, | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| body: `${COMMENT_MARKER}\nhello`, | ||
| delta: -200, | ||
| baseSize: 1000, | ||
| }); | ||
|
|
||
| assert.deepEqual(result, { action: "created", commentId: 999 }); | ||
| sinon.assert.calledOnce(createStub); | ||
| }); | ||
|
|
||
| await it("skips when no existing comment and delta is below threshold", async () => { | ||
| stubExistingComments([]); | ||
| const createStub = sinon.stub(octokit.rest.issues, "createComment"); | ||
| const updateStub = sinon.stub(octokit.rest.issues, "updateComment"); | ||
|
|
||
| const result = await upsertSizeComment({ | ||
| octokit, | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| body: `${COMMENT_MARKER}\nhello`, | ||
| delta: 50, | ||
| baseSize: 1000, | ||
| }); | ||
|
|
||
| assert.equal(result.action, "skipped"); | ||
| sinon.assert.notCalled(createStub); | ||
| sinon.assert.notCalled(updateStub); | ||
| }); | ||
|
|
||
| await it("updates the existing comment when the delta is significant", async () => { | ||
| stubExistingComments([{ id: 7, body: `${COMMENT_MARKER}\nold body` }]); | ||
| const updateStub = sinon | ||
| .stub(octokit.rest.issues, "updateComment") | ||
| .resolves({ data: { id: 7 } } as never); | ||
|
|
||
| const result = await upsertSizeComment({ | ||
| octokit, | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| body: `${COMMENT_MARKER}\nnew body`, | ||
| delta: 200, | ||
| baseSize: 1000, | ||
| }); | ||
|
|
||
| assert.deepEqual(result, { action: "updated", commentId: 7 }); | ||
| sinon.assert.calledOnce(updateStub); | ||
| const updateArgs = updateStub.firstCall.args[0]!; | ||
| assert.equal(updateArgs.comment_id, 7); | ||
| assert.ok(updateArgs.body.includes("new body")); | ||
| }); | ||
|
|
||
| await it("updates an existing comment even when the delta is below threshold", async () => { | ||
| // This keeps the comment in sync after a PR that initially had a big diff | ||
| // gets reduced below the threshold by a follow-up commit. | ||
| stubExistingComments([{ id: 7, body: `${COMMENT_MARKER}\nold body` }]); | ||
| const updateStub = sinon | ||
| .stub(octokit.rest.issues, "updateComment") | ||
| .resolves({ data: { id: 7 } } as never); | ||
|
|
||
| const result = await upsertSizeComment({ | ||
| octokit, | ||
| owner, | ||
| repo, | ||
| prNumber, | ||
| body: `${COMMENT_MARKER}\nnew body`, | ||
| delta: 1, | ||
| baseSize: 1000, | ||
| }); | ||
|
|
||
| assert.deepEqual(result, { action: "updated", commentId: 7 }); | ||
| sinon.assert.calledOnce(updateStub); | ||
| }); | ||
| }); | ||
|
|
||
| function escapeRegExp(s: string): string { | ||
| return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.