From f5e52463f260af95680cf86acc427e3918d138da Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 09:09:39 +0200 Subject: [PATCH] feat: add CI build matrix planning from manifest targets --- README.md | 67 +++++++ cmd/mcp-framework/build.go | 307 +++++++++++++++++++++++++++++--- cmd/mcp-framework/build_test.go | 106 +++++++++++ manifest/manifest.go | 40 ++++- manifest/manifest_test.go | 18 ++ scaffold/scaffold.go | 3 + scaffold/scaffold_test.go | 3 + 7 files changed, 512 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 18502d8..fb61c92 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ les projets consommateurs : go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build ``` +Et une commande de planification de matrice CI : + +```bash +go run gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest build plan --format github +``` + Par défaut la commande : - lit `mcp.toml` (en remontant les répertoires parents) @@ -64,10 +70,59 @@ Options principales : - `--build-dir` - `--goos` - `--goarch` +- `--target` (`os/arch`, ex: `linux/amd64`) - `--version` - `--version-var` - `--ldflag` (répétable) +`build plan` options principales : + +- `--manifest-dir` +- `--binary` +- `--package` +- `--build-dir` +- `--version-var` +- `--format` (`json`, `github`, `gitlab`) + +Exemple GitHub Actions (matrix dynamique) : + +```yaml +jobs: + plan: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - id: plan + run: echo "matrix=$(mcp-framework build plan --format github | tr -d '\n')" >> "$GITHUB_OUTPUT" + + build: + needs: [plan] + strategy: + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: mcp-framework build --target "${{ matrix.target }}" --version "${{ github.ref_name }}" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_path }} +``` + +Exemple GitLab CI (snippet matrix généré) : + +```bash +mcp-framework build plan --format gitlab > matrix.yml +``` + +Puis inclure le snippet `parallel.matrix` dans le job de build et exécuter : + +```bash +mcp-framework build --target "${GOOS}/${GOARCH}" --version "${CI_COMMIT_TAG}" +``` + ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. @@ -161,6 +216,17 @@ token_header = "Authorization" token_prefix = "token" token_env_names = ["GITEA_TOKEN"] +[build] +main_package = "./cmd/my-mcp" +output_dir = "build" +version_var = "main.version" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "darwin" +arch = "arm64" + [environment] known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"] @@ -196,6 +262,7 @@ Champs supportés : - `[build].main_package` : package main à builder (ex: `./cmd/my-mcp`). - `[build].output_dir` : répertoire de sortie des artefacts (défaut `build`). - `[build].version_var` : variable Go ciblée par `-X` pour injecter la version (`main.version`, `gitlab.../internal/app.Version`, etc.). +- `[[build.targets]]` : cibles de compilation (`os`, `arch`) exploitées par `mcp-framework build plan`. - `[environment].known` : variables d'environnement connues du projet. - `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`). - `[profiles].default` : profil recommandé par défaut. diff --git a/cmd/mcp-framework/build.go b/cmd/mcp-framework/build.go index de70f8d..1e62344 100644 --- a/cmd/mcp-framework/build.go +++ b/cmd/mcp-framework/build.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "flag" "fmt" @@ -16,6 +17,43 @@ import ( type stringListFlag []string +type buildTarget struct { + GOOS string + GOARCH string +} + +type buildConfig struct { + ProjectDir string + BinaryName string + MainPackage string + BuildDir string + VersionVar string + ManifestTargets []manifestpkg.BuildTarget +} + +type buildConfigOverrides struct { + BinaryName string + MainPackage string + BuildDir string + VersionVar string +} + +type buildPlanTarget struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Target string `json:"target"` + ArtifactName string `json:"artifact_name"` + ArtifactPath string `json:"artifact_path"` +} + +type buildPlanOutput struct { + BinaryName string `json:"binary_name"` + MainPackage string `json:"main_package"` + BuildDir string `json:"build_dir"` + VersionVar string `json:"version_var"` + Targets []buildPlanTarget `json:"targets"` +} + func (f *stringListFlag) String() string { return strings.Join(*f, ",") } @@ -26,6 +64,13 @@ func (f *stringListFlag) Set(value string) error { } func runBuild(args []string, stdout, stderr io.Writer) error { + if len(args) > 0 { + switch strings.TrimSpace(args[0]) { + case "plan": + return runBuildPlan(args[1:], stdout, stderr) + } + } + if shouldShowHelp(args) { printBuildHelp(stdout) return nil @@ -34,15 +79,8 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs := flag.NewFlagSet("build", flag.ContinueOnError) fs.SetOutput(io.Discard) - defaultGOOS := strings.TrimSpace(os.Getenv("GOOS")) - if defaultGOOS == "" { - defaultGOOS = runtime.GOOS - } - - defaultGOARCH := strings.TrimSpace(os.Getenv("GOARCH")) - if defaultGOARCH == "" { - defaultGOARCH = runtime.GOARCH - } + defaultGOOS := resolveDefaultGOOS() + defaultGOARCH := resolveDefaultGOARCH() var manifestDir string var binaryName string @@ -50,6 +88,7 @@ func runBuild(args []string, stdout, stderr io.Writer) error { var buildDir string var goos string var goarch string + var target string var version string var versionVar string var gocache string @@ -61,6 +100,7 @@ func runBuild(args []string, stdout, stderr io.Writer) error { fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") fs.StringVar(&goos, "goos", defaultGOOS, "GOOS cible") fs.StringVar(&goarch, "goarch", defaultGOARCH, "GOARCH cible") + fs.StringVar(&target, "target", "", "Cible au format os/arch (override --goos/--goarch)") fs.StringVar(&version, "version", "", "Version injectée (par défaut VERSION env, puis git describe, puis dev)") fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") fs.StringVar(&gocache, "gocache", strings.TrimSpace(os.Getenv("GOCACHE")), "Valeur GOCACHE pour go build") @@ -75,30 +115,31 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) } - file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) + cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: buildDir, + VersionVar: versionVar, + }) if err != nil { return err } - projectDir := filepath.Dir(manifestPath) - - binaryName = firstNonEmpty(binaryName, file.BinaryName) - if binaryName == "" { - return errors.New("binary name is required (set binary_name in mcp.toml or --binary)") + if strings.TrimSpace(target) != "" { + parsedTarget, err := parseBuildTarget(target) + if err != nil { + return err + } + goos = parsedTarget.GOOS + goarch = parsedTarget.GOARCH } - mainPackage = firstNonEmpty(mainPackage, file.Build.MainPackage) - if mainPackage == "" { - mainPackage = fmt.Sprintf("./cmd/%s", binaryName) - } + goos = firstNonEmpty(goos, resolveDefaultGOOS()) + goarch = firstNonEmpty(goarch, resolveDefaultGOARCH()) + version = resolveBuildVersion(version, cfg.ProjectDir) + versionVar = cfg.VersionVar - buildDir = firstNonEmpty(buildDir, file.Build.OutputDir, "build") - goos = firstNonEmpty(goos, runtime.GOOS) - goarch = firstNonEmpty(goarch, runtime.GOARCH) - version = resolveBuildVersion(version, projectDir) - versionVar = firstNonEmpty(versionVar, file.Build.VersionVar, "main.version") - - outputPath, err := buildOutputPath(projectDir, buildDir, binaryName, goos, goarch) + outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, goos, goarch) if err != nil { return err } @@ -120,10 +161,10 @@ func runBuild(args []string, stdout, stderr io.Writer) error { if len(ldflags) > 0 { cmdArgs = append(cmdArgs, "-ldflags", strings.Join(ldflags, " ")) } - cmdArgs = append(cmdArgs, "-o", outputPath, mainPackage) + cmdArgs = append(cmdArgs, "-o", outputPath, cfg.MainPackage) cmd := exec.Command("go", cmdArgs...) - cmd.Dir = projectDir + cmd.Dir = cfg.ProjectDir cmd.Stdout = stdout cmd.Stderr = stderr cmd.Env = withEnvOverrides(os.Environ(), map[string]string{ @@ -143,10 +184,210 @@ func runBuild(args []string, stdout, stderr io.Writer) error { return nil } +func runBuildPlan(args []string, stdout, stderr io.Writer) error { + if shouldShowHelp(args) { + printBuildPlanHelp(stdout) + return nil + } + + fs := flag.NewFlagSet("build plan", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var manifestDir string + var binaryName string + var mainPackage string + var buildDir string + var versionVar string + var format string + + fs.StringVar(&manifestDir, "manifest-dir", ".", "Répertoire de départ pour trouver mcp.toml") + fs.StringVar(&binaryName, "binary", "", "Nom du binaire (override binary_name)") + fs.StringVar(&mainPackage, "package", "", "Package main à builder (override [build].main_package)") + fs.StringVar(&buildDir, "build-dir", "", "Répertoire de sortie (override [build].output_dir)") + fs.StringVar(&versionVar, "version-var", "", "Variable cible pour -X (override [build].version_var)") + fs.StringVar(&format, "format", "json", "Format de sortie: json|github|gitlab") + + if err := fs.Parse(args); err != nil { + _ = stderr + return fmt.Errorf("parse build plan flags: %w", err) + } + + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) + } + + cfg, err := resolveBuildConfig(manifestDir, buildConfigOverrides{ + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: buildDir, + VersionVar: versionVar, + }) + if err != nil { + return err + } + + targets, err := resolveBuildTargets(cfg.ManifestTargets, resolveDefaultGOOS(), resolveDefaultGOARCH()) + if err != nil { + return err + } + + planTargets := make([]buildPlanTarget, 0, len(targets)) + for _, target := range targets { + outputPath, err := buildOutputPath(cfg.ProjectDir, cfg.BuildDir, cfg.BinaryName, target.GOOS, target.GOARCH) + if err != nil { + return err + } + + planTargets = append(planTargets, buildPlanTarget{ + GOOS: target.GOOS, + GOARCH: target.GOARCH, + Target: formatBuildTarget(target.GOOS, target.GOARCH), + ArtifactName: filepath.Base(outputPath), + ArtifactPath: formatArtifactPath(cfg.ProjectDir, outputPath), + }) + } + + switch strings.ToLower(strings.TrimSpace(format)) { + case "json": + return renderJSON(stdout, buildPlanOutput{ + BinaryName: cfg.BinaryName, + MainPackage: cfg.MainPackage, + BuildDir: cfg.BuildDir, + VersionVar: cfg.VersionVar, + Targets: planTargets, + }) + case "github": + include := make([]map[string]string, 0, len(planTargets)) + for _, target := range planTargets { + include = append(include, map[string]string{ + "goos": target.GOOS, + "goarch": target.GOARCH, + "target": target.Target, + "artifact_name": target.ArtifactName, + "artifact_path": target.ArtifactPath, + }) + } + return renderJSON(stdout, map[string]any{"include": include}) + case "gitlab": + return renderGitLabMatrix(stdout, planTargets) + default: + return fmt.Errorf("unsupported build plan format %q (expected json, github or gitlab)", format) + } +} + +func resolveBuildConfig(manifestDir string, overrides buildConfigOverrides) (buildConfig, error) { + file, manifestPath, err := manifestpkg.LoadDefault(manifestDir) + if err != nil { + return buildConfig{}, err + } + + projectDir := filepath.Dir(manifestPath) + binaryName := firstNonEmpty(overrides.BinaryName, file.BinaryName) + if binaryName == "" { + return buildConfig{}, errors.New("binary name is required (set binary_name in mcp.toml or --binary)") + } + + mainPackage := firstNonEmpty(overrides.MainPackage, file.Build.MainPackage) + if mainPackage == "" { + mainPackage = fmt.Sprintf("./cmd/%s", binaryName) + } + + return buildConfig{ + ProjectDir: projectDir, + BinaryName: binaryName, + MainPackage: mainPackage, + BuildDir: firstNonEmpty(overrides.BuildDir, file.Build.OutputDir, "build"), + VersionVar: firstNonEmpty(overrides.VersionVar, file.Build.VersionVar, "main.version"), + ManifestTargets: append([]manifestpkg.BuildTarget(nil), file.Build.Targets...), + }, nil +} + +func resolveBuildTargets(manifestTargets []manifestpkg.BuildTarget, defaultGOOS, defaultGOARCH string) ([]buildTarget, error) { + if len(manifestTargets) == 0 { + defaultTarget, err := parseBuildTarget(formatBuildTarget(defaultGOOS, defaultGOARCH)) + if err != nil { + return nil, err + } + return []buildTarget{defaultTarget}, nil + } + + targets := make([]buildTarget, 0, len(manifestTargets)) + for _, manifestTarget := range manifestTargets { + target, err := parseBuildTarget(formatBuildTarget(manifestTarget.OS, manifestTarget.Arch)) + if err != nil { + return nil, err + } + targets = append(targets, target) + } + + return targets, nil +} + +func parseBuildTarget(value string) (buildTarget, error) { + raw := strings.TrimSpace(value) + parts := strings.Split(raw, "/") + if len(parts) != 2 { + return buildTarget{}, fmt.Errorf("invalid target %q (expected os/arch)", value) + } + + goos := strings.ToLower(strings.TrimSpace(parts[0])) + goarch := strings.ToLower(strings.TrimSpace(parts[1])) + if goos == "" || goarch == "" { + return buildTarget{}, fmt.Errorf("invalid target %q (expected non-empty os/arch)", value) + } + + return buildTarget{GOOS: goos, GOARCH: goarch}, nil +} + +func formatBuildTarget(goos, goarch string) string { + return strings.ToLower(strings.TrimSpace(goos)) + "/" + strings.ToLower(strings.TrimSpace(goarch)) +} + +func formatArtifactPath(projectDir, outputPath string) string { + rel, err := filepath.Rel(projectDir, outputPath) + if err == nil && rel != "" && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + return filepath.ToSlash(rel) + } + return filepath.ToSlash(outputPath) +} + +func renderJSON(w io.Writer, payload any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(payload) +} + +func renderGitLabMatrix(w io.Writer, targets []buildPlanTarget) error { + if _, err := fmt.Fprintln(w, "parallel:"); err != nil { + return err + } + if _, err := fmt.Fprintln(w, " matrix:"); err != nil { + return err + } + for _, target := range targets { + if _, err := fmt.Fprintf(w, " - GOOS: %q\n", target.GOOS); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " GOARCH: %q\n", target.GOARCH); err != nil { + return err + } + } + return nil +} + func printBuildHelp(w io.Writer) { fmt.Fprintf( w, - "Usage:\n %s build [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n", + "Usage:\n %s build [flags]\n %s build plan [flags]\n\nBuild flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml (défaut: .)\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --goos GOOS cible (défaut: env GOOS ou runtime)\n --goarch GOARCH cible (défaut: env GOARCH ou runtime)\n --target Cible os/arch (ex: linux/amd64), prioritaire sur --goos/--goarch\n --version Version injectée (défaut: env VERSION, git describe, puis dev)\n --version-var Variable cible pour -X (défaut: [build].version_var puis main.version)\n --gocache Valeur GOCACHE pour go build\n --ldflag Option additionnelle pour -ldflags (répéter si besoin)\n\nBuild plan flags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab\n\nManifest optional ([build]):\n main_package = \"./cmd/\"\n output_dir = \"build\"\n version_var = \"main.version\"\n [[build.targets]]\n os = \"linux\"\n arch = \"amd64\"\n", + toolName, + toolName, + ) +} + +func printBuildPlanHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s build plan [flags]\n\nFlags:\n --manifest-dir Répertoire de départ pour trouver mcp.toml\n --binary Override de binary_name\n --package Override de [build].main_package\n --build-dir Override de [build].output_dir\n --version-var Override de [build].version_var\n --format json|github|gitlab (défaut: json)\n", toolName, ) } @@ -232,3 +473,11 @@ func firstNonEmpty(values ...string) string { } return "" } + +func resolveDefaultGOOS() string { + return firstNonEmpty(os.Getenv("GOOS"), runtime.GOOS) +} + +func resolveDefaultGOARCH() string { + return firstNonEmpty(os.Getenv("GOARCH"), runtime.GOARCH) +} diff --git a/cmd/mcp-framework/build_test.go b/cmd/mcp-framework/build_test.go index 5fa1fd4..85b9718 100644 --- a/cmd/mcp-framework/build_test.go +++ b/cmd/mcp-framework/build_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "os" "os/exec" "path/filepath" @@ -110,6 +111,108 @@ func main() { } } +func TestRunBuildSupportsTargetFlag(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "target-flag", ` +module example.com/target-flag + +go 1.25.0 +`, ` +binary_name = "target-flag" +`, ` +package main + +import "fmt" + +var version = "dev" + +func main() { + fmt.Print(version) +} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", + "--manifest-dir", projectDir, + "--target", runtime.GOOS + "/" + runtime.GOARCH, + "--version", "2.0.0", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + artifactPath := artifactPath(projectDir, "build", "target-flag", runtime.GOOS, runtime.GOARCH) + output, err := exec.Command(artifactPath).Output() + if err != nil { + t.Fatalf("run artifact: %v", err) + } + if strings.TrimSpace(string(output)) != "2.0.0" { + t.Fatalf("artifact output = %q, want %q", strings.TrimSpace(string(output)), "2.0.0") + } +} + +func TestRunBuildPlanGithubFormat(t *testing.T) { + projectDir := t.TempDir() + writeBuildFixture(t, projectDir, "matrix-mcp", ` +module example.com/matrix-mcp + +go 1.25.0 +`, ` +binary_name = "matrix-mcp" + +[build] +output_dir = "dist" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "darwin" +arch = "arm64" +`, ` +package main + +func main() {} +`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{ + "build", "plan", + "--manifest-dir", projectDir, + "--format", "github", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run returned error: %v (stderr: %s)", err, stderr.String()) + } + + var payload struct { + Include []struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Target string `json:"target"` + ArtifactPath string `json:"artifact_path"` + } `json:"include"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode github matrix: %v", err) + } + if len(payload.Include) != 2 { + t.Fatalf("include length = %d, want 2", len(payload.Include)) + } + + if payload.Include[0].Target != "linux/amd64" { + t.Fatalf("first target = %q", payload.Include[0].Target) + } + if payload.Include[0].ArtifactPath != "dist/matrix-mcp-linux-amd64" { + t.Fatalf("first artifact path = %q", payload.Include[0].ArtifactPath) + } + if payload.Include[1].Target != "darwin/arm64" { + t.Fatalf("second target = %q", payload.Include[1].Target) + } +} + func TestBuildHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -125,6 +228,9 @@ func TestBuildHelp(t *testing.T) { if !strings.Contains(output, "version_var") { t.Fatalf("build help should mention manifest build config: %q", output) } + if !strings.Contains(output, "build plan") { + t.Fatalf("build help should mention build plan: %q", output) + } } func writeBuildFixture(t *testing.T, projectDir, binaryName, goModContent, manifestContent, mainContent string) { diff --git a/manifest/manifest.go b/manifest/manifest.go index b9238f3..5c686c7 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -40,9 +40,15 @@ type Update struct { } type Build struct { - MainPackage string `toml:"main_package"` - OutputDir string `toml:"output_dir"` - VersionVar string `toml:"version_var"` + MainPackage string `toml:"main_package"` + OutputDir string `toml:"output_dir"` + VersionVar string `toml:"version_var"` + Targets []BuildTarget `toml:"targets"` +} + +type BuildTarget struct { + OS string `toml:"os"` + Arch string `toml:"arch"` } type Environment struct { @@ -172,6 +178,7 @@ func (b *Build) normalize() { b.MainPackage = strings.TrimSpace(b.MainPackage) b.OutputDir = strings.TrimSpace(b.OutputDir) b.VersionVar = strings.TrimSpace(b.VersionVar) + b.Targets = normalizeBuildTargets(b.Targets) } func (e *Environment) normalize() { @@ -243,3 +250,30 @@ func normalizeStringList(values []string) []string { } return normalized } + +func normalizeBuildTargets(values []BuildTarget) []BuildTarget { + if len(values) == 0 { + return values + } + + normalized := values[:0] + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + target := BuildTarget{ + OS: strings.ToLower(strings.TrimSpace(value.OS)), + Arch: strings.ToLower(strings.TrimSpace(value.Arch)), + } + if target.OS == "" || target.Arch == "" { + continue + } + + key := target.OS + "/" + target.Arch + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, target) + } + + return normalized +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 6ccf1d6..42e632f 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -155,6 +155,18 @@ latest_release_url = "https://example.com/latest" main_package = " ./cmd/my-mcp " output_dir = " build " version_var = " main.version " +[[build.targets]] +os = " Linux " +arch = " AMD64 " +[[build.targets]] +os = "darwin" +arch = "arm64" +[[build.targets]] +os = "linux" +arch = "amd64" +[[build.targets]] +os = "" +arch = "amd64" [environment] known = [" MCP_PROFILE ", "", "MCP_TOKEN"] @@ -197,6 +209,12 @@ description = " Client MCP interne " if file.Build.VersionVar != "main.version" { t.Fatalf("build version var = %q", file.Build.VersionVar) } + if !slices.Equal(file.Build.Targets, []BuildTarget{ + {OS: "linux", Arch: "amd64"}, + {OS: "darwin", Arch: "arm64"}, + }) { + t.Fatalf("build targets = %#v", file.Build.Targets) + } if file.SecretStore.BackendPolicy != "auto" { t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy) } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 7a543f7..3ee85da 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -665,6 +665,9 @@ token_env_names = ["{{.ReleaseTokenEnv}}"] main_package = "./cmd/{{.BinaryName}}" output_dir = "build" version_var = "main.version" +[[build.targets]] +os = "linux" +arch = "amd64" [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 7425d65..e7ad407 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -90,6 +90,9 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "[environment]", "[profiles]", "version_var = \"main.version\"", + "[[build.targets]]", + "os = \"linux\"", + "arch = \"amd64\"", "backend_policy = \"auto\"", } { if !strings.Contains(string(manifestContent), snippet) {