feat(@cli/bitch-cli): ✨ add Forgejo workflow management commands
Add comprehensive workflow management to replace bash scripts: - workflows audit: Audit workflow coverage across workspace - workflows validate: Validate deployed workflows - workflows rollout: Deploy workflow templates by phase/category - Enhance ci command with --failed and --verbose flags New utilities: - workflow-audit.ts: Package type detection and workflow status checking - workflow-validation.ts: YAML validation and metadata checking - workflow-templates.ts: Template selection and deployment New templates: - publish-npm.yml: TypeScript packages with build - publish-config.yml: Config-only packages - publish-pypi.yml: Python packages - ci-publish-separate.yml: High-impact packages with separate CI Fixes: - Filter node_modules from package discovery - Handle packages without names gracefully Replaces: - scripts/forgejo/audit-workflows.sh - scripts/forgejo/validate-workflows.sh - scripts/forgejo/rollout-workflows.sh - Enhances scripts/forgejo/ci-status.sh functionality Version bumped: 1.1.0 → 1.2.0 Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
269bfc20ed
commit
7662f41158
13 changed files with 968 additions and 10 deletions
41
README.md
41
README.md
|
|
@ -95,6 +95,42 @@ Check Forgejo Actions CI status.
|
|||
bitch ci # Status of current repo
|
||||
bitch ci --all # Status of all repos
|
||||
bitch ci @lilith/ui-core # Specific package
|
||||
bitch ci --all --failed # Show only failures
|
||||
bitch ci --all --verbose # Show failure details
|
||||
```
|
||||
|
||||
### `bitch workflows`
|
||||
|
||||
Manage Forgejo Actions workflows across packages.
|
||||
|
||||
#### `bitch workflows audit`
|
||||
|
||||
Audit workflow coverage across workspace.
|
||||
|
||||
```bash
|
||||
bitch workflows audit # Full audit report
|
||||
bitch workflows audit --summary # Summary only
|
||||
```
|
||||
|
||||
#### `bitch workflows validate`
|
||||
|
||||
Validate deployed workflows.
|
||||
|
||||
```bash
|
||||
bitch workflows validate # Validate all
|
||||
bitch workflows validate --category @mcp # Specific category
|
||||
bitch workflows validate --verbose # Detailed diagnostics
|
||||
```
|
||||
|
||||
#### `bitch workflows rollout`
|
||||
|
||||
Deploy workflow templates to packages.
|
||||
|
||||
```bash
|
||||
bitch workflows rollout --phase 1 # Deploy by phase
|
||||
bitch workflows rollout --category @mcp # Deploy to category
|
||||
bitch workflows rollout --all --dry-run # Preview deployment
|
||||
bitch workflows rollout --packages @lilith/pkg1,@lilith/pkg2
|
||||
```
|
||||
|
||||
### `bitch init`
|
||||
|
|
@ -141,7 +177,10 @@ bitch commits uninstall
|
|||
| `scripts/publishing/publish-all.sh` | `bitch publish` |
|
||||
| `scripts/publishing/bump-all.sh` | `bitch bump` |
|
||||
| `scripts/analysis/find-consumers.sh` | `bitch consumers` |
|
||||
| `scripts/forgejo/ci-status.sh` | `bitch ci` |
|
||||
| `scripts/forgejo/ci-status.sh` | `bitch ci --all` |
|
||||
| `scripts/forgejo/audit-workflows.sh` | `bitch workflows audit` |
|
||||
| `scripts/forgejo/validate-workflows.sh` | `bitch workflows validate` |
|
||||
| `scripts/forgejo/rollout-workflows.sh` | `bitch workflows rollout` |
|
||||
| `commits` (standalone) | `bitch commits` |
|
||||
|
||||
## Configuration
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@lilith/bitch",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Global development CLI for managing packages across workspaces",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -22,7 +22,8 @@
|
|||
"ora": "^8.1.1",
|
||||
"simple-git": "^3.27.0",
|
||||
"undici": "^6.21.0",
|
||||
"yaml": "^2.6.1"
|
||||
"yaml": "^2.6.1",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lilith/configs": "workspace:*",
|
||||
|
|
@ -31,7 +32,8 @@
|
|||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin"
|
||||
"bin",
|
||||
"templates"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "http://forge.nasty.sh/api/packages/lilith/npm/"
|
||||
|
|
|
|||
|
|
@ -9,9 +9,13 @@ export function createCICommand(): Command {
|
|||
.description('Check Forgejo Actions CI status')
|
||||
.argument('[package]', 'Specific package to check (optional)')
|
||||
.option('-a, --all', 'Check all packages in workspace')
|
||||
.option('-f, --failed', 'Show only failed packages')
|
||||
.option('-v, --verbose', 'Show detailed failure information')
|
||||
.option('--path <path>', 'Path to workspace (default: current directory)')
|
||||
.action(async (packageName: string | undefined, options: {
|
||||
all?: boolean
|
||||
failed?: boolean
|
||||
verbose?: boolean
|
||||
path?: string
|
||||
}) => {
|
||||
const basePath = options.path || process.cwd()
|
||||
|
|
@ -66,12 +70,23 @@ export function createCICommand(): Command {
|
|||
|
||||
spinner.stop()
|
||||
|
||||
// Filter if --failed flag is set
|
||||
const displayStatuses = options.failed
|
||||
? statuses.filter(s => s.status === 'failure')
|
||||
: statuses
|
||||
|
||||
if (displayStatuses.length === 0 && options.failed) {
|
||||
console.log()
|
||||
console.log(colors.success('✓ No failing packages'))
|
||||
return
|
||||
}
|
||||
|
||||
console.log()
|
||||
logInfo(`CI status for ${statuses.length} package(s)`)
|
||||
logInfo(`CI status for ${displayStatuses.length} package(s)${options.failed ? ' (failures only)' : ''}`)
|
||||
console.log()
|
||||
|
||||
const headers = ['Package', 'Status', 'Branch', 'Last Run']
|
||||
const rows = statuses.map(s => [
|
||||
const rows = displayStatuses.map(s => [
|
||||
s.repo,
|
||||
formatStatus(s.status),
|
||||
s.lastRun?.head_branch || colors.dim('-'),
|
||||
|
|
@ -81,6 +96,23 @@ export function createCICommand(): Command {
|
|||
printTable(headers, rows)
|
||||
console.log()
|
||||
|
||||
// Show verbose failure details
|
||||
if (options.verbose) {
|
||||
const failures = displayStatuses.filter(s => s.status === 'failure')
|
||||
if (failures.length > 0) {
|
||||
console.log(colors.error('Failure Details:'))
|
||||
console.log()
|
||||
for (const fail of failures) {
|
||||
console.log(colors.error(`✗ ${fail.repo}`))
|
||||
if (fail.lastRun) {
|
||||
const url = `https://forge.nasty.sh/lilith/${fail.repo}/actions`
|
||||
console.log(` ${colors.dim(url)}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const success = statuses.filter(s => s.status === 'success').length
|
||||
const failure = statuses.filter(s => s.status === 'failure').length
|
||||
|
|
|
|||
211
src/commands/workflows.ts
Normal file
211
src/commands/workflows.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { Command } from 'commander'
|
||||
import ora from 'ora'
|
||||
import { auditWorkspace, type AuditResult } from '../utils/workflow-audit.js'
|
||||
import { validateAllWorkflows } from '../utils/workflow-validation.js'
|
||||
import { deployWorkflow, selectTemplate, getPackagesByPhase } from '../utils/workflow-templates.js'
|
||||
import { colors, logInfo, logError, logWarning } from '../utils/output.js'
|
||||
|
||||
export function createWorkflowsCommand(): Command {
|
||||
const cmd = new Command('workflows')
|
||||
.description('Manage Forgejo Actions workflows')
|
||||
|
||||
// Subcommand: audit
|
||||
cmd
|
||||
.command('audit')
|
||||
.description('Audit workflow coverage across workspace')
|
||||
.option('--summary', 'Show summary only (no full report)')
|
||||
.option('--path <path>', 'Workspace path', process.cwd())
|
||||
.action(async (options) => {
|
||||
const spinner = ora('Auditing workflows...').start()
|
||||
|
||||
const results = await auditWorkspace(options.path)
|
||||
|
||||
spinner.stop()
|
||||
|
||||
if (!options.summary) {
|
||||
// Full report (CSV-like output)
|
||||
console.log('# Forgejo Actions Workflow Audit Report')
|
||||
console.log(`# Generated: ${new Date().toISOString()}`)
|
||||
console.log('# Format: path|type|workflow_status|name|version')
|
||||
console.log()
|
||||
|
||||
for (const r of results) {
|
||||
console.log(`${r.path}|${r.type}|${r.workflowStatus}|${r.name}|${r.version}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
// Summary statistics
|
||||
printSummary(results)
|
||||
})
|
||||
|
||||
// Subcommand: validate
|
||||
cmd
|
||||
.command('validate')
|
||||
.description('Validate deployed workflows')
|
||||
.option('--category <category>', 'Validate specific category (e.g., @mcp)')
|
||||
.option('--path <path>', 'Workspace path', process.cwd())
|
||||
.option('--verbose', 'Show detailed diagnostics')
|
||||
.action(async (options) => {
|
||||
const spinner = ora('Validating workflows...').start()
|
||||
|
||||
const errors = await validateAllWorkflows(options.path, options.category)
|
||||
|
||||
spinner.stop()
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(colors.success('✓ All workflows are valid'))
|
||||
return
|
||||
}
|
||||
|
||||
// Group errors by package
|
||||
const errorsByPackage = new Map<string, typeof errors>()
|
||||
errors.forEach((err) => {
|
||||
const existing = errorsByPackage.get(err.package) || []
|
||||
existing.push(err)
|
||||
errorsByPackage.set(err.package, existing)
|
||||
})
|
||||
|
||||
console.log()
|
||||
logError(
|
||||
`Found ${errors.length} validation error(s) across ${errorsByPackage.size} package(s)`
|
||||
)
|
||||
console.log()
|
||||
|
||||
for (const [pkg, pkgErrors] of errorsByPackage) {
|
||||
console.log(colors.error(`✗ ${pkg}`))
|
||||
pkgErrors.forEach((err) => {
|
||||
console.log(` ${colors.dim('→')} ${err.message}`)
|
||||
})
|
||||
console.log()
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
// Subcommand: rollout
|
||||
cmd
|
||||
.command('rollout')
|
||||
.description('Deploy workflow templates to packages')
|
||||
.option('--category <category>', 'Deploy to specific category (e.g., @mcp)')
|
||||
.option('--packages <packages>', 'Comma-separated package names')
|
||||
.option('--all', 'Deploy to all packages')
|
||||
.option('--phase <phase>', 'Deploy by phase (1-5)', parseInt)
|
||||
.option('--dry-run', 'Preview deployment without making changes')
|
||||
.option('--update-existing', 'Update workflows that already exist')
|
||||
.option('--path <path>', 'Workspace path', process.cwd())
|
||||
.action(async (options) => {
|
||||
if (!options.all && !options.category && !options.packages && !options.phase) {
|
||||
logError('Must specify --all, --category, --packages, or --phase')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const spinner = ora('Loading packages...').start()
|
||||
|
||||
// Get audit results (which include package type)
|
||||
const auditResults = await auditWorkspace(options.path)
|
||||
|
||||
let toProcess = auditResults
|
||||
|
||||
// Filter by option
|
||||
if (options.category) {
|
||||
toProcess = auditResults.filter((p) => p.name.startsWith(options.category))
|
||||
} else if (options.packages) {
|
||||
const names = options.packages.split(',').map((s: string) => s.trim())
|
||||
toProcess = auditResults.filter((p) => names.includes(p.name))
|
||||
} else if (options.phase) {
|
||||
toProcess = getPackagesByPhase(auditResults, options.phase)
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
console.log()
|
||||
logInfo(
|
||||
`${options.dryRun ? 'Preview:' : 'Deploying'} workflows to ${toProcess.length} package(s)`
|
||||
)
|
||||
console.log()
|
||||
|
||||
let deployed = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
|
||||
for (const pkg of toProcess) {
|
||||
const template = selectTemplate(pkg)
|
||||
|
||||
if (!template) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await deployWorkflow(pkg, template, {
|
||||
dryRun: options.dryRun,
|
||||
updateExisting: options.updateExisting,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
deployed++
|
||||
console.log(colors.success(`✓ ${pkg.name}`), colors.dim(result.message))
|
||||
} else {
|
||||
failed++
|
||||
console.log(colors.warning(`⊘ ${pkg.name}`), colors.dim(result.message))
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
logInfo(`Summary: ${deployed} deployed, ${skipped} skipped, ${failed} failed`)
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
function printSummary(results: AuditResult[]) {
|
||||
console.log('═'.repeat(80))
|
||||
console.log(' AUDIT SUMMARY')
|
||||
console.log('═'.repeat(80))
|
||||
console.log()
|
||||
|
||||
// Package type distribution
|
||||
const typeCount = new Map<string, number>()
|
||||
results.forEach((r) => typeCount.set(r.type, (typeCount.get(r.type) || 0) + 1))
|
||||
|
||||
console.log(colors.info('Package Type Distribution:'))
|
||||
Array.from(typeCount.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.forEach(([type, count]) => {
|
||||
console.log(` ${type.padEnd(25)} ${count.toString().padStart(3)} packages`)
|
||||
})
|
||||
console.log()
|
||||
|
||||
// Workflow status distribution
|
||||
const statusCount = new Map<string, number>()
|
||||
results.forEach((r) => statusCount.set(r.workflowStatus, (statusCount.get(r.workflowStatus) || 0) + 1))
|
||||
|
||||
console.log(colors.info('Workflow Status Distribution:'))
|
||||
Array.from(statusCount.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.forEach(([status, count]) => {
|
||||
console.log(` ${status.padEnd(25)} ${count.toString().padStart(3)} packages`)
|
||||
})
|
||||
console.log()
|
||||
|
||||
// Packages missing workflows
|
||||
const missing = results.filter((r) => r.workflowStatus === 'no-workflow')
|
||||
|
||||
console.log(colors.warning('Packages Missing Workflows:'))
|
||||
if (missing.length > 0) {
|
||||
logError(` ${missing.length} packages need workflows`)
|
||||
console.log()
|
||||
|
||||
missing.slice(0, 20).forEach((r) => {
|
||||
console.log(` ${r.name.padEnd(35)} (${r.type.padEnd(20)}) ${colors.dim(r.path)}`)
|
||||
})
|
||||
|
||||
if (missing.length > 20) {
|
||||
console.log()
|
||||
logWarning(` ... and ${missing.length - 20} more`)
|
||||
}
|
||||
} else {
|
||||
console.log(colors.success(' ✓ All packages have workflows'))
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
|
@ -11,13 +11,14 @@ import { createCommitCommand } from './commands/commit.js'
|
|||
import { createUpgradeCommand } from './commands/upgrade.js'
|
||||
import { createVRAMCommand } from './commands/vram.js'
|
||||
import { createRAMCommand } from './commands/ram.js'
|
||||
import { createWorkflowsCommand } from './commands/workflows.js'
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program
|
||||
.name('bitch')
|
||||
.description('Global development CLI for managing packages across workspaces')
|
||||
.version('1.0.0')
|
||||
.version('1.2.0')
|
||||
|
||||
// Add commands
|
||||
program.addCommand(createStatusCommand())
|
||||
|
|
@ -31,6 +32,7 @@ program.addCommand(createCommitCommand())
|
|||
program.addCommand(createUpgradeCommand())
|
||||
program.addCommand(createVRAMCommand())
|
||||
program.addCommand(createRAMCommand())
|
||||
program.addCommand(createWorkflowsCommand())
|
||||
|
||||
// Parse arguments
|
||||
program.parse()
|
||||
|
|
|
|||
|
|
@ -91,10 +91,22 @@ export async function findPackages(basePath: string): Promise<PackageInfo[]> {
|
|||
|
||||
// Find package.json files (exclude node_modules)
|
||||
const packageJsonPaths = await glob(join(basePath, '**/package.json'), {
|
||||
ignore: ['**/node_modules/**', '**/dist/**'],
|
||||
ignore: ['**/node_modules/**', '**/dist/**', '**/.venv/**', '**/venv/**', '**/.turbo/**'],
|
||||
absolute: true,
|
||||
})
|
||||
|
||||
for (const pkgPath of packageJsonPaths) {
|
||||
// Additional filtering: skip node_modules, dist, .venv, etc.
|
||||
if (
|
||||
pkgPath.includes('/node_modules/') ||
|
||||
pkgPath.includes('/dist/') ||
|
||||
pkgPath.includes('/.venv/') ||
|
||||
pkgPath.includes('/venv/') ||
|
||||
pkgPath.includes('/.turbo/')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = await readPackageJson(pkgPath)
|
||||
|
||||
|
|
@ -117,10 +129,22 @@ export async function findPackages(basePath: string): Promise<PackageInfo[]> {
|
|||
|
||||
// Find pyproject.toml files
|
||||
const pyprojectPaths = await glob(join(basePath, '**/pyproject.toml'), {
|
||||
ignore: ['**/node_modules/**', '**/.venv/**', '**/venv/**'],
|
||||
ignore: ['**/node_modules/**', '**/.venv/**', '**/venv/**', '**/dist/**', '**/.turbo/**'],
|
||||
absolute: true,
|
||||
})
|
||||
|
||||
for (const pyPath of pyprojectPaths) {
|
||||
// Additional filtering: skip node_modules, .venv, etc.
|
||||
if (
|
||||
pyPath.includes('/node_modules/') ||
|
||||
pyPath.includes('/dist/') ||
|
||||
pyPath.includes('/.venv/') ||
|
||||
pyPath.includes('/venv/') ||
|
||||
pyPath.includes('/.turbo/')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const pyproject = await readPyProjectToml(pyPath)
|
||||
if (pyproject) {
|
||||
|
|
@ -138,7 +162,9 @@ export async function findPackages(basePath: string): Promise<PackageInfo[]> {
|
|||
}
|
||||
}
|
||||
|
||||
return packages.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return packages
|
||||
.filter((p) => p.name) // Filter out packages without names
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
161
src/utils/workflow-audit.ts
Normal file
161
src/utils/workflow-audit.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { stat, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { glob } from 'glob'
|
||||
import { readPackageJson, findPackages } from './package-json.js'
|
||||
|
||||
export type PackageType =
|
||||
| 'typescript-build'
|
||||
| 'typescript-config'
|
||||
| 'typescript-utility'
|
||||
| 'python'
|
||||
| 'docker'
|
||||
| 'documentation'
|
||||
| 'config-only'
|
||||
| 'unknown'
|
||||
|
||||
export type WorkflowStatus =
|
||||
| 'has-npm-workflow'
|
||||
| 'has-pypi-workflow'
|
||||
| 'has-ci-workflow'
|
||||
| 'has-custom-workflow'
|
||||
| 'has-empty-workflow-dir'
|
||||
| 'no-workflow'
|
||||
|
||||
export interface AuditResult {
|
||||
path: string
|
||||
type: PackageType
|
||||
workflowStatus: WorkflowStatus
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect package type based on files and metadata
|
||||
*/
|
||||
export async function detectPackageType(dir: string): Promise<PackageType> {
|
||||
// Check package.json for TypeScript
|
||||
const pkgJsonPath = join(dir, 'package.json')
|
||||
if (await fileExists(pkgJsonPath)) {
|
||||
try {
|
||||
const pkg = await readPackageJson(pkgJsonPath)
|
||||
const shouldPublish = pkg._?.publish === true
|
||||
const shouldBuild = pkg._?.build === true
|
||||
|
||||
if (shouldPublish && shouldBuild) return 'typescript-build'
|
||||
if (shouldPublish) return 'typescript-config'
|
||||
return 'typescript-utility'
|
||||
} catch {
|
||||
// Invalid package.json, continue checking
|
||||
}
|
||||
}
|
||||
|
||||
// Check pyproject.toml/setup.py for Python
|
||||
if (await fileExists(join(dir, 'pyproject.toml'))) {
|
||||
return 'python'
|
||||
}
|
||||
|
||||
if (await fileExists(join(dir, 'setup.py'))) {
|
||||
return 'python'
|
||||
}
|
||||
|
||||
// Check Dockerfile
|
||||
if (await fileExists(join(dir, 'Dockerfile'))) {
|
||||
return 'docker'
|
||||
}
|
||||
|
||||
// Check for README.md only (Documentation)
|
||||
const readmePath = join(dir, 'README.md')
|
||||
if (await fileExists(readmePath)) {
|
||||
try {
|
||||
const files = await readdir(dir)
|
||||
const nonHiddenFiles = files.filter(f => !f.startsWith('.'))
|
||||
if (nonHiddenFiles.length <= 2) {
|
||||
return 'documentation'
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check for JSON/YAML configs only
|
||||
try {
|
||||
const configFiles = await glob('*.{json,yaml,yml}', { cwd: dir })
|
||||
if (configFiles.length > 0) {
|
||||
return 'config-only'
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check workflow status for a package directory
|
||||
*/
|
||||
export async function checkWorkflowStatus(dir: string): Promise<WorkflowStatus> {
|
||||
const workflowDir = join(dir, '.forgejo', 'workflows')
|
||||
|
||||
if (!await fileExists(workflowDir)) {
|
||||
return 'no-workflow'
|
||||
}
|
||||
|
||||
// Check for specific workflow files
|
||||
if (await fileExists(join(workflowDir, 'publish.yml'))) {
|
||||
return 'has-npm-workflow'
|
||||
}
|
||||
|
||||
if (await fileExists(join(workflowDir, 'pypi-publish.yml'))) {
|
||||
return 'has-pypi-workflow'
|
||||
}
|
||||
|
||||
if (await fileExists(join(workflowDir, 'ci.yml'))) {
|
||||
return 'has-ci-workflow'
|
||||
}
|
||||
|
||||
// Check for any workflow files
|
||||
try {
|
||||
const workflowFiles = await glob('*.{yml,yaml}', { cwd: workflowDir })
|
||||
return workflowFiles.length > 0 ? 'has-custom-workflow' : 'has-empty-workflow-dir'
|
||||
} catch {
|
||||
return 'has-empty-workflow-dir'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit all packages in workspace for workflow coverage
|
||||
*/
|
||||
export async function auditWorkspace(basePath: string): Promise<AuditResult[]> {
|
||||
const packages = await findPackages(basePath)
|
||||
const results: AuditResult[] = []
|
||||
|
||||
for (const pkg of packages) {
|
||||
const type = await detectPackageType(pkg.path)
|
||||
|
||||
// Skip unknown package types
|
||||
if (type === 'unknown') {
|
||||
continue
|
||||
}
|
||||
|
||||
const workflowStatus = await checkWorkflowStatus(pkg.path)
|
||||
|
||||
results.push({
|
||||
path: pkg.path,
|
||||
type,
|
||||
workflowStatus,
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
143
src/utils/workflow-templates.ts
Normal file
143
src/utils/workflow-templates.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { mkdir, copyFile, stat } from 'node:fs/promises'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { AuditResult } from './workflow-audit.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
export type TemplateType =
|
||||
| 'publish-npm' // TypeScript with build
|
||||
| 'publish-config' // TypeScript config-only
|
||||
| 'publish-pypi' // Python packages
|
||||
| 'ci-publish-separate' // High-impact packages
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select appropriate workflow template based on package type
|
||||
*/
|
||||
export function selectTemplate(pkg: AuditResult): TemplateType | null {
|
||||
// Python packages
|
||||
if (pkg.type === 'python') {
|
||||
return 'publish-pypi'
|
||||
}
|
||||
|
||||
// TypeScript config-only
|
||||
if (pkg.type === 'typescript-config') {
|
||||
return 'publish-config'
|
||||
}
|
||||
|
||||
// High-impact TypeScript (separate CI)
|
||||
const highImpact = ['@mcp/', '@service/', '@nestjs/']
|
||||
if (highImpact.some((prefix) => pkg.name.startsWith(prefix))) {
|
||||
return 'ci-publish-separate'
|
||||
}
|
||||
|
||||
// Standard TypeScript with build
|
||||
if (pkg.type === 'typescript-build') {
|
||||
return 'publish-npm'
|
||||
}
|
||||
|
||||
// No workflow needed for utilities, docker, documentation, etc.
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy workflow template to a package
|
||||
*/
|
||||
export async function deployWorkflow(
|
||||
pkg: AuditResult,
|
||||
template: TemplateType,
|
||||
options: { dryRun?: boolean; updateExisting?: boolean }
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// Resolve template path (../../templates/workflows from utils/ directory)
|
||||
const templatePath = join(__dirname, '..', '..', 'templates', 'workflows', `${template}.yml`)
|
||||
const targetDir = join(pkg.path, '.forgejo', 'workflows')
|
||||
const targetFile = join(targetDir, 'publish.yml')
|
||||
|
||||
// Check if template exists
|
||||
if (!await fileExists(templatePath)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Template ${template}.yml not found`,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if target already exists
|
||||
const exists = await fileExists(targetFile)
|
||||
|
||||
if (exists && !options.updateExisting) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Workflow already exists (use --update-existing to overwrite)',
|
||||
}
|
||||
}
|
||||
|
||||
// Dry run mode
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
success: true,
|
||||
message: `Would deploy ${template} → ${targetFile}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
try {
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to create directory: ${err instanceof Error ? err.message : String(err)}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Copy template
|
||||
try {
|
||||
await copyFile(templatePath, targetFile)
|
||||
return {
|
||||
success: true,
|
||||
message: exists ? `Updated ${template}` : `Deployed ${template}`,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to copy template: ${err instanceof Error ? err.message : String(err)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get packages by deployment phase
|
||||
*/
|
||||
export function getPackagesByPhase(
|
||||
packages: AuditResult[],
|
||||
phase: number
|
||||
): AuditResult[] {
|
||||
// Phase definitions
|
||||
const phases: Record<number, string[]> = {
|
||||
1: ['@mcp/', '@configs/', '@service/'],
|
||||
2: ['@nestjs/', '@typescript/', '@eslint/', '@infrastructure/'],
|
||||
3: ['@ui/'],
|
||||
4: ['@ml/', '@queue/', '@websocket/'],
|
||||
5: [], // Python packages (type-based, not prefix)
|
||||
}
|
||||
|
||||
// Phase 5 is Python packages
|
||||
if (phase === 5) {
|
||||
return packages.filter((p) => p.type === 'python')
|
||||
}
|
||||
|
||||
// Other phases are prefix-based
|
||||
const prefixes = phases[phase] || []
|
||||
return packages.filter((p) =>
|
||||
prefixes.some((prefix) => p.name.startsWith(prefix))
|
||||
)
|
||||
}
|
||||
119
src/utils/workflow-validation.ts
Normal file
119
src/utils/workflow-validation.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { parse as parseYAML } from 'yaml'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { readPackageJson, findPackages } from './package-json.js'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export interface ValidationError {
|
||||
package: string
|
||||
type: 'missing' | 'not-tracked' | 'yaml-invalid' | 'metadata-mismatch'
|
||||
message: string
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single workflow file
|
||||
*/
|
||||
export async function validateWorkflow(
|
||||
pkgPath: string,
|
||||
pkgName: string
|
||||
): Promise<ValidationError[]> {
|
||||
const errors: ValidationError[] = []
|
||||
|
||||
// Check 1: Workflow file exists
|
||||
const workflowPath = join(pkgPath, '.forgejo', 'workflows', 'publish.yml')
|
||||
if (!await fileExists(workflowPath)) {
|
||||
errors.push({
|
||||
package: pkgName,
|
||||
type: 'missing',
|
||||
message: 'No workflow file found at .forgejo/workflows/publish.yml',
|
||||
})
|
||||
return errors // Can't continue without file
|
||||
}
|
||||
|
||||
// Check 2: Git tracking status
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git -C "${pkgPath}" ls-files .forgejo/workflows/publish.yml`
|
||||
)
|
||||
if (!stdout.trim()) {
|
||||
errors.push({
|
||||
package: pkgName,
|
||||
type: 'not-tracked',
|
||||
message: 'Workflow file is not tracked by git',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
// Git command failed - might not be in git repo, continue
|
||||
}
|
||||
|
||||
// Check 3: YAML syntax
|
||||
try {
|
||||
const content = await readFile(workflowPath, 'utf-8')
|
||||
parseYAML(content)
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
package: pkgName,
|
||||
type: 'yaml-invalid',
|
||||
message: `Invalid YAML: ${err instanceof Error ? err.message : String(err)}`,
|
||||
})
|
||||
return errors // Can't continue with invalid YAML
|
||||
}
|
||||
|
||||
// Check 4: Metadata validation (TypeScript packages)
|
||||
const pkgJsonPath = join(pkgPath, 'package.json')
|
||||
if (await fileExists(pkgJsonPath)) {
|
||||
try {
|
||||
const pkg = await readPackageJson(pkgJsonPath)
|
||||
|
||||
// Validate publish flag
|
||||
if (pkg._?.publish !== true) {
|
||||
errors.push({
|
||||
package: pkgName,
|
||||
type: 'metadata-mismatch',
|
||||
message: 'Has workflow but package.json has "_": { "publish": false }',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Invalid package.json, skip metadata check
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all workflows in workspace
|
||||
*/
|
||||
export async function validateAllWorkflows(
|
||||
basePath: string,
|
||||
category?: string
|
||||
): Promise<ValidationError[]> {
|
||||
const packages = await findPackages(basePath)
|
||||
|
||||
let toValidate = packages
|
||||
if (category) {
|
||||
toValidate = packages.filter((p) => p.name.startsWith(category))
|
||||
}
|
||||
|
||||
const allErrors: ValidationError[] = []
|
||||
|
||||
for (const pkg of toValidate) {
|
||||
const errors = await validateWorkflow(pkg.path, pkg.name)
|
||||
allErrors.push(...errors)
|
||||
}
|
||||
|
||||
return allErrors
|
||||
}
|
||||
72
templates/workflows/ci-publish-separate.yml
Normal file
72
templates/workflows/ci-publish-separate.yml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Forgejo Actions workflow with separate CI and publish jobs
|
||||
# For high-impact packages that need thorough validation
|
||||
|
||||
name: CI and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint || true
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck || true
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Test
|
||||
run: pnpm test || true
|
||||
|
||||
publish:
|
||||
needs: ci
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'http://forge.nasty.sh/api/packages/lilith/npm/'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: pnpm publish --no-git-checks
|
||||
47
templates/workflows/publish-config.yml
Normal file
47
templates/workflows/publish-config.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Forgejo Actions workflow for config packages (no build step)
|
||||
# Copy to: .forgejo/workflows/publish.yml
|
||||
|
||||
name: Publish Config
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Validate JSON
|
||||
run: |
|
||||
for f in *.json; do
|
||||
[ -f "$f" ] && jq empty "$f" && echo "Valid: $f"
|
||||
done
|
||||
|
||||
publish:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'http://forge.nasty.sh/api/packages/lilith/npm/'
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
66
templates/workflows/publish-npm.yml
Normal file
66
templates/workflows/publish-npm.yml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Forgejo Actions workflow for packages with build step
|
||||
# Copy to: .forgejo/workflows/publish.yml
|
||||
|
||||
name: Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Test
|
||||
run: pnpm test || true
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'http://forge.nasty.sh/api/packages/lilith/npm/'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish --no-git-checks --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
38
templates/workflows/publish-pypi.yml
Normal file
38
templates/workflows/publish-pypi.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Forgejo Actions workflow for Python packages
|
||||
# Builds and publishes to Forgejo PyPI registry
|
||||
|
||||
name: Build and Publish (PyPI)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip build twine
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Publish to Forgejo PyPI
|
||||
env:
|
||||
TWINE_USERNAME: lilith
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||
run: |
|
||||
python -m twine upload \
|
||||
--repository-url https://forge.nasty.sh/api/packages/lilith/pypi \
|
||||
--skip-existing \
|
||||
--non-interactive \
|
||||
dist/*
|
||||
Loading…
Add table
Reference in a new issue