diff --git a/.claude/skills/core/SKILL.md b/.claude/skills/core/SKILL.md index 8a7608c..beec7a4 100644 --- a/.claude/skills/core/SKILL.md +++ b/.claude/skills/core/SKILL.md @@ -25,7 +25,8 @@ The `core` command provides a unified interface for Go/Wails development, multi- | Deploy PHP app | `core php deploy` | Coolify deployment | | Build project | `core build` | Auto-detects project type | | Build for targets | `core build --targets linux/amd64,darwin/arm64` | Cross-compile | -| Release | `core ci` | Build + publish to GitHub/npm/Homebrew | +| Build SDK | `core build sdk` | Generate API clients from OpenAPI | +| Publish release | `core ci` | Publish pre-built artifacts | | Check environment | `core doctor` | Verify tools installed | | Multi-repo status | `core dev health` | Quick summary across repos | | Multi-repo workflow | `core dev work` | Status + commit + push | @@ -35,7 +36,8 @@ The `core` command provides a unified interface for Go/Wails development, multi- | List issues | `core dev issues` | Open issues across repos | | List PRs | `core dev reviews` | PRs needing review | | Check CI | `core dev ci` | GitHub Actions status | -| Generate SDK | `core sdk` | Generate API clients from OpenAPI | +| Validate OpenAPI | `core sdk validate` | Validate OpenAPI spec | +| Check API changes | `core sdk diff` | Detect breaking API changes | | Sync docs | `core docs sync` | Sync docs across repos | | Search packages | `core pkg search ` | GitHub search for core-* repos | | Install package | `core pkg install ` | Clone and register package | @@ -65,6 +67,31 @@ core build --ci **Why:** Handles cross-compilation, code signing, archiving, checksums, and CI output formatting. +## Releasing + +Build and publish are **separated** to prevent accidental releases: + +```bash +# Step 1: Build artifacts (safe - no publishing) +core build +core build sdk + +# Step 2: Publish to configured targets (requires pre-built artifacts) +core ci # Publish what's in dist/ +core ci --dry-run # Preview what would be published +core ci --draft # Create as draft release +core ci --prerelease # Mark as prerelease +``` + +**Why separate?** Running `core ci` without first building will fail safely - no accidental publishes. + +```bash +# Release workflow utilities +core ci init # Initialize .core/release.yaml +core ci changelog # Generate changelog from commits +core ci version # Show determined version +``` + ## Multi-Repo Workflow When working across host-uk repositories: @@ -121,14 +148,31 @@ Generate API clients from OpenAPI specs: ```bash # Generate all configured SDKs -core sdk +core build sdk # Generate specific language -core sdk --lang typescript -core sdk --lang php +core build sdk --lang typescript +core build sdk --lang php # Specify OpenAPI spec -core sdk --spec ./openapi.yaml +core build sdk --spec ./openapi.yaml + +# Preview without generating +core build sdk --dry-run +``` + +## SDK Validation + +Validate specs and check for breaking changes: + +```bash +# Validate OpenAPI spec +core sdk validate +core sdk validate --spec ./api.yaml + +# Check for breaking API changes +core sdk diff --base v1.0.0 +core sdk diff --base ./old-api.yaml --spec ./new-api.yaml ``` ## Documentation @@ -476,7 +520,8 @@ Go project? └── Lint: core go lint └── Tidy modules: core go mod tidy └── Build: core build [--targets ] - └── Release: core ci + └── Build SDK: core build sdk + └── Publish: core ci [--dry-run] PHP/Laravel project? └── Start dev: core php dev [--https] @@ -526,6 +571,8 @@ Managing packages? | Raw `linuxkit run` | `core vm run` | Unified interface, templates | | `gh repo clone` | `core pkg install` | Auto-detects org, adds to registry | | Manual GitHub search | `core pkg search` | Filtered to org, formatted output | +| `core ci` without build | `core build && core ci` | Build first, then publish | +| `core sdk generate` | `core build sdk` | SDK generation moved to build | ## Configuration diff --git a/cmd/core/cmd/build.go b/cmd/core/cmd/build.go index 68a253a..89f2fed 100644 --- a/cmd/core/cmd/build.go +++ b/cmd/core/cmd/build.go @@ -18,6 +18,7 @@ import ( "github.com/host-uk/core/pkg/build" "github.com/host-uk/core/pkg/build/builders" "github.com/host-uk/core/pkg/build/signing" + "github.com/host-uk/core/pkg/sdk" "github.com/leaanthony/clir" "github.com/leaanthony/debme" "github.com/leaanthony/gosod" @@ -125,6 +126,25 @@ func AddBuildCommand(app *clir.Cli) { } return runPwaBuild(pwaURL) }) + + // --- `build sdk` command --- + sdkBuildCmd := buildCmd.NewSubCommand("sdk", "Generate API SDKs from OpenAPI spec") + sdkBuildCmd.LongDescription("Generates typed API clients from OpenAPI specifications.\n" + + "Supports TypeScript, Python, Go, and PHP.\n\n" + + "Examples:\n" + + " core build sdk # Generate all configured SDKs\n" + + " core build sdk --lang typescript # Generate only TypeScript SDK\n" + + " core build sdk --spec api.yaml # Use specific OpenAPI spec") + + var sdkSpec, sdkLang, sdkVersion string + var sdkDryRun bool + sdkBuildCmd.StringFlag("spec", "Path to OpenAPI spec file", &sdkSpec) + sdkBuildCmd.StringFlag("lang", "Generate only this language (typescript, python, go, php)", &sdkLang) + sdkBuildCmd.StringFlag("version", "Version to embed in generated SDKs", &sdkVersion) + sdkBuildCmd.BoolFlag("dry-run", "Show what would be generated without writing files", &sdkDryRun) + sdkBuildCmd.Action(func() error { + return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun) + }) } // runProjectBuild handles the main `core build` command with auto-detection. @@ -516,6 +536,73 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) { } } +// --- SDK Build Logic --- + +func runBuildSDK(specPath, lang, version string, dryRun bool) error { + ctx := context.Background() + + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Load config + config := sdk.DefaultConfig() + if specPath != "" { + config.Spec = specPath + } + + s := sdk.New(projectDir, config) + if version != "" { + s.SetVersion(version) + } + + fmt.Printf("%s Generating SDKs\n", buildHeaderStyle.Render("Build SDK:")) + if dryRun { + fmt.Printf(" %s\n", buildDimStyle.Render("(dry-run mode)")) + } + fmt.Println() + + // Detect spec + detectedSpec, err := s.DetectSpec() + if err != nil { + fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) + return err + } + fmt.Printf(" Spec: %s\n", buildTargetStyle.Render(detectedSpec)) + + if dryRun { + if lang != "" { + fmt.Printf(" Language: %s\n", buildTargetStyle.Render(lang)) + } else { + fmt.Printf(" Languages: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", "))) + } + fmt.Println() + fmt.Printf("%s Would generate SDKs (dry-run)\n", buildSuccessStyle.Render("OK:")) + return nil + } + + if lang != "" { + // Generate single language + if err := s.GenerateLanguage(ctx, lang); err != nil { + fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) + return err + } + fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(lang)) + } else { + // Generate all + if err := s.Generate(ctx); err != nil { + fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) + return err + } + fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", "))) + } + + fmt.Println() + fmt.Printf("%s SDK generation complete\n", buildSuccessStyle.Render("Success:")) + return nil +} + // --- PWA Build Logic --- func runPwaBuild(pwaURL string) error { diff --git a/cmd/core/cmd/ci_release.go b/cmd/core/cmd/ci_release.go index 0703634..3fb6b9b 100644 --- a/cmd/core/cmd/ci_release.go +++ b/cmd/core/cmd/ci_release.go @@ -45,20 +45,15 @@ func AddCIReleaseCommand(app *clir.Cli) { var version string var draft bool var prerelease bool - var target string releaseCmd.BoolFlag("dry-run", "Preview release without publishing", &dryRun) releaseCmd.StringFlag("version", "Version to release (e.g., v1.2.3)", &version) releaseCmd.BoolFlag("draft", "Create release as a draft", &draft) releaseCmd.BoolFlag("prerelease", "Mark release as a prerelease", &prerelease) - releaseCmd.StringFlag("target", "CIRelease target (sdk)", &target) - // Default action for `core release` + // Default action for `core ci` - publish only (expects artifacts in dist/) releaseCmd.Action(func() error { - if target == "sdk" { - return runCIReleaseSDK(dryRun, version) - } - return runCIRelease(dryRun, version, draft, prerelease) + return runCIPublish(dryRun, version, draft, prerelease) }) // `release init` subcommand @@ -86,8 +81,9 @@ func AddCIReleaseCommand(app *clir.Cli) { }) } -// runCIRelease executes the main release workflow. -func runCIRelease(dryRun bool, version string, draft, prerelease bool) error { +// runCIPublish publishes pre-built artifacts from dist/. +// It does NOT build - use `core build` first. +func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { ctx := context.Background() // Get current directory @@ -120,14 +116,19 @@ func runCIRelease(dryRun bool, version string, draft, prerelease bool) error { } // Print header - fmt.Printf("%s Starting release process\n", releaseHeaderStyle.Render("CIRelease:")) + fmt.Printf("%s Publishing release\n", releaseHeaderStyle.Render("CI:")) if dryRun { fmt.Printf(" %s\n", releaseDimStyle.Render("(dry-run mode)")) } fmt.Println() - // Run the release - rel, err := release.Run(ctx, cfg, dryRun) + // Check for publishers + if len(cfg.Publishers) == 0 { + return fmt.Errorf("no publishers configured in .core/release.yaml") + } + + // Publish pre-built artifacts + rel, err := release.Publish(ctx, cfg, dryRun) if err != nil { fmt.Printf("%s %v\n", releaseErrorStyle.Render("Error:"), err) return err @@ -135,11 +136,11 @@ func runCIRelease(dryRun bool, version string, draft, prerelease bool) error { // Print summary fmt.Println() - fmt.Printf("%s CIRelease completed!\n", releaseSuccessStyle.Render("Success:")) + fmt.Printf("%s Publish completed!\n", releaseSuccessStyle.Render("Success:")) fmt.Printf(" Version: %s\n", releaseValueStyle.Render(rel.Version)) fmt.Printf(" Artifacts: %d\n", len(rel.Artifacts)) - if !dryRun && len(cfg.Publishers) > 0 { + if !dryRun { for _, pub := range cfg.Publishers { fmt.Printf(" Published: %s\n", releaseValueStyle.Render(pub.Type)) } @@ -148,50 +149,6 @@ func runCIRelease(dryRun bool, version string, draft, prerelease bool) error { return nil } -// runCIReleaseSDK executes SDK-only release. -func runCIReleaseSDK(dryRun bool, version string) error { - ctx := context.Background() - - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Load configuration - cfg, err := release.LoadConfig(projectDir) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Apply CLI overrides - if version != "" { - cfg.SetVersion(version) - } - - // Print header - fmt.Printf("%s Generating SDKs\n", releaseHeaderStyle.Render("SDK CIRelease:")) - if dryRun { - fmt.Printf(" %s\n", releaseDimStyle.Render("(dry-run mode)")) - } - fmt.Println() - - // Run SDK release - result, err := release.RunSDK(ctx, cfg, dryRun) - if err != nil { - fmt.Printf("%s %v\n", releaseErrorStyle.Render("Error:"), err) - return err - } - - // Print summary - fmt.Println() - fmt.Printf("%s SDK generation complete!\n", releaseSuccessStyle.Render("Success:")) - fmt.Printf(" Version: %s\n", releaseValueStyle.Render(result.Version)) - fmt.Printf(" Languages: %v\n", result.Languages) - fmt.Printf(" Output: %s/\n", releaseValueStyle.Render(result.Output)) - - return nil -} - // runCIReleaseInit creates a release configuration interactively. func runCIReleaseInit() error { projectDir, err := os.Getwd() diff --git a/cmd/core/cmd/sdk.go b/cmd/core/cmd/sdk.go index e65a88d..f1c582d 100644 --- a/cmd/core/cmd/sdk.go +++ b/cmd/core/cmd/sdk.go @@ -1,7 +1,6 @@ package cmd import ( - "context" "fmt" "os" @@ -29,22 +28,16 @@ var ( // AddSDKCommand adds the sdk command and its subcommands. func AddSDKCommand(app *clir.Cli) { - sdkCmd := app.NewSubCommand("sdk", "Generate and manage API SDKs") - sdkCmd.LongDescription("Generate typed API clients from OpenAPI specs.\n" + - "Supports TypeScript, Python, Go, and PHP.") - - // sdk generate - genCmd := sdkCmd.NewSubCommand("generate", "Generate SDKs from OpenAPI spec") - var specPath, lang string - genCmd.StringFlag("spec", "Path to OpenAPI spec file", &specPath) - genCmd.StringFlag("lang", "Generate only this language", &lang) - genCmd.Action(func() error { - return runSDKGenerate(specPath, lang) - }) + sdkCmd := app.NewSubCommand("sdk", "SDK validation and API compatibility tools") + sdkCmd.LongDescription("Tools for validating OpenAPI specs and checking API compatibility.\n" + + "To generate SDKs, use: core build sdk\n\n" + + "Commands:\n" + + " diff Check for breaking API changes\n" + + " validate Validate OpenAPI spec syntax") // sdk diff diffCmd := sdkCmd.NewSubCommand("diff", "Check for breaking API changes") - var basePath string + var basePath, specPath string diffCmd.StringFlag("base", "Base spec (version tag or file)", &basePath) diffCmd.StringFlag("spec", "Current spec file", &specPath) diffCmd.Action(func() error { @@ -59,42 +52,6 @@ func AddSDKCommand(app *clir.Cli) { }) } -func runSDKGenerate(specPath, lang string) error { - ctx := context.Background() - - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Load config - config := sdk.DefaultConfig() - if specPath != "" { - config.Spec = specPath - } - - s := sdk.New(projectDir, config) - - fmt.Printf("%s Generating SDKs\n", sdkHeaderStyle.Render("SDK:")) - - if lang != "" { - // Generate single language - if err := s.GenerateLanguage(ctx, lang); err != nil { - fmt.Printf("%s %v\n", sdkErrorStyle.Render("Error:"), err) - return err - } - } else { - // Generate all - if err := s.Generate(ctx); err != nil { - fmt.Printf("%s %v\n", sdkErrorStyle.Render("Error:"), err) - return err - } - } - - fmt.Printf("%s SDK generation complete\n", sdkSuccessStyle.Render("Success:")) - return nil -} - func runSDKDiff(basePath, specPath string) error { projectDir, err := os.Getwd() if err != nil { diff --git a/pkg/release/release.go b/pkg/release/release.go index ba51ed0..699e354 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -6,7 +6,9 @@ package release import ( "context" "fmt" + "os" "path/filepath" + "strings" "github.com/host-uk/core/pkg/build" "github.com/host-uk/core/pkg/build/builders" @@ -25,8 +27,116 @@ type Release struct { ProjectDir string } -// Run executes the release process: determine version, build artifacts, +// Publish publishes pre-built artifacts from dist/ to configured targets. +// Use this after `core build` to separate build and publish concerns. +// If dryRun is true, it will show what would be done without actually publishing. +func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { + if cfg == nil { + return nil, fmt.Errorf("release.Publish: config is nil") + } + + projectDir := cfg.projectDir + if projectDir == "" { + projectDir = "." + } + + // Resolve to absolute path + absProjectDir, err := filepath.Abs(projectDir) + if err != nil { + return nil, fmt.Errorf("release.Publish: failed to resolve project directory: %w", err) + } + + // Step 1: Determine version + version := cfg.version + if version == "" { + version, err = DetermineVersion(absProjectDir) + if err != nil { + return nil, fmt.Errorf("release.Publish: failed to determine version: %w", err) + } + } + + // Step 2: Find pre-built artifacts in dist/ + distDir := filepath.Join(absProjectDir, "dist") + artifacts, err := findArtifacts(distDir) + if err != nil { + return nil, fmt.Errorf("release.Publish: %w", err) + } + + if len(artifacts) == 0 { + return nil, fmt.Errorf("release.Publish: no artifacts found in dist/\nRun 'core build' first to create artifacts") + } + + // Step 3: Generate changelog + changelog, err := Generate(absProjectDir, "", version) + if err != nil { + // Non-fatal: continue with empty changelog + changelog = fmt.Sprintf("Release %s", version) + } + + release := &Release{ + Version: version, + Artifacts: artifacts, + Changelog: changelog, + ProjectDir: absProjectDir, + } + + // Step 4: Publish to configured targets + if len(cfg.Publishers) > 0 { + pubRelease := publishers.NewRelease(release.Version, release.Artifacts, release.Changelog, release.ProjectDir) + + for _, pubCfg := range cfg.Publishers { + publisher, err := getPublisher(pubCfg.Type) + if err != nil { + return release, fmt.Errorf("release.Publish: %w", err) + } + + extendedCfg := buildExtendedConfig(pubCfg) + publisherCfg := publishers.NewPublisherConfig(pubCfg.Type, pubCfg.Prerelease, pubCfg.Draft, extendedCfg) + if err := publisher.Publish(ctx, pubRelease, publisherCfg, cfg, dryRun); err != nil { + return release, fmt.Errorf("release.Publish: publish to %s failed: %w", pubCfg.Type, err) + } + } + } + + return release, nil +} + +// findArtifacts discovers pre-built artifacts in the dist directory. +func findArtifacts(distDir string) ([]build.Artifact, error) { + if _, err := os.Stat(distDir); os.IsNotExist(err) { + return nil, fmt.Errorf("dist/ directory not found") + } + + var artifacts []build.Artifact + + entries, err := os.ReadDir(distDir) + if err != nil { + return nil, fmt.Errorf("failed to read dist/: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + path := filepath.Join(distDir, name) + + // Include archives and checksums + if strings.HasSuffix(name, ".tar.gz") || + strings.HasSuffix(name, ".zip") || + strings.HasSuffix(name, ".txt") || + strings.HasSuffix(name, ".sig") { + artifacts = append(artifacts, build.Artifact{Path: path}) + } + } + + return artifacts, nil +} + +// Run executes the full release process: determine version, build artifacts, // generate changelog, and publish to configured targets. +// For separated concerns, prefer using `core build` then `core ci` (Publish). // If dryRun is true, it will show what would be done without actually publishing. func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { if cfg == nil {