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 @@
-
\ No newline at end of file
+
\ 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
}