Merge pull request 'fix: support fresh delivery promotion' (#32) from fix/fresh-delivery-contract into main

Reviewed-on: #32
This commit is contained in:
eding 2026-09-06 21:17:55 +02:00
commit 8cf30c0429
6 changed files with 343 additions and 14 deletions

View file

@ -28,6 +28,12 @@ spec:
default: "" default: ""
- name: app-revision - name: app-revision
default: "" default: ""
- name: forgejo-base-url
default: ""
- name: forgejo-owner
default: ""
- name: manifests-repo
default: ""
steps: steps:
- name: update - name: update
image: alpine/git:2.47.2 image: alpine/git:2.47.2
@ -52,6 +58,12 @@ spec:
value: $(params.app-url) value: $(params.app-url)
- name: APP_REVISION - name: APP_REVISION
value: $(params.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: | script: |
#!/bin/sh #!/bin/sh
set -eu set -eu
@ -61,13 +73,29 @@ spec:
valid_commit() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; } 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_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() { 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_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_name "$APP_NAME"
valid_url "$MANIFESTS_URL" valid_url "$MANIFESTS_URL"
valid_revision "$MANIFESTS_BRANCH" valid_revision "$MANIFESTS_BRANCH"
valid_repository "$APP_REPOSITORY" valid_repository "$APP_REPOSITORY"
valid_commit "$TAG" valid_commit "$TAG"
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 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 cd /tmp/manifests
if [ "$ENVIRONMENT" = preview ]; then if [ "$ENVIRONMENT" = preview ]; then
valid_pr_number "$PR_NUMBER" valid_pr_number "$PR_NUMBER"
@ -159,10 +187,96 @@ spec:
root=apps/previews/kustomization.yaml root=apps/previews/kustomization.yaml
grep -q '^resources:' "$root" || fail 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" 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 elif [ "$ENVIRONMENT" = staging ]; then
app_dir="apps/$ENVIRONMENT/$APP_NAME" app_dir="apps/staging/$APP_NAME"
[ -f "$app_dir/release.yaml" ] || fail 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" 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" <<EOF
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: $APP_NAME
spec:
interval: 5m
chart:
spec:
chart: ./charts/$APP_NAME
sourceRef:
kind: GitRepository
name: $APP_NAME
namespace: flux-system
values:
image:
repository: $IMAGE
tag: $TAG
EOF
cat > "$app_dir/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- release.yaml
EOF
if [ ! -e "$root" ]; then
mkdir -p "$(dirname "$root")"
cat > "$root" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
EOF
fi
[ -f "$root" ] && [ ! -L "$root" ] && grep -qx 'resources:' "$root" || fail
grep -qxF " - $APP_NAME" "$root" 2>/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" <<EOF
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: $APP_NAME
spec:
interval: 5m
chart:
spec:
chart: ./charts/$APP_NAME
sourceRef:
kind: GitRepository
name: $APP_NAME
namespace: flux-system
values:
image:
repository: $IMAGE
tag: $TAG
EOF
cat > "$app_dir/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- release.yaml
EOF
if [ ! -e "$root" ]; then
mkdir -p "$(dirname "$root")"
cat > "$root" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
EOF
fi
[ -f "$root" ] && [ ! -L "$root" ] && grep -qx 'resources:' "$root" || fail
grep -qxF " - $APP_NAME" "$root" 2>/dev/null || printf ' - %s\n' "$APP_NAME" >> "$root"
else else
fail fail
fi fi
@ -170,7 +284,39 @@ spec:
git config user.email maidn@free-maidn.com git config user.email maidn@free-maidn.com
git add apps git add apps
git diff --cached --quiet || git commit -m "chore: deploy $APP_NAME $TAG" git diff --cached --quiet || git commit -m "chore: deploy $APP_NAME $TAG"
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" git push origin "$MANIFESTS_BRANCH"
fi
--- ---
apiVersion: tekton.dev/v1 apiVersion: tekton.dev/v1
kind: Pipeline kind: Pipeline
@ -197,6 +343,12 @@ spec:
default: {{ quote .ManifestsURL }} default: {{ quote .ManifestsURL }}
- name: manifests-branch - name: manifests-branch
default: {{ quote .ManifestsBranch }} default: {{ quote .ManifestsBranch }}
- name: forgejo-base-url
default: {{ quote .ForgejoBaseURL }}
- name: forgejo-owner
default: {{ quote .ForgejoOwner }}
- name: manifests-repo
default: {{ quote .ManifestsRepo }}
workspaces: workspaces:
- name: source - name: source
taskRunTemplate: taskRunTemplate:
@ -307,6 +459,12 @@ spec:
value: $(params.git-revision) value: $(params.git-revision)
- name: environment - name: environment
value: production 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 - name: cleanup-preview
when: when:
- input: $(params.event-type) - input: $(params.event-type)

View file

@ -543,7 +543,9 @@ type appDeliveryTemplateConfig struct {
BuildOutputDirectory string BuildOutputDirectory string
BuildConfiguration string BuildConfiguration string
ForgejoBaseURL string ForgejoBaseURL string
ForgejoOwner string
ManifestsURL string ManifestsURL string
ManifestsRepo string
ManifestsBranch string ManifestsBranch string
} }
@ -619,9 +621,9 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL, AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository, AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, 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 { if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name) return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
} }

View file

@ -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) { func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}) err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})

View file

@ -23,11 +23,11 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil { if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil {
return err return err
} }
branch, err := forgejo.CurrentBranch(sourceDir) sourceBranch, err := forgejo.CurrentBranch(sourceDir)
if err != nil { if err != nil {
return err return err
} }
if branch != resolved.Delivery.AppRepoRef { if sourceBranch != resolved.Delivery.AppRepoRef {
return errors.New("--from branch must match delivery appRepoRef") return errors.New("--from branch must match delivery appRepoRef")
} }
owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL) 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 { if _, err := manager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil {
return err 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 { if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err) return fmt.Errorf("protect Forgejo production branch: %w", err)
} }
if err := manager.PushRef(sourceDir, resolved.Delivery.AppRepoURL, branch, branch); err != nil { changed, err := manager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
return err
}
changed, err := manager.PublishDeliveryBranch(sourceDir, branch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
return GenerateAppDelivery(dir, resolved) return GenerateAppDelivery(dir, resolved)
}) })
if err != nil { if err != nil {
@ -98,6 +98,45 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
return nil 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. // RegisterAppInCluster writes only the managed Flux registration for one app.
func RegisterAppInCluster(dir string, cfg config.Config) error { func RegisterAppInCluster(dir string, cfg config.Config) error {
content, err := renderAppRegistration(cfg) content, err := renderAppRegistration(cfg)

View file

@ -2,13 +2,26 @@ package bootstrap
import ( import (
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "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 { func onboardingConfig() config.Config {
return config.Config{ return config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "test-org-2"}, 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) 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)
}
}

View file

@ -640,6 +640,43 @@ func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) {
return true, nil 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) { func CurrentBranch(dir string) (string, error) {
command := exec.Command("git", "branch", "--show-current") command := exec.Command("git", "branch", "--show-current")
command.Dir = dir command.Dir = dir