feat: migrate repo releases to manual release-please (#293)
This commit is contained in:
229
src/release/components.ts
Normal file
229
src/release/components.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { readJson } from "../utils/files"
|
||||
import type {
|
||||
BumpLevel,
|
||||
BumpOverride,
|
||||
ComponentDecision,
|
||||
ParsedReleaseIntent,
|
||||
ReleaseComponent,
|
||||
ReleasePreview,
|
||||
} from "./types"
|
||||
|
||||
const RELEASE_COMPONENTS: ReleaseComponent[] = [
|
||||
"cli",
|
||||
"compound-engineering",
|
||||
"coding-tutor",
|
||||
"marketplace",
|
||||
]
|
||||
|
||||
const FILE_COMPONENT_MAP: Array<{ component: ReleaseComponent; prefixes: string[] }> = [
|
||||
{
|
||||
component: "cli",
|
||||
prefixes: ["src/", "package.json", "bun.lock", "tests/cli.test.ts"],
|
||||
},
|
||||
{
|
||||
component: "compound-engineering",
|
||||
prefixes: ["plugins/compound-engineering/"],
|
||||
},
|
||||
{
|
||||
component: "coding-tutor",
|
||||
prefixes: ["plugins/coding-tutor/"],
|
||||
},
|
||||
{
|
||||
component: "marketplace",
|
||||
prefixes: [".claude-plugin/marketplace.json", ".cursor-plugin/marketplace.json"],
|
||||
},
|
||||
]
|
||||
|
||||
const SCOPES_TO_COMPONENTS: Record<string, ReleaseComponent> = {
|
||||
cli: "cli",
|
||||
compound: "compound-engineering",
|
||||
"compound-engineering": "compound-engineering",
|
||||
"coding-tutor": "coding-tutor",
|
||||
marketplace: "marketplace",
|
||||
}
|
||||
|
||||
const NON_RELEASABLE_TYPES = new Set(["docs", "chore", "test", "ci", "build", "style"])
|
||||
const PATCH_TYPES = new Set(["fix", "perf", "refactor", "revert"])
|
||||
|
||||
type VersionSources = Record<ReleaseComponent, string>
|
||||
|
||||
type RootPackageJson = {
|
||||
version: string
|
||||
}
|
||||
|
||||
type PluginManifest = {
|
||||
version: string
|
||||
}
|
||||
|
||||
type MarketplaceManifest = {
|
||||
metadata: {
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export function parseReleaseIntent(rawTitle: string): ParsedReleaseIntent {
|
||||
const trimmed = rawTitle.trim()
|
||||
const match = /^(?<type>[a-z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s+(?<description>.+)$/.exec(trimmed)
|
||||
|
||||
if (!match?.groups) {
|
||||
return {
|
||||
raw: rawTitle,
|
||||
type: null,
|
||||
scope: null,
|
||||
description: null,
|
||||
breaking: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
raw: rawTitle,
|
||||
type: match.groups.type ?? null,
|
||||
scope: match.groups.scope ?? null,
|
||||
description: match.groups.description ?? null,
|
||||
breaking: match.groups.bang === "!",
|
||||
}
|
||||
}
|
||||
|
||||
export function inferBumpFromIntent(intent: ParsedReleaseIntent): BumpLevel | null {
|
||||
if (intent.breaking) return "major"
|
||||
if (!intent.type) return null
|
||||
if (intent.type === "feat") return "minor"
|
||||
if (PATCH_TYPES.has(intent.type)) return "patch"
|
||||
if (NON_RELEASABLE_TYPES.has(intent.type)) return null
|
||||
return null
|
||||
}
|
||||
|
||||
export function detectComponentsFromFiles(files: string[]): Map<ReleaseComponent, string[]> {
|
||||
const componentFiles = new Map<ReleaseComponent, string[]>()
|
||||
|
||||
for (const component of RELEASE_COMPONENTS) {
|
||||
componentFiles.set(component, [])
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
for (const mapping of FILE_COMPONENT_MAP) {
|
||||
if (mapping.prefixes.some((prefix) => file === prefix || file.startsWith(prefix))) {
|
||||
componentFiles.get(mapping.component)!.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [component, matchedFiles] of componentFiles.entries()) {
|
||||
if (component === "cli" && matchedFiles.length === 0) continue
|
||||
if (component !== "cli" && matchedFiles.length === 0) continue
|
||||
}
|
||||
|
||||
return componentFiles
|
||||
}
|
||||
|
||||
export function resolveComponentWarnings(
|
||||
intent: ParsedReleaseIntent,
|
||||
detectedComponents: ReleaseComponent[],
|
||||
): string[] {
|
||||
const warnings: string[] = []
|
||||
|
||||
if (!intent.type) {
|
||||
warnings.push("Title does not match the expected conventional format: <type>(optional-scope): description")
|
||||
return warnings
|
||||
}
|
||||
|
||||
if (intent.scope) {
|
||||
const normalized = intent.scope.trim().toLowerCase()
|
||||
const expected = SCOPES_TO_COMPONENTS[normalized]
|
||||
if (expected && detectedComponents.length > 0 && !detectedComponents.includes(expected)) {
|
||||
warnings.push(
|
||||
`Optional scope "${intent.scope}" does not match the detected component set: ${detectedComponents.join(", ")}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedComponents.length === 0 && inferBumpFromIntent(intent) !== null) {
|
||||
warnings.push("No releasable component files were detected for this change")
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
export function applyOverride(
|
||||
inferred: BumpLevel | null,
|
||||
override: BumpOverride,
|
||||
): BumpLevel | null {
|
||||
if (override === "auto") return inferred
|
||||
return override
|
||||
}
|
||||
|
||||
export function bumpVersion(version: string, bump: BumpLevel | null): string | null {
|
||||
if (!bump) return null
|
||||
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported version format: ${version}`)
|
||||
}
|
||||
|
||||
const major = Number(match[1])
|
||||
const minor = Number(match[2])
|
||||
const patch = Number(match[3])
|
||||
|
||||
switch (bump) {
|
||||
case "major":
|
||||
return `${major + 1}.0.0`
|
||||
case "minor":
|
||||
return `${major}.${minor + 1}.0`
|
||||
case "patch":
|
||||
return `${major}.${minor}.${patch + 1}`
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCurrentVersions(cwd = process.cwd()): Promise<VersionSources> {
|
||||
const root = await readJson<RootPackageJson>(`${cwd}/package.json`)
|
||||
const ce = await readJson<PluginManifest>(`${cwd}/plugins/compound-engineering/.claude-plugin/plugin.json`)
|
||||
const codingTutor = await readJson<PluginManifest>(`${cwd}/plugins/coding-tutor/.claude-plugin/plugin.json`)
|
||||
const marketplace = await readJson<MarketplaceManifest>(`${cwd}/.claude-plugin/marketplace.json`)
|
||||
|
||||
return {
|
||||
cli: root.version,
|
||||
"compound-engineering": ce.version,
|
||||
"coding-tutor": codingTutor.version,
|
||||
marketplace: marketplace.metadata.version,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildReleasePreview(options: {
|
||||
title: string
|
||||
files: string[]
|
||||
overrides?: Partial<Record<ReleaseComponent, BumpOverride>>
|
||||
cwd?: string
|
||||
}): Promise<ReleasePreview> {
|
||||
const intent = parseReleaseIntent(options.title)
|
||||
const inferredBump = inferBumpFromIntent(intent)
|
||||
const componentFilesMap = detectComponentsFromFiles(options.files)
|
||||
const currentVersions = await loadCurrentVersions(options.cwd)
|
||||
|
||||
const detectedComponents = RELEASE_COMPONENTS.filter(
|
||||
(component) => (componentFilesMap.get(component) ?? []).length > 0,
|
||||
)
|
||||
|
||||
const warnings = resolveComponentWarnings(intent, detectedComponents)
|
||||
|
||||
const components: ComponentDecision[] = detectedComponents.map((component) => {
|
||||
const override = options.overrides?.[component] ?? "auto"
|
||||
const effectiveBump = applyOverride(inferredBump, override)
|
||||
const currentVersion = currentVersions[component]
|
||||
|
||||
return {
|
||||
component,
|
||||
files: componentFilesMap.get(component) ?? [],
|
||||
currentVersion,
|
||||
inferredBump,
|
||||
effectiveBump,
|
||||
override,
|
||||
nextVersion: bumpVersion(currentVersion, effectiveBump),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
intent,
|
||||
warnings,
|
||||
components,
|
||||
}
|
||||
}
|
||||
218
src/release/metadata.ts
Normal file
218
src/release/metadata.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { readJson, readText, writeJson, writeText } from "../utils/files"
|
||||
import type { ReleaseComponent } from "./types"
|
||||
|
||||
type ClaudePluginManifest = {
|
||||
version: string
|
||||
description?: string
|
||||
mcpServers?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type CursorPluginManifest = {
|
||||
version: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type MarketplaceManifest = {
|
||||
metadata: {
|
||||
version: string
|
||||
description?: string
|
||||
}
|
||||
plugins: Array<{
|
||||
name: string
|
||||
version?: string
|
||||
description?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type SyncOptions = {
|
||||
root?: string
|
||||
componentVersions?: Partial<Record<ReleaseComponent, string>>
|
||||
write?: boolean
|
||||
}
|
||||
|
||||
type FileUpdate = {
|
||||
path: string
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
export type MetadataSyncResult = {
|
||||
updates: FileUpdate[]
|
||||
}
|
||||
|
||||
export async function countMarkdownFiles(root: string): Promise<number> {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
let total = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(root, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
total += await countMarkdownFiles(fullPath)
|
||||
continue
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
total += 1
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
export async function countSkillDirectories(root: string): Promise<number> {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
let total = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const skillPath = path.join(root, entry.name, "SKILL.md")
|
||||
try {
|
||||
await fs.access(skillPath)
|
||||
total += 1
|
||||
} catch {
|
||||
// Ignore non-skill directories.
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
export async function countMcpServers(pluginRoot: string): Promise<number> {
|
||||
const mcpPath = path.join(pluginRoot, ".mcp.json")
|
||||
const manifest = await readJson<{ mcpServers?: Record<string, unknown> }>(mcpPath)
|
||||
return Object.keys(manifest.mcpServers ?? {}).length
|
||||
}
|
||||
|
||||
export async function buildCompoundEngineeringDescription(root: string): Promise<string> {
|
||||
const pluginRoot = path.join(root, "plugins", "compound-engineering")
|
||||
const agents = await countMarkdownFiles(path.join(pluginRoot, "agents"))
|
||||
const skills = await countSkillDirectories(path.join(pluginRoot, "skills"))
|
||||
const mcpServers = await countMcpServers(pluginRoot)
|
||||
return `AI-powered development tools. ${agents} agents, ${skills} skills, ${mcpServers} MCP server${mcpServers === 1 ? "" : "s"} for code review, research, design, and workflow automation.`
|
||||
}
|
||||
|
||||
export async function syncReleaseMetadata(options: SyncOptions = {}): Promise<MetadataSyncResult> {
|
||||
const root = options.root ?? process.cwd()
|
||||
const write = options.write ?? false
|
||||
const versions = options.componentVersions ?? {}
|
||||
const updates: FileUpdate[] = []
|
||||
|
||||
const compoundDescription = await buildCompoundEngineeringDescription(root)
|
||||
|
||||
const compoundClaudePath = path.join(root, "plugins", "compound-engineering", ".claude-plugin", "plugin.json")
|
||||
const compoundCursorPath = path.join(root, "plugins", "compound-engineering", ".cursor-plugin", "plugin.json")
|
||||
const codingTutorClaudePath = path.join(root, "plugins", "coding-tutor", ".claude-plugin", "plugin.json")
|
||||
const codingTutorCursorPath = path.join(root, "plugins", "coding-tutor", ".cursor-plugin", "plugin.json")
|
||||
const marketplaceClaudePath = path.join(root, ".claude-plugin", "marketplace.json")
|
||||
|
||||
const compoundClaude = await readJson<ClaudePluginManifest>(compoundClaudePath)
|
||||
const compoundCursor = await readJson<CursorPluginManifest>(compoundCursorPath)
|
||||
const codingTutorClaude = await readJson<ClaudePluginManifest>(codingTutorClaudePath)
|
||||
const codingTutorCursor = await readJson<CursorPluginManifest>(codingTutorCursorPath)
|
||||
const marketplaceClaude = await readJson<MarketplaceManifest>(marketplaceClaudePath)
|
||||
|
||||
let changed = false
|
||||
if (versions["compound-engineering"] && compoundClaude.version !== versions["compound-engineering"]) {
|
||||
compoundClaude.version = versions["compound-engineering"]
|
||||
changed = true
|
||||
}
|
||||
if (compoundClaude.description !== compoundDescription) {
|
||||
compoundClaude.description = compoundDescription
|
||||
changed = true
|
||||
}
|
||||
updates.push({ path: compoundClaudePath, changed })
|
||||
if (write && changed) await writeJson(compoundClaudePath, compoundClaude)
|
||||
|
||||
changed = false
|
||||
if (versions["compound-engineering"] && compoundCursor.version !== versions["compound-engineering"]) {
|
||||
compoundCursor.version = versions["compound-engineering"]
|
||||
changed = true
|
||||
}
|
||||
if (compoundCursor.description !== compoundDescription) {
|
||||
compoundCursor.description = compoundDescription
|
||||
changed = true
|
||||
}
|
||||
updates.push({ path: compoundCursorPath, changed })
|
||||
if (write && changed) await writeJson(compoundCursorPath, compoundCursor)
|
||||
|
||||
changed = false
|
||||
if (versions["coding-tutor"] && codingTutorClaude.version !== versions["coding-tutor"]) {
|
||||
codingTutorClaude.version = versions["coding-tutor"]
|
||||
changed = true
|
||||
}
|
||||
updates.push({ path: codingTutorClaudePath, changed })
|
||||
if (write && changed) await writeJson(codingTutorClaudePath, codingTutorClaude)
|
||||
|
||||
changed = false
|
||||
if (versions["coding-tutor"] && codingTutorCursor.version !== versions["coding-tutor"]) {
|
||||
codingTutorCursor.version = versions["coding-tutor"]
|
||||
changed = true
|
||||
}
|
||||
updates.push({ path: codingTutorCursorPath, changed })
|
||||
if (write && changed) await writeJson(codingTutorCursorPath, codingTutorCursor)
|
||||
|
||||
changed = false
|
||||
if (versions.marketplace && marketplaceClaude.metadata.version !== versions.marketplace) {
|
||||
marketplaceClaude.metadata.version = versions.marketplace
|
||||
changed = true
|
||||
}
|
||||
|
||||
for (const plugin of marketplaceClaude.plugins) {
|
||||
if (plugin.name === "compound-engineering") {
|
||||
if (versions["compound-engineering"] && plugin.version !== versions["compound-engineering"]) {
|
||||
plugin.version = versions["compound-engineering"]
|
||||
changed = true
|
||||
}
|
||||
if (plugin.description !== `AI-powered development tools that get smarter with every use. Make each unit of engineering work easier than the last. Includes ${await countMarkdownFiles(path.join(root, "plugins", "compound-engineering", "agents"))} specialized agents and ${await countSkillDirectories(path.join(root, "plugins", "compound-engineering", "skills"))} skills.`) {
|
||||
plugin.description = `AI-powered development tools that get smarter with every use. Make each unit of engineering work easier than the last. Includes ${await countMarkdownFiles(path.join(root, "plugins", "compound-engineering", "agents"))} specialized agents and ${await countSkillDirectories(path.join(root, "plugins", "compound-engineering", "skills"))} skills.`
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (plugin.name === "coding-tutor" && versions["coding-tutor"] && plugin.version !== versions["coding-tutor"]) {
|
||||
plugin.version = versions["coding-tutor"]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
updates.push({ path: marketplaceClaudePath, changed })
|
||||
if (write && changed) await writeJson(marketplaceClaudePath, marketplaceClaude)
|
||||
|
||||
return { updates }
|
||||
}
|
||||
|
||||
export async function updateRootChangelog(options: {
|
||||
root?: string
|
||||
entries: Array<{ component: ReleaseComponent; version: string; date: string; sections: Record<string, string[]> }>
|
||||
write?: boolean
|
||||
}): Promise<{ path: string; changed: boolean; content: string }> {
|
||||
const root = options.root ?? process.cwd()
|
||||
const changelogPath = path.join(root, "CHANGELOG.md")
|
||||
const existing = await readText(changelogPath)
|
||||
const renderedEntries = options.entries
|
||||
.map((entry) => renderChangelogEntry(entry.component, entry.version, entry.date, entry.sections))
|
||||
.join("\n\n")
|
||||
const next = `${existing.trimEnd()}\n\n${renderedEntries}\n`
|
||||
const changed = next !== existing
|
||||
if (options.write && changed) {
|
||||
await writeText(changelogPath, next)
|
||||
}
|
||||
return { path: changelogPath, changed, content: next }
|
||||
}
|
||||
|
||||
export function renderChangelogEntry(
|
||||
component: ReleaseComponent,
|
||||
version: string,
|
||||
date: string,
|
||||
sections: Record<string, string[]>,
|
||||
): string {
|
||||
const lines = [`## ${component} v${version} - ${date}`]
|
||||
for (const [section, items] of Object.entries(sections)) {
|
||||
if (items.length === 0) continue
|
||||
lines.push("", `### ${section}`)
|
||||
for (const item of items) {
|
||||
lines.push(`- ${item}`)
|
||||
}
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
43
src/release/types.ts
Normal file
43
src/release/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type ReleaseComponent = "cli" | "compound-engineering" | "coding-tutor" | "marketplace"
|
||||
|
||||
export type BumpLevel = "patch" | "minor" | "major"
|
||||
|
||||
export type BumpOverride = BumpLevel | "auto"
|
||||
|
||||
export type ConventionalReleaseType =
|
||||
| "feat"
|
||||
| "fix"
|
||||
| "perf"
|
||||
| "refactor"
|
||||
| "docs"
|
||||
| "chore"
|
||||
| "test"
|
||||
| "ci"
|
||||
| "build"
|
||||
| "revert"
|
||||
| "style"
|
||||
| string
|
||||
|
||||
export type ParsedReleaseIntent = {
|
||||
raw: string
|
||||
type: ConventionalReleaseType | null
|
||||
scope: string | null
|
||||
description: string | null
|
||||
breaking: boolean
|
||||
}
|
||||
|
||||
export type ComponentDecision = {
|
||||
component: ReleaseComponent
|
||||
files: string[]
|
||||
currentVersion: string
|
||||
inferredBump: BumpLevel | null
|
||||
effectiveBump: BumpLevel | null
|
||||
override: BumpOverride
|
||||
nextVersion: string | null
|
||||
}
|
||||
|
||||
export type ReleasePreview = {
|
||||
intent: ParsedReleaseIntent
|
||||
warnings: string[]
|
||||
components: ComponentDecision[]
|
||||
}
|
||||
Reference in New Issue
Block a user