ci: move workflow helpers into tools
This commit is contained in:
parent
e870e01521
commit
7d516bf82b
85
.github/workflows/build.yml
vendored
85
.github/workflows/build.yml
vendored
|
|
@ -145,75 +145,7 @@ jobs:
|
|||
|
||||
- name: Prepare publish directories
|
||||
run: |
|
||||
node - <<'NODE'
|
||||
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
|
||||
bun tools/release-packages/src/prepare-publish-dirs.ts
|
||||
find .publish -maxdepth 3 -type f | sort
|
||||
|
||||
- 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-mcp-*.tgz | sed -n '1,40p' || true
|
||||
tar -tf .npm-packages/open-pencil-vue-*.tgz | sed -n '1,60p' || true
|
||||
node --input-type=module <<'NODE'
|
||||
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
|
||||
bun tools/release-packages/src/validate-tarballs.ts
|
||||
|
||||
- name: Publish packages to npm
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"test:unit": "bun test ./tests/engine",
|
||||
"test:coverage": "bun test --coverage ./tests/engine",
|
||||
"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: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",
|
||||
|
|
|
|||
|
|
@ -59,14 +59,19 @@ export function markdownTableCells(line: 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 {
|
||||
const [rawCheckName = '', status = ''] = markdownTableCells(line)
|
||||
const checkName = rawCheckName.replace(/^\[ignored\]\s*/i, '')
|
||||
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)
|
||||
|
||||
if (!isRelevantCheck || !needsMaintainerAttention) return null
|
||||
|
|
@ -167,7 +172,9 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro
|
|||
|
||||
const checks = reviewGuidanceChecks(context.text)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
await monitorPRReviewGuidance()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
eventContext,
|
||||
monitorPRReviewGuidance,
|
||||
|
|
|
|||
10
tools/release-packages/package.json
Normal file
10
tools/release-packages/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
tools/release-packages/src/prepare-publish-dirs.ts
Normal file
18
tools/release-packages/src/prepare-publish-dirs.ts
Normal 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`)
|
||||
})
|
||||
117
tools/release-packages/src/publish-dirs.ts
Normal file
117
tools/release-packages/src/publish-dirs.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
47
tools/release-packages/src/tarballs.ts
Normal file
47
tools/release-packages/src/tarballs.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
3
tools/release-packages/src/validate-tarballs.ts
Normal file
3
tools/release-packages/src/validate-tarballs.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { validatePackedTarballs } from './tarballs'
|
||||
|
||||
await validatePackedTarballs('.npm-packages')
|
||||
72
tools/release-packages/tests/publish-dirs.test.ts
Normal file
72
tools/release-packages/tests/publish-dirs.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
19
tools/release-packages/tests/tarballs.test.ts
Normal file
19
tools/release-packages/tests/tarballs.test.ts
Normal 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
26
tools/test.ts
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue