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. // // DNS is handled by external-dns via Ingress annotations — no // separate DNS step is needed in the workflow. 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 writeDeployHeader(&b, cfg, opts) writeDeployJobs(&b, cfg, opts) return b.String(), nil } func writeDeployHeader(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\n") fmt.Fprintf(b, "# KFORGE_NODE_IP (external-dns annotation target)\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 writeDeployJobs(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") writeStep(b, "Checkout", map[string]any{ "uses": "actions/checkout@v4", "with": map[string]any{"fetch-depth": 0}, }) writeStep(b, "Create short SHA", map[string]any{ "run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`, }) 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 }}", }, }) 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, }, }) if cfg.ActionRef != "" { writeActionDeploySteps(b, cfg, opts) } else { writeInlineDeploySteps(b, cfg, opts) } } // writeActionDeploySteps emits one `uses: docker://image` step per environment. // The docker:// prefix tells act/Gitea Actions to pull the image from the OCI // registry directly, bypassing GitHub/Gitea source resolution. func writeActionDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) { image := "docker://" + cfg.ActionRef for _, envKey := range opts.Environments { env, err := config.ResolveEnvironment(cfg, envKey) if err != nil { continue } label := strings.Title(envKey) //nolint:staticcheck writeStep(b, fmt.Sprintf("Deploy (%s)", label), map[string]any{ "uses": image, "with": map[string]any{ "command": "deploy", "env": envKey, "namespace": env.Namespace, }, "env": actionEnv(), }) } } // writeInlineDeploySteps emits the classic multi-step inline approach. func writeInlineDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) { 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", }) 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 writeStep(b, fmt.Sprintf("Validate kforge config (%s)", label), map[string]any{ "run": "kforge validate", "env": giteaNodeIPEnv(), }) writeStep(b, fmt.Sprintf("Apply cluster secrets (%s)", label), map[string]any{ "run": fmt.Sprintf("kforge secrets apply --env %s", envKey), "env": giteaKubeEnv(), }) 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": giteaNodeIPEnv(), }) 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, ), }, }) 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, ), }, }) } 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, ), }, }) } // ------------------------------------------------------------ // Preview workflow // ------------------------------------------------------------ // GeneratePreviewActions produces a Gitea Actions workflow YAML // that deploys an ephemeral environment per pull request. // // On PR open/sync: builds a PR-tagged image, applies secrets, // generates manifests (including Namespace), and deploys. // On PR close: deletes the preview namespace, removing all resources. func GeneratePreviewActions(cfg *config.KforgeConfig) (string, error) { if !cfg.Preview.Enabled { return "", fmt.Errorf("preview is not enabled in kforge.yml (set preview.enabled: true)") } nsPrefix := cfg.Preview.NamespacePrefix if nsPrefix == "" { nsPrefix = config.DefaultPreviewNamespacePrefix } fullRepo := cfg.Registry.URL + "/" + cfg.Meta.Tenant + "/" + cfg.Meta.Name var b strings.Builder fmt.Fprintf(&b, "# Generated by kforge — do not edit manually.\n") fmt.Fprintf(&b, "# Re-generate: kforge gitea-preview > .gitea/workflows/preview.yml\n") fmt.Fprintf(&b, "#\n") fmt.Fprintf(&b, "# Required secrets (same as deploy workflow):\n") fmt.Fprintf(&b, "# DOCKER_USERNAME, DOCKER_PASSWORD\n") fmt.Fprintf(&b, "# KFORGE_NODE_IP, KUBE_HOST, KUBE_TOKEN, KUBE_CERTIFICATE\n") fmt.Fprintf(&b, "\n") fmt.Fprintf(&b, "name: Preview Environment\n") fmt.Fprintf(&b, "\n") fmt.Fprintf(&b, "on:\n") fmt.Fprintf(&b, " pull_request:\n") fmt.Fprintf(&b, " types: [opened, synchronize, reopened, closed]\n") fmt.Fprintf(&b, "\n") fmt.Fprintf(&b, "jobs:\n") fmt.Fprintf(&b, " preview:\n") fmt.Fprintf(&b, " runs-on: ubuntu-latest\n") fmt.Fprintf(&b, " steps:\n") writeStep(&b, "Checkout", map[string]any{ "uses": "actions/checkout@v4", "with": map[string]any{"fetch-depth": 0}, }) writeStep(&b, "Login to registry", map[string]any{ "if": "${{ github.event.action != 'closed' }}", "uses": "docker/login-action@v2", "with": map[string]any{ "registry": cfg.Registry.URL, "username": "${{ secrets.DOCKER_USERNAME }}", "password": "${{ secrets.DOCKER_PASSWORD }}", }, }) writeStep(&b, "Build and push preview image", map[string]any{ "if": "${{ github.event.action != 'closed' }}", "uses": "docker/build-push-action@v5", "with": map[string]any{ "context": ".", "platforms": "linux/amd64", "file": cfg.Defaults.Dockerfile, "push": true, "tags": fmt.Sprintf("%s:pr-${{ github.event.number }}", fullRepo), "provenance": false, "sbom": false, }, }) if cfg.ActionRef != "" { image := "docker://" + cfg.ActionRef writeStep(&b, "Deploy preview", map[string]any{ "if": "${{ github.event.action != 'closed' }}", "uses": image, "with": map[string]any{ "command": "preview-up", "pr_number": "${{ github.event.number }}", "namespace_prefix": nsPrefix, }, "env": actionEnv(), }) writeStep(&b, "Destroy preview", map[string]any{ "if": "${{ github.event.action == 'closed' }}", "uses": image, "with": map[string]any{ "command": "preview-down", "pr_number": "${{ github.event.number }}", "namespace_prefix": nsPrefix, }, "env": actionEnv(), }) } else { writeStep(&b, "Install kforge", map[string]any{ "if": "${{ github.event.action != 'closed' }}", "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", }) writeStep(&b, "Apply preview secrets", map[string]any{ "if": "${{ github.event.action != 'closed' }}", "run": "kforge secrets apply --pr-number ${{ github.event.number }}", "env": giteaKubeEnv(), }) writeStep(&b, "Generate preview manifests", map[string]any{ "if": "${{ github.event.action != 'closed' }}", "run": "kforge generate --pr-number ${{ github.event.number }} --output .kforge-out", "env": map[string]any{ "KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}", "PR_NUMBER": "${{ github.event.number }}", }, }) writeStep(&b, "Apply preview manifests", map[string]any{ "if": "${{ github.event.action != 'closed' }}", "uses": "actions-hub/kubectl@master", "env": giteaKubeEnv(), "with": map[string]any{ "args": "apply -f .kforge-out/ --insecure-skip-tls-verify", }, }) writeStep(&b, "Wait for preview rollout", map[string]any{ "if": "${{ github.event.action != 'closed' }}", "uses": "actions-hub/kubectl@master", "env": giteaKubeEnv(), "with": map[string]any{ "args": fmt.Sprintf( "rollout status deployment -n %s-${{ github.event.number }} --timeout=120s --insecure-skip-tls-verify", nsPrefix, ), }, }) writeStep(&b, "Destroy preview namespace", map[string]any{ "if": "${{ github.event.action == 'closed' }}", "uses": "actions-hub/kubectl@master", "env": giteaKubeEnv(), "with": map[string]any{ "args": fmt.Sprintf( "delete namespace %s-${{ github.event.number }} --ignore-not-found --insecure-skip-tls-verify", nsPrefix, ), }, }) } return b.String(), nil } // ------------------------------------------------------------ // Shared step helpers // ------------------------------------------------------------ // 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) order := []string{"if", "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) { 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+" ") } } } // giteaNodeIPEnv returns env vars needed for kforge commands that // resolve ${KFORGE_NODE_IP} tokens (validate, generate). func giteaNodeIPEnv() map[string]any { return map[string]any{ "KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}", } } // 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 }}", } } // actionEnv returns the combined env block for a kforge action step — // kubectl auth plus the node IP for external-dns annotation resolution. func actionEnv() map[string]any { return map[string]any{ "KUBE_CERTIFICATE": "${{ secrets.KUBE_CERTIFICATE }}", "KUBE_HOST": "${{ secrets.KUBE_HOST }}", "KUBE_TOKEN": "${{ secrets.KUBE_TOKEN }}", "KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}", } } 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) 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 }