From 70381977577e9b7589a1897cf3c386eadced97d5 Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 6 Sep 2026 21:16:48 +0200 Subject: [PATCH] fix: support fresh delivery promotion --- .../templates/delivery-pipeline.yaml.tmpl | 170 +++++++++++++++++- internal/bootstrap/bootstrap.go | 6 +- internal/bootstrap/bootstrap_test.go | 27 +++ internal/bootstrap/onboard.go | 51 +++++- internal/bootstrap/onboard_test.go | 66 +++++++ internal/forgejo/repo.go | 37 ++++ 6 files changed, 343 insertions(+), 14 deletions(-) diff --git a/internal/assets/templates/delivery-pipeline.yaml.tmpl b/internal/assets/templates/delivery-pipeline.yaml.tmpl index ac325b5..3a87362 100644 --- a/internal/assets/templates/delivery-pipeline.yaml.tmpl +++ b/internal/assets/templates/delivery-pipeline.yaml.tmpl @@ -28,6 +28,12 @@ spec: default: "" - name: app-revision default: "" + - name: forgejo-base-url + default: "" + - name: forgejo-owner + default: "" + - name: manifests-repo + default: "" steps: - name: update image: alpine/git:2.47.2 @@ -52,6 +58,12 @@ spec: value: $(params.app-url) - name: APP_REVISION value: $(params.app-revision) + - name: FORGEJO_BASE_URL + value: $(params.forgejo-base-url) + - name: FORGEJO_OWNER + value: $(params.forgejo-owner) + - name: MANIFESTS_REPO + value: $(params.manifests-repo) script: | #!/bin/sh set -eu @@ -61,13 +73,29 @@ spec: valid_commit() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; } valid_url() { case "$1" in https://*/*.git) ;; *) fail ;; esac; case "$1" in *[@?#]*) fail ;; esac; } valid_repository() { case "$1" in */*) ;; *) fail ;; esac; case "$1" in *..*|*//*|/*|*/) fail ;; esac; } + valid_repository_part() { case "$1" in ''|*[!A-Za-z0-9._-]*|.*|*.) fail ;; esac; } + valid_forgejo_base_url() { case "$1" in https://*) ;; *) fail ;; esac; host=${1#https://}; case "$host" in *:*) name=${host%:*}; port=${host##*:}; case "$port" in ''|*[!0-9]*) fail ;; esac ;; *) name=$host ;; esac; case "$name" in ''|*[!A-Za-z0-9.-]*|.*|*.) fail ;; esac; } valid_pr_number() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; [ $((${#APP_NAME} + ${#1} + 4)) -le 63 ] || fail; } valid_name "$APP_NAME" valid_url "$MANIFESTS_URL" valid_revision "$MANIFESTS_BRANCH" valid_repository "$APP_REPOSITORY" valid_commit "$TAG" - git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests + if [ "$ENVIRONMENT" = production ]; then + valid_forgejo_base_url "$FORGEJO_BASE_URL" + valid_repository_part "$FORGEJO_OWNER" + valid_repository_part "$MANIFESTS_REPO" + [ "$MANIFESTS_URL" = "$FORGEJO_BASE_URL/$FORGEJO_OWNER/$MANIFESTS_REPO.git" ] || fail + PROMOTION_BRANCH="maidn/promotion-$APP_NAME-$TAG" + if git ls-remote --exit-code "$MANIFESTS_URL" "refs/heads/$PROMOTION_BRANCH" >/dev/null 2>&1; then + git clone --branch "$PROMOTION_BRANCH" "$MANIFESTS_URL" /tmp/manifests + else + git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests + git -C /tmp/manifests checkout -b "$PROMOTION_BRANCH" + fi + else + git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests + fi cd /tmp/manifests if [ "$ENVIRONMENT" = preview ]; then valid_pr_number "$PR_NUMBER" @@ -159,10 +187,96 @@ spec: root=apps/previews/kustomization.yaml grep -q '^resources:' "$root" || fail grep -qxF " - $APP_NAME-pr-$PR_NUMBER" "$root" 2>/dev/null || printf ' - %s-pr-%s\n' "$APP_NAME" "$PR_NUMBER" >> "$root" - elif [ "$ENVIRONMENT" = staging ] || [ "$ENVIRONMENT" = production ]; then - app_dir="apps/$ENVIRONMENT/$APP_NAME" - [ -f "$app_dir/release.yaml" ] || fail - sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml" + elif [ "$ENVIRONMENT" = staging ]; then + app_dir="apps/staging/$APP_NAME" + root=apps/staging/kustomization.yaml + if [ -e "$app_dir/release.yaml" ]; then + [ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$app_dir/release.yaml" ] && [ ! -L "$app_dir/release.yaml" ] || fail + sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml" + else + [ ! -e "$app_dir" ] || fail + mkdir -p "$app_dir" + cat > "$app_dir/release.yaml" < "$app_dir/kustomization.yaml" < "$root" </dev/null || printf ' - %s\n' "$APP_NAME" >> "$root" + fi + elif [ "$ENVIRONMENT" = production ]; then + app_dir="apps/production/$APP_NAME" + root=apps/production/kustomization.yaml + if [ -e "$app_dir" ]; then + [ -d "$app_dir" ] && [ ! -L "$app_dir" ] || fail + fi + mkdir -p "$app_dir" + for file in "$app_dir/release.yaml" "$app_dir/kustomization.yaml"; do + [ ! -e "$file" ] || { [ -f "$file" ] && [ ! -L "$file" ]; } || fail + done + cat > "$app_dir/release.yaml" < "$app_dir/kustomization.yaml" < "$root" </dev/null || printf ' - %s\n' "$APP_NAME" >> "$root" else fail fi @@ -170,7 +284,39 @@ spec: git config user.email maidn@free-maidn.com git add apps git diff --cached --quiet || git commit -m "chore: deploy $APP_NAME $TAG" - git push origin "$MANIFESTS_BRANCH" + if [ "$ENVIRONMENT" = production ]; then + git push origin "HEAD:$PROMOTION_BRANCH" + credentials=$(printf 'url=%s\n\n' "$MANIFESTS_URL" | GIT_TERMINAL_PROMPT=0 git credential fill) + forgejo_user=$(printf '%s\n' "$credentials" | sed -n 's/^username=//p') + forgejo_password=$(printf '%s\n' "$credentials" | sed -n 's/^password=//p') + unset credentials + [ -n "$forgejo_user" ] && [ -n "$forgejo_password" ] || fail + forgejo_auth=$(printf '%s:%s' "$forgejo_user" "$forgejo_password" | base64 | tr -d '\n') + unset forgejo_user forgejo_password + pr_endpoint="$FORGEJO_BASE_URL/api/v1/repos/$FORGEJO_OWNER/$MANIFESTS_REPO/pulls" + pr_query="$pr_endpoint?state=open&head=$FORGEJO_OWNER%3A$PROMOTION_BRANCH" + pr_response=$(mktemp) + pr_body=$(mktemp) + trap 'rm -f "$pr_response" "$pr_body"' EXIT + open_pr_count() { + grep -q '^[[:space:]]*\[' "$pr_response" || fail + grep -o '"number"[[:space:]]*:[[:space:]]*[0-9][0-9]*' "$pr_response" | wc -l | tr -d ' ' + } + wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" "$pr_query" || fail + case "$(open_pr_count)" in + 0) + printf '{"title":"chore: promote %s %s","head":"%s","base":"%s"}' "$APP_NAME" "$TAG" "$PROMOTION_BRANCH" "$MANIFESTS_BRANCH" > "$pr_body" + wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" --header="Content-Type: application/json" --post-file "$pr_body" "$pr_endpoint" || true + wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" "$pr_query" || fail + [ "$(open_pr_count)" = 1 ] || fail + ;; + 1) ;; + *) fail ;; + esac + unset forgejo_auth + else + git push origin "$MANIFESTS_BRANCH" + fi --- apiVersion: tekton.dev/v1 kind: Pipeline @@ -197,6 +343,12 @@ spec: default: {{ quote .ManifestsURL }} - name: manifests-branch default: {{ quote .ManifestsBranch }} + - name: forgejo-base-url + default: {{ quote .ForgejoBaseURL }} + - name: forgejo-owner + default: {{ quote .ForgejoOwner }} + - name: manifests-repo + default: {{ quote .ManifestsRepo }} workspaces: - name: source taskRunTemplate: @@ -307,6 +459,12 @@ spec: value: $(params.git-revision) - name: environment value: production + - name: forgejo-base-url + value: $(params.forgejo-base-url) + - name: forgejo-owner + value: $(params.forgejo-owner) + - name: manifests-repo + value: $(params.manifests-repo) - name: cleanup-preview when: - input: $(params.event-type) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index ec73775..c77d2a5 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -543,7 +543,9 @@ type appDeliveryTemplateConfig struct { BuildOutputDirectory string BuildConfiguration string ForgejoBaseURL string + ForgejoOwner string ManifestsURL string + ManifestsRepo string ManifestsBranch string } @@ -619,9 +621,9 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) { AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL, AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, - ForgejoBaseURL: origin, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsBranch: cfg.Flux.Branch, + ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, } - for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "manifestsUrl": values.ManifestsURL, "manifestsBranch": values.ManifestsBranch} { + for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch} { if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value { return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name) } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index faeb6de..1143e15 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -111,6 +111,33 @@ func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing. } } +func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) { + cfg := config.Config{ + Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, + Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"}, + Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production"}, + } + content, err := renderAppDelivery(cfg) + if err != nil { + t.Fatal(err) + } + rendered := string(content) + for _, expected := range []string{ + `app_dir="apps/staging/$APP_NAME"`, `root=apps/staging/kustomization.yaml`, `name: $APP_NAME`, `namespace: flux-system`, + `PROMOTION_BRANCH="maidn/promotion-$APP_NAME-$TAG"`, `git push origin "HEAD:$PROMOTION_BRANCH"`, `--post-file "$pr_body"`, + `head=$FORGEJO_OWNER%3A$PROMOTION_BRANCH`, `value: $(params.forgejo-base-url)`, + } { + if !strings.Contains(rendered, expected) { + t.Fatalf("generated delivery does not contain %q", expected) + } + } + production := rendered[strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`):] + production = production[:strings.Index(production, "\n else\n")] + if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) { + t.Fatal("production path pushes directly to manifests main") + } +} + func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) { dir := t.TempDir() err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}) diff --git a/internal/bootstrap/onboard.go b/internal/bootstrap/onboard.go index 4172475..3fb34ef 100644 --- a/internal/bootstrap/onboard.go +++ b/internal/bootstrap/onboard.go @@ -23,11 +23,11 @@ func OnboardApp(cfg config.Config, sourceDir string) error { if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil { return err } - branch, err := forgejo.CurrentBranch(sourceDir) + sourceBranch, err := forgejo.CurrentBranch(sourceDir) if err != nil { return err } - if branch != resolved.Delivery.AppRepoRef { + if sourceBranch != resolved.Delivery.AppRepoRef { return errors.New("--from branch must match delivery appRepoRef") } owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL) @@ -45,13 +45,13 @@ func OnboardApp(cfg config.Config, sourceDir string) error { if _, err := manager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil { return err } + if err := publishInitialAppBranches(manager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil { + return err + } if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil { return fmt.Errorf("protect Forgejo production branch: %w", err) } - if err := manager.PushRef(sourceDir, resolved.Delivery.AppRepoURL, branch, branch); err != nil { - return err - } - changed, err := manager.PublishDeliveryBranch(sourceDir, branch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { + changed, err := manager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { return GenerateAppDelivery(dir, resolved) }) if err != nil { @@ -98,6 +98,45 @@ func OnboardApp(cfg config.Config, sourceDir string) error { return nil } +// publishInitialAppBranches establishes the immutable source baseline before delivery setup. +func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error { + sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch) + if err != nil { + return err + } + mainRevision, err := manager.RemoteBranchRevision(targetURL, targetBranch) + if err != nil { + return fmt.Errorf("read target base branch: %w", err) + } + if mainRevision == "" { + if err := manager.PushRef(sourceDir, targetURL, sourceRevision, targetBranch); err != nil { + return fmt.Errorf("publish source base branch: %w", err) + } + } else if mainRevision != sourceRevision { + return errors.New("target base branch differs from the validated source ref; refusing to overwrite it") + } + if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil { + return fmt.Errorf("read target production branch: %w", err) + } else if productionRevision == "" { + if err := manager.PushRef(sourceDir, targetURL, sourceRevision, productionBranch); err != nil { + return fmt.Errorf("create production from source base branch: %w", err) + } + } + mainRevision, err = manager.RemoteBranchRevision(targetURL, targetBranch) + if err != nil { + return fmt.Errorf("verify target base branch: %w", err) + } + if mainRevision != sourceRevision { + return errors.New("target base branch does not match the validated source ref") + } + if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil { + return fmt.Errorf("verify target production branch: %w", err) + } else if productionRevision == "" { + return errors.New("target production branch was not created") + } + return nil +} + // RegisterAppInCluster writes only the managed Flux registration for one app. func RegisterAppInCluster(dir string, cfg config.Config) error { content, err := renderAppRegistration(cfg) diff --git a/internal/bootstrap/onboard_test.go b/internal/bootstrap/onboard_test.go index f446eaf..5ab93f9 100644 --- a/internal/bootstrap/onboard_test.go +++ b/internal/bootstrap/onboard_test.go @@ -2,13 +2,26 @@ package bootstrap import ( "os" + "os/exec" "path/filepath" "strings" "testing" "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" ) +func onboardingGit(t *testing.T, dir string, args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output) + } + return strings.TrimSpace(string(output)) +} + func onboardingConfig() config.Config { return config.Config{ Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "test-org-2"}, @@ -76,3 +89,56 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) { t.Fatalf("unmanaged app registration was accepted: %v", err) } } + +func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) { + source := filepath.Join(t.TempDir(), "source") + target := filepath.Join(t.TempDir(), "target.git") + if err := os.Mkdir(source, 0755); err != nil { + t.Fatal(err) + } + onboardingGit(t, source, "init", "-b", "source") + onboardingGit(t, source, "config", "user.name", "Test") + onboardingGit(t, source, "config", "user.email", "test@example.test") + if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("source\n"), 0644); err != nil { + t.Fatal(err) + } + onboardingGit(t, source, "add", "README.md") + onboardingGit(t, source, "commit", "-m", "source") + sourceRevision := onboardingGit(t, source, "rev-parse", "source") + onboardingGit(t, "", "init", "--bare", target) + + manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "") + if err := publishInitialAppBranches(manager, source, target, "source", "main", "production"); err != nil { + t.Fatal(err) + } + for _, branch := range []string{"main", "production"} { + if got := onboardingGit(t, "", "--git-dir", target, "rev-parse", "refs/heads/"+branch); got != sourceRevision { + t.Fatalf("%s = %s, want source %s", branch, got, sourceRevision) + } + } + + preservedTarget := filepath.Join(t.TempDir(), "preserved-target.git") + onboardingGit(t, "", "init", "--bare", preservedTarget) + onboardingGit(t, source, "push", preservedTarget, "source:main") + production := filepath.Join(t.TempDir(), "production") + if err := os.Mkdir(production, 0755); err != nil { + t.Fatal(err) + } + onboardingGit(t, production, "init", "-b", "production") + onboardingGit(t, production, "config", "user.name", "Test") + onboardingGit(t, production, "config", "user.email", "test@example.test") + if err := os.WriteFile(filepath.Join(production, "README.md"), []byte("existing production\n"), 0644); err != nil { + t.Fatal(err) + } + onboardingGit(t, production, "add", "README.md") + onboardingGit(t, production, "commit", "-m", "existing production") + existingProduction := onboardingGit(t, production, "rev-parse", "production") + onboardingGit(t, production, "push", preservedTarget, "production:production") + + if err := publishInitialAppBranches(manager, source, preservedTarget, "source", "main", "production"); err != nil { + t.Fatal(err) + } + if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction { + t.Fatalf("production = %s, want existing %s", got, existingProduction) + } +} diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index 8750d12..ed3cb62 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -640,6 +640,43 @@ func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) { return true, nil } +// BranchRevision resolves branch to the checked-out commit that may be published. +func BranchRevision(dir, branch string) (string, error) { + command := exec.Command("git", "rev-parse", "--verify", branch+"^{commit}") + command.Dir = dir + revision, err := command.Output() + if err != nil { + return "", fmt.Errorf("resolve source branch %q: %w", branch, err) + } + if revision = bytes.TrimSpace(revision); len(revision) == 0 { + return "", fmt.Errorf("source branch %q has no commit", branch) + } + return string(revision), nil +} + +// RemoteBranchRevision returns the remote branch commit, or an empty string when absent. +func (rm *RepoManager) RemoteBranchRevision(repoURL, branch string) (string, error) { + cleanupAskPass, environment, err := rm.gitEnvironment() + if err != nil { + return "", err + } + defer cleanupAskPass() + command := exec.Command("git", "ls-remote", "--refs", repoURL, "refs/heads/"+branch) + command.Env = environment + output, err := command.Output() + if err != nil { + return "", err + } + fields := strings.Fields(string(output)) + if len(fields) == 0 { + return "", nil + } + if len(fields) != 2 || fields[1] != "refs/heads/"+branch { + return "", fmt.Errorf("unexpected remote ref response for branch %q", branch) + } + return fields[0], nil +} + func CurrentBranch(dir string) (string, error) { command := exec.Command("git", "branch", "--show-current") command.Dir = dir -- 2.43.7