From b78ebb55042c9e442f69c9305830d859744c9542 Mon Sep 17 00:00:00 2001 From: aatarasoff Date: Sun, 15 Nov 2026 13:01:50 +0400 Subject: [PATCH] feat: add sticky PR drift diff comments, Node 24 runtime, and CI fixes --- .github/linters/.eslintrc.yml | 2 + .github/workflows/check-dist.yml | 18 +- .github/workflows/ci.yml | 25 ++- .github/workflows/codeql-analysis.yml | 54 +---- .github/workflows/linter.yml | 46 ---- README.md | 92 +++++--- __tests__/main.test.js | 294 ++++++++++++++++++++++++-- action.yml | 42 +++- badges/coverage.svg | 2 +- dist/index.js | 247 ++++++++++++++++++++-- package-lock.json | 31 +-- package.json | 6 +- src/main.js | 247 ++++++++++++++++++++-- 13 files changed, 869 insertions(+), 237 deletions(-) delete mode 100644 .github/workflows/linter.yml diff --git a/.github/linters/.eslintrc.yml b/.github/linters/.eslintrc.yml index b92a4c3..3a0e135 100644 --- a/.github/linters/.eslintrc.yml +++ b/.github/linters/.eslintrc.yml @@ -5,8 +5,10 @@ env: node: true globals: + AbortSignal: readonly Atomics: readonly SharedArrayBuffer: readonly + fetch: readonly ignorePatterns: - '!.*' diff --git a/.github/workflows/check-dist.yml b/.github/workflows/check-dist.yml index dff2a01..3391e7f 100644 --- a/.github/workflows/check-dist.yml +++ b/.github/workflows/check-dist.yml @@ -1,11 +1,3 @@ -# In JavaScript actions, `dist/index.js` is a special file. When you reference -# an action with `uses:`, `dist/index.js` is the code that will be run. For this -# project, the `dist/index.js` file is generated from other source files through -# the build process. We need to make sure that the checked-in `dist/index.js` -# file matches what is expected from the build. -# -# This workflow will fail if the checked-in `dist/index.js` file does not match -# what is expected from the build. name: Check dist/ on: @@ -31,12 +23,12 @@ jobs: steps: - name: Checkout id: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: npm - name: Install Dependencies @@ -56,9 +48,7 @@ jobs: exit 1 fi - # If index.js was different than expected, upload the expected version as - # a workflow artifact. - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 if: ${{ failure() && steps.diff.conclusion == 'failure' }} with: name: dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 507b122..50d58c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,13 +15,13 @@ jobs: steps: - name: Checkout id: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js id: setup-node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: npm - name: Install Dependencies @@ -47,25 +47,28 @@ jobs: steps: - name: Checkout id: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Test Local Action + - name: Test Local Action (Pinned Version) id: test-action uses: ./ with: - version: v0.5.0 + version: v0.16.0 - - name: Check pbuf cli + - name: Check pbuf CLI id: check-pbuf-cli - run: pbuf init pbuf/pbuf-setup-action + run: | + mkdir -p /tmp/pbuf-smoke && cd /tmp/pbuf-smoke + pbuf init pbufio/pbuf-setup-action + test -f pbuf.yaml - - name: Test Local Action With Config + - name: Test Local Action With Auth Token (Latest) id: test-action-with-config uses: ./ with: - version: v0.5.0 + version: latest pbuf_token: "hello_token" - name: Check .netrc id: check-netrc - run: cat ~/.netrc + run: test -s ~/.netrc diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 26b5e75..f49d93a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,21 +1,9 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL" on: push: branches: [ main ] pull_request: - # The branches below must be a subset of the branches above branches: [ main ] schedule: - cron: '24 5 * * 6' @@ -33,39 +21,19 @@ jobs: fail-fast: false matrix: language: [ 'javascript' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v6 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - config-file: ./.github/codeql/codeql-config.yml - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + - name: Autobuild + uses: github/codeql-action/autobuild@v4 - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml deleted file mode 100644 index c451d77..0000000 --- a/.github/workflows/linter.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Lint Code Base - -on: - pull_request: - branches: - - main - push: - branches-ignore: - - main - -jobs: - lint: - name: Lint Code Base - runs-on: ubuntu-latest - - permissions: - contents: read - packages: read - statuses: write - - steps: - - name: Checkout - id: checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - id: setup-node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install Dependencies - id: install - run: npm ci - - - name: Lint Code Base - id: super-linter - uses: super-linter/super-linter/slim@v5 - env: - DEFAULT_BRANCH: main - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - JAVASCRIPT_DEFAULT_STYLE: prettier - VALIDATE_JSCPD: false - VALIDATE_MARKDOWN: false - VALIDATE_NATURAL_LANGUAGE: false diff --git a/README.md b/README.md index d146647..6658b2e 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,78 @@ # `pbuf-setup` ## Overview -The `pbuf-setup` GitHub Action is designed to install `pbuf-cli`, a command-line interface tool, for use in other jobs within a GitHub Actions workflow. +The `pbuf-setup` GitHub Action installs the `pbuf` CLI on Linux and macOS runners, configures registry authentication, and optionally runs `pbuf drift diff` on pull requests with a sticky Markdown summary comment. ## Requirements -- GitHub Actions runner with either Linux or Darwin operating system. -- Architecture must be either AMD64 or ARM64. - -## Features -- **Version Control**: Specify the desired version of `pbuf-cli` to be installed. -- **Platform Compatibility Check**: Ensures compatibility with Linux and Darwin operating systems and AMD64 or ARM64 architectures. -- **Automatic Download and Installation**: Downloads and installs `pbuf-cli` automatically to the runner's environment. -- **Token Auth Support**: Supports usage of a `pbuf_token` for operations requiring authentication with the PBUF registry. +- GitHub Actions runner with Linux or macOS (`darwin`). +- Architecture: `amd64` (`x86_64`) or `arm64` (`aarch64`). ## Inputs -- **`version`**: The version of the CLI to install. The default is 'v0.9.0'. This input is optional. -- **`pbuf_token`**: The API token for authenticating with the PBUF registry. This input is optional. + +| Input | Required | Default | Description | +| :--- | :--- | :--- | :--- | +| `version` | No | `'v0.16.0'` | Version of `pbuf` CLI to install (for example `'v0.16.0'` or `'latest'`). | +| `pbuf_token` | No | `''` | API token for authenticating with the PBUF registry (`pbuf auth`). | +| `drift_diff` | No | `'false'` | When `'true'`, runs `pbuf drift diff --format=markdown` against the registry baseline. | +| `fail_on` | No | `'critical'` | Severity threshold to fail drift checks (`critical`, `warning`, `info`, or `none`). | +| `against` | No | `''` | Baseline registry tag to compare against (defaults to the latest published tag). | +| `working_directory` | No | `''` | Directory containing `pbuf.yaml` when running `pbuf drift diff`. | +| `comment_on_pr` | No | `'false'` | When `'true'` on `pull_request` events, posts or updates a single sticky PR comment (``) with the Markdown report. | +| `github_token` | No | `${{ github.token }}` | GitHub token used to post or update sticky PR comments (requires `pull-requests: write`). | + +## Outputs + +| Output | Description | +| :--- | :--- | +| `drift_markdown` | Markdown report generated by `pbuf drift diff`. | +| `drift_exit_code` | Exit code returned by `pbuf drift diff`. | ## Usage -To use this action, include it in your workflow `.yml` file with the necessary inputs. Below is a sample configuration: -```yml +### Install CLI and Push Modules on Tag + +```yaml steps: -- name: Install pbuf-cli - uses: pbufio/pbuf-setup-action@v1.1.0 - with: - version: 'v0.9.0' # Optional. Specify the desired version - pbuf_token: ${{ secrets.PBUF_TOKEN }} # Optional, your PBUF API token - -- name: Register Module - id: pbuf-register - run: pbuf modules register - -- name: Push Module - id: pbuf-push - run: pbuf modules push ${{ github.ref_name }} + - uses: actions/checkout@v6 + + - name: Install pbuf CLI + uses: pbufio/pbuf-setup-action@v1 + with: + version: 'v0.16.0' + pbuf_token: ${{ secrets.PBUF_TOKEN }} + + - name: Register and Push Module + run: | + pbuf modules register + pbuf modules push ${{ github.ref_name }} +``` + +### Pull Request Schema Drift Check with Sticky PR Comment + +```yaml +name: Schema Drift Check + +on: + pull_request: + paths: + - '**.proto' + - 'pbuf.yaml' + +permissions: + contents: read + pull-requests: write + +jobs: + drift-diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check Schema Drift + uses: pbufio/pbuf-setup-action@v1 + with: + pbuf_token: ${{ secrets.PBUF_TOKEN }} + drift_diff: true + fail_on: critical + comment_on_pr: true ``` diff --git a/__tests__/main.test.js b/__tests__/main.test.js index 6fb1041..ce09592 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -1,35 +1,49 @@ /** * Unit tests for the action's main functionality, src/main.js */ +const fs = require('fs') +const os = require('os') +const path = require('path') const core = require('@actions/core') const io = require('@actions/io') -const os = require('os') const exec = require('@actions/exec') const main = require('../src/main') -// Mock the GitHub Actions core library const infoMock = jest.spyOn(core, 'info').mockImplementation() +const warningMock = jest.spyOn(core, 'warning').mockImplementation() const getInputMock = jest.spyOn(core, 'getInput').mockImplementation() const setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation() const setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation() +const setSecretMock = jest.spyOn(core, 'setSecret').mockImplementation() const ioWhichMock = jest.spyOn(io, 'which').mockImplementation() -const osPlatformMock = jest.spyOn(os, 'platform').mockImplementation() -const osArchMock = jest.spyOn(os, 'arch').mockImplementation() const execMock = jest.spyOn(exec, 'exec').mockImplementation() -// Mock the action's main function -const runMock = jest.spyOn(main, 'run') - describe('action', () => { + const originalEnv = process.env + let tempDir + beforeEach(() => { jest.clearAllMocks() + process.env = { ...originalEnv } + delete process.env.GITHUB_EVENT_PATH + delete process.env.GITHUB_REPOSITORY + delete process.env.GITHUB_STEP_SUMMARY + delete process.env.GITHUB_TOKEN + delete process.env.GITHUB_API_URL + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pbuf-action-test-')) + execMock.mockResolvedValue(0) }) - it('calls the main function', async () => { + afterEach(() => { + process.env = originalEnv + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('installs a pinned CLI version and authenticates safely', async () => { getInputMock.mockImplementation(name => { switch (name) { case 'version': - return 'v0.3.0' + return 'v0.16.0' case 'pbuf_token': return 'pbuf_token_test' default: @@ -37,30 +51,264 @@ describe('action', () => { } }) - osPlatformMock.mockReturnValue('linux') - osArchMock.mockReturnValue('x64') - await main.run() - expect(runMock).toHaveReturned() + expect(setFailedMock).not.toHaveBeenCalled() + expect(setSecretMock).toHaveBeenCalledWith('pbuf_token_test') + expect(infoMock).toHaveBeenCalledWith('Installing pbuf cli version v0.16.0') + expect(execMock).toHaveBeenCalledWith('/bin/bash', [ + '-lc', + 'curl -fsSL https://raw.githubusercontent.com/pbufio/pbuf-registry/main/scripts/install.sh | sh -s -- -v "$1"', + '_', + 'v0.16.0' + ]) + expect(ioWhichMock).toHaveBeenCalledWith('pbuf', true) + expect(execMock).toHaveBeenCalledWith('pbuf', ['auth', 'pbuf_token_test']) + }) - expect(infoMock).toHaveBeenNthCalledWith( - 1, - 'Installing pbuf cli version v0.3.0' + it('supports installing latest version without -v flag', async () => { + getInputMock.mockImplementation(name => + name === 'version' ? 'latest' : '' ) - expect(setFailedMock).not.toHaveBeenCalled() + await main.run() - // Check that we run the official install script with the provided version + expect(setFailedMock).not.toHaveBeenCalled() expect(execMock).toHaveBeenCalledWith('/bin/bash', [ '-lc', - expect.stringContaining( - 'curl -fsSL https://raw.githubusercontent.com/pbufio/pbuf-cli/main/install.sh | sh -s -- -v v0.3.0' - ) + 'curl -fsSL https://raw.githubusercontent.com/pbufio/pbuf-registry/main/scripts/install.sh | sh' ]) + }) - expect(ioWhichMock).toHaveBeenNthCalledWith(1, 'pbuf', true) + it.each([ + ['', 'You must provide a version'], + ['v0.16.0; whoami', 'Invalid version "v0.16.0; whoami"'] + ])('rejects invalid version input %j', async (versionInput, expectedErr) => { + getInputMock.mockImplementation(name => + name === 'version' ? versionInput : '' + ) - expect(execMock).toHaveBeenCalledWith('pbuf', ['auth', 'pbuf_token_test']) + await main.run() + + expect(setFailedMock).toHaveBeenCalledWith(expectedErr) + expect(execMock).not.toHaveBeenCalled() + }) + + it('posts a new sticky PR comment and fails step when drift exceeds fail_on', async () => { + const eventFile = path.join(tempDir, 'event.json') + fs.writeFileSync( + eventFile, + JSON.stringify({ pull_request: { number: 42 } }) + ) + process.env.GITHUB_EVENT_PATH = eventFile + process.env.GITHUB_REPOSITORY = 'pbufio/example-protos' + + const report = + '### Schema Drift Diff: `pbufio/example` (against `v1.0.0`)\n\n| Severity | File | Change | Details |\n| :--- | :--- | :--- | :--- |\n| **CRITICAL** | `api/v1/user.proto` | `FIELD_REMOVED` | field User#2 (string email) was removed |\n' + + getInputMock.mockImplementation(name => { + switch (name) { + case 'version': + return 'v0.16.0' + case 'drift_diff': + return 'true' + case 'fail_on': + return 'critical' + case 'against': + return 'v1.0.0' + case 'comment_on_pr': + return 'true' + case 'github_token': + return 'ghs_test_token' + default: + return '' + } + }) + + execMock.mockImplementation(async (cmd, args, options) => { + if (cmd === 'pbuf' && args[0] === 'drift') { + options.listeners.stdout(Buffer.from(report)) + options.listeners.stderr( + Buffer.from( + 'Error: schema drift check failed: detected changes meeting or exceeding CRITICAL severity\n' + ) + ) + return 1 + } + return 0 + }) + + const fetchMock = jest + .spyOn(global, 'fetch') + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [{ id: 10, body: 'unrelated comment' }] + }) + .mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ id: 99 }) + }) + + await main.run() + + expect(execMock).toHaveBeenCalledWith( + 'pbuf', + [ + 'drift', + 'diff', + '--format=markdown', + '--fail-on=critical', + '--against=v1.0.0' + ], + expect.objectContaining({ ignoreReturnCode: true }) + ) + expect(setOutputMock).toHaveBeenCalledWith('drift_markdown', report.trim()) + expect(setOutputMock).toHaveBeenCalledWith('drift_exit_code', '1') + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.github.com/repos/pbufio/example-protos/issues/42/comments', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + body: `${main.COMMENT_MARKER}\n${report.trim()}` + }) + }) + ) + expect(setFailedMock).toHaveBeenCalledWith( + 'Error: schema drift check failed: detected changes meeting or exceeding CRITICAL severity' + ) + + fetchMock.mockRestore() + }) + + it('updates an existing sticky PR comment instead of creating duplicates', async () => { + const eventFile = path.join(tempDir, 'event.json') + fs.writeFileSync(eventFile, JSON.stringify({ pull_request: { number: 7 } })) + process.env.GITHUB_EVENT_PATH = eventFile + process.env.GITHUB_REPOSITORY = 'pbufio/example-protos' + + const report = + '### Schema Drift Diff: `pbufio/example` (against `v1.0.0`)\n\nNo schema changes detected.\n' + + getInputMock.mockImplementation(name => { + switch (name) { + case 'version': + return 'v0.16.0' + case 'drift_diff': + return 'true' + case 'comment_on_pr': + return 'true' + case 'github_token': + return 'ghs_test_token' + default: + return '' + } + }) + + execMock.mockImplementation(async (cmd, args, options) => { + if (cmd === 'pbuf' && args[0] === 'drift') { + options.listeners.stdout(Buffer.from(report)) + return 0 + } + return 0 + }) + + const fetchMock = jest + .spyOn(global, 'fetch') + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { id: 555, body: `${main.COMMENT_MARKER}\nold report` } + ] + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 555 }) + }) + + await main.run() + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.github.com/repos/pbufio/example-protos/issues/comments/555', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + body: `${main.COMMENT_MARKER}\n${report.trim()}` + }) + }) + ) + expect(setFailedMock).not.toHaveBeenCalled() + + fetchMock.mockRestore() + }) + + it('warns without failing build when fork PR lacks comment write permission', async () => { + const eventFile = path.join(tempDir, 'event.json') + fs.writeFileSync(eventFile, JSON.stringify({ pull_request: { number: 9 } })) + process.env.GITHUB_EVENT_PATH = eventFile + process.env.GITHUB_REPOSITORY = 'pbufio/example-protos' + + getInputMock.mockImplementation(name => { + switch (name) { + case 'version': + return 'v0.16.0' + case 'drift_diff': + return 'true' + case 'comment_on_pr': + return 'true' + case 'github_token': + return 'ghs_readonly_token' + default: + return '' + } + }) + + execMock.mockImplementation(async (cmd, args, options) => { + if (cmd === 'pbuf' && args[0] === 'drift') { + options.listeners.stdout(Buffer.from('No schema changes detected.\n')) + return 0 + } + return 0 + }) + + const fetchMock = jest + .spyOn(global, 'fetch') + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => [] }) + .mockResolvedValueOnce({ ok: false, status: 403 }) + + await main.run() + + expect(warningMock).toHaveBeenCalledWith( + expect.stringContaining('POST comment failed with status 403') + ) + expect(setFailedMock).not.toHaveBeenCalled() + + fetchMock.mockRestore() + }) + + it('rejects invalid fail_on threshold', async () => { + getInputMock.mockImplementation(name => { + switch (name) { + case 'version': + return 'v0.16.0' + case 'drift_diff': + return 'true' + case 'fail_on': + return 'bogus' + default: + return '' + } + }) + + await main.run() + + expect(setFailedMock).toHaveBeenCalledWith( + 'Invalid fail_on "bogus" (must be critical, warning, info, or none)' + ) }) }) diff --git a/action.yml b/action.yml index fd5e0a8..e3f5eb5 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ -name: "pbuf-setup" -description: "Install pbuf-cli for use in other jobs" -author: "pbuf.io" +name: 'pbuf-setup' +description: 'Install pbuf-cli and optionally run schema drift checks with sticky PR comments' +author: 'pbuf.io' branding: color: purple @@ -8,13 +8,43 @@ branding: inputs: version: - description: The version of the CLI + description: The version of the CLI to install (e.g., v0.16.0 or latest) required: false - default: 'v0.9.0' + default: 'v0.16.0' pbuf_token: description: The API token to use with PBUF registry. required: false + drift_diff: + description: Run pbuf drift diff against the registry baseline. + required: false + default: 'false' + fail_on: + description: Severity threshold to fail drift diff check (critical, warning, info, or none). + required: false + default: 'critical' + against: + description: Baseline registry tag to compare against (defaults to latest published tag). + required: false + default: '' + working_directory: + description: Working directory containing pbuf.yaml for drift diff checks. + required: false + default: '' + comment_on_pr: + description: Post or update a sticky pull request comment with the drift diff report. + required: false + default: 'false' + github_token: + description: GitHub token used to post or update sticky pull request comments. + required: false + default: ${{ github.token }} + +outputs: + drift_markdown: + description: Markdown report generated by pbuf drift diff. + drift_exit_code: + description: Exit code returned by pbuf drift diff. runs: - using: node20 + using: node24 main: dist/index.js diff --git a/badges/coverage.svg b/badges/coverage.svg index 1541a91..2d1e298 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 86.36%Coverage86.36% \ No newline at end of file +Coverage: 86.77%Coverage86.77% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index b7a16eb..7276585 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3956,43 +3956,252 @@ exports["default"] = _default; /***/ 713: /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { +const fs = __nccwpck_require__(147) const core = __nccwpck_require__(186) -const os = __nccwpck_require__(37) const io = __nccwpck_require__(436) const exec = __nccwpck_require__(514) -async function run() { - // Get inputs from workflow file - const version = core.getInput('version') - const pbuf_token = core.getInput('pbuf_token') +const INSTALL_SCRIPT_URL = + 'https://raw.githubusercontent.com/pbufio/pbuf-registry/main/scripts/install.sh' +const VERSION_RE = /^(latest|v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/ +const VALID_FAIL_ON = new Set(['critical', 'warning', 'info', 'none']) +const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ +const COMMENT_MARKER = '' +const MAX_COMMENT_CHARS = 60000 + +function getPullRequestNumber() { + const eventPath = process.env.GITHUB_EVENT_PATH + if (!eventPath || !fs.existsSync(eventPath)) { + return null + } + const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')) + if (event.pull_request && event.pull_request.number) { + return event.pull_request.number + } + if (event.issue && event.issue.pull_request && event.issue.number) { + return event.issue.number + } + return null +} + +async function upsertPullRequestComment(markdown, githubToken) { + try { + const prNumber = getPullRequestNumber() + if (!prNumber) { + core.info('Skipping PR comment: workflow event is not a pull request') + return + } + + const repository = process.env.GITHUB_REPOSITORY || '' + if (!REPO_RE.test(repository)) { + core.warning('Skipping PR comment: valid GITHUB_REPOSITORY is not set') + return + } + + if (!githubToken) { + core.warning('Skipping PR comment: github_token is not provided') + return + } + + const truncated = + markdown.length > MAX_COMMENT_CHARS + ? `${markdown.slice(0, MAX_COMMENT_CHARS)}\n\n_...report truncated..._` + : markdown + const body = `${COMMENT_MARKER}\n${truncated}` + const apiBase = ( + process.env.GITHUB_API_URL || 'https://api.github.com' + ).replace(/\/+$/, '') + const headers = { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${githubToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'pbuf-setup-action', + 'X-GitHub-Api-Version': '2022-11-28' + } + + let existingId = null + for (let page = 1; page <= 5; page += 1) { + const listUrl = `${apiBase}/repos/${repository}/issues/${prNumber}/comments?per_page=100&page=${page}` + const listRes = await fetch(listUrl, { + method: 'GET', + headers, + signal: AbortSignal.timeout(10000) + }) + if (!listRes.ok) { + throw new Error(`GET comments failed with status ${listRes.status}`) + } + const comments = await listRes.json() + const found = comments.find( + comment => + typeof comment.body === 'string' && + comment.body.includes(COMMENT_MARKER) + ) + if (found) { + existingId = found.id + break + } + if (comments.length < 100) { + break + } + } - if (version === '') { - core.setFailed('You must provide a version') + if (existingId) { + const patchUrl = `${apiBase}/repos/${repository}/issues/comments/${existingId}` + const patchRes = await fetch(patchUrl, { + method: 'PATCH', + headers, + body: JSON.stringify({ body }), + signal: AbortSignal.timeout(10000) + }) + if (!patchRes.ok) { + throw new Error(`PATCH comment failed with status ${patchRes.status}`) + } + core.info( + `Updated sticky drift diff comment #${existingId} on PR #${prNumber}` + ) + return + } + + const createUrl = `${apiBase}/repos/${repository}/issues/${prNumber}/comments` + const createRes = await fetch(createUrl, { + method: 'POST', + headers, + body: JSON.stringify({ body }), + signal: AbortSignal.timeout(10000) + }) + if (!createRes.ok) { + throw new Error(`POST comment failed with status ${createRes.status}`) + } + core.info(`Posted sticky drift diff comment on PR #${prNumber}`) + } catch (error) { + core.warning( + `Could not post PR comment (${error.message}). Ensure pull-requests: write permission is granted.` + ) + } +} + +async function runDriftDiff() { + const failOn = (core.getInput('fail_on').trim() || 'critical').toLowerCase() + if (!VALID_FAIL_ON.has(failOn)) { + core.setFailed( + `Invalid fail_on "${failOn}" (must be critical, warning, info, or none)` + ) return } - core.info(`Installing pbuf cli version ${version}`) + const against = core.getInput('against').trim() + const workingDirectory = core.getInput('working_directory').trim() + const commentOnPr = + core.getInput('comment_on_pr').trim().toLowerCase() === 'true' + const githubToken = + core.getInput('github_token') || process.env.GITHUB_TOKEN || '' + if (githubToken !== '') { + core.setSecret(githubToken) + } + + const args = ['drift', 'diff', '--format=markdown', `--fail-on=${failOn}`] + if (against !== '') { + args.push(`--against=${against}`) + } - if (pbuf_token === '') { - core.info('No pbuf_token provided, skipping login') + let stdout = '' + let stderr = '' + const exitCode = await exec.exec('pbuf', args, { + cwd: workingDirectory || undefined, + ignoreReturnCode: true, + listeners: { + stdout: data => { + stdout += data.toString() + }, + stderr: data => { + stderr += data.toString() + } + } + }) + + const markdown = stdout.trim() + core.setOutput('drift_markdown', markdown) + core.setOutput('drift_exit_code', String(exitCode)) + + if (exitCode !== 0 && markdown === '') { + core.setFailed( + stderr.trim() || `pbuf drift diff failed with exit code ${exitCode}` + ) + return } - // Install via official install script - const installCmd = `curl -fsSL https://raw.githubusercontent.com/pbufio/pbuf-registry/main/scripts/install.sh | sh -s -- -v ${version}` - await exec.exec('/bin/bash', ['-lc', installCmd]) + if (markdown !== '' && process.env.GITHUB_STEP_SUMMARY) { + await core.summary.addRaw(markdown).addEOL().write() + } - // Verify pbuf is available on PATH - await io.which('pbuf', true) + if (commentOnPr && markdown !== '') { + await upsertPullRequestComment(markdown, githubToken) + } - if (pbuf_token !== '') { - core.info(`Authenticate into the pbuf registry`) - await exec.exec('pbuf', ['auth', pbuf_token]) + if (exitCode !== 0) { + core.setFailed( + stderr.trim() || `Schema drift check failed (fail_on=${failOn})` + ) } +} + +async function run() { + try { + const version = core.getInput('version').trim() + const pbufToken = core.getInput('pbuf_token') + + if (version === '') { + core.setFailed('You must provide a version') + return + } + if (!VERSION_RE.test(version)) { + core.setFailed(`Invalid version "${version}"`) + return + } + + if (pbufToken !== '') { + core.setSecret(pbufToken) + } + + core.info(`Installing pbuf cli version ${version}`) - core.info(`Successfully setup pbuf cli`) + if (pbufToken === '') { + core.info('No pbuf_token provided, skipping login') + } + + if (version === 'latest') { + await exec.exec('/bin/bash', [ + '-lc', + `curl -fsSL ${INSTALL_SCRIPT_URL} | sh` + ]) + } else { + await exec.exec('/bin/bash', [ + '-lc', + `curl -fsSL ${INSTALL_SCRIPT_URL} | sh -s -- -v "$1"`, + '_', + version + ]) + } + + await io.which('pbuf', true) + + if (pbufToken !== '') { + core.info('Authenticate into the pbuf registry') + await exec.exec('pbuf', ['auth', pbufToken]) + } + + core.info('Successfully setup pbuf cli') + + if (core.getInput('drift_diff').trim().toLowerCase() === 'true') { + await runDriftDiff() + } + } catch (error) { + core.setFailed(error instanceof Error ? error.message : String(error)) + } } module.exports = { + COMMENT_MARKER, run } diff --git a/package-lock.json b/package-lock.json index fdfdec5..f39b496 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "pbuf-setup-action", - "version": "0.3.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pbuf-setup-action", - "version": "0.3.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@actions/core": "^1.10.1", - "@actions/io": "^1.1.3", - "@actions/tool-cache": "^2.0.1" + "@actions/exec": "^1.1.1", + "@actions/io": "^1.1.3" }, "devDependencies": { "@babel/core": "^7.23.3", @@ -69,28 +69,6 @@ "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==" }, - "node_modules/@actions/tool-cache": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@actions/tool-cache/-/tool-cache-2.0.1.tgz", - "integrity": "sha512-iPU+mNwrbA8jodY8eyo/0S/QqCKDajiR8OxWTnSk/SnYg0sj8Hp4QcUEVC1YFpHWXtrfbQrE13Jz4k4HXJQKcA==", - "dependencies": { - "@actions/core": "^1.2.6", - "@actions/exec": "^1.0.0", - "@actions/http-client": "^2.0.1", - "@actions/io": "^1.1.1", - "semver": "^6.1.0", - "uuid": "^3.3.2" - } - }, - "node_modules/@actions/tool-cache/node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "bin": { - "uuid": "bin/uuid" - } - }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -7801,6 +7779,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } diff --git a/package.json b/package.json index 39b82ff..2190660 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pbuf-setup-action", "description": "GitHub Action to setup pbuf-cli", - "version": "1.1.0", + "version": "1.3.0", "author": "aatarasoff@gmail.com", "exports": { ".": "./dist/index.js" @@ -50,8 +50,8 @@ }, "dependencies": { "@actions/core": "^1.10.1", - "@actions/io": "^1.1.3", - "@actions/tool-cache": "^2.0.1" + "@actions/exec": "^1.1.1", + "@actions/io": "^1.1.3" }, "devDependencies": { "@babel/core": "^7.23.3", diff --git a/src/main.js b/src/main.js index 5981851..dcd765f 100644 --- a/src/main.js +++ b/src/main.js @@ -1,39 +1,248 @@ +const fs = require('fs') const core = require('@actions/core') -const os = require('os') const io = require('@actions/io') const exec = require('@actions/exec') -async function run() { - // Get inputs from workflow file - const version = core.getInput('version') - const pbuf_token = core.getInput('pbuf_token') +const INSTALL_SCRIPT_URL = + 'https://raw.githubusercontent.com/pbufio/pbuf-registry/main/scripts/install.sh' +const VERSION_RE = /^(latest|v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/ +const VALID_FAIL_ON = new Set(['critical', 'warning', 'info', 'none']) +const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ +const COMMENT_MARKER = '' +const MAX_COMMENT_CHARS = 60000 + +function getPullRequestNumber() { + const eventPath = process.env.GITHUB_EVENT_PATH + if (!eventPath || !fs.existsSync(eventPath)) { + return null + } + const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')) + if (event.pull_request && event.pull_request.number) { + return event.pull_request.number + } + if (event.issue && event.issue.pull_request && event.issue.number) { + return event.issue.number + } + return null +} + +async function upsertPullRequestComment(markdown, githubToken) { + try { + const prNumber = getPullRequestNumber() + if (!prNumber) { + core.info('Skipping PR comment: workflow event is not a pull request') + return + } + + const repository = process.env.GITHUB_REPOSITORY || '' + if (!REPO_RE.test(repository)) { + core.warning('Skipping PR comment: valid GITHUB_REPOSITORY is not set') + return + } + + if (!githubToken) { + core.warning('Skipping PR comment: github_token is not provided') + return + } - if (version === '') { - core.setFailed('You must provide a version') + const truncated = + markdown.length > MAX_COMMENT_CHARS + ? `${markdown.slice(0, MAX_COMMENT_CHARS)}\n\n_...report truncated..._` + : markdown + const body = `${COMMENT_MARKER}\n${truncated}` + const apiBase = ( + process.env.GITHUB_API_URL || 'https://api.github.com' + ).replace(/\/+$/, '') + const headers = { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${githubToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'pbuf-setup-action', + 'X-GitHub-Api-Version': '2022-11-28' + } + + let existingId = null + for (let page = 1; page <= 5; page += 1) { + const listUrl = `${apiBase}/repos/${repository}/issues/${prNumber}/comments?per_page=100&page=${page}` + const listRes = await fetch(listUrl, { + method: 'GET', + headers, + signal: AbortSignal.timeout(10000) + }) + if (!listRes.ok) { + throw new Error(`GET comments failed with status ${listRes.status}`) + } + const comments = await listRes.json() + const found = comments.find( + comment => + typeof comment.body === 'string' && + comment.body.includes(COMMENT_MARKER) + ) + if (found) { + existingId = found.id + break + } + if (comments.length < 100) { + break + } + } + + if (existingId) { + const patchUrl = `${apiBase}/repos/${repository}/issues/comments/${existingId}` + const patchRes = await fetch(patchUrl, { + method: 'PATCH', + headers, + body: JSON.stringify({ body }), + signal: AbortSignal.timeout(10000) + }) + if (!patchRes.ok) { + throw new Error(`PATCH comment failed with status ${patchRes.status}`) + } + core.info( + `Updated sticky drift diff comment #${existingId} on PR #${prNumber}` + ) + return + } + + const createUrl = `${apiBase}/repos/${repository}/issues/${prNumber}/comments` + const createRes = await fetch(createUrl, { + method: 'POST', + headers, + body: JSON.stringify({ body }), + signal: AbortSignal.timeout(10000) + }) + if (!createRes.ok) { + throw new Error(`POST comment failed with status ${createRes.status}`) + } + core.info(`Posted sticky drift diff comment on PR #${prNumber}`) + } catch (error) { + core.warning( + `Could not post PR comment (${error.message}). Ensure pull-requests: write permission is granted.` + ) + } +} + +async function runDriftDiff() { + const failOn = (core.getInput('fail_on').trim() || 'critical').toLowerCase() + if (!VALID_FAIL_ON.has(failOn)) { + core.setFailed( + `Invalid fail_on "${failOn}" (must be critical, warning, info, or none)` + ) return } - core.info(`Installing pbuf cli version ${version}`) + const against = core.getInput('against').trim() + const workingDirectory = core.getInput('working_directory').trim() + const commentOnPr = + core.getInput('comment_on_pr').trim().toLowerCase() === 'true' + const githubToken = + core.getInput('github_token') || process.env.GITHUB_TOKEN || '' + if (githubToken !== '') { + core.setSecret(githubToken) + } - if (pbuf_token === '') { - core.info('No pbuf_token provided, skipping login') + const args = ['drift', 'diff', '--format=markdown', `--fail-on=${failOn}`] + if (against !== '') { + args.push(`--against=${against}`) } - // Install via official install script - const installCmd = `curl -fsSL https://raw.githubusercontent.com/pbufio/pbuf-registry/main/scripts/install.sh | sh -s -- -v ${version}` - await exec.exec('/bin/bash', ['-lc', installCmd]) + let stdout = '' + let stderr = '' + const exitCode = await exec.exec('pbuf', args, { + cwd: workingDirectory || undefined, + ignoreReturnCode: true, + listeners: { + stdout: data => { + stdout += data.toString() + }, + stderr: data => { + stderr += data.toString() + } + } + }) - // Verify pbuf is available on PATH - await io.which('pbuf', true) + const markdown = stdout.trim() + core.setOutput('drift_markdown', markdown) + core.setOutput('drift_exit_code', String(exitCode)) - if (pbuf_token !== '') { - core.info(`Authenticate into the pbuf registry`) - await exec.exec('pbuf', ['auth', pbuf_token]) + if (exitCode !== 0 && markdown === '') { + core.setFailed( + stderr.trim() || `pbuf drift diff failed with exit code ${exitCode}` + ) + return } - core.info(`Successfully setup pbuf cli`) + if (markdown !== '' && process.env.GITHUB_STEP_SUMMARY) { + await core.summary.addRaw(markdown).addEOL().write() + } + + if (commentOnPr && markdown !== '') { + await upsertPullRequestComment(markdown, githubToken) + } + + if (exitCode !== 0) { + core.setFailed( + stderr.trim() || `Schema drift check failed (fail_on=${failOn})` + ) + } +} + +async function run() { + try { + const version = core.getInput('version').trim() + const pbufToken = core.getInput('pbuf_token') + + if (version === '') { + core.setFailed('You must provide a version') + return + } + if (!VERSION_RE.test(version)) { + core.setFailed(`Invalid version "${version}"`) + return + } + + if (pbufToken !== '') { + core.setSecret(pbufToken) + } + + core.info(`Installing pbuf cli version ${version}`) + + if (pbufToken === '') { + core.info('No pbuf_token provided, skipping login') + } + + if (version === 'latest') { + await exec.exec('/bin/bash', [ + '-lc', + `curl -fsSL ${INSTALL_SCRIPT_URL} | sh` + ]) + } else { + await exec.exec('/bin/bash', [ + '-lc', + `curl -fsSL ${INSTALL_SCRIPT_URL} | sh -s -- -v "$1"`, + '_', + version + ]) + } + + await io.which('pbuf', true) + + if (pbufToken !== '') { + core.info('Authenticate into the pbuf registry') + await exec.exec('pbuf', ['auth', pbufToken]) + } + + core.info('Successfully setup pbuf cli') + + if (core.getInput('drift_diff').trim().toLowerCase() === 'true') { + await runDriftDiff() + } + } catch (error) { + core.setFailed(error instanceof Error ? error.message : String(error)) + } } module.exports = { + COMMENT_MARKER, run }