From 0784708630fc7951212955c571ab897cabcfd07f Mon Sep 17 00:00:00 2001 From: Argus Date: Wed, 29 Jul 2026 19:20:17 +0000 Subject: [PATCH] ci(npm): enforce canonical package author Signed-off-by: Argus --- .../workflows/check-npm-package-metadata.yml | 38 ++++++++ .github/workflows/publish-cli.yml | 4 + .github/workflows/publish-electron-sdk.yml | 22 +++-- .github/workflows/publish-node.yml | 22 +++-- clients/electron/package.json | 4 + packages/cli/package.json | 4 + packages/feedback-react/package.json | 4 + packages/node/package.json | 4 + scripts/verify-npm-author.mjs | 97 +++++++++++++++++++ scripts/verify-npm-author.test.mjs | 27 ++++++ 10 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/check-npm-package-metadata.yml create mode 100644 scripts/verify-npm-author.mjs create mode 100644 scripts/verify-npm-author.test.mjs diff --git a/.github/workflows/check-npm-package-metadata.yml b/.github/workflows/check-npm-package-metadata.yml new file mode 100644 index 00000000..b965ff7c --- /dev/null +++ b/.github/workflows/check-npm-package-metadata.yml @@ -0,0 +1,38 @@ +name: Check npm package metadata + +on: + pull_request: + paths: + - "**/package.json" + - "scripts/verify-npm-author.mjs" + - "scripts/verify-npm-author.test.mjs" + - ".github/workflows/check-npm-package-metadata.yml" + - ".github/workflows/publish-cli.yml" + - ".github/workflows/publish-electron-sdk.yml" + - ".github/workflows/publish-node.yml" + push: + branches: [main] + paths: + - "**/package.json" + - "scripts/verify-npm-author.mjs" + - "scripts/verify-npm-author.test.mjs" + - ".github/workflows/check-npm-package-metadata.yml" + - ".github/workflows/publish-cli.yml" + - ".github/workflows/publish-electron-sdk.yml" + - ".github/workflows/publish-node.yml" + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Test author metadata gate + run: node --test scripts/verify-npm-author.test.mjs + - name: Verify every publishable npm package + run: node scripts/verify-npm-author.mjs --all diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index d66753d1..360b130b 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -44,6 +44,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify publishable package author metadata + run: node scripts/verify-npm-author.mjs --all + - name: Resolve version id: version shell: bash @@ -86,6 +89,7 @@ jobs: pnpm pack --pack-destination "$GITHUB_WORKSPACE/dist-packages" package_path="$(find "$GITHUB_WORKSPACE/dist-packages" -maxdepth 1 -name 'botiverse-hands-cli-*.tgz' -print -quit)" test -n "${package_path}" + node "$GITHUB_WORKSPACE/scripts/verify-npm-author.mjs" --tarball "${package_path}" echo "path=${package_path}" >> "$GITHUB_OUTPUT" - name: Publish dry run diff --git a/.github/workflows/publish-electron-sdk.yml b/.github/workflows/publish-electron-sdk.yml index 558b383b..4c4a6698 100644 --- a/.github/workflows/publish-electron-sdk.yml +++ b/.github/workflows/publish-electron-sdk.yml @@ -43,6 +43,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify publishable package author metadata + run: node scripts/verify-npm-author.mjs --all + - name: Resolve version id: version shell: bash @@ -65,22 +68,29 @@ jobs: - name: Build Electron SDK run: pnpm --filter @botiverse/hands-electron build - - name: Pack check + - name: Pack Electron SDK and verify packed metadata + id: pack working-directory: clients/electron - run: npm pack --dry-run + shell: bash + run: | + set -euo pipefail + mkdir -p "$GITHUB_WORKSPACE/dist-packages" + npm pack --pack-destination "$GITHUB_WORKSPACE/dist-packages" + package_path="$(find "$GITHUB_WORKSPACE/dist-packages" -maxdepth 1 -name 'botiverse-hands-electron-*.tgz' -print -quit)" + test -n "${package_path}" + node "$GITHUB_WORKSPACE/scripts/verify-npm-author.mjs" --tarball "${package_path}" + echo "path=${package_path}" >> "$GITHUB_OUTPUT" - name: Publish dry run if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} - working-directory: clients/electron - run: npm publish --access public --dry-run + run: npm publish "${{ steps.pack.outputs.path }}" --access public --dry-run - name: Publish to npm if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }} - working-directory: clients/electron shell: bash run: | set -euo pipefail - npm publish --access public + npm publish "${{ steps.pack.outputs.path }}" --access public - name: Summary shell: bash diff --git a/.github/workflows/publish-node.yml b/.github/workflows/publish-node.yml index bc4fa801..862ca6a4 100644 --- a/.github/workflows/publish-node.yml +++ b/.github/workflows/publish-node.yml @@ -44,6 +44,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify publishable package author metadata + run: node scripts/verify-npm-author.mjs --all + - name: Resolve version id: version shell: bash @@ -68,21 +71,28 @@ jobs: pnpm --filter @botiverse/hands-node test pnpm --filter @botiverse/hands-node build - - name: Pack check + - name: Pack Node SDK and verify packed metadata + id: pack working-directory: packages/node - run: npm pack --dry-run + shell: bash + run: | + set -euo pipefail + mkdir -p "$GITHUB_WORKSPACE/dist-packages" + npm pack --pack-destination "$GITHUB_WORKSPACE/dist-packages" + package_path="$(find "$GITHUB_WORKSPACE/dist-packages" -maxdepth 1 -name 'botiverse-hands-node-*.tgz' -print -quit)" + test -n "${package_path}" + node "$GITHUB_WORKSPACE/scripts/verify-npm-author.mjs" --tarball "${package_path}" + echo "path=${package_path}" >> "$GITHUB_OUTPUT" - name: Publish dry run if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} - working-directory: packages/node - run: npm publish --access public --dry-run + run: npm publish "${{ steps.pack.outputs.path }}" --access public --dry-run env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish to npm if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }} - working-directory: packages/node - run: npm publish --access public + run: npm publish "${{ steps.pack.outputs.path }}" --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/clients/electron/package.json b/clients/electron/package.json index 8f6887be..fd1c6691 100644 --- a/clients/electron/package.json +++ b/clients/electron/package.json @@ -1,6 +1,10 @@ { "name": "@botiverse/hands-electron", "version": "0.2.1", + "author": { + "name": "jiacheng", + "email": "jiacheng@botiverse.dev" + }, "description": "Hands crash reporting for Electron apps — main + renderer, Crashpad minidumps auto-uploaded and symbolicated server-side.", "license": "MIT", "type": "module", diff --git a/packages/cli/package.json b/packages/cli/package.json index da2f4769..2c6b388d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,10 @@ { "name": "@botiverse/hands-cli", "version": "0.5.14", + "author": { + "name": "jiacheng", + "email": "jiacheng@botiverse.dev" + }, "description": "Hands CLI \u2014 manage apps, builds, releases from the terminal.", "license": "MIT", "repository": { diff --git a/packages/feedback-react/package.json b/packages/feedback-react/package.json index 692dc515..8c0ab21e 100644 --- a/packages/feedback-react/package.json +++ b/packages/feedback-react/package.json @@ -1,6 +1,10 @@ { "name": "@botiverse/hands-feedback-react", "version": "0.1.0", + "author": { + "name": "jiacheng", + "email": "jiacheng@botiverse.dev" + }, "description": "Embeddable reporter-facing Hands feedback inbox components.", "license": "MIT", "repository": { diff --git a/packages/node/package.json b/packages/node/package.json index be531403..886643ba 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,10 @@ { "name": "@botiverse/hands-node", "version": "0.1.0", + "author": { + "name": "jiacheng", + "email": "jiacheng@botiverse.dev" + }, "description": "Hands logging, redaction, and policy-governed log collection for Node.js.", "license": "MIT", "repository": { diff --git a/scripts/verify-npm-author.mjs b/scripts/verify-npm-author.mjs new file mode 100644 index 00000000..c8f69305 --- /dev/null +++ b/scripts/verify-npm-author.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const EXPECTED_AUTHOR = Object.freeze({ + name: 'jiacheng', + email: 'jiacheng@botiverse.dev', +}); + +export function verifyAuthor(manifest, source) { + if (manifest.private === true) return; + const author = manifest.author; + if (author === null || typeof author !== 'object' || Array.isArray(author)) { + throw new Error(`${source}: publishable package must use an author object`); + } + if (author.name !== EXPECTED_AUTHOR.name || author.email !== EXPECTED_AUTHOR.email) { + throw new Error(`${source}: author must be ${EXPECTED_AUTHOR.name} <${EXPECTED_AUTHOR.email}>`); + } +} + +function discoverPackageManifests(root) { + const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage']); + const manifests = []; + const visit = (directory) => { + for (const entry of readdirSync(directory)) { + if (ignored.has(entry)) continue; + const path = resolve(directory, entry); + const stat = statSync(path); + if (stat.isDirectory()) visit(path); + else if (entry === 'package.json') manifests.push(path); + } + }; + visit(root); + return manifests.sort(); +} + +function readPackedManifest(tarball) { + let text; + try { + text = execFileSync( + 'tar', + ['--extract', '--to-stdout', '--file', tarball, 'package/package.json'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + } catch (error) { + throw new Error(`${tarball}: cannot read packed package/package.json: ${String(error)}`); + } + let manifest; + try { + manifest = JSON.parse(text); + } catch (error) { + throw new Error(`${tarball}: packed package.json is invalid: ${String(error)}`); + } + verifyAuthor(manifest, `${tarball}:package/package.json`); +} + +function main(args) { + const root = resolve(fileURLToPath(new URL('..', import.meta.url))); + if (args.length === 1 && args[0] === '--all') { + let checked = 0; + for (const path of discoverPackageManifests(root)) { + const manifest = JSON.parse(readFileSync(path, 'utf8')); + if (manifest.private === true) continue; + verifyAuthor(manifest, path.slice(root.length + 1)); + checked += 1; + } + if (checked === 0) throw new Error('no publishable npm package manifests found'); + process.stdout.write(`npm author metadata: ${checked} publishable manifests PASS\n`); + return; + } + if (args.length === 2 && args[0] === '--manifest') { + const path = resolve(args[1]); + verifyAuthor(JSON.parse(readFileSync(path, 'utf8')), args[1]); + process.stdout.write(`npm author metadata: ${args[1]} PASS\n`); + return; + } + if (args.length === 2 && args[0] === '--tarball') { + const tarball = resolve(args[1]); + if (!existsSync(tarball)) throw new Error(`${args[1]}: tarball does not exist`); + readPackedManifest(tarball); + process.stdout.write(`npm author metadata: ${args[1]} packed manifest PASS\n`); + return; + } + throw new Error('usage: verify-npm-author.mjs --all | --manifest | --tarball '); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/verify-npm-author.test.mjs b/scripts/verify-npm-author.test.mjs new file mode 100644 index 00000000..946c792a --- /dev/null +++ b/scripts/verify-npm-author.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { EXPECTED_AUTHOR, verifyAuthor } from './verify-npm-author.mjs'; + +test('accepts the exact approved author object', () => { + assert.doesNotThrow(() => verifyAuthor({ author: EXPECTED_AUTHOR }, 'fixture')); +}); + +test('rejects missing, string, partial, and drifted author metadata', () => { + for (const author of [ + undefined, + 'jiacheng ', + { name: 'jiacheng' }, + { email: 'jiacheng@botiverse.dev' }, + { name: 'Jiacheng', email: 'jiacheng@botiverse.dev' }, + { name: 'jiacheng', email: 'other@example.com' }, + ]) { + assert.throws( + () => verifyAuthor({ author }, 'fixture'), + /publishable package must use an author object|author must be/, + ); + } +}); + +test('ignores private workspace packages', () => { + assert.doesNotThrow(() => verifyAuthor({ private: true }, 'fixture')); +});