package generator import ( "fmt" "sort" "strings" "kforge/internal/config" ) // GiteaActionsOptions controls what the generated workflow does. type GiteaActionsOptions struct { // Branch that triggers the workflow. Default: main. Branch string // NodeVersion for setup-node. Default: 22. NodeVersion string // Environments to deploy, in order. Default: all environments // sorted alphabetically (staging before production). Environments []string } // GenerateGiteaActions produces a Gitea Actions workflow YAML // that builds the Docker image and deploys to each environment // using kforge generate + kubectl apply. // // The generated file is designed to replace your existing // hand-written workflow. It assumes kforge is available in the // PATH (either pre-installed on the runner or fetched as a step). func GenerateGiteaActions(cfg *config.KforgeConfig, opts GiteaActionsOptions) (string, error) { if opts.Branch == "" { opts.Branch = "main" } if opts.NodeVersion == "" { opts.NodeVersion = "22" } if len(opts.Environments) == 0 { opts.Environments = sortedEnvKeys(cfg) } var b strings.Builder writeGiteaHeader(&b, cfg, opts) writeGiteaJobs(&b, cfg, opts) return b.String(), nil } func writeGiteaHeader(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) { fmt.Fprintf(b, "# Generated by kforge — do not edit manually.\n") fmt.Fprintf(b, "# Re-generate: kforge gitea-actions > .gitea/workflows/deploy.yml\n") fmt.Fprintf(b, "#\n") fmt.Fprintf(b, "# Required Gitea org secrets:\n") fmt.Fprintf(b, "# DOCKER_USERNAME, DOCKER_PASSWORD, KFORGE_NODE_IP\n") fmt.Fprintf(b, "# CLOUDFLARE_API_TOKEN, CF_ZONE_ID_* (per zone)\n") fmt.Fprintf(b, "# SOPS_AGE_KEY\n") fmt.Fprintf(b, "# Required Gitea repo secrets:\n") fmt.Fprintf(b, "# KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n") fmt.Fprintf(b, "\n") fmt.Fprintf(b, "name: Build and Deploy\n") fmt.Fprintf(b, "\n") fmt.Fprintf(b, "on:\n") fmt.Fprintf(b, " push:\n") fmt.Fprintf(b, " branches:\n") fmt.Fprintf(b, " - %s\n", opts.Branch) fmt.Fprintf(b, "\n") } func writeGiteaJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) { fmt.Fprintf(b, "jobs:\n") fmt.Fprintf(b, " build-and-deploy:\n") fmt.Fprintf(b, " runs-on: ubuntu-latest\n") fmt.Fprintf(b, " steps:\n") // Checkout writeStep(b, "Checkout", map[string]any{ "uses": "actions/checkout@v4", "with": map[string]any{"fetch-depth": 0}, }) // Node (optional — only if package.json exists) writeStep(b, "Setup Node", map[string]any{ "uses": "actions/setup-node@v4", "with": map[string]any{"node-version": opts.NodeVersion}, }) // Short SHA writeStep(b, "Create short commit hash", map[string]any{ "run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`, }) // Docker login writeStep(b, "Login to registry", map[string]any{ "uses": "docker/login-action@v2", "with": map[string]any{ "registry": cfg.Registry.URL, "username": "${{ secrets.DOCKER_USERNAME }}", "password": "${{ secrets.DOCKER_PASSWORD }}", }, }) // Docker build + push fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name writeStep(b, "Build and push image", map[string]any{ "uses": "docker/build-push-action@v5", "with": map[string]any{ "context": ".", "platforms": "linux/amd64", "file": cfg.Defaults.Dockerfile, "push": true, "tags": fmt.Sprintf("%s:latest\n%s:${{ env.SHORT_SHA }}", fullRepo, fullRepo), "provenance": false, "sbom": false, }, }) // Install kforge on runner writeStep(b, "Install kforge", map[string]any{ "run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge", }) // Per-environment deploy steps for _, envKey := range opts.Environments { env, err := config.ResolveEnvironment(cfg, envKey) if err != nil { continue } writeEnvDeploySteps(b, cfg, &env, envKey) } } func writeEnvDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, env *config.ResolvedEnvironment, envKey string) { label := strings.Title(envKey) //nolint:staticcheck // simple capitalisation // Validate kforge config before doing anything destructive. writeStep(b, fmt.Sprintf("Validate kforge config (%s)", label), map[string]any{ "run": "kforge validate", "env": giteaSecretEnv(), }) // Apply cluster secrets (only creates if missing — idempotent). writeStep(b, fmt.Sprintf("Apply cluster secrets (%s)", label), map[string]any{ "run": fmt.Sprintf("kforge secrets apply --env %s", envKey), "env": giteaKubeEnv(), }) // DNS records hasDNSHosts := false for _, h := range env.Ingress.Hosts { if h.DNSRecord { hasDNSHosts = true break } } if hasDNSHosts && !cfg.DNS.SkipDNS { writeStep(b, fmt.Sprintf("Ensure DNS records (%s)", label), map[string]any{ "run": fmt.Sprintf("kforge dns ensure --env %s", envKey), "env": mergeMaps(giteaSecretEnv(), giteaKubeEnv()), }) } // Generate manifests writeStep(b, fmt.Sprintf("Generate manifests (%s)", label), map[string]any{ "run": fmt.Sprintf( "kforge generate --env %s --output .kforge-out --set image_tag=${{ env.SHORT_SHA }}", envKey, ), "env": giteaSecretEnv(), }) // kubectl apply writeStep(b, fmt.Sprintf("Apply manifests (%s)", label), map[string]any{ "uses": "actions-hub/kubectl@master", "env": giteaKubeEnv(), "with": map[string]any{ "args": fmt.Sprintf( "apply -f .kforge-out/%s-core.yaml -n %s --insecure-skip-tls-verify", envKey, env.Namespace, ), }, }) // Apply infra manifests if any infrastructure is enabled infra := env.Infrastructure if infra.Database != nil || infra.Cache != nil || infra.Storage != nil || infra.Queue != nil || infra.Search != nil || infra.Monitoring != nil { writeStep(b, fmt.Sprintf("Apply infra manifests (%s)", label), map[string]any{ "uses": "actions-hub/kubectl@master", "env": giteaKubeEnv(), "with": map[string]any{ "args": fmt.Sprintf( `apply -f .kforge-out/ -l app=%s -n %s --insecure-skip-tls-verify`, env.FullName, env.Namespace, ), }, }) } // Rollout restart writeStep(b, fmt.Sprintf("Rollout restart (%s)", label), map[string]any{ "uses": "actions-hub/kubectl@master", "env": giteaKubeEnv(), "with": map[string]any{ "args": fmt.Sprintf( "rollout restart deployment/%s -n %s --insecure-skip-tls-verify", env.FullName, env.Namespace, ), }, }) } // writeStep writes a single step in the jobs.steps list. func writeStep(b *strings.Builder, name string, fields map[string]any) { fmt.Fprintf(b, "\n - name: %s\n", name) // Emit fields in a stable order. order := []string{"uses", "run", "with", "env"} for _, k := range order { v, ok := fields[k] if !ok { continue } switch val := v.(type) { case string: if strings.Contains(val, "\n") { fmt.Fprintf(b, " %s: |\n", k) for _, line := range strings.Split(val, "\n") { fmt.Fprintf(b, " %s\n", line) } } else { fmt.Fprintf(b, " %s: %s\n", k, val) } case bool: fmt.Fprintf(b, " %s: %v\n", k, val) case int: fmt.Fprintf(b, " %s: %d\n", k, val) case map[string]any: fmt.Fprintf(b, " %s:\n", k) writeMapFields(b, val, " ") } } } func writeMapFields(b *strings.Builder, m map[string]any, indent string) { // Sort keys for stable output. keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { v := m[k] switch val := v.(type) { case string: if strings.Contains(val, "\n") { fmt.Fprintf(b, "%s%s: |\n", indent, k) for _, line := range strings.Split(val, "\n") { fmt.Fprintf(b, "%s %s\n", indent, line) } } else { fmt.Fprintf(b, "%s%s: %s\n", indent, k, val) } case bool: fmt.Fprintf(b, "%s%s: %v\n", indent, k, val) case int: fmt.Fprintf(b, "%s%s: %d\n", indent, k, val) case map[string]any: fmt.Fprintf(b, "%s%s:\n", indent, k) writeMapFields(b, val, indent+" ") } } } // giteaSecretEnv returns the env block referencing Gitea secrets // needed for kforge itself (DNS, registry tokens, etc.). func giteaSecretEnv() map[string]any { return map[string]any{ "CLOUDFLARE_API_TOKEN": "${{ secrets.CLOUDFLARE_API_TOKEN }}", "KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}", "SOPS_AGE_KEY": "${{ secrets.SOPS_AGE_KEY }}", } } // giteaKubeEnv returns the env block for kubectl auth. func giteaKubeEnv() map[string]any { return map[string]any{ "KUBE_CERTIFICATE": "${{ secrets.KUBE_CERTIFICATE }}", "KUBE_HOST": "${{ secrets.KUBE_HOST }}", "KUBE_TOKEN": "${{ secrets.KUBE_TOKEN }}", } } func mergeMaps(maps ...map[string]any) map[string]any { result := map[string]any{} for _, m := range maps { for k, v := range m { result[k] = v } } return result } func sortedEnvKeys(cfg *config.KforgeConfig) []string { keys := config.EnvironmentKeys(cfg) // Put staging/dev before production — a simple heuristic that // matches the most common deploy order. priority := map[string]int{"dev": 0, "development": 0, "staging": 1, "production": 2, "prod": 2} sort.Slice(keys, func(i, j int) bool { pi, pj := priority[keys[i]], priority[keys[j]] if pi != pj { return pi < pj } return keys[i] < keys[j] }) return keys }