refactor(cli): separate build and publish concerns

- Move SDK generation to `core build sdk` subcommand
- Make `core ci` publish-only (expects artifacts in dist/)
- Add release.Publish() for publishing pre-built artifacts
- Keep `core sdk diff` and `core sdk validate` for API validation
- Update SKILL.md documentation

This separation prevents accidental releases - running `core ci`
without first building will fail safely.

Workflow:
  core build          # Build binaries
  core build sdk      # Build SDKs
  core ci             # Publish what's in dist/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 12:48:28 +00:00
parent 148670fd82
commit 331032cd57
5 changed files with 274 additions and 116 deletions

View file

@ -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 <query>` | GitHub search for core-* repos |
| Install package | `core pkg install <name>` | 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 <os/arch>]
└── 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

View file

@ -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 {

View file

@ -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()

View file

@ -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 {

View file

@ -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 {