ci: move workflow helpers into tools

This commit is contained in:
Danila Poyarkov 2026-06-07 09:15:15 +03:00
parent e870e01521
commit 7d516bf82b
12 changed files with 329 additions and 90 deletions

View file

@ -145,75 +145,7 @@ jobs:
- name: Prepare publish directories - name: Prepare publish directories
run: | run: |
node - <<'NODE' bun tools/release-packages/src/prepare-publish-dirs.ts
const fs = require('fs')
const path = require('path')
const root = process.cwd()
const outRoot = path.join(root, '.publish')
const coreVersion = require('./packages/core/package.json').version
fs.rmSync(outRoot, { recursive: true, force: true })
fs.mkdirSync(outRoot, { recursive: true })
const copyRecursive = (from, to) => {
const stat = fs.statSync(from)
if (stat.isDirectory()) {
fs.mkdirSync(to, { recursive: true })
for (const entry of fs.readdirSync(from)) {
copyRecursive(path.join(from, entry), path.join(to, entry))
}
return
}
fs.mkdirSync(path.dirname(to), { recursive: true })
fs.copyFileSync(from, to)
}
const packages = [
{ dir: 'packages/core', include: ['dist', 'src', 'assets'], extraFiles: [] },
{ dir: 'packages/cli', include: ['bin', 'dist'], extraFiles: [] },
{ dir: 'packages/mcp', include: ['dist'], extraFiles: [] },
{ dir: 'packages/vue', include: ['dist'], extraFiles: ['README.md'] }
]
for (const pkg of packages) {
const srcDir = path.join(root, pkg.dir)
const destDir = path.join(outRoot, path.basename(pkg.dir))
fs.mkdirSync(destDir, { recursive: true })
for (const rel of pkg.include) {
const from = path.join(srcDir, rel)
if (fs.existsSync(from)) copyRecursive(from, path.join(destDir, rel))
}
for (const rel of pkg.extraFiles) {
const from = path.join(srcDir, rel)
if (fs.existsSync(from)) copyRecursive(from, path.join(destDir, rel))
}
const pkgJsonPath = path.join(srcDir, 'package.json')
const json = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
if (!json[field]) continue
for (const [name, version] of Object.entries(json[field])) {
if (typeof version === 'string' && version.startsWith('workspace:')) {
json[field][name] = '^' + coreVersion
}
}
}
delete json.scripts
delete json.devDependencies
if (json.publishConfig) {
for (const [key, value] of Object.entries(json.publishConfig)) {
if (key === 'access' || key === 'provenance' || key === 'registry') continue
json[key] = value
}
delete json.publishConfig
}
fs.writeFileSync(path.join(destDir, 'package.json'), JSON.stringify(json, null, 2) + '\n')
console.log(`Prepared ${destDir}`)
}
NODE
find .publish -maxdepth 3 -type f | sort find .publish -maxdepth 3 -type f | sort
- name: Pack packages for publish - name: Pack packages for publish
@ -239,20 +171,7 @@ jobs:
tar -tf .npm-packages/open-pencil-cli-*.tgz | sed -n '1,40p' || true tar -tf .npm-packages/open-pencil-cli-*.tgz | sed -n '1,40p' || true
tar -tf .npm-packages/open-pencil-mcp-*.tgz | sed -n '1,40p' || true tar -tf .npm-packages/open-pencil-mcp-*.tgz | sed -n '1,40p' || true
tar -tf .npm-packages/open-pencil-vue-*.tgz | sed -n '1,60p' || true tar -tf .npm-packages/open-pencil-vue-*.tgz | sed -n '1,60p' || true
node --input-type=module <<'NODE' bun tools/release-packages/src/validate-tarballs.ts
import fs from 'node:fs'
import { execFileSync } from 'node:child_process'
for (const tarball of fs.readdirSync('.npm-packages').filter((name) => name.endsWith('.tgz'))) {
const entries = new Set(execFileSync('tar', ['-tf', `.npm-packages/${tarball}`], { encoding: 'utf8' }).trim().split('\n'))
const packageJson = JSON.parse(execFileSync('tar', ['-xOf', `.npm-packages/${tarball}`, 'package/package.json'], { encoding: 'utf8' }))
const bin = packageJson.bin
const targets = typeof bin === 'string' ? { [packageJson.name]: bin } : bin || {}
for (const [name, target] of Object.entries(targets)) {
const entry = `package/${String(target).replace(/^\.\//, '')}`
if (!entries.has(entry)) throw new Error(`${tarball}: bin ${name} target missing from tarball: ${entry}`)
}
}
NODE
- name: Publish packages to npm - name: Publish packages to npm
run: | run: |

View file

@ -28,7 +28,7 @@
"test:unit": "bun test ./tests/engine", "test:unit": "bun test ./tests/engine",
"test:coverage": "bun test --coverage ./tests/engine", "test:coverage": "bun test --coverage ./tests/engine",
"test:type-shapes": "bun scripts/type-shapes.ts", "test:type-shapes": "bun scripts/type-shapes.ts",
"test:tools": "bun --cwd tools/pr-review-guidance test", "test:tools": "bun tools/test.ts",
"test:dupes": "jscpd packages/core/src packages/cli/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0", "test:dupes": "jscpd packages/core/src packages/cli/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0",
"test:packages": "bun scripts/check-package-metadata.ts && bun scripts/smoke-packages.ts", "test:packages": "bun scripts/check-package-metadata.ts && bun scripts/smoke-packages.ts",
"build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build", "build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build",

View file

@ -59,14 +59,19 @@ export function markdownTableCells(line: string): string[] {
} }
export function normalizedCheckName(value: string): string { export function normalizedCheckName(value: string): string {
return value.replace(/[^a-z0-9]+/gi, ' ').trim().toLowerCase() return value
.replace(/[^a-z0-9]+/gi, ' ')
.trim()
.toLowerCase()
} }
export function reviewGuidanceCheckName(line: string): string | null { export function reviewGuidanceCheckName(line: string): string | null {
const [rawCheckName = '', status = ''] = markdownTableCells(line) const [rawCheckName = '', status = ''] = markdownTableCells(line)
const checkName = rawCheckName.replace(/^\[ignored\]\s*/i, '') const checkName = rawCheckName.replace(/^\[ignored\]\s*/i, '')
const normalizedName = normalizedCheckName(checkName) const normalizedName = normalizedCheckName(checkName)
const isRelevantCheck = REVIEW_GUIDANCE_PREFIXES.some((prefix) => normalizedName.startsWith(prefix)) const isRelevantCheck = REVIEW_GUIDANCE_PREFIXES.some((prefix) =>
normalizedName.startsWith(prefix)
)
const needsMaintainerAttention = //u.test(status) || /\berror\b/i.test(status) const needsMaintainerAttention = //u.test(status) || /\berror\b/i.test(status)
if (!isRelevantCheck || !needsMaintainerAttention) return null if (!isRelevantCheck || !needsMaintainerAttention) return null
@ -167,7 +172,9 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro
const checks = reviewGuidanceChecks(context.text) const checks = reviewGuidanceChecks(context.text)
if (checks.length === 0) { if (checks.length === 0) {
log('CodeRabbit did not report a PR description/readability check that needs maintainer attention.') log(
'CodeRabbit did not report a PR description/readability check that needs maintainer attention.'
)
return return
} }
@ -189,7 +196,9 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro
) )
} }
const isDirectRun = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false const isDirectRun = process.argv[1]
? import.meta.url === pathToFileURL(process.argv[1]).href
: false
if (isDirectRun) { if (isDirectRun) {
await monitorPRReviewGuidance() await monitorPRReviewGuidance()

View file

@ -1,9 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { mkdtemp, writeFile } from 'node:fs/promises' import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { import {
eventContext, eventContext,
monitorPRReviewGuidance, monitorPRReviewGuidance,

View file

@ -0,0 +1,10 @@
{
"name": "@open-pencil/release-packages",
"private": true,
"type": "module",
"scripts": {
"prepare-publish-dirs": "bun src/prepare-publish-dirs.ts",
"validate-tarballs": "bun src/validate-tarballs.ts",
"test": "bun test tests"
}
}

View file

@ -0,0 +1,18 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { DEFAULT_PACKAGES, preparePublishDirectories } from './publish-dirs'
const root = process.cwd()
const corePackageJSON = JSON.parse(
await readFile(join(root, 'packages/core/package.json'), 'utf8')
) as {
version: string
}
await preparePublishDirectories({
coreVersion: corePackageJSON.version,
packages: DEFAULT_PACKAGES,
root,
log: (message) => process.stdout.write(`${message}\n`)
})

View file

@ -0,0 +1,117 @@
import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
interface PackagePublishConfig {
dir: string
extraFiles: string[]
include: string[]
}
interface PreparePublishDirectoriesOptions {
coreVersion: string
packages: PackagePublishConfig[]
root: string
outRoot?: string
log?: (message: string) => void
}
type PackageJSON = Record<string, unknown> & {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
publishConfig?: Record<string, unknown>
scripts?: unknown
}
const PACKAGE_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies'] as const
const PUBLISH_CONFIG_FIELDS = new Set(['access', 'provenance', 'registry'])
export const DEFAULT_PACKAGES: PackagePublishConfig[] = [
{ dir: 'packages/core', include: ['dist', 'src', 'assets'], extraFiles: [] },
{ dir: 'packages/cli', include: ['bin', 'dist'], extraFiles: [] },
{ dir: 'packages/mcp', include: ['dist'], extraFiles: [] },
{ dir: 'packages/vue', include: ['dist'], extraFiles: ['README.md'] }
]
async function exists(path: string) {
try {
await stat(path)
return true
} catch {
return false
}
}
async function copyRecursive(from: string, to: string): Promise<void> {
const sourceStat = await stat(from)
if (sourceStat.isDirectory()) {
await mkdir(to, { recursive: true })
for (const entry of await readdir(from)) {
await copyRecursive(join(from, entry), join(to, entry))
}
return
}
await mkdir(dirname(to), { recursive: true })
await copyFile(from, to)
}
export function publishPackageJSON(source: PackageJSON, coreVersion: string): PackageJSON {
const json = structuredClone(source)
for (const field of PACKAGE_FIELDS) {
const dependencies = json[field]
if (!dependencies) continue
for (const [name, version] of Object.entries(dependencies)) {
if (version.startsWith('workspace:')) dependencies[name] = `^${coreVersion}`
}
}
delete json.scripts
delete json.devDependencies
if (json.publishConfig) {
for (const [key, value] of Object.entries(json.publishConfig)) {
if (!PUBLISH_CONFIG_FIELDS.has(key)) json[key] = value
}
delete json.publishConfig
}
return json
}
export async function preparePublishDirectories(
options: PreparePublishDirectoriesOptions
): Promise<void> {
const outRoot = options.outRoot ?? join(options.root, '.publish')
const log = options.log
await rm(outRoot, { recursive: true, force: true })
await mkdir(outRoot, { recursive: true })
for (const pkg of options.packages) {
const sourceDir = join(options.root, pkg.dir)
const destinationDir = join(outRoot, basename(pkg.dir))
await mkdir(destinationDir, { recursive: true })
for (const relativePath of pkg.include) {
const from = join(sourceDir, relativePath)
if (await exists(from)) await copyRecursive(from, join(destinationDir, relativePath))
}
for (const relativePath of pkg.extraFiles) {
const from = join(sourceDir, relativePath)
if (await exists(from)) await copyRecursive(from, join(destinationDir, relativePath))
}
const packageJSON = JSON.parse(
await readFile(join(sourceDir, 'package.json'), 'utf8')
) as PackageJSON
const publishJSON = publishPackageJSON(packageJSON, options.coreVersion)
await writeFile(
join(destinationDir, 'package.json'),
`${JSON.stringify(publishJSON, null, 2)}\n`
)
log?.(`Prepared ${destinationDir}`)
}
}

View file

@ -0,0 +1,47 @@
import { execFile } from 'node:child_process'
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
type PackageJSON = {
bin?: Record<string, string> | string
name: string
}
export function packageBinTargets(packageJSON: PackageJSON): Record<string, string> {
if (typeof packageJSON.bin === 'string') return { [packageJSON.name]: packageJSON.bin }
return packageJSON.bin ?? {}
}
export async function tarballEntries(tarballPath: string): Promise<Set<string>> {
const { stdout } = await execFileAsync('tar', ['-tf', tarballPath], { encoding: 'utf8' })
return new Set(stdout.trim().split('\n').filter(Boolean))
}
export async function tarballPackageJSON(tarballPath: string): Promise<PackageJSON> {
const { stdout } = await execFileAsync('tar', ['-xOf', tarballPath, 'package/package.json'], {
encoding: 'utf8'
})
return JSON.parse(stdout) as PackageJSON
}
export async function validateTarballBinTargets(tarballPath: string): Promise<void> {
const entries = await tarballEntries(tarballPath)
const packageJSON = await tarballPackageJSON(tarballPath)
for (const [name, target] of Object.entries(packageBinTargets(packageJSON))) {
const entry = `package/${target.replace(/^\.\//, '')}`
if (!entries.has(entry)) {
throw new Error(`${tarballPath}: bin ${name} target missing from tarball: ${entry}`)
}
}
}
export async function validatePackedTarballs(directory: string): Promise<void> {
const tarballs = (await readdir(directory)).filter((name) => name.endsWith('.tgz'))
for (const tarball of tarballs) {
await validateTarballBinTargets(join(directory, tarball))
}
}

View file

@ -0,0 +1,3 @@
import { validatePackedTarballs } from './tarballs'
await validatePackedTarballs('.npm-packages')

View file

@ -0,0 +1,72 @@
import { describe, expect, test } from 'bun:test'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { preparePublishDirectories, publishPackageJSON } from '../src/publish-dirs'
async function fixtureRoot() {
const root = join(tmpdir(), `open-pencil-release-packages-${crypto.randomUUID()}`)
await mkdir(join(root, 'packages/example/dist'), { recursive: true })
await writeFile(join(root, 'packages/example/dist/index.js'), 'export {}\n')
await writeFile(
join(root, 'packages/example/package.json'),
JSON.stringify(
{
name: '@open-pencil/example',
version: '1.0.0',
scripts: { build: 'tsdown' },
dependencies: { '@open-pencil/core': 'workspace:*', zod: '^4.0.0' },
devDependencies: { typescript: '^5.0.0' },
publishConfig: { access: 'public', main: './dist/index.js', types: './dist/index.d.ts' }
},
null,
2
)
)
return root
}
describe('publishPackageJSON', () => {
test('rewrites workspace dependencies and strips private build fields', () => {
const json = publishPackageJSON(
{
name: '@open-pencil/example',
scripts: { build: 'tsdown' },
dependencies: { '@open-pencil/core': 'workspace:*', zod: '^4.0.0' },
devDependencies: { typescript: '^5.0.0' },
publishConfig: { access: 'public', main: './dist/index.js' }
},
'0.13.2'
)
expect(json).toEqual({
name: '@open-pencil/example',
dependencies: { '@open-pencil/core': '^0.13.2', zod: '^4.0.0' },
main: './dist/index.js'
})
})
})
describe('preparePublishDirectories', () => {
test('copies requested files and writes publish package metadata', async () => {
const root = await fixtureRoot()
const outRoot = join(root, '.publish')
await preparePublishDirectories({
coreVersion: '0.13.2',
outRoot,
packages: [{ dir: 'packages/example', include: ['dist'], extraFiles: [] }],
root
})
expect(await readFile(join(outRoot, 'example/dist/index.js'), 'utf8')).toBe('export {}\n')
expect(JSON.parse(await readFile(join(outRoot, 'example/package.json'), 'utf8'))).toEqual({
name: '@open-pencil/example',
version: '1.0.0',
dependencies: { '@open-pencil/core': '^0.13.2', zod: '^4.0.0' },
main: './dist/index.js',
types: './dist/index.d.ts'
})
})
})

View file

@ -0,0 +1,19 @@
import { describe, expect, test } from 'bun:test'
import { packageBinTargets } from '../src/tarballs'
describe('packageBinTargets', () => {
test('normalizes string bin fields', () => {
expect(packageBinTargets({ name: '@open-pencil/cli', bin: './bin/openpencil.js' })).toEqual({
'@open-pencil/cli': './bin/openpencil.js'
})
})
test('keeps named bin fields', () => {
expect(
packageBinTargets({ name: '@open-pencil/cli', bin: { openpencil: './bin/openpencil.js' } })
).toEqual({
openpencil: './bin/openpencil.js'
})
})
})

26
tools/test.ts Normal file
View file

@ -0,0 +1,26 @@
import { spawn } from 'node:child_process'
import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
interface PackageJSON {
scripts?: Record<string, string>
}
async function run(command: string, args: string[], cwd: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { cwd, stdio: 'inherit' })
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) resolve()
else reject(new Error(`${command} ${args.join(' ')} failed in ${cwd}`))
})
})
}
for (const entry of await readdir('tools', { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const cwd = join('tools', entry.name)
const packageJSON = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as PackageJSON
if (packageJSON.scripts?.test) await run('bun', ['test'], cwd)
}