diff --git a/README.md b/README.md index 6061bb5..c2437c3 100644 --- a/README.md +++ b/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 diff --git a/package.json b/package.json index d5cbf2f..902b97c 100644 --- a/package.json +++ b/package.json @@ -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/" diff --git a/src/commands/ci.ts b/src/commands/ci.ts index 2b11f08..79ab69d 100644 --- a/src/commands/ci.ts +++ b/src/commands/ci.ts @@ -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 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 diff --git a/src/commands/workflows.ts b/src/commands/workflows.ts new file mode 100644 index 0000000..0d9de30 --- /dev/null +++ b/src/commands/workflows.ts @@ -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 ', '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 ', 'Validate specific category (e.g., @mcp)') + .option('--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() + 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 ', 'Deploy to specific category (e.g., @mcp)') + .option('--packages ', 'Comma-separated package names') + .option('--all', 'Deploy to all packages') + .option('--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 ', '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() + 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() + 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() +} diff --git a/src/index.ts b/src/index.ts index 1d3e304..079d848 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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() diff --git a/src/utils/package-json.ts b/src/utils/package-json.ts index ec04f5d..f724d1c 100644 --- a/src/utils/package-json.ts +++ b/src/utils/package-json.ts @@ -91,10 +91,22 @@ export async function findPackages(basePath: string): Promise { // 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 { // 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 { } } - 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)) } /** diff --git a/src/utils/workflow-audit.ts b/src/utils/workflow-audit.ts new file mode 100644 index 0000000..7ad8c5e --- /dev/null +++ b/src/utils/workflow-audit.ts @@ -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 { + try { + await stat(path) + return true + } catch { + return false + } +} + +/** + * Detect package type based on files and metadata + */ +export async function detectPackageType(dir: string): Promise { + // 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 { + 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 { + 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 +} diff --git a/src/utils/workflow-templates.ts b/src/utils/workflow-templates.ts new file mode 100644 index 0000000..a7e2d40 --- /dev/null +++ b/src/utils/workflow-templates.ts @@ -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 { + 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 = { + 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)) + ) +} diff --git a/src/utils/workflow-validation.ts b/src/utils/workflow-validation.ts new file mode 100644 index 0000000..a06f6dc --- /dev/null +++ b/src/utils/workflow-validation.ts @@ -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 { + try { + await stat(path) + return true + } catch { + return false + } +} + +/** + * Validate a single workflow file + */ +export async function validateWorkflow( + pkgPath: string, + pkgName: string +): Promise { + 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 { + 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 +} diff --git a/templates/workflows/ci-publish-separate.yml b/templates/workflows/ci-publish-separate.yml new file mode 100644 index 0000000..a4a9b66 --- /dev/null +++ b/templates/workflows/ci-publish-separate.yml @@ -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 diff --git a/templates/workflows/publish-config.yml b/templates/workflows/publish-config.yml new file mode 100644 index 0000000..3115e34 --- /dev/null +++ b/templates/workflows/publish-config.yml @@ -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 }} diff --git a/templates/workflows/publish-npm.yml b/templates/workflows/publish-npm.yml new file mode 100644 index 0000000..3230e27 --- /dev/null +++ b/templates/workflows/publish-npm.yml @@ -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 }} diff --git a/templates/workflows/publish-pypi.yml b/templates/workflows/publish-pypi.yml new file mode 100644 index 0000000..3d1cd0f --- /dev/null +++ b/templates/workflows/publish-pypi.yml @@ -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/*