From 3bc7b4a522f3ad5be23e662ea8394cad85cf662d Mon Sep 17 00:00:00 2001 From: Mohsen Azimi Date: Wed, 8 Apr 2026 11:33:19 +0200 Subject: [PATCH 1/5] Add check-run output mode for PR analysis --- .github/workflows/analyze.yml | 289 ++++++++++-- README.md | 33 +- __tests__/index.js | 390 +++++++++++++++- compare.js | 848 ++++++++++++++++++++++++---------- generate.js | 3 +- generate.mjs | 108 ++++- package-lock.json | 552 ++++++++++++++++------ package.json | 2 +- report.js | 198 ++++---- template.yml | 4 + utils.js | 70 ++- 11 files changed, 1965 insertions(+), 532 deletions(-) mode change 100755 => 100644 compare.js mode change 100755 => 100644 report.js diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index de331af..d06b2e7 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -21,16 +21,36 @@ on: required: false type: string default: ./ - build-build-output-directory: + build-output-directory: description: 'The distDir specified in your Next.js config' required: false type: string - default: '.next' + default: '' build-command: description: 'If your app uses a custom build command, override this. Defaults to `next build`.' required: false type: string default: ./node_modules/.bin/next build + output-mode: + description: 'How bundle analysis should be published. One of: comment, check, both.' + required: false + type: string + default: '' + minimum-change-threshold: + description: 'Override the repo config threshold for page bundle change reporting and check-mode PR comments.' + required: false + type: string + default: '' + minimum-total-change-threshold: + description: 'Override the repo config threshold for check-mode PR comments on global bundle increases.' + required: false + type: string + default: '' + skip-comment-if-empty: + description: 'Override the repo config to skip posting empty comments when no bundle changes are detected.' + required: false + type: string + default: '' jobs: analyze: @@ -39,42 +59,88 @@ jobs: contents: read # for checkout repository actions: read # for fetching base branch bundle stats pull-requests: write # for comments + checks: write # for check runs defaults: run: # change this if your nextjs app does not live at the root of the repo working-directory: ${{ inputs.working-directory }} + env: + NEXTJS_BUNDLE_ANALYSIS_OUTPUT_MODE: ${{ inputs.output-mode }} + NEXTJS_BUNDLE_ANALYSIS_MINIMUM_CHANGE_THRESHOLD: ${{ inputs.minimum-change-threshold }} + NEXTJS_BUNDLE_ANALYSIS_MINIMUM_TOTAL_CHANGE_THRESHOLD: ${{ inputs.minimum-total-change-threshold }} + NEXTJS_BUNDLE_ANALYSIS_SKIP_COMMENT_IF_EMPTY: ${{ inputs.skip-comment-if-empty }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 ### Dependency installation ### - name: Install Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: ${{ inputs.node-version }} # based on the package-manager input it will use npm, yarn, or pnpm - name: Install dependencies - if: inputs.package-manager == 'npm' || inputs.package-manager == 'yarn' - uses: bahmutov/npm-install@v1 + if: inputs.package-manager == 'npm' + run: npm ci + + - name: Install dependencies (yarn) + if: inputs.package-manager == 'yarn' + run: yarn install --frozen-lockfile - name: Install dependencies (pnpm) if: inputs.package-manager == 'pnpm' - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v4 id: pnpm-install with: - version: 7 - run_install: true + version: 9 + run_install: false + + - name: Resolve build output directory + id: build-output + run: | + node <<'NODE' + const fs = require('fs') + const path = require('path') + + const workflowInput = process.env.INPUT_BUILD_OUTPUT_DIRECTORY + let resolved = workflowInput + + if (!resolved) { + const packageJsonPath = path.join( + process.env.GITHUB_WORKSPACE, + process.env.INPUT_WORKING_DIRECTORY, + 'package.json' + ) + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + resolved = + (packageJson.nextBundleAnalysis && + packageJson.nextBundleAnalysis.buildOutputDirectory) || + '.next' + } + + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `path=${resolved}\n` + ) + NODE + env: + INPUT_BUILD_OUTPUT_DIRECTORY: ${{ inputs.build-output-directory }} + INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }} + + - name: Install dependencies (pnpm packages) + if: inputs.package-manager == 'pnpm' + run: pnpm install --frozen-lockfile ### Next build ### - name: Restore Next build - uses: actions/cache@v3 + uses: actions/cache@v4 id: restore-build-cache env: cache-name: cache-next-build with: # if you use a custom build directory, replace all instances of `.next` in this file with your build directory # ex: if your app builds to `dist`, replace `.next` with `dist` - path: ${{ inputs.working-directory }}${{ inputs.build-output-directory }}/cache + path: ${{ inputs.working-directory }}${{ steps.build-output.outputs.path }}/cache # change this if you prefer a more strict cache key: ${{ runner.os }}-build-${{ env.cache-name }} @@ -85,21 +151,24 @@ jobs: # Here's the first place where next-bundle-analysis' own script is used # This step pulls the raw bundle stats for the current bundle - name: Analyze bundle + env: + NEXTJS_BUNDLE_ANALYSIS_BUILD_OUTPUT_DIRECTORY: ${{ steps.build-output.outputs.path }} run: npx -p nextjs-bundle-analysis report - name: Upload bundle - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: bundle - path: ${{ inputs.working-directory }}${{ inputs.build-output-directory }}/analyze/__bundle_analysis.json + path: ${{ inputs.working-directory }}${{ steps.build-output.outputs.path }}/analyze/__bundle_analysis.json - name: Download base branch bundle stats - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v20 if: success() && github.event.number + continue-on-error: true with: workflow: nextjs_bundle_analysis.yml branch: ${{ github.event.pull_request.base.ref }} - path: ${{ inputs.working-directory }}${{ inputs.build-output-directory }}/analyze/base + path: ${{ inputs.working-directory }}${{ steps.build-output.outputs.path }}/analyze/base # And here's the second place - this runs after we have both the current and # base branch bundle stats, and will compare them to determine what changed. @@ -116,38 +185,174 @@ jobs: # entry in your package.json file. - name: Compare with base branch bundle if: success() && github.event.number - run: ls -laR ${{ inputs.working-directory }}${{ inputs.build-output-directory }}/analyze/base && npx -p nextjs-bundle-analysis compare + env: + NEXTJS_BUNDLE_ANALYSIS_BUILD_OUTPUT_DIRECTORY: ${{ steps.build-output.outputs.path }} + run: npx -p nextjs-bundle-analysis compare - ### PR commenting ### - - name: Get Comment Body - id: get-comment-body + - name: Read bundle analysis outputs + id: bundle-analysis if: success() && github.event.number - # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings run: | - echo "body<> $GITHUB_OUTPUT - echo "$(cat ${{ inputs.working-directory }}${{ inputs.build-output-directory }}/analyze/__bundle_analysis_comment.txt)" >> $GITHUB_OUTPUT - echo EOF >> $GITHUB_OUTPUT + node <<'NODE' + const fs = require('fs') + const path = require('path') - - name: Find Comment - uses: peter-evans/find-comment@v2 - if: success() && github.event.number - id: fc - with: - issue-number: ${{ github.event.number }} - body-includes: '' + const outputPath = path.join( + process.env.GITHUB_WORKSPACE, + process.env.INPUT_WORKING_DIRECTORY, + process.env.INPUT_BUILD_OUTPUT_DIRECTORY, + 'analyze/__bundle_analysis_output.json' + ) + + const output = JSON.parse(fs.readFileSync(outputPath, 'utf8')) + const githubOutputPath = process.env.GITHUB_OUTPUT + const delimiter = `OUTPUT_${Date.now()}_${Math.random() + .toString(16) + .slice(2)}` + + /** + * @param {string} key + * @param {string} value + */ + function appendMultilineOutput(key, value) { + fs.appendFileSync( + githubOutputPath, + `${key}<<${delimiter}\n${value}\n${delimiter}\n` + ) + } - - name: Create Comment - uses: peter-evans/create-or-update-comment@v2 - if: success() && github.event.number && steps.fc.outputs.comment-id == 0 + appendMultilineOutput('check_name', output.checkName) + appendMultilineOutput('check_title', output.checkTitle) + fs.appendFileSync( + githubOutputPath, + `check_conclusion=${output.checkConclusion}\n` + ) + appendMultilineOutput('report', output.report) + appendMultilineOutput('comment_body', output.commentBody || '') + fs.appendFileSync( + githubOutputPath, + `managed_comment_tag=${output.managedCommentTag}\n` + ) + appendMultilineOutput( + 'legacy_managed_comment_tag', + output.legacyManagedCommentTag || '' + ) + fs.appendFileSync( + githubOutputPath, + `output_mode=${output.outputMode}\n` + ) + NODE + env: + INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }} + INPUT_BUILD_OUTPUT_DIRECTORY: ${{ steps.build-output.outputs.path }} + + - name: Publish check run + uses: actions/github-script@v7 + if: success() && github.event.number && (steps.bundle-analysis.outputs.output_mode == 'check' || steps.bundle-analysis.outputs.output_mode == 'both') with: - issue-number: ${{ github.event.number }} - body: ${{ steps.get-comment-body.outputs.body }} + script: | + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: process.env.CHECK_NAME, + head_sha: context.payload.pull_request.head.sha, + status: 'completed', + conclusion: process.env.CHECK_CONCLUSION, + output: { + title: process.env.CHECK_TITLE, + summary: process.env.REPORT, + }, + }) + env: + CHECK_NAME: ${{ steps.bundle-analysis.outputs.check_name }} + CHECK_TITLE: ${{ steps.bundle-analysis.outputs.check_title }} + CHECK_CONCLUSION: ${{ steps.bundle-analysis.outputs.check_conclusion }} + REPORT: ${{ steps.bundle-analysis.outputs.report }} - - name: Update Comment - uses: peter-evans/create-or-update-comment@v2 - if: success() && github.event.number && steps.fc.outputs.comment-id != 0 + - name: Refresh managed PR comment + uses: actions/github-script@v7 + if: success() && github.event.number with: - issue-number: ${{ github.event.number }} - body: ${{ steps.get-comment-body.outputs.body }} - comment-id: ${{ steps.fc.outputs.comment-id }} - edit-mode: replace + script: | + const issue_number = context.payload.pull_request.number + const marker = process.env.MANAGED_COMMENT_TAG + const legacyMarker = process.env.LEGACY_MANAGED_COMMENT_TAG + const body = process.env.COMMENT_BODY + const outputMode = process.env.OUTPUT_MODE + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + per_page: 100, + } + ) + + const managedComments = comments.filter((comment) => { + if (!comment.body) return false + return comment.body.includes(marker) || ( + legacyMarker && comment.body.includes(legacyMarker) + ) + }) + + if (outputMode === 'comment' || outputMode === 'both') { + const [primaryComment, ...duplicateComments] = managedComments + + for (const comment of duplicateComments) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }) + } + + if (primaryComment) { + if (body && body.trim()) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: primaryComment.id, + body, + }) + } else { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: primaryComment.id, + }) + } + } else if (body && body.trim()) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body, + }) + } + + return + } + + for (const comment of managedComments) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }) + } + + if (body && body.trim()) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body, + }) + } + env: + MANAGED_COMMENT_TAG: ${{ steps.bundle-analysis.outputs.managed_comment_tag }} + LEGACY_MANAGED_COMMENT_TAG: ${{ steps.bundle-analysis.outputs.legacy_managed_comment_tag }} + COMMENT_BODY: ${{ steps.bundle-analysis.outputs.comment_body }} + OUTPUT_MODE: ${{ steps.bundle-analysis.outputs.output_mode }} diff --git a/README.md b/README.md index 494590a..f50f55d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Next.js Bundle Analysis Github Action -Analyzes each PR's impact on your next.js app's bundle size and displays it using a comment. Optionally supports performance budgets. +Analyzes each PR's impact on your next.js app's bundle size and displays it using a PR comment, a GitHub Check Run, or both. Optionally supports performance budgets. ![screenshot of bundle analysis comment](https://p176.p0.n0.cdn.getcloudapp.com/items/BluKP76d/2b51f74a-9c0f-481f-b76a-9b36cf37d369.png?v=ddd23d0d9ee1ee9ad40487d181ed917f) ## Installation -It's pretty simple to get this set up. Run the following command and answer the prompts. The command will create a `.github/workflows` directory in your project root and add a `next_bundle_analysis.yml` file to it - that's all it takes! +It's pretty simple to get this set up. Run the following command and answer the prompts. The command will create a `.github/workflows` directory in your project root and add a `nextjs_bundle_analysis.yml` file to it - that's all it takes! ```sh $ npx -p nextjs-bundle-analysis generate @@ -16,18 +16,20 @@ $ npx -p nextjs-bundle-analysis generate Config values are written to `package.json` under the key `nextBundleAnalysis`, and can be changed there any time. You can directly edit the workflow file if you want to adjust your default branch or the directory that your nextjs app lives in (especially if you are using a `srcDir` or something similar). +The reusable workflow also accepts `with:` inputs for `output-mode`, `minimum-change-threshold`, `minimum-total-change-threshold`, `skip-comment-if-empty`, `build-output-directory`, and the existing install/build settings. Workflow inputs take precedence over `package.json`. + ### `showDetails (boolean)` (Optional, defaults to `true`) This option renders a collapsed "details" section under each section of the bundle analysis comment explaining some of the finer details of the numbers provided. If you feel like this is not necessary and you and/or those working on your project understand the details, you can set this option to `false` and that section will not render. ### `buildOutputDirectory (string)` -(Optional, defaults to `.next`) If your application [builds to a custom directory](https://nextjs.org/docs/api-reference/next.config.js/setting-a-custom-build-directory), you can specify this with the key `buildOutputDirectory`. You will also need to replace all instances of `.next` in `next_bundle_analysis.yml` with your custom output directory. +(Optional, defaults to `.next`) If your application [builds to a custom directory](https://nextjs.org/docs/api-reference/next.config.js/setting-a-custom-build-directory), you can specify this with the key `buildOutputDirectory`. You can set the same value either in `package.json` or with the reusable workflow's `build-output-directory` input. For example, if you build to `dist`, you should: - Set `package.json.nextBundleAnalysis.buildOutputDirectory` to `"dist"`. -- In `next_bundle_analysis.yml`, update the `build-output-directory` input to `dist`. +- In `nextjs_bundle_analysis.yml`, update the `build-output-directory` input to `dist`. ### `budget (number)` @@ -39,7 +41,19 @@ For example, if you build to `dist`, you should: ### `minimumChangeThreshold (number)` -(Optional, defaults to `0`) The threshold under which pages will be considered unchanged. For example, if `minimumChangeThreshold` was set to `500` and a page's size increased by `300 B`, it will be considered unchanged. +(Optional, defaults to `0`) The threshold under which pages will be considered unchanged. In `outputMode: "check"`, this same threshold is also used to decide whether an increased page bundle should trigger a PR warning comment. For example, if `minimumChangeThreshold` was set to `500` and a page's size increased by `300 B`, it will be considered unchanged and will not trigger a threshold comment. + +### `minimumTotalChangeThreshold (number)` + +(Optional, defaults to `1024`) The threshold under which global bundle increases will not trigger a PR warning comment when `outputMode` is set to `check`. The full report still appears in the check run. + +### `outputMode ("comment" | "check" | "both")` + +(Optional, defaults to `"comment"`) Controls where bundle analysis is published. + +- `comment`: keeps the existing behavior and posts the full report as a PR comment. +- `check`: posts the full report to a GitHub Check Run and only posts a short PR comment when bundle increases exceed the configured thresholds. +- `both`: posts the full report to both the PR conversation and a GitHub Check Run. ### `alwaysShowGzipDiff (boolean)` @@ -49,8 +63,15 @@ For example, if you build to `dist`, you should: (Optional, defaults to `false`) When set to `true`, if no pages have changed size the generated comment will be an empty string. +## Check Run Behavior + +- In `check` and `both` modes, the action creates a real GitHub Check Run named after the package, for example `nextjs-bundle-analysis / my-app`. +- If there are no bundle changes, the check completes successfully with a `No bundle changes` message. +- If the base bundle artifact is missing, the check completes with a neutral conclusion explaining why no comparison was generated. +- In `check` mode, managed PR comments are deleted and recreated so at most one bundle-analysis comment exists on the PR, and it stays near the latest commit activity. + ## Caveats - This plugin only analyzes the direct bundle output from next.js. If you have added any other scripts via the `