diff --git a/docs/plans/2026-01-29-sdk-release-design.md b/docs/plans/2026-01-29-sdk-release-design.md new file mode 100644 index 0000000..37a26ae --- /dev/null +++ b/docs/plans/2026-01-29-sdk-release-design.md @@ -0,0 +1,210 @@ +# SDK Release Integration Design (S3.4) + +## Summary + +Add `core release --target sdk` to generate SDKs as a separate release target. Runs breaking change detection before generating, uses release version for SDK versioning, outputs locally for manual publishing. + +## Design Decisions + +- **Separate target**: `--target sdk` runs ONLY SDK generation (no binary builds) +- **Local output**: Generates to `sdk/` directory, user handles publishing +- **Diff first**: Run breaking change detection before generating +- **Match version**: SDK version matches release version from git tags + +## CLI + +```bash +core release --target sdk # Generate SDKs only +core release --target sdk --version v1.2.3 # Explicit version +core release --target sdk --dry-run # Preview what would generate +core release # Normal release (unchanged) +``` + +## Config Schema + +In `.core/release.yaml`: + +```yaml +sdk: + spec: openapi.yaml # or auto-detect + languages: [typescript, python, go, php] + output: sdk # output directory + package: + name: myapi-sdk + diff: + enabled: true + fail_on_breaking: false # warn but continue +``` + +## Flow + +``` +core release --target sdk + ↓ +1. Load release config (.core/release.yaml) + ↓ +2. Check sdk config exists (error if not configured) + ↓ +3. Determine version (git tag or --version flag) + ↓ +4. If diff.enabled: + - Get previous tag + - Run oasdiff against current spec + - If breaking && fail_on_breaking: abort + - If breaking && !fail_on_breaking: warn, continue + ↓ +5. Generate SDKs for each language + - Pass version to generators + - Output to sdk/{language}/ + ↓ +6. Print summary (languages generated, output paths) +``` + +## Package Structure + +``` +pkg/release/ +├── sdk.go # RunSDK() orchestration + diff helper ← NEW +├── release.go # Existing Run() unchanged +└── config.go # Existing SDKConfig unchanged + +pkg/sdk/ +└── sdk.go # Add SetVersion() method ← MODIFY + +cmd/core/cmd/ +└── release.go # Add --target flag ← MODIFY +``` + +## RunSDK Implementation + +```go +// pkg/release/sdk.go + +// RunSDK executes SDK-only release: diff check + generate. +func RunSDK(ctx context.Context, cfg *Config, dryRun bool) (*SDKRelease, error) { + if cfg.SDK == nil { + return nil, fmt.Errorf("sdk not configured in .core/release.yaml") + } + + projectDir := cfg.projectDir + if projectDir == "" { + projectDir = "." + } + + // Determine version + version := cfg.version + if version == "" { + var err error + version, err = DetermineVersion(projectDir) + if err != nil { + return nil, fmt.Errorf("failed to determine version: %w", err) + } + } + + // Run diff check if enabled + if cfg.SDK.Diff.Enabled { + breaking, err := checkBreakingChanges(projectDir, cfg.SDK) + if err != nil { + // Non-fatal: warn and continue + fmt.Printf("Warning: diff check failed: %v\n", err) + } else if breaking { + if cfg.SDK.Diff.FailOnBreaking { + return nil, fmt.Errorf("breaking API changes detected") + } + fmt.Printf("Warning: breaking API changes detected\n") + } + } + + if dryRun { + return &SDKRelease{ + Version: version, + Languages: cfg.SDK.Languages, + Output: cfg.SDK.Output, + }, nil + } + + // Generate SDKs + sdkCfg := toSDKConfig(cfg.SDK) + s := sdk.New(projectDir, sdkCfg) + s.SetVersion(version) + + if err := s.Generate(ctx); err != nil { + return nil, fmt.Errorf("sdk generation failed: %w", err) + } + + return &SDKRelease{ + Version: version, + Languages: cfg.SDK.Languages, + Output: cfg.SDK.Output, + }, nil +} + +// SDKRelease holds the result of an SDK release. +type SDKRelease struct { + Version string + Languages []string + Output string +} +``` + +## CLI Integration + +```go +// cmd/core/cmd/release.go + +var target string +releaseCmd.StringFlag("target", "Release target (sdk)", &target) + +releaseCmd.Action(func() error { + if target == "sdk" { + return runReleaseSDK(dryRun, version) + } + return runRelease(dryRun, version, draft, prerelease) +}) + +func runReleaseSDK(dryRun bool, version string) error { + ctx := context.Background() + projectDir, _ := os.Getwd() + + cfg, err := release.LoadConfig(projectDir) + if err != nil { + return err + } + + if version != "" { + cfg.SetVersion(version) + } + + fmt.Printf("%s Generating SDKs\n", releaseHeaderStyle.Render("SDK Release:")) + if dryRun { + fmt.Printf(" %s\n", releaseDimStyle.Render("(dry-run mode)")) + } + + result, err := release.RunSDK(ctx, cfg, dryRun) + if err != nil { + fmt.Printf("%s %v\n", releaseErrorStyle.Render("Error:"), err) + return err + } + + fmt.Printf("%s SDK generation complete\n", releaseSuccessStyle.Render("Success:")) + fmt.Printf(" Version: %s\n", result.Version) + fmt.Printf(" Languages: %v\n", result.Languages) + fmt.Printf(" Output: %s/\n", result.Output) + + return nil +} +``` + +## Implementation Steps + +1. Add `SetVersion()` method to `pkg/sdk/sdk.go` +2. Create `pkg/release/sdk.go` with `RunSDK()` and helpers +3. Add `--target` flag to `cmd/core/cmd/release.go` +4. Add `runReleaseSDK()` function to CLI +5. Add tests for `pkg/release/sdk_test.go` +6. Final verification and TODO update + +## Dependencies + +- `oasdiff` CLI (for breaking change detection) +- Existing SDK generators (openapi-generator, etc.) diff --git a/docs/plans/2026-01-29-sdk-release-impl.md b/docs/plans/2026-01-29-sdk-release-impl.md new file mode 100644 index 0000000..78d26e1 --- /dev/null +++ b/docs/plans/2026-01-29-sdk-release-impl.md @@ -0,0 +1,576 @@ +# SDK Release Implementation Plan (S3.4) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `core release --target sdk` to generate SDKs with version and diff checking + +**Architecture:** Separate release target that runs diff check then SDK generation, outputs locally + +**Tech Stack:** Go, existing pkg/sdk generators, oasdiff for diff + +--- + +## Task 1: Add SetVersion to SDK struct + +**Files:** +- Modify: `pkg/sdk/sdk.go` +- Test: `pkg/sdk/sdk_test.go` (create if needed) + +**Step 1: Write the failing test** + +```go +// pkg/sdk/sdk_test.go +package sdk + +import ( + "testing" +) + +func TestSDK_Good_SetVersion(t *testing.T) { + s := New("/tmp", nil) + s.SetVersion("v1.2.3") + + if s.version != "v1.2.3" { + t.Errorf("expected version v1.2.3, got %s", s.version) + } +} + +func TestSDK_Good_VersionPassedToGenerator(t *testing.T) { + config := &Config{ + Languages: []string{"typescript"}, + Output: "sdk", + Package: PackageConfig{ + Name: "test-sdk", + }, + } + s := New("/tmp", config) + s.SetVersion("v2.0.0") + + // Version should override config + if s.config.Package.Version != "v2.0.0" { + t.Errorf("expected config version v2.0.0, got %s", s.config.Package.Version) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sdk/... -run TestSDK_Good_SetVersion -v` +Expected: FAIL with "s.version undefined" or similar + +**Step 3: Write minimal implementation** + +Add to `pkg/sdk/sdk.go`: + +```go +// SDK struct - add version field +type SDK struct { + config *Config + projectDir string + version string // ADD THIS +} + +// SetVersion sets the SDK version, overriding config. +func (s *SDK) SetVersion(version string) { + s.version = version + if s.config != nil { + s.config.Package.Version = version + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./pkg/sdk/... -run TestSDK_Good -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add pkg/sdk/sdk.go pkg/sdk/sdk_test.go +git commit -m "feat(sdk): add SetVersion method for release integration" +``` + +--- + +## Task 2: Create pkg/release/sdk.go structure + +**Files:** +- Create: `pkg/release/sdk.go` + +**Step 1: Create file with types and helper** + +```go +// pkg/release/sdk.go +package release + +import ( + "context" + "fmt" + + "github.com/host-uk/core/pkg/sdk" +) + +// SDKRelease holds the result of an SDK release. +type SDKRelease struct { + // Version is the SDK version. + Version string + // Languages that were generated. + Languages []string + // Output directory. + Output string +} + +// toSDKConfig converts release.SDKConfig to sdk.Config. +func toSDKConfig(cfg *SDKConfig) *sdk.Config { + if cfg == nil { + return nil + } + return &sdk.Config{ + Spec: cfg.Spec, + Languages: cfg.Languages, + Output: cfg.Output, + Package: sdk.PackageConfig{ + Name: cfg.Package.Name, + Version: cfg.Package.Version, + }, + Diff: sdk.DiffConfig{ + Enabled: cfg.Diff.Enabled, + FailOnBreaking: cfg.Diff.FailOnBreaking, + }, + } +} +``` + +**Step 2: Verify it compiles** + +Run: `go build ./pkg/release/...` +Expected: Success + +**Step 3: Commit** + +```bash +git add pkg/release/sdk.go +git commit -m "feat(release): add SDK release types and config converter" +``` + +--- + +## Task 3: Implement RunSDK function + +**Files:** +- Modify: `pkg/release/sdk.go` +- Test: `pkg/release/sdk_test.go` + +**Step 1: Write the failing test** + +```go +// pkg/release/sdk_test.go +package release + +import ( + "context" + "testing" +) + +func TestRunSDK_Bad_NoConfig(t *testing.T) { + cfg := &Config{ + SDK: nil, + } + cfg.projectDir = "/tmp" + + _, err := RunSDK(context.Background(), cfg, true) + if err == nil { + t.Error("expected error when SDK config is nil") + } +} + +func TestRunSDK_Good_DryRun(t *testing.T) { + cfg := &Config{ + SDK: &SDKConfig{ + Languages: []string{"typescript", "python"}, + Output: "sdk", + }, + } + cfg.projectDir = "/tmp" + cfg.version = "v1.0.0" + + result, err := RunSDK(context.Background(), cfg, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Version != "v1.0.0" { + t.Errorf("expected version v1.0.0, got %s", result.Version) + } + if len(result.Languages) != 2 { + t.Errorf("expected 2 languages, got %d", len(result.Languages)) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./pkg/release/... -run TestRunSDK -v` +Expected: FAIL with "RunSDK undefined" + +**Step 3: Write implementation** + +Add to `pkg/release/sdk.go`: + +```go +// RunSDK executes SDK-only release: diff check + generate. +// If dryRun is true, it shows what would be done without generating. +func RunSDK(ctx context.Context, cfg *Config, dryRun bool) (*SDKRelease, error) { + if cfg == nil { + return nil, fmt.Errorf("release.RunSDK: config is nil") + } + if cfg.SDK == nil { + return nil, fmt.Errorf("release.RunSDK: sdk not configured in .core/release.yaml") + } + + projectDir := cfg.projectDir + if projectDir == "" { + projectDir = "." + } + + // Determine version + version := cfg.version + if version == "" { + var err error + version, err = DetermineVersion(projectDir) + if err != nil { + return nil, fmt.Errorf("release.RunSDK: failed to determine version: %w", err) + } + } + + // Run diff check if enabled + if cfg.SDK.Diff.Enabled { + breaking, err := checkBreakingChanges(projectDir, cfg.SDK) + if err != nil { + // Non-fatal: warn and continue + fmt.Printf("Warning: diff check failed: %v\n", err) + } else if breaking { + if cfg.SDK.Diff.FailOnBreaking { + return nil, fmt.Errorf("release.RunSDK: breaking API changes detected") + } + fmt.Printf("Warning: breaking API changes detected\n") + } + } + + // Prepare result + output := cfg.SDK.Output + if output == "" { + output = "sdk" + } + + result := &SDKRelease{ + Version: version, + Languages: cfg.SDK.Languages, + Output: output, + } + + if dryRun { + return result, nil + } + + // Generate SDKs + sdkCfg := toSDKConfig(cfg.SDK) + s := sdk.New(projectDir, sdkCfg) + s.SetVersion(version) + + if err := s.Generate(ctx); err != nil { + return nil, fmt.Errorf("release.RunSDK: generation failed: %w", err) + } + + return result, nil +} + +// checkBreakingChanges runs oasdiff to detect breaking changes. +func checkBreakingChanges(projectDir string, cfg *SDKConfig) (bool, error) { + // Get previous tag for comparison + prevTag, err := getPreviousTag(projectDir) + if err != nil { + return false, fmt.Errorf("no previous tag found: %w", err) + } + + // Detect spec path + specPath := cfg.Spec + if specPath == "" { + s := sdk.New(projectDir, nil) + specPath, err = s.DetectSpec() + if err != nil { + return false, err + } + } + + // Run diff + result, err := sdk.Diff(prevTag, specPath) + if err != nil { + return false, err + } + + return result.Breaking, nil +} + +// getPreviousTag gets the most recent tag before HEAD. +func getPreviousTag(projectDir string) (string, error) { + // Use git describe to get previous tag + // This is a simplified version - may need refinement + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", "HEAD^") + cmd.Dir = projectDir + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} +``` + +Add import for `os/exec` and `strings`. + +**Step 4: Run test to verify it passes** + +Run: `go test ./pkg/release/... -run TestRunSDK -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add pkg/release/sdk.go pkg/release/sdk_test.go +git commit -m "feat(release): implement RunSDK for SDK-only releases" +``` + +--- + +## Task 4: Add --target flag to CLI + +**Files:** +- Modify: `cmd/core/cmd/release.go` + +**Step 1: Add target flag and routing** + +In `AddReleaseCommand`, add: + +```go +var target string +releaseCmd.StringFlag("target", "Release target (sdk)", &target) + +// Update the action +releaseCmd.Action(func() error { + if target == "sdk" { + return runReleaseSDK(dryRun, version) + } + return runRelease(dryRun, version, draft, prerelease) +}) +``` + +**Step 2: Verify it compiles** + +Run: `go build ./cmd/core/...` +Expected: FAIL with "runReleaseSDK undefined" + +**Step 3: Commit partial progress** + +```bash +git add cmd/core/cmd/release.go +git commit -m "feat(cli): add --target flag to release command" +``` + +--- + +## Task 5: Implement runReleaseSDK CLI function + +**Files:** +- Modify: `cmd/core/cmd/release.go` + +**Step 1: Add the function** + +```go +// runReleaseSDK executes SDK-only release. +func runReleaseSDK(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 Release:")) + 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 +} +``` + +**Step 2: Verify it compiles and help shows flag** + +Run: `go build -o bin/core ./cmd/core && ./bin/core release --help` +Expected: Shows `--target` flag in help output + +**Step 3: Commit** + +```bash +git add cmd/core/cmd/release.go +git commit -m "feat(cli): implement runReleaseSDK for SDK generation" +``` + +--- + +## Task 6: Add integration tests + +**Files:** +- Modify: `pkg/release/sdk_test.go` + +**Step 1: Add more test cases** + +```go +func TestRunSDK_Good_WithDiffEnabled(t *testing.T) { + cfg := &Config{ + SDK: &SDKConfig{ + Languages: []string{"typescript"}, + Output: "sdk", + Diff: SDKDiffConfig{ + Enabled: true, + FailOnBreaking: false, + }, + }, + } + cfg.projectDir = "/tmp" + cfg.version = "v1.0.0" + + // Dry run should succeed even without git repo + result, err := RunSDK(context.Background(), cfg, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Version != "v1.0.0" { + t.Errorf("expected v1.0.0, got %s", result.Version) + } +} + +func TestRunSDK_Good_DefaultOutput(t *testing.T) { + cfg := &Config{ + SDK: &SDKConfig{ + Languages: []string{"go"}, + // Output not set - should default to "sdk" + }, + } + cfg.projectDir = "/tmp" + cfg.version = "v1.0.0" + + result, err := RunSDK(context.Background(), cfg, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output != "sdk" { + t.Errorf("expected default output 'sdk', got %s", result.Output) + } +} + +func TestToSDKConfig_Good_Conversion(t *testing.T) { + relCfg := &SDKConfig{ + Spec: "api.yaml", + Languages: []string{"typescript", "python"}, + Output: "generated", + Package: SDKPackageConfig{ + Name: "my-sdk", + Version: "v2.0.0", + }, + Diff: SDKDiffConfig{ + Enabled: true, + FailOnBreaking: true, + }, + } + + sdkCfg := toSDKConfig(relCfg) + + if sdkCfg.Spec != "api.yaml" { + t.Errorf("expected spec api.yaml, got %s", sdkCfg.Spec) + } + if len(sdkCfg.Languages) != 2 { + t.Errorf("expected 2 languages, got %d", len(sdkCfg.Languages)) + } + if sdkCfg.Package.Name != "my-sdk" { + t.Errorf("expected package name my-sdk, got %s", sdkCfg.Package.Name) + } +} + +func TestToSDKConfig_Good_NilInput(t *testing.T) { + result := toSDKConfig(nil) + if result != nil { + t.Error("expected nil for nil input") + } +} +``` + +**Step 2: Run all tests** + +Run: `go test ./pkg/release/... -v` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add pkg/release/sdk_test.go +git commit -m "test(release): add SDK release integration tests" +``` + +--- + +## Task 7: Final verification and TODO update + +**Step 1: Build CLI** + +Run: `go build -o bin/core ./cmd/core` +Expected: Success + +**Step 2: Test help output** + +Run: `./bin/core release --help` +Expected: Shows `--target` flag + +**Step 3: Run all tests** + +Run: `go test ./pkg/release/... ./pkg/sdk/... -v` +Expected: All PASS + +**Step 4: Update TODO.md** + +Mark S3.4 `core release --target sdk` as complete in `tasks/TODO.md`. + +**Step 5: Commit** + +```bash +git add tasks/TODO.md +git commit -m "docs: mark S3.4 SDK release integration as complete" +```