package main
import (
"context"
"fmt"
"strconv"
"strings"
)
// bullet is one CHANGELOG.md entry under a version heading.
type bullet struct {
Text string
Breaking bool
}
// versionChangelog is everything gitfed-ctl found for one version in an
// update range. TagMissing means the release commit is on the upstream
// branch but no "vX.Y.Z" tag exists yet for it (a real gap: update.sh only
// tags after a manual `git push --tags`) — rather than silently skipping
// what might be a breaking change we can't actually read yet, this
// produces one synthetic, non-dismissible bullet forcing acknowledgment.
type versionChangelog struct {
Version string
Bullets []bullet
TagMissing bool
}
// checkForUpdates fetches tags and compares what's actually deployed on the
// cluster right now against whatever VERSION says at the tip of the
// configured upstream branch, without touching the working tree (no pull
// yet — that only happens once the wizard is confirmed).
//
// "current" deliberately comes from the live deployment (kubectl), not the
// checkout's own VERSION file — found live: a previous run's git pull can
// succeed (advancing VERSION) while a later step (build/import/apply/
// rollout) fails, leaving the checkout ahead of what's actually running.
// Reading VERSION here would then report "up to date" even though
// production is still on the old image, and silently skip re-showing any
// breaking changes for a version that was never actually deployed.
func checkForUpdates(ctx context.Context, sourceDir string) (current, target string, upToDate bool, err error) {
if _, err := runQuiet(ctx, sourceDir, "git", "fetch", "--tags"); err != nil {
return "", "", false, fmt.Errorf("git fetch: %w", err)
}
ref, err := upstreamRef(ctx, sourceDir)
if err != nil {
return "", "", false, err
}
targetRaw, err := runQuiet(ctx, sourceDir, "git", "show", ref+":VERSION")
if err != nil {
return "", "", false, fmt.Errorf("read VERSION at %s: %w", ref, err)
}
target = strings.TrimSpace(targetRaw)
current, err = deployedVersion(ctx)
if err != nil {
return "", "", false, fmt.Errorf("read deployed version: %w", err)
}
return current, target, current == target, nil
}
// deployedVersion reads the image tag actually configured on the live
// "gitfed" deployment (namespace gitfed, container "server" — see
// deploy/k8s/deployment.yaml) via kubectl, the source of truth for "what's
// really running" this check needs.
func deployedVersion(ctx context.Context) (string, error) {
out, err := runQuiet(ctx, "", "kubectl", "-n", "gitfed", "get", "deployment", "gitfed",
"-o", `jsonpath={.spec.template.spec.containers[?(@.name=="server")].image}`)
if err != nil {
return "", err
}
tag := strings.TrimPrefix(strings.TrimSpace(out), "gitfed:")
if tag == "" {
return "", fmt.Errorf("empty image tag reported by kubectl")
}
return tag, nil
}
// upstreamRef resolves the branch's configured tracking remote, falling
// back to origin/main if none is set (e.g. a checkout cloned in a way that
// never set one up).
func upstreamRef(ctx context.Context, sourceDir string) (string, error) {
out, err := runQuiet(ctx, sourceDir, "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if err != nil {
return "origin/main", nil
}
ref := strings.TrimSpace(out)
if ref == "" {
return "origin/main", nil
}
return ref, nil
}
// versionsInRange returns every version strictly after currentVersion and
// up to and including targetVersion, in ascending order, each with its
// CHANGELOG.md bullets read straight from its tag (git show, never
// checked out) so nothing in the working tree changes before the wizard is
// confirmed.
func versionsInRange(ctx context.Context, sourceDir, currentVersion, targetVersion string) ([]versionChangelog, error) {
tagsOut, err := runQuiet(ctx, sourceDir, "git", "tag", "--list", "v*", "--sort=v:refname")
if err != nil {
return nil, fmt.Errorf("git tag --list: %w", err)
}
var versions []versionChangelog
seen := map[string]bool{}
for _, line := range strings.Split(tagsOut, "\n") {
tag := strings.TrimSpace(line)
if tag == "" || !strings.HasPrefix(tag, "v") {
continue
}
ver := strings.TrimPrefix(tag, "v")
if compareVersions(ver, currentVersion) <= 0 || compareVersions(ver, targetVersion) > 0 {
continue
}
seen[ver] = true
out, err := runQuiet(ctx, sourceDir, "git", "show", tag+":CHANGELOG.md")
if err != nil {
versions = append(versions, versionChangelog{
Version: ver,
TagMissing: true,
Bullets: []bullet{{
Text: "impossible de lire CHANGELOG.md pour " + tag + " — le tag existe mais son contenu n'a pas pu être lu",
Breaking: true,
}},
})
continue
}
versions = append(versions, versionChangelog{Version: ver, Bullets: parseSection(out, ver)})
}
// The release commit for targetVersion may be on the upstream branch
// without a tag yet (update.sh only tags after a manual git push) —
// fail safe rather than silently skip it.
if !seen[targetVersion] {
versions = append(versions, versionChangelog{
Version: targetVersion,
TagMissing: true,
Bullets: []bullet{{
Text: "aucun tag trouvé pour " + targetVersion + " — impossible de vérifier s'il contient un changement cassant",
Breaking: true,
}},
})
}
sortVersions(versions)
return versions, nil
}
func sortVersions(versions []versionChangelog) {
for i := 1; i < len(versions); i++ {
for j := i; j > 0 && compareVersions(versions[j].Version, versions[j-1].Version) < 0; j-- {
versions[j], versions[j-1] = versions[j-1], versions[j]
}
}
}
// parseSection extracts the bullets under "## version" in changelogMD, up
// to the next "## " heading or EOF, and flags any bullet whose text starts
// with "**BREAKING:**" (see the convention note at the top of
// CHANGELOG.md).
func parseSection(changelogMD, version string) []bullet {
heading := "## " + version
lines := strings.Split(changelogMD, "\n")
start := -1
for i, l := range lines {
if strings.TrimSpace(l) == heading {
start = i + 1
break
}
}
if start == -1 {
return nil
}
var bullets []bullet
for _, l := range lines[start:] {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, "## ") {
break
}
if !strings.HasPrefix(trimmed, "- ") {
continue
}
text := strings.TrimPrefix(trimmed, "- ")
bullets = append(bullets, bullet{
Text: text,
Breaking: strings.HasPrefix(text, "**BREAKING:**"),
})
}
return bullets
}
// compareVersions compares two bare "X.Y.Z" semver strings, returning -1,
// 0 or 1. Malformed components parse as 0, matching update.sh's own
// unchecked assumption that VERSION/tags are always well-formed.
func compareVersions(a, b string) int {
pa, pb := versionParts(a), versionParts(b)
for i := 0; i < 3; i++ {
if pa[i] != pb[i] {
if pa[i] < pb[i] {
return -1
}
return 1
}
}
return 0
}
func versionParts(v string) [3]int {
var parts [3]int
fields := strings.SplitN(v, ".", 3)
for i := 0; i < len(fields) && i < 3; i++ {
n, _ := strconv.Atoi(fields[i])
parts[i] = n
}
return parts
}
// flattenBreaking collects every breaking (or tag-missing) bullet across a
// version range, in order — the update wizard's acknowledgment screen
// works off this flat list.
func flattenBreaking(versions []versionChangelog) []bullet {
var out []bullet
for _, v := range versions {
for _, b := range v.Bullets {
if b.Breaking {
out = append(out, b)
}
}
}
return out
}