From 9e9a7f12e024a65f1f1eae6a6e84210fad8f7adf Mon Sep 17 00:00:00 2001 From: Fellipe Benoni Date: Mon, 27 Jul 2026 11:59:20 -0300 Subject: [PATCH 01/10] feat(pipe): add generate-compatibility tool for version support matrix Generates a per-chart support-window matrix (N..N-3) in the README and a machine-readable docs/compatibility.json, derived from Chart.yaml version (authoritative N), git tags (history + release dates) and the optional lerian.studio/compatibility annotation (requires ranges). - N comes from Chart.yaml; N-1..N-3 from git tags (pre-releases segregated, tags above N discarded) - README table enriched in-place (Support, Released, per-dependency Requer columns), idempotent via BEGIN/END COMPAT markers, respects irregular layouts - app-version extraction shared with update-chart-version-readme via tableutil - single-service charts are not prompted for cross-compat (chart-type gate) - adds Masterminds/semver/v3 v3.2.1 (Go 1.21 floor) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../generate-compatibility/annotation.go | 113 ++++++++ .../generate-compatibility/annotation_test.go | 62 +++++ .../generate-compatibility/builddoc_test.go | 93 +++++++ .../scripts/generate-compatibility/chart.go | 36 +++ .../generate-compatibility/chart_test.go | 82 ++++++ .../generate-compatibility/check_test.go | 43 +++ .../generate-compatibility/ensure_test.go | 117 ++++++++ .../scripts/generate-compatibility/json.go | 53 ++++ .../generate-compatibility/json_test.go | 87 ++++++ .../scripts/generate-compatibility/main.go | 228 ++++++++++++++++ .../scripts/generate-compatibility/markers.go | 249 ++++++++++++++++++ .../generate-compatibility/markers_test.go | 88 +++++++ .../generate-compatibility/render_readme.go | 183 +++++++++++++ .../render_readme_test.go | 111 ++++++++ .../generate-compatibility/resolve_test.go | 225 ++++++++++++++++ .../generate-compatibility/run_test.go | 57 ++++ .../generate-compatibility/section_test.go | 40 +++ .../scripts/generate-compatibility/state.go | 84 ++++++ .../generate-compatibility/state_test.go | 103 ++++++++ .../scripts/generate-compatibility/tags.go | 115 ++++++++ .../generate-compatibility/tags_test.go | 63 +++++ .../testdata/golden_two_products.json | 60 +++++ .../testdata/readme_irregular_golden.md | 31 +++ .../testdata/readme_irregular_in.md | 27 ++ .../scripts/generate-compatibility/warn.go | 49 ++++ .../generate-compatibility/warn_test.go | 117 ++++++++ .../scripts/generate-compatibility/window.go | 116 ++++++++ .../generate-compatibility/window_test.go | 115 ++++++++ .../generate-compatibility/write_readme.go | 112 ++++++++ .../write_readme_test.go | 137 ++++++++++ .github/scripts/go.mod | 2 + .github/scripts/go.sum | 2 + .github/scripts/tableutil/appversions.go | 121 +++++++++ .github/scripts/tableutil/appversions_test.go | 126 +++++++++ .../update-chart-version-readme/main.go | 99 +------ 35 files changed, 3258 insertions(+), 88 deletions(-) create mode 100644 .github/scripts/generate-compatibility/annotation.go create mode 100644 .github/scripts/generate-compatibility/annotation_test.go create mode 100644 .github/scripts/generate-compatibility/builddoc_test.go create mode 100644 .github/scripts/generate-compatibility/chart.go create mode 100644 .github/scripts/generate-compatibility/chart_test.go create mode 100644 .github/scripts/generate-compatibility/check_test.go create mode 100644 .github/scripts/generate-compatibility/ensure_test.go create mode 100644 .github/scripts/generate-compatibility/json.go create mode 100644 .github/scripts/generate-compatibility/json_test.go create mode 100644 .github/scripts/generate-compatibility/main.go create mode 100644 .github/scripts/generate-compatibility/markers.go create mode 100644 .github/scripts/generate-compatibility/markers_test.go create mode 100644 .github/scripts/generate-compatibility/render_readme.go create mode 100644 .github/scripts/generate-compatibility/render_readme_test.go create mode 100644 .github/scripts/generate-compatibility/resolve_test.go create mode 100644 .github/scripts/generate-compatibility/run_test.go create mode 100644 .github/scripts/generate-compatibility/section_test.go create mode 100644 .github/scripts/generate-compatibility/state.go create mode 100644 .github/scripts/generate-compatibility/state_test.go create mode 100644 .github/scripts/generate-compatibility/tags.go create mode 100644 .github/scripts/generate-compatibility/tags_test.go create mode 100644 .github/scripts/generate-compatibility/testdata/golden_two_products.json create mode 100644 .github/scripts/generate-compatibility/testdata/readme_irregular_golden.md create mode 100644 .github/scripts/generate-compatibility/testdata/readme_irregular_in.md create mode 100644 .github/scripts/generate-compatibility/warn.go create mode 100644 .github/scripts/generate-compatibility/warn_test.go create mode 100644 .github/scripts/generate-compatibility/window.go create mode 100644 .github/scripts/generate-compatibility/window_test.go create mode 100644 .github/scripts/generate-compatibility/write_readme.go create mode 100644 .github/scripts/generate-compatibility/write_readme_test.go create mode 100644 .github/scripts/tableutil/appversions.go create mode 100644 .github/scripts/tableutil/appversions_test.go diff --git a/.github/scripts/generate-compatibility/annotation.go b/.github/scripts/generate-compatibility/annotation.go new file mode 100644 index 000000000..0742c77bc --- /dev/null +++ b/.github/scripts/generate-compatibility/annotation.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" + "gopkg.in/yaml.v3" +) + +// compatAnnotationKey is the reverse-DNS annotation carrying compatibility data. +// It follows the precedent of lerian.studio/chart-type +// (validate-helm-charts/main.go:21). +const compatAnnotationKey = "lerian.studio/compatibility" + +// chartTypeAnnotationKey is the pre-existing annotation every chart carries. +// It decides whether a missing cross-compatibility declaration is worth an INFO +// reminder: single-service charts are standalone and are NOT expected to declare +// compatibility, so their absence is silent. +const chartTypeAnnotationKey = "lerian.studio/chart-type" + +const ( + chartTypeSingleService = "single-service" + chartTypeMultiComponent = "multi-component" + chartTypeDependencyWrapper = "dependency-wrapper" +) + +// CompatAnnotation is the parsed representation of the embedded YAML in the +// lerian.studio/compatibility annotation (data-model §A.1). Both maps are +// optional in v1; a wholly absent annotation is represented by a nil pointer. +type CompatAnnotation struct { + Requires map[string]string `yaml:"requires"` + TestedWith map[string]string `yaml:"testedWith"` +} + +// parseCompatAnnotation is the SECOND step of the two-step unmarshal: the caller +// has already pulled the annotation string out of Chart.yaml.annotations; here +// we unmarshal that embedded YAML document. An empty/whitespace-only string +// means "no annotation declared" and returns (nil, nil). Broken YAML returns an +// error so the caller can emit a V1 WARN and continue (never aborts). +func parseCompatAnnotation(raw string) (*CompatAnnotation, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + var ann CompatAnnotation + if err := yaml.Unmarshal([]byte(raw), &ann); err != nil { + return nil, err + } + return &ann, nil +} + +// validateCompat applies rules V3–V6 (api-design §I.3) without ever failing. +// V1 (valid YAML) and V2 (unknown keys) are handled by parseCompatAnnotation +// and the typed struct respectively. knownProducts is the set of chart names +// present in the repo, used for the V3 existence check. +// +// chartType (lerian.studio/chart-type) gates the V6 reminder for a MISSING +// cross-compatibility declaration: +// - single-service → standalone; no reminder (cross-compat N/A). +// - multi-component / +// dependency-wrapper → INFO reminder when nothing is declared. +// - "" (missing) → treated as multi-component (conservative) PLUS a +// WARN that the chart-type annotation is absent. +// +// A DECLARED compatibility is always validated (V3/V4/V5) regardless of type. +func validateCompat(chart, chartType string, ann *CompatAnnotation, knownProducts map[string]bool) []Warning { + var out []Warning + + if chartType == "" { + // chart-type should always be present; flag its absence and fall through + // as if multi-component (the stricter branch that still reminds on V6). + out = append(out, Warning{SevWarn, chart, "CT", "lerian.studio/chart-type annotation is missing; treating as multi-component"}) + chartType = chartTypeMultiComponent + } + + if ann == nil || (len(ann.Requires) == 0 && len(ann.TestedWith) == 0) { + // Nothing declared. Single-service charts are standalone and are not + // expected to declare cross-compatibility, so stay silent for them. + if chartType == chartTypeSingleService { + return out + } + // multi-component / dependency-wrapper: soft, non-blocking reminder. + out = append(out, Warning{ + Severity: SevInfo, + Chart: chart, + Rule: "V6", + Detail: "no compatibility declared (testedWith expected in v1)", + }) + return out + } + + // V3 + V4: requires. + for product, rng := range ann.Requires { + if !knownProducts[product] { + out = append(out, Warning{SevWarn, chart, "V3", fmt.Sprintf("requires[%s] — unknown chart", product)}) + } + if _, err := semver.NewConstraint(rng); err != nil { + out = append(out, Warning{SevWarn, chart, "V4", fmt.Sprintf("requires[%s] — invalid semver range %q", product, rng)}) + } + } + + // V3 + V5: testedWith. + for product, ver := range ann.TestedWith { + if !knownProducts[product] { + out = append(out, Warning{SevWarn, chart, "V3", fmt.Sprintf("testedWith[%s] — unknown chart", product)}) + } + if _, err := semver.NewVersion(ver); err != nil { + out = append(out, Warning{SevWarn, chart, "V5", fmt.Sprintf("testedWith[%s] — not an exact version %q", product, ver)}) + } + } + + return out +} diff --git a/.github/scripts/generate-compatibility/annotation_test.go b/.github/scripts/generate-compatibility/annotation_test.go new file mode 100644 index 000000000..cfbb9ea2e --- /dev/null +++ b/.github/scripts/generate-compatibility/annotation_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestParseCompatAnnotation(t *testing.T) { + tests := []struct { + name string + raw string + want *CompatAnnotation + wantError bool + }{ + { + name: "complete requires + testedWith", + raw: `requires: + midaz-helm: ">=8.4.0 <9.0.0" +testedWith: + midaz-helm: "8.6.0" +`, + want: &CompatAnnotation{ + Requires: map[string]string{"midaz-helm": ">=8.4.0 <9.0.0"}, + TestedWith: map[string]string{"midaz-helm": "8.6.0"}, + }, + }, + { + name: "only testedWith (typical v1 post-backfill)", + raw: `testedWith: + midaz-helm: "8.6.0" +`, + want: &CompatAnnotation{ + TestedWith: map[string]string{"midaz-helm": "8.6.0"}, + }, + }, + { + name: "empty string => nil (absent)", + raw: "", + want: nil, + }, + { + name: "broken embedded YAML => error (V1)", + raw: "requires:\n midaz-helm \">=8.4.0\"\n", // missing colon + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCompatAnnotation(tt.raw) + if (err != nil) != tt.wantError { + t.Fatalf("err = %v, wantError = %v", err, tt.wantError) + } + if tt.wantError { + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("got %+v, want %+v", got, tt.want) + } + }) + } +} diff --git a/.github/scripts/generate-compatibility/builddoc_test.go b/.github/scripts/generate-compatibility/builddoc_test.go new file mode 100644 index 000000000..b9e1065fd --- /dev/null +++ b/.github/scripts/generate-compatibility/builddoc_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "testing" +) + +// fakeTagLister returns canned tags (and optional dates) per dir. +type fakeTagLister struct { + tags map[string][]string + dates map[string]map[string]string // dir -> (tag -> YYYY-MM-DD) +} + +func (f fakeTagLister) listTags(dir string) ([]string, error) { + return f.tags[dir], nil +} + +func (f fakeTagLister) listTagDates(dir string) (map[string]string, error) { + if f.dates == nil { + return map[string]string{}, nil + } + return f.dates[dir], nil +} + +func TestBuildDoc_Golden(t *testing.T) { + root := t.TempDir() + + // midaz: N=8.6.0, tags cover 8.6..8.2. + writeChart(t, root, "midaz", `apiVersion: v2 +name: midaz-helm +type: application +version: 8.6.0 +appVersion: "3.7.8" +`) + // plugin-fees: N=7.2.0, only its own tag; declares requires+testedWith. + writeChart(t, root, "plugin-fees", `apiVersion: v2 +name: plugin-fees-helm +type: application +version: 7.2.0 +appVersion: "3.3.0" +annotations: + lerian.studio/compatibility: | + requires: + midaz-helm: ">=8.4.0 <9.0.0" + testedWith: + midaz-helm: "8.6.0" +`) + + lister := fakeTagLister{ + tags: map[string][]string{ + "midaz": { + "midaz-v8.6.0", "midaz-v8.5.0", "midaz-v8.4.0", + "midaz-v8.3.0", "midaz-v8.2.0", "midaz-v8.6.0-beta.11", + }, + "plugin-fees": {"plugin-fees-v7.2.0"}, + }, + dates: map[string]map[string]string{ + "midaz": { + "midaz-v8.6.0": "2026-06-01", "midaz-v8.5.0": "2026-05-01", + "midaz-v8.4.0": "2026-04-01", "midaz-v8.3.0": "2026-03-01", + "midaz-v8.2.0": "2026-02-01", + }, + "plugin-fees": {"plugin-fees-v7.2.0": "2026-06-15"}, + }, + } + + doc, err := buildDoc(root, lister, io.Discard) + if err != nil { + t.Fatalf("buildDoc: %v", err) + } + doc.GeneratedFrom = "test" // pin the provenance field for a stable golden + + got, err := renderJSON(doc) + if err != nil { + t.Fatalf("renderJSON: %v", err) + } + + goldenPath := filepath.Join("testdata", "golden_two_products.json") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(goldenPath, got, 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden (run with UPDATE_GOLDEN=1 first): %v", err) + } + if string(got) != string(want) { + t.Fatalf("JSON mismatch.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} diff --git a/.github/scripts/generate-compatibility/chart.go b/.github/scripts/generate-compatibility/chart.go new file mode 100644 index 000000000..61e24b1ec --- /dev/null +++ b/.github/scripts/generate-compatibility/chart.go @@ -0,0 +1,36 @@ +// Command generate-compatibility produces the per-product support-window matrix +// (README blocks + docs/compatibility.json) for the charts in this repo. +// +// It mirrors the sibling tools in .github/scripts (generate-values-schemas, +// update-readme-matrix): invoked with --root ../.., reads the repo state, and +// writes deterministic artifacts. No network access beyond the git tags already +// present in the checkout. +package main + +import ( + "os" + "path/filepath" + "sort" +) + +// chartDirectories returns the names (not full paths) of the immediate +// subdirectories of /charts, sorted ascending. Loose files under charts/ +// are ignored. Mirrors validate-helm-charts/main.go:292-307 but returns bare +// names (the caller joins paths), copied here because Go forbids importing one +// package main from another. +func chartDirectories(root string) ([]string, error) { + chartsRoot := filepath.Join(root, "charts") + entries, err := os.ReadDir(chartsRoot) + if err != nil { + return nil, err + } + + dirs := []string{} + for _, entry := range entries { + if entry.IsDir() { + dirs = append(dirs, entry.Name()) + } + } + sort.Strings(dirs) + return dirs, nil +} diff --git a/.github/scripts/generate-compatibility/chart_test.go b/.github/scripts/generate-compatibility/chart_test.go new file mode 100644 index 000000000..3b7936243 --- /dev/null +++ b/.github/scripts/generate-compatibility/chart_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// writeTree cria uma árvore charts/ temporária e devolve o root. +func writeTree(t *testing.T, chartDirs []string, extraFiles map[string]string) string { + t.Helper() + root := t.TempDir() + // Always create the charts/ root so the "empty charts dir" case exercises an + // existing-but-empty directory (returns []), not a missing one (returns err, + // which TestChartDirectories_MissingRoot covers separately). + if err := os.MkdirAll(filepath.Join(root, "charts"), 0o755); err != nil { + t.Fatalf("mkdir charts: %v", err) + } + for _, d := range chartDirs { + if err := os.MkdirAll(filepath.Join(root, "charts", d), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + for rel, content := range extraFiles { + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + return root +} + +func TestChartDirectories(t *testing.T) { + tests := []struct { + name string + dirs []string + extra map[string]string + want []string + wantError bool + }{ + { + name: "sorted ascending, dirs only", + dirs: []string{"midaz", "plugin-fees", "br-spi"}, + want: []string{"br-spi", "midaz", "plugin-fees"}, + }, + { + name: "ignores loose files in charts/", + dirs: []string{"midaz"}, + extra: map[string]string{"charts/README.md": "x"}, + want: []string{"midaz"}, + }, + { + name: "empty charts dir returns empty slice", + dirs: []string{}, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := writeTree(t, tt.dirs, tt.extra) + got, err := chartDirectories(root) + if (err != nil) != tt.wantError { + t.Fatalf("err = %v, wantError = %v", err, tt.wantError) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestChartDirectories_MissingRoot(t *testing.T) { + _, err := chartDirectories(filepath.Join(t.TempDir(), "does-not-exist")) + if err == nil { + t.Fatal("expected error for missing charts dir, got nil") + } +} diff --git a/.github/scripts/generate-compatibility/check_test.go b/.github/scripts/generate-compatibility/check_test.go new file mode 100644 index 000000000..f1424c705 --- /dev/null +++ b/.github/scripts/generate-compatibility/check_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRun_Check(t *testing.T) { + t.Run("in-sync repo => ok, exit 0, no WARN", func(t *testing.T) { + root := seedRepo(t) + // First write so disk matches expectation. + var w1out, w1err bytes.Buffer + if code := run([]string{"--root", root, "--output", "docs/compatibility.json"}, &w1out, &w1err); code != 0 { + t.Fatalf("seed write exit=%d err=%s", code, w1err.String()) + } + // Now check: should be clean. + var out, errb bytes.Buffer + code := run([]string{"--check", "--root", root}, &out, &errb) + if code != 0 { + t.Fatalf("check exit = %d, want 0. stderr=%s", code, errb.String()) + } + if !strings.Contains(out.String(), "ok") { + t.Errorf("stdout should say ok, got %q", out.String()) + } + if strings.Contains(errb.String(), "stale") { + t.Errorf("unexpected drift WARN: %s", errb.String()) + } + }) + + t.Run("drift => WARN on stderr, still exit 0", func(t *testing.T) { + root := seedRepo(t) + // Do NOT write first: disk README has no COMPAT block => drift. + var out, errb bytes.Buffer + code := run([]string{"--check", "--root", root}, &out, &errb) + if code != 0 { + t.Fatalf("check exit = %d, want 0 (non-blocking v1). stderr=%s", code, errb.String()) + } + if !strings.Contains(errb.String(), "stale") { + t.Errorf("expected drift WARN with 'stale', got stderr=%q", errb.String()) + } + }) +} diff --git a/.github/scripts/generate-compatibility/ensure_test.go b/.github/scripts/generate-compatibility/ensure_test.go new file mode 100644 index 000000000..cd196dff8 --- /dev/null +++ b/.github/scripts/generate-compatibility/ensure_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "strings" + "testing" +) + +func TestEnsureCompatBlock(t *testing.T) { + body := []string{"| Version | Support |", "| :---: | :---: |", "| `1.0.0` | 🟢 |"} + + t.Run("existing markers => replace path", func(t *testing.T) { + doc := strings.Split("### X\n\n\nOLD\n", "\n") + out, err := ensureCompatBlock(doc, "x-helm", body) + if err != nil { + t.Fatal(err) + } + s := strings.Join(out, "\n") + if strings.Contains(s, "OLD") || !strings.Contains(s, "1.0.0") { + t.Errorf("replace path failed:\n%s", s) + } + }) + + t.Run("existing table => replaced IN PLACE (no duplicate), prose+heading kept", func(t *testing.T) { + doc := strings.Split("### Matcher\n\nExisting prose.\n\n#### Application Version Mapping\n\n| Chart Version | Matcher Version |\n| :---: | :---: |\n| `3.0.0` | 1.0.0 |\n\n-----------------", "\n") + out, err := ensureCompatBlock(doc, "matcher-helm", body) + if err != nil { + t.Fatal(err) + } + s := strings.Join(out, "\n") + // Prose, heading and trailing separator preserved. + if !strings.Contains(s, "Existing prose.") { + t.Error("prose lost") + } + if !strings.Contains(s, "#### Application Version Mapping") { + t.Error("mapping heading lost") + } + if !strings.Contains(s, "-----------------") { + t.Error("trailing separator lost") + } + // Markers + new body present. + if !strings.Contains(s, "") || !strings.Contains(s, "1.0.0") { + t.Errorf("markers/body not present:\n%s", s) + } + // The OLD simple table must be GONE (replaced in place, not duplicated). + if strings.Contains(s, "| Chart Version | Matcher Version |") { + t.Errorf("old table still present (duplicate!):\n%s", s) + } + // Exactly one marker pair. + if strings.Count(s, "") != 1 || + strings.Count(s, "") != 1 { + t.Errorf("markers duplicated:\n%s", s) + } + // Block sits where the table was: AFTER the mapping heading, BEFORE the separator. + beginIdx := indexOfLine(out, "") + headingIdx := indexOfLine(out, "#### Application Version Mapping") + sepIdx := indexOfLine(out, "-----------------") + if !(headingIdx != -1 && beginIdx > headingIdx && beginIdx < sepIdx) { + t.Errorf("block not placed at table location (heading=%d begin=%d sep=%d)", headingIdx, beginIdx, sepIdx) + } + }) + + t.Run("in-place replace is idempotent (run 1 converts table, run 2 swaps body)", func(t *testing.T) { + doc := strings.Split("### Matcher\n\nprose\n\n#### Application Version Mapping\n\n| Chart Version | Matcher Version |\n| :---: | :---: |\n| `3.0.0` | 1.0.0 |\n\n-----------------", "\n") + once, err := ensureCompatBlock(doc, "matcher-helm", body) + if err != nil { + t.Fatal(err) + } + twice, err := ensureCompatBlock(once, "matcher-helm", body) + if err != nil { + t.Fatal(err) + } + if strings.Join(once, "\n") != strings.Join(twice, "\n") { + t.Fatalf("in-place path not idempotent:\n--once--\n%s\n--twice--\n%s", strings.Join(once, "\n"), strings.Join(twice, "\n")) + } + // No duplicate markers after two runs. + s := strings.Join(twice, "\n") + if strings.Count(s, "") != 1 { + t.Errorf("markers duplicated after 2 runs:\n%s", s) + } + }) + + t.Run("no section (ADR-5 br-spi) => create minimal section at end", func(t *testing.T) { + doc := strings.Split("# Charts\n\n### Midaz Helm Chart\n\nprose", "\n") + out, err := ensureCompatBlock(doc, "br-spi-helm", body) + if err != nil { + t.Fatal(err) + } + s := strings.Join(out, "\n") + if !strings.Contains(s, "### Br Spi") { + t.Errorf("minimal section title not created:\n%s", s) + } + if !strings.Contains(s, "") { + t.Error("markers not created") + } + if !strings.Contains(s, "prose") || !strings.Contains(s, "### Midaz Helm Chart") { + t.Error("existing content disturbed") + } + }) + + t.Run("idempotent across all paths", func(t *testing.T) { + doc := strings.Split("# Charts\n\n### Midaz Helm Chart\n\nprose", "\n") + once, _ := ensureCompatBlock(doc, "br-spi-helm", body) + twice, _ := ensureCompatBlock(once, "br-spi-helm", body) + if strings.Join(once, "\n") != strings.Join(twice, "\n") { + t.Fatalf("not idempotent:\n--once--\n%s\n--twice--\n%s", strings.Join(once, "\n"), strings.Join(twice, "\n")) + } + }) +} + +func indexOfLine(lines []string, want string) int { + for i, l := range lines { + if strings.TrimSpace(l) == want { + return i + } + } + return -1 +} diff --git a/.github/scripts/generate-compatibility/json.go b/.github/scripts/generate-compatibility/json.go new file mode 100644 index 000000000..ea8f14a71 --- /dev/null +++ b/.github/scripts/generate-compatibility/json.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "encoding/json" +) + +// CompatDoc is the root of docs/compatibility.json (data-model §B.1). +type CompatDoc struct { + SchemaVersion int `json:"schemaVersion"` + GeneratedFrom string `json:"generatedFrom"` + Products map[string]Product `json:"products"` +} + +// Product is one chart's projection (data-model §B.2). +// +// AppVersion is the Chart.yaml appVersion, carried in-memory as informational +// context; it is intentionally NOT serialized (json:"-"). Note the README N-row +// app-version cells are resolved from values.yaml {component}.image.tag (shared +// tableutil logic, multi-column), NOT from this single field. +type Product struct { + Dir string `json:"dir"` + Current string `json:"current"` + AppVersion string `json:"-"` + Cycles []Cycle `json:"cycles,omitempty"` +} + +// Cycle is one minor-cycle line (data-model §B.3). There is no tier field: +// the badge/tier is presentation, derived at render time, never persisted. +// Released is the ISO (YYYY-MM-DD) date of the cycle's latest tag; it is an +// additive optional field (schemaVersion stays 1) and is omitted when unknown. +type Cycle struct { + Cycle string `json:"cycle"` + Latest string `json:"latest"` + Released string `json:"released,omitempty"` + Supported bool `json:"supported"` + Requires map[string]string `json:"requires,omitempty"` + TestedWith map[string]string `json:"testedWith,omitempty"` +} + +// renderJSON marshals the document deterministically (Go's encoding/json sorts +// map keys) with two-space indent and a trailing newline, matching the sibling +// generate-values-schemas output style. +func renderJSON(doc CompatDoc) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(doc); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/.github/scripts/generate-compatibility/json_test.go b/.github/scripts/generate-compatibility/json_test.go new file mode 100644 index 000000000..a54dba5fe --- /dev/null +++ b/.github/scripts/generate-compatibility/json_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "strings" + "testing" +) + +func TestRenderJSON_DeterministicAndSchemaV1(t *testing.T) { + doc := CompatDoc{ + SchemaVersion: 1, + GeneratedFrom: "test", + Products: map[string]Product{ + "plugin-fees-helm": {Dir: "plugin-fees", Current: "7.2.0"}, + "midaz-helm": {Dir: "midaz", Current: "8.6.0"}, + }, + } + + out1, err := renderJSON(doc) + if err != nil { + t.Fatalf("renderJSON: %v", err) + } + out2, err := renderJSON(doc) + if err != nil { + t.Fatalf("renderJSON (2nd): %v", err) + } + if string(out1) != string(out2) { + t.Fatal("renderJSON not deterministic across runs") + } + + s := string(out1) + if !strings.Contains(s, `"schemaVersion": 1`) { + t.Errorf("missing schemaVersion:1\n%s", s) + } + // Products must be emitted in sorted key order: midaz-helm before plugin-fees-helm. + iMidaz := strings.Index(s, "midaz-helm") + iFees := strings.Index(s, "plugin-fees-helm") + if iMidaz == -1 || iFees == -1 || iMidaz > iFees { + t.Errorf("products not in sorted order\n%s", s) + } + if !strings.HasSuffix(s, "\n") { + t.Error("output must end with a trailing newline") + } +} + +func TestRenderJSON_NoTierField(t *testing.T) { + doc := CompatDoc{ + SchemaVersion: 1, + GeneratedFrom: "test", + Products: map[string]Product{ + "midaz-helm": { + Dir: "midaz", + Current: "8.6.0", + Cycles: []Cycle{ + {Cycle: "8.6", Latest: "8.6.0", Supported: true}, + {Cycle: "8.2", Latest: "8.2.0", Supported: false}, + }, + }, + }, + } + out, err := renderJSON(doc) + if err != nil { + t.Fatalf("renderJSON: %v", err) + } + s := string(out) + if strings.Contains(s, `"tier"`) { + t.Fatalf("JSON must NOT contain a tier field (presentation-only)\n%s", s) + } + for _, must := range []string{`"cycle": "8.6"`, `"latest": "8.6.0"`, `"supported": true`, `"supported": false`} { + if !strings.Contains(s, must) { + t.Errorf("missing %q\n%s", must, s) + } + } +} + +func TestRenderJSON_OmitsEmptyRequiresTestedWith(t *testing.T) { + doc := CompatDoc{ + SchemaVersion: 1, + Products: map[string]Product{ + "matcher-helm": {Dir: "matcher", Current: "3.0.0", Cycles: []Cycle{{Cycle: "3.0", Latest: "3.0.0", Supported: true}}}, + }, + } + out, _ := renderJSON(doc) + s := string(out) + if strings.Contains(s, `"requires"`) || strings.Contains(s, `"testedWith"`) { + t.Fatalf("empty maps must be omitted (omitempty)\n%s", s) + } +} diff --git a/.github/scripts/generate-compatibility/main.go b/.github/scripts/generate-compatibility/main.go new file mode 100644 index 000000000..20cc15550 --- /dev/null +++ b/.github/scripts/generate-compatibility/main.go @@ -0,0 +1,228 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +// run parses flags and executes the requested mode, returning the process exit +// code (api-design §II.2): 0 success (incl. WARN), 1 environment/usage error +// (unreadable root), 2 conflicting flags. stdout carries the operation result; +// stderr carries diagnostics. +func run(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("generate-compatibility", flag.ContinueOnError) + fs.SetOutput(stderr) + write := fs.Bool("write", false, "Write mode (default when no mode given)") + check := fs.Bool("check", false, "Check mode: detect drift without writing") + root := fs.String("root", "../..", "Repository root containing charts/") + chart := fs.String("chart", "", "Restrict README update to a single chart (JSON always full)") + output := fs.String("output", "docs/compatibility.json", "JSON destination, relative to --root") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "ERROR invalid flags: %v\n", err) + return 2 + } + + // Mode selection + conflict check. + if *write && *check { + fmt.Fprintln(stderr, "ERROR --write and --check are mutually exclusive") + return 2 + } + + // Environment check: root must be a readable directory with charts/. + if info, err := os.Stat(filepath.Join(*root, "charts")); err != nil || !info.IsDir() { + fmt.Fprintf(stderr, "ERROR --root %q has no readable charts/ directory\n", *root) + return 1 + } + + doc, err := buildDoc(*root, gitTagLister{root: *root}, stderr) + if err != nil { + fmt.Fprintf(stderr, "ERROR %v\n", err) + return 1 + } + + if *check { + return runCheck(*root, *output, doc, stdout, stderr) + } + return runWrite(*root, *output, *chart, doc, stdout, stderr) +} + +// runWrite writes the JSON and README (chart filter applies to README only; the +// JSON is always regenerated in full for determinism — api-design §II.1). +func runWrite(root, output, chart string, doc CompatDoc, stdout, stderr io.Writer) int { + data, err := renderJSON(doc) + if err != nil { + fmt.Fprintf(stderr, "ERROR marshal compatibility.json: %v\n", err) + return 1 + } + if err := os.WriteFile(filepath.Join(root, output), data, 0o644); err != nil { + fmt.Fprintf(stderr, "ERROR write %s: %v\n", output, err) + return 1 + } + + readmeDoc := doc + if chart != "" { + readmeDoc = filterProduct(doc, chart) + } + if err := writeReadme(root, readmeDoc, stderr); err != nil { + fmt.Fprintf(stderr, "ERROR write README.md: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "wrote %s and README.md (%d products)\n", output, len(doc.Products)) + return 0 +} + +// filterProduct returns a copy of doc containing only the named product, so a +// --chart run touches just that README block. +func filterProduct(doc CompatDoc, chart string) CompatDoc { + filtered := CompatDoc{SchemaVersion: doc.SchemaVersion, GeneratedFrom: doc.GeneratedFrom, Products: map[string]Product{}} + if p, ok := doc.Products[chart]; ok { + filtered.Products[chart] = p + } + return filtered +} + +// runCheck compares the expected JSON + README against what is on disk without +// writing. Drift is reported as WARN on stderr but never fails the build in v1 +// (ADR-4): exit stays 0. A clean repo prints "ok". +func runCheck(root, output string, doc CompatDoc, stdout, stderr io.Writer) int { + drift := false + + // JSON drift. + expectedJSON, err := renderJSON(doc) + if err != nil { + fmt.Fprintf(stderr, "ERROR marshal compatibility.json: %v\n", err) + return 1 + } + actualJSON, err := os.ReadFile(filepath.Join(root, output)) + if err != nil || string(actualJSON) != string(expectedJSON) { + fmt.Fprintf(stderr, "WARN %s: compatibility JSON is stale\n", output) + drift = true + } + + // README drift: render expected README from the current one and compare. + readmePath := filepath.Join(root, "README.md") + current, err := os.ReadFile(readmePath) + if err != nil { + fmt.Fprintf(stderr, "ERROR read README.md: %v\n", err) + return 1 + } + // Render with a discarded stderr: check only reports drift, not the + // app-version WARNs (those surface during --write). + expectedLines, err := renderReadmeLines(root, strings.Split(string(current), "\n"), doc, io.Discard) + if err != nil { + fmt.Fprintf(stderr, "ERROR render README: %v\n", err) + return 1 + } + if strings.Join(expectedLines, "\n") != string(current) { + fmt.Fprintln(stderr, "WARN README.md: compatibility block is stale") + drift = true + } + + if drift { + fmt.Fprintln(stdout, "drift detected (non-blocking v1)") + } else { + fmt.Fprintln(stdout, "ok") + } + return 0 +} + +// buildDoc reads every chart under /charts and assembles the document, +// emitting WARN/INFO diagnostics to stderr. It never aborts on data problems +// (ADR-4); only environment errors (unreadable charts/ dir) propagate. +func buildDoc(root string, lister tagLister, stderr io.Writer) (CompatDoc, error) { + dirs, err := chartDirectories(root) + if err != nil { + return CompatDoc{}, fmt.Errorf("list charts: %w", err) + } + + // First pass: read all states so we know the full set of known products + // before running the V3 existence check. + states := make([]ChartState, 0, len(dirs)) + knownProducts := map[string]bool{} + for _, dir := range dirs { + state, err := readChartState(root, dir) + var badAnn *badAnnotationError + switch { + case errors.As(err, &badAnn): + fmt.Fprintln(stderr, Warning{SevWarn, badAnn.chart, "V1", err.Error()}.Line()) + case err != nil: + fmt.Fprintf(stderr, "WARN %s: cannot read Chart.yaml — %v\n", dir, err) + continue + } + if state.Name == "" { + fmt.Fprintf(stderr, "WARN %s: Chart.yaml has no name; skipping\n", dir) + continue + } + states = append(states, state) + knownProducts[state.Name] = true + } + + // Second pass: validate annotations, resolve windows, and build the document. + doc := CompatDoc{ + SchemaVersion: 1, + GeneratedFrom: "local", + Products: map[string]Product{}, + } + for _, state := range states { + emitWarnings(stderr, validateCompat(state.Name, state.ChartType, state.Compat, knownProducts)) + + rawTags, err := lister.listTags(state.Dir) + if err != nil { + // A tag-listing failure degrades to "no tags" (window = only N), + // never an abort (ADR-3): N is authoritative. + fmt.Fprintf(stderr, "WARN %s: cannot list tags — %v\n", state.Dir, err) + rawTags = nil + } + tagVers := parseTags(state.Dir, rawTags) + + // Release dates come from the same tags (creatordate). A failure here is + // non-fatal: cycles simply carry no Released (ADR-3, additive field). + tagDates, err := lister.listTagDates(state.Dir) + if err != nil { + fmt.Fprintf(stderr, "WARN %s: cannot read tag dates — %v\n", state.Dir, err) + tagDates = nil + } + releaseDates := releaseDatesByVersion(state.Dir, tagDates) + + cycles, ws := resolveWindow(state.Version, tagVers, releaseDates) + emitWarnings(stderr, tagChart(state.Name, ws)) + + // Attach declared requires/testedWith to the N cycle (index 0), the only + // cycle whose cross-compatibility we know from the current Chart.yaml. + if len(cycles) > 0 && state.Compat != nil { + if len(state.Compat.Requires) > 0 { + cycles[0].Requires = state.Compat.Requires + } + if len(state.Compat.TestedWith) > 0 { + cycles[0].TestedWith = state.Compat.TestedWith + } + } + + doc.Products[state.Name] = Product{ + Dir: state.Dir, + Current: state.Version, + AppVersion: state.AppVersion, + Cycles: cycles, + } + } + + return doc, nil +} + +// tagChart rewrites the Chart field of window warnings (which carry the raw +// version string) to the product name, for a consistent WARN/INFO contract. +func tagChart(name string, ws []Warning) []Warning { + for i := range ws { + ws[i].Chart = name + } + return ws +} diff --git a/.github/scripts/generate-compatibility/markers.go b/.github/scripts/generate-compatibility/markers.go new file mode 100644 index 000000000..68a029897 --- /dev/null +++ b/.github/scripts/generate-compatibility/markers.go @@ -0,0 +1,249 @@ +package main + +import ( + "fmt" + "strings" +) + +// beginMarker / endMarker build the HTML-comment sentinels for a chart's block. +// Only content strictly between them is ever rewritten (terraform-docs pattern), +// which keeps hand-written prose and irregular separators intact. +func beginMarker(chart string) string { return "" } +func endMarker(chart string) string { return "" } + +// replaceCompatBlock replaces the lines between the BEGIN/END markers for the +// given chart with blockBody, preserving everything outside the markers exactly. +// Returns found=false (and the document unchanged) when the BEGIN marker is +// absent, so the caller can decide to create a section instead. Returns an error +// for a malformed document (END without BEGIN, or BEGIN without END). +func replaceCompatBlock(lines []string, chart string, blockBody []string) ([]string, bool, error) { + begin, end := beginMarker(chart), endMarker(chart) + + beginIdx, endIdx := -1, -1 + for i, line := range lines { + switch strings.TrimSpace(line) { + case begin: + beginIdx = i + case end: + endIdx = i + } + } + + if beginIdx == -1 && endIdx == -1 { + return lines, false, nil + } + if beginIdx == -1 || endIdx == -1 || endIdx < beginIdx { + return nil, false, fmt.Errorf("malformed COMPAT markers for %q (begin=%d end=%d)", chart, beginIdx, endIdx) + } + + out := make([]string, 0, len(lines)-(endIdx-beginIdx)+len(blockBody)+2) + out = append(out, lines[:beginIdx+1]...) // keep everything up to & including BEGIN + out = append(out, blockBody...) // fresh body + out = append(out, lines[endIdx:]...) // END marker onward, unchanged + return out, true, nil +} + +// sectionHeaderIndex returns the line index of the "### " header for a +// chart, or -1 if the chart has no section (e.g. br-spi — ADR-5). It reuses the +// exact name-normalization contract of tableutil.ParseTableForChart so section +// matching stays consistent with the sibling README tooling: strip -helm, +// hyphens -> spaces, lowercase. +func sectionHeaderIndex(lines []string, chart string) int { + normalized := strings.ToLower(strings.TrimSuffix(chart, "-helm")) + normalized = strings.ReplaceAll(normalized, "-", " ") + + for i, line := range lines { + lower := strings.ToLower(line) + if strings.HasPrefix(lower, "### ") && strings.Contains(lower, normalized) { + return i + } + } + return -1 +} + +// appLabelsFor extracts ALL app-version column labels used in a chart's section +// mapping table, i.e. every "" of a header "| Chart Version | Version | +// [ Version...] | ... |" (e.g. ["Tracer"] or ["Fees", "UI"]). Preserving +// every column means the enriched COMPAT block never drops an existing app +// column (multi-app charts like plugin-fees). +// +// It scans only within the chart's section (its "### " header to the next +// "### "/"---" boundary). Crucially it reads the FIRST "| Chart Version |" +// header it finds — which, after the first run, is the header INSIDE the COMPAT +// block (the original table was replaced in place). To stay idempotent it stops +// collecting labels at the generated "Support" column (and never treats +// "Support"/"Requer ..." as app columns), so run 1 (original table) and run 2+ +// (generated block) yield the identical label set. Returns the fallback +// (title-cased chart name) when no mapping header exists. +func appLabelsFor(lines []string, chart string) []string { + fallback := []string{sectionTitle(chart)} + + start := sectionHeaderIndex(lines, chart) + if start == -1 { + return fallback + } + for i := start + 1; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + // Stop at the next section boundary. + if strings.HasPrefix(trimmed, "### ") || strings.HasPrefix(trimmed, "---") { + break + } + // Match a header row of the form "| Chart Version | Version | ... |". + if strings.HasPrefix(trimmed, "| Chart Version |") { + if labels := appLabelsFromHeader(trimmed); len(labels) > 0 { + return labels + } + } + } + return fallback +} + +// appLabelsFromHeader pulls the app-version labels out of a "| Chart Version | +// ... |" header row. It reads every column after "Chart Version" and stops at +// the first generated column ("Released" or "Support", whichever comes first), +// so a previously generated block header (which appends Released, Support and +// optional "Requer " columns) yields exactly the same labels as the +// original mapping table — the key to idempotency. +func appLabelsFromHeader(headerRow string) []string { + cells := splitTableCells(headerRow) + var labels []string + for _, cell := range cells[1:] { // skip "Chart Version" + cell = strings.TrimSpace(cell) + if cell == "" { + continue + } + if cell == releasedHeader || cell == "Support" || strings.HasPrefix(cell, requiresHeaderPrefix+" ") { + break // generated columns are not app labels + } + // Prefer the " Version" convention; keep verbatim otherwise so we + // never silently drop a column we don't recognize. + if label := strings.TrimSpace(strings.TrimSuffix(cell, " Version")); label != "" && label != cell { + labels = append(labels, label) + } else { + labels = append(labels, cell) + } + } + return labels +} + +// mappingTableRange locates the existing app-mapping markdown table inside a +// chart's section and returns [tableStart, tableEnd) line indices (tableEnd is +// exclusive), or (-1,-1) when the section or table is absent. The table is the +// run of lines starting at the "| Chart Version |" header and continuing while +// lines are table rows ("| ... |") — i.e. header + alignment separator + data +// rows. Scanning is confined to the chart's section (its "### " header to the +// next "### "/"---" boundary) so we never touch another chart's table. This is +// the in-place replacement target: the enriched COMPAT block takes the table's +// place, leaving the "#### Application Version Mapping" heading, prose, links and +// trailing "-----------------" separator untouched. +func mappingTableRange(lines []string, chart string) (int, int) { + start := sectionHeaderIndex(lines, chart) + if start == -1 { + return -1, -1 + } + for i := start + 1; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, "### ") || strings.HasPrefix(trimmed, "---") { + return -1, -1 // left the section without finding a table + } + if strings.HasPrefix(trimmed, "| Chart Version |") { + tableStart := i + end := i + 1 + for end < len(lines) { + t := strings.TrimSpace(lines[end]) + if strings.HasPrefix(t, "|") && strings.Count(t, "|") > 1 { + end++ + continue + } + break + } + return tableStart, end + } + } + return -1, -1 +} + +// splitTableCells splits a markdown table row "| a | b | c |" into ["a","b","c"]. +func splitTableCells(row string) []string { + trimmed := strings.Trim(strings.TrimSpace(row), "|") + parts := strings.Split(trimmed, "|") + cells := make([]string, 0, len(parts)) + for _, p := range parts { + cells = append(cells, strings.TrimSpace(p)) + } + return cells +} + +// sectionTitle renders the human title used when a chart has no README section +// (ADR-5): strip -helm, hyphens -> spaces, Title Case. e.g. "br-spi-helm" -> +// "Br Spi". Mirrors the tableutil normalization, then title-cases for display. +func sectionTitle(chart string) string { + base := strings.TrimSuffix(chart, "-helm") + words := strings.Split(strings.ReplaceAll(base, "-", " "), " ") + for i, w := range words { + if w == "" { + continue + } + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + return strings.Join(words, " ") +} + +// ensureCompatBlock guarantees the chart's COMPAT block reflects blockBody, +// choosing one of four paths (all idempotent): +// 1. markers already present -> replace their contents (replaceCompatBlock); +// 2. an app-mapping table exists in the section -> replace THAT table in place +// with markers+body, so the "#### Application Version Mapping" heading, prose +// and trailing separator stay put and no duplicate table is created; +// 3. section header present but no table -> inject markers+body just after the +// "### ..." header (rare: a section that never had a mapping table); +// 4. no section (ADR-5) -> append a minimal section (title + block) at the end. +func ensureCompatBlock(lines []string, chart string, blockBody []string) ([]string, error) { + // Path 1: markers exist -> replace their contents. + replaced, found, err := replaceCompatBlock(lines, chart, blockBody) + if err != nil { + return nil, err + } + if found { + return replaced, nil + } + + block := wrapBlock(chart, blockBody) + + // Path 2: an existing app-mapping table -> replace it in place. This is the + // common first-run path and the fix for the duplicate-table bug: the markers + // wrap the location the original table occupied. + if ts, te := mappingTableRange(lines, chart); ts != -1 { + out := make([]string, 0, len(lines)-(te-ts)+len(block)) + out = append(out, lines[:ts]...) // up to (not including) the old table + out = append(out, block...) // markers + enriched table + out = append(out, lines[te:]...) // everything after the old table + return out, nil + } + + // Path 3: section exists but has no mapping table -> inject after the header. + if idx := sectionHeaderIndex(lines, chart); idx != -1 { + out := make([]string, 0, len(lines)+len(block)+1) + out = append(out, lines[:idx+1]...) // through the "### ..." header + out = append(out, "") // blank line after header + out = append(out, block...) + out = append(out, lines[idx+1:]...) + return out, nil + } + + // Path 4: no section -> minimal section at end (ADR-5). + out := make([]string, 0, len(lines)+len(block)+3) + out = append(out, lines...) + out = append(out, "", "### "+sectionTitle(chart), "") + out = append(out, block...) + return out, nil +} + +// wrapBlock frames blockBody with the BEGIN/END markers for a chart. +func wrapBlock(chart string, blockBody []string) []string { + out := make([]string, 0, len(blockBody)+2) + out = append(out, beginMarker(chart)) + out = append(out, blockBody...) + out = append(out, endMarker(chart)) + return out +} diff --git a/.github/scripts/generate-compatibility/markers_test.go b/.github/scripts/generate-compatibility/markers_test.go new file mode 100644 index 000000000..12d4ae6b1 --- /dev/null +++ b/.github/scripts/generate-compatibility/markers_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "strings" + "testing" +) + +func lns(s string) []string { return strings.Split(s, "\n") } +func str(ls []string) string { return strings.Join(ls, "\n") } + +func TestReplaceCompatBlock(t *testing.T) { + t.Run("replaces only between markers, prose untouched", func(t *testing.T) { + doc := lns(`### Midaz Helm Chart + +Intro prose that must survive. + + +OLD CONTENT + + +----------------- + +### Next Chart`) + body := []string{"| Version | Support |", "| :---: | :---: |", "| `8.6.0` | 🟢 |"} + out, found, err := replaceCompatBlock(doc, "midaz-helm", body) + if err != nil { + t.Fatalf("err: %v", err) + } + if !found { + t.Fatal("expected found=true") + } + s := str(out) + if !strings.Contains(s, "Intro prose that must survive.") { + t.Error("prose above block was lost") + } + if !strings.Contains(s, "-----------------") { + t.Error("separator below block was lost") + } + if !strings.Contains(s, "### Next Chart") { + t.Error("next section header was lost") + } + if strings.Contains(s, "OLD CONTENT") { + t.Error("old content not replaced") + } + if !strings.Contains(s, "8.6.0") { + t.Error("new body not inserted") + } + // Markers themselves must be preserved exactly once each. + if strings.Count(s, "") != 1 || + strings.Count(s, "") != 1 { + t.Errorf("markers duplicated/lost:\n%s", s) + } + }) + + t.Run("idempotent: applying twice yields identical output", func(t *testing.T) { + doc := lns(` +whatever +`) + body := []string{"NEW"} + once, _, _ := replaceCompatBlock(doc, "x", body) + twice, _, _ := replaceCompatBlock(once, "x", body) + if str(once) != str(twice) { + t.Fatalf("not idempotent:\n--once--\n%s\n--twice--\n%s", str(once), str(twice)) + } + }) + + t.Run("markers absent => found=false, doc unchanged", func(t *testing.T) { + doc := lns("### Some Chart\n\nno markers here") + out, found, err := replaceCompatBlock(doc, "some-chart", []string{"BODY"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if found { + t.Fatal("expected found=false") + } + if str(out) != str(doc) { + t.Fatal("doc changed despite absent markers") + } + }) + + t.Run("only END marker => error (malformed)", func(t *testing.T) { + doc := lns("") + _, _, err := replaceCompatBlock(doc, "x", []string{"B"}) + if err == nil { + t.Fatal("expected error for END-without-BEGIN") + } + }) +} diff --git a/.github/scripts/generate-compatibility/render_readme.go b/.github/scripts/generate-compatibility/render_readme.go new file mode 100644 index 000000000..cadaf71e9 --- /dev/null +++ b/.github/scripts/generate-compatibility/render_readme.go @@ -0,0 +1,183 @@ +package main + +import ( + "fmt" + "sort" + "strings" +) + +// supportLabel maps a supported cycle's position (0=N, 1=N-1, ...) to its rich +// Support-column cell: badge + tier label + N-offset (data-model §1). The tier +// is presentation only — it is derived here from position, never persisted in +// the JSON. Positions >=4 are never supported (they collapse into the EOL row). +var supportLabel = []string{ + "🟢 Full (N)", + "🔵 Security (N-1)", + "🟡 Extended (N-2)", + "🟠 Extended (N-3)", +} + +// eolSupportLabel is the single summary cell for all end-of-life cycles. +const eolSupportLabel = "🔴 EOL" + +// requiresHeaderPrefix labels the optional cross-compat column. +const requiresHeaderPrefix = "Requer" + +// cellUnknown is the placeholder ("—") used wherever we do not have a confident +// value: historical/EOL app versions, historical/EOL requires cells, and extra +// app columns even on the N row (we only know one app version — the Chart.yaml +// appVersion — so extra columns are never invented). +const cellUnknown = "—" + +// renderCompatTable produces the enriched "Application Version Mapping" table +// for one product, wrapped later in COMPAT markers. It reuses ALL app-version +// column labels already present in the chart's README section (e.g. ["Tracer"] +// or ["Fees", "UI"]) so the enriched table never drops an existing column: +// +// | Chart Version | Version | [ Version...] | Support | [Requer ...] | +// +// Rows are one per cycle (ordered descending, index 0 = N), Support driven by +// position (Full/Security/Extended), and a single collapsed EOL row that +// references only the ceiling of the dead range as "≤ ". +// +// Value policy (no invention): +// - Only the N row carries real app versions. Each app column is filled from +// appVersions[label] (resolved from values.yaml {component}.image.tag by the +// shared tableutil extractor); a column with no resolved tag stays "—". +// - Historical/EOL app versions are "—". +// - The Requer column carries the declared range only on the N row; N-1..N-3 +// and EOL are "—" (in v1 the dev declares requires for the current version +// only; history is filled by E2E in v2). +// +// appVersions maps app LABEL (e.g. "Fees") to its resolved current tag. +func renderCompatTable(p Product, appLabels []string, appVersions map[string]string) []string { + if len(appLabels) == 0 { + // Defensive: always render at least one app-version column. + appLabels = []string{sectionTitle("")} + } + + // Split supported vs EOL, preserving descending order. + var supported, eol []Cycle + for _, c := range p.Cycles { + if c.Supported { + supported = append(supported, c) + } else { + eol = append(eol, c) + } + } + + // Distinct requires targets across supported cycles (sorted for determinism). + requireTargets := map[string]bool{} + for _, c := range supported { + for target := range c.Requires { + requireTargets[target] = true + } + } + var targets []string + for target := range requireTargets { + targets = append(targets, target) + } + sort.Strings(targets) + + // Header: Chart Version | Version... | Released | Support | [Requer ...] + headers := []string{"Chart Version"} + for _, lbl := range appLabels { + headers = append(headers, lbl+" Version") + } + headers = append(headers, releasedHeader, "Support") + for _, tgt := range targets { + headers = append(headers, fmt.Sprintf("%s %s", requiresHeaderPrefix, tgt)) + } + + lines := []string{tableRow(headers), separator(len(headers))} + + // appCells builds the app-version cells for a row. Only the N row carries + // real values, one per app column resolved from values.yaml; a column with + // no resolved tag (and every historical/EOL cell) stays "—". + appCells := func(isN bool) []string { + cells := make([]string, len(appLabels)) + for i, lbl := range appLabels { + cells[i] = cellUnknown + if isN { + if tag, ok := appVersions[lbl]; ok && tag != "" { + cells[i] = tag + } + } + } + return cells + } + + // requiresCells builds the Requer cells for a row. Only the N row carries the + // declared range; N-1..N-3 (and EOL) are "—". + requiresCells := func(c *Cycle) []string { + cells := make([]string, len(targets)) + for i, tgt := range targets { + if c != nil { + if rng, ok := c.Requires[tgt]; ok && rng != "" { + cells[i] = rng + continue + } + } + cells[i] = cellUnknown + } + return cells + } + + // Supported rows. + for i := range supported { + c := supported[i] + label := eolSupportLabel + if i < len(supportLabel) { + label = supportLabel[i] + } + isN := i == 0 + + row := []string{"`" + c.Latest + "`"} + row = append(row, appCells(isN)...) + row = append(row, releasedCell(c.Released), label) + if isN { + row = append(row, requiresCells(&c)...) + } else { + row = append(row, requiresCells(nil)...) + } + lines = append(lines, tableRow(row)) + } + + // Single collapsed EOL summary line: reference only the ceiling of the dead + // range ("≤ "), never the full cycle list. eol[0] is the + // highest EOL cycle because cycles arrive ordered descending. + if len(eol) > 0 { + row := []string{"`≤ " + eol[0].Latest + "`"} + row = append(row, appCells(false)...) + // Released is "—" for the EOL row: it aggregates multiple versions. + row = append(row, cellUnknown, eolSupportLabel) + row = append(row, requiresCells(nil)...) + lines = append(lines, tableRow(row)) + } + + return lines +} + +// releasedHeader is the column title for the release date (before Support). +const releasedHeader = "Released" + +// releasedCell renders a cycle's release-date cell: the ISO date, or "—" when +// unknown (e.g. a just-created N with no published tag yet). +func releasedCell(date string) string { + if date == "" { + return cellUnknown + } + return date +} + +func tableRow(cells []string) string { + return "| " + strings.Join(cells, " | ") + " |" +} + +func separator(n int) string { + seps := make([]string, n) + for i := range seps { + seps[i] = ":---:" + } + return "| " + strings.Join(seps, " | ") + " |" +} diff --git a/.github/scripts/generate-compatibility/render_readme_test.go b/.github/scripts/generate-compatibility/render_readme_test.go new file mode 100644 index 000000000..7893992cf --- /dev/null +++ b/.github/scripts/generate-compatibility/render_readme_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "strings" + "testing" +) + +func joined(lines []string) string { return strings.Join(lines, "\n") } + +func TestRenderCompatTable(t *testing.T) { + t.Run("single app col, Released before Support, EOL ceiling row", func(t *testing.T) { + p := Product{ + Dir: "midaz", Current: "8.6.0", + Cycles: []Cycle{ + {Cycle: "8.6", Latest: "8.6.0", Released: "2026-06-01", Supported: true}, + {Cycle: "8.5", Latest: "8.5.0", Released: "2026-05-01", Supported: true}, + {Cycle: "8.4", Latest: "8.4.0", Released: "2026-04-01", Supported: true}, + {Cycle: "8.3", Latest: "8.3.0", Released: "2026-03-01", Supported: true}, + {Cycle: "8.2", Latest: "8.2.0", Released: "2026-02-01", Supported: false}, + {Cycle: "8.1", Latest: "8.1.0", Released: "2026-01-01", Supported: false}, + }, + } + out := joined(renderCompatTable(p, []string{"Midaz"}, map[string]string{"Midaz": "3.7.8"})) + for _, must := range []string{ + "| Chart Version | Midaz Version | Released | Support |", + "| :---: | :---: | :---: | :---: |", + "| `8.6.0` | 3.7.8 | 2026-06-01 | 🟢 Full (N) |", + "| `8.5.0` | — | 2026-05-01 | 🔵 Security (N-1) |", + "| `8.4.0` | — | 2026-04-01 | 🟡 Extended (N-2) |", + "| `8.3.0` | — | 2026-03-01 | 🟠 Extended (N-3) |", + } { + if !strings.Contains(out, must) { + t.Errorf("missing %q in:\n%s", must, out) + } + } + // EOL row: ceiling reference, Released = — (aggregate). + if !strings.Contains(out, "| `≤ 8.2.0` | — | — | 🔴 EOL |") { + t.Errorf("expected EOL ceiling row with Released=— , got:\n%s", out) + } + if strings.Contains(out, "8.1") { + t.Errorf("EOL row must not list lower cycles (8.1 leaked):\n%s", out) + } + if strings.Count(out, "🔴") != 1 { + t.Errorf("expected exactly one EOL (🔴) line, got %d\n%s", strings.Count(out, "🔴"), out) + } + }) + + t.Run("multi app cols + Released + Requer coexist", func(t *testing.T) { + p := Product{ + Dir: "plugin-fees", Current: "7.2.0", + Cycles: []Cycle{ + {Cycle: "7.2", Latest: "7.2.0", Released: "2026-06-15", Supported: true, + Requires: map[string]string{"midaz-helm": ">=8.4.0 <9.0.0"}}, + {Cycle: "7.1", Latest: "7.1.0", Released: "2026-05-10", Supported: true}, + }, + } + out := joined(renderCompatTable(p, []string{"Fees", "UI"}, map[string]string{"Fees": "3.3.0", "UI": "3.0.0"})) + if !strings.Contains(out, "| Chart Version | Fees Version | UI Version | Released | Support | Requer midaz-helm |") { + t.Errorf("multi-app + Released + Requer header wrong:\n%s", out) + } + // N row: both app cols filled, Released set, Requer = real range. + if !strings.Contains(out, "| `7.2.0` | 3.3.0 | 3.0.0 | 2026-06-15 | 🟢 Full (N) | >=8.4.0 <9.0.0 |") { + t.Errorf("N row wrong:\n%s", out) + } + // N-1 row: app cols —, Released set (its own tag date), Requer —. + if !strings.Contains(out, "| `7.1.0` | — | — | 2026-05-10 | 🔵 Security (N-1) | — |") { + t.Errorf("N-1 row wrong:\n%s", out) + } + }) + + t.Run("unknown release date => Released — ; unresolved app col => — even on N", func(t *testing.T) { + p := Product{ + Dir: "plugin-fees", Current: "7.2.0", + Cycles: []Cycle{{Cycle: "7.2", Latest: "7.2.0", Supported: true}}, // no Released + } + out := joined(renderCompatTable(p, []string{"Fees", "UI"}, map[string]string{"Fees": "3.3.0"})) + if !strings.Contains(out, "| `7.2.0` | 3.3.0 | — | — | 🟢 Full (N) |") { + t.Errorf("expected UI=— and Released=— on N, got:\n%s", out) + } + }) + + t.Run("no requires => no Requer column (tracer pilot shape) with Released", func(t *testing.T) { + p := Product{ + Dir: "tracer", Current: "2.1.0", + Cycles: []Cycle{ + {Cycle: "2.1", Latest: "2.1.0", Released: "2026-06-18", Supported: true}, + {Cycle: "2.0", Latest: "2.0.0", Released: "2026-06-09", Supported: true}, + {Cycle: "1.0", Latest: "1.0.0", Released: "2026-01-30", Supported: true}, + }, + } + out := joined(renderCompatTable(p, []string{"Tracer"}, map[string]string{"Tracer": "1.0.0"})) + if strings.Contains(out, "Requer") { + t.Errorf("did not expect Requer column, got:\n%s", out) + } + if !strings.Contains(out, "| Chart Version | Tracer Version | Released | Support |") { + t.Errorf("expected Tracer header with Released, got:\n%s", out) + } + if !strings.Contains(out, "| `2.1.0` | 1.0.0 | 2026-06-18 | 🟢 Full (N) |") { + t.Errorf("expected N row, got:\n%s", out) + } + if !strings.Contains(out, "| `2.0.0` | — | 2026-06-09 | 🔵 Security (N-1) |") { + t.Errorf("expected N-1 row, got:\n%s", out) + } + if !strings.Contains(out, "| `1.0.0` | — | 2026-01-30 | 🟡 Extended (N-2) |") { + t.Errorf("expected N-2 row, got:\n%s", out) + } + if strings.Contains(out, "🔴") { + t.Errorf("all supported => no EOL line, got:\n%s", out) + } + }) +} diff --git a/.github/scripts/generate-compatibility/resolve_test.go b/.github/scripts/generate-compatibility/resolve_test.go new file mode 100644 index 000000000..425df9824 --- /dev/null +++ b/.github/scripts/generate-compatibility/resolve_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "testing" + + "github.com/Masterminds/semver/v3" +) + +func tagVers(t *testing.T, ss ...string) []*semver.Version { + t.Helper() + out := make([]*semver.Version, 0, len(ss)) + for _, s := range ss { + v, err := semver.NewVersion(s) + if err != nil { + t.Fatalf("bad version %q: %v", s, err) + } + out = append(out, v) + } + return out +} + +// cyclePairs flattens []Cycle to a compact [cycle, latest, supported] view. +func cyclePairs(cs []Cycle) []struct { + cycle string + latest string + supported bool +} { + out := make([]struct { + cycle string + latest string + supported bool + }, 0, len(cs)) + for _, c := range cs { + out = append(out, struct { + cycle string + latest string + supported bool + }{c.Cycle, c.Latest, c.Supported}) + } + return out +} + +func TestResolveWindow(t *testing.T) { + t.Run("N=8.6.0 with 8.6..8.2 => top-4 supported, 8.2 unsupported", func(t *testing.T) { + cs, ws := resolveWindow("8.6.0", tagVers(t, + "8.6.0", "8.5.0", "8.4.0", "8.3.0", "8.2.0"), nil) + got := cyclePairs(cs) + want := [][3]string{ + {"8.6", "8.6.0", "true"}, + {"8.5", "8.5.0", "true"}, + {"8.4", "8.4.0", "true"}, + {"8.3", "8.3.0", "true"}, + {"8.2", "8.2.0", "false"}, + } + assertCycles(t, got, want) + assertNoINFO(t, ws) + }) + + t.Run("N authoritative: chart=8.6.0 but tags stale at 8.5 => N cycle still present & supported", func(t *testing.T) { + cs, _ := resolveWindow("8.6.0", tagVers(t, "8.5.0", "8.4.0"), nil) + got := cyclePairs(cs) + // N=8.6 must appear as top cycle, supported, latest=8.6.0 (from Chart.yaml). + if got[0].cycle != "8.6" || got[0].supported != true || got[0].latest != "8.6.0" { + t.Fatalf("N cycle wrong: %+v", got[0]) + } + }) + + t.Run("0 tags => only N cycle + INFO", func(t *testing.T) { + cs, ws := resolveWindow("1.0.0", tagVers(t), nil) + if len(cs) != 1 || cs[0].Cycle != "1.0" || !cs[0].Supported || cs[0].Latest != "1.0.0" { + t.Fatalf("expected single supported N cycle, got %+v", cs) + } + if !hasINFO(ws) { + t.Fatal("expected INFO warning for 0 tags") + } + }) + + t.Run("<4 minors => only existing", func(t *testing.T) { + cs, _ := resolveWindow("3.0.0", tagVers(t, "3.0.0", "2.9.0"), nil) + got := cyclePairs(cs) + want := [][3]string{ + {"3.0", "3.0.0", "true"}, + {"2.9", "2.9.0", "true"}, + } + assertCycles(t, got, want) + }) + + t.Run("pre-release tag is ignored, does not create a cycle", func(t *testing.T) { + cs, _ := resolveWindow("8.6.0", tagVers(t, "8.6.0-beta.11", "8.6.0", "8.5.0"), nil) + for _, c := range cs { + if c.Cycle == "8.6" && c.Latest != "8.6.0" { + t.Fatalf("pre-release leaked into cycle: %+v", c) + } + } + if len(cs) != 2 { + t.Fatalf("expected 2 cycles (8.6, 8.5), got %d: %+v", len(cs), cs) + } + }) + + t.Run("higher tag than N is dropped (never exceeds N)", func(t *testing.T) { + // A tag 8.7.0 exists but Chart.yaml says N=8.6.0: 8.7 must NOT appear. + cs, _ := resolveWindow("8.6.0", tagVers(t, "8.7.0", "8.6.0", "8.5.0"), nil) + for _, c := range cs { + if c.Cycle == "8.7" { + t.Fatalf("cycle above N leaked in: %+v", cs) + } + } + if cs[0].Cycle != "8.6" { + t.Fatalf("top cycle should be N=8.6, got %q", cs[0].Cycle) + } + }) + + // Real pilot case: tracer N=2.1.0. Stable tags 1.0.0/2.0.0/2.1.0 plus many + // betas plus 2.2.0-beta.1 (a pre-release ABOVE N). Expect exactly the three + // stable minors, all supported (<4), no EOL, and no INFO (tags exist). + t.Run("tracer pilot: N=2.1.0, betas + 2.2.0-beta.1 above N discarded", func(t *testing.T) { + cs, ws := resolveWindow("2.1.0", tagVers(t, + "1.0.0", "1.0.0-beta.1", + "2.0.0", "2.0.0-beta.1", "2.0.0-beta.2", "2.0.0-beta.3", + "2.0.0-beta.4", "2.0.0-beta.5", "2.0.0-beta.6", "2.0.0-beta.7", + "2.1.0", "2.1.0-beta.1", "2.1.0-beta.2", + "2.2.0-beta.1"), nil) + got := cyclePairs(cs) + want := [][3]string{ + {"2.1", "2.1.0", "true"}, + {"2.0", "2.0.0", "true"}, + {"1.0", "1.0.0", "true"}, + } + assertCycles(t, got, want) + assertNoINFO(t, ws) + }) + + t.Run("release dates associate to each cycle by its latest version", func(t *testing.T) { + dates := map[string]string{ + "2.1.0": "2026-06-18", + "2.0.0": "2026-06-09", + "1.0.0": "2026-01-30", + // 2.2.0-beta.1 date intentionally present but must be ignored (pre-release). + "2.2.0-beta.1": "2026-07-01", + } + cs, _ := resolveWindow("2.1.0", tagVers(t, "1.0.0", "2.0.0", "2.1.0", "2.2.0-beta.1"), dates) + want := map[string]string{"2.1": "2026-06-18", "2.0": "2026-06-09", "1.0": "2026-01-30"} + if len(cs) != 3 { + t.Fatalf("expected 3 cycles, got %d: %+v", len(cs), cs) + } + for _, c := range cs { + if c.Released != want[c.Cycle] { + t.Errorf("cycle %s Released = %q, want %q", c.Cycle, c.Released, want[c.Cycle]) + } + } + }) + + t.Run("cycle with no known date has empty Released", func(t *testing.T) { + // N=5.0.0 with a tag but no date entry => Released stays "". + cs, _ := resolveWindow("5.0.0", tagVers(t, "5.0.0"), map[string]string{}) + if len(cs) != 1 || cs[0].Released != "" { + t.Fatalf("expected empty Released, got %+v", cs) + } + }) +} + +func TestParseTagDates(t *testing.T) { + raw := "tracer-v2.1.0 2026-06-18\ntracer-v2.0.0 2026-06-09\n\ntracer-v1.0.0\nmalformed-line-no-space\n" + got := parseTagDates(raw) + if got["tracer-v2.1.0"] != "2026-06-18" || got["tracer-v2.0.0"] != "2026-06-09" { + t.Errorf("dates not parsed: %v", got) + } + // A tag with no date field is omitted. + if _, ok := got["tracer-v1.0.0"]; ok { + t.Errorf("expected tracer-v1.0.0 omitted (no date), got %v", got) + } +} + +func TestReleaseDatesByVersion(t *testing.T) { + in := map[string]string{ + "tracer-v2.1.0": "2026-06-18", + "tracer-v2.2.0-beta.1": "2026-07-01", + "other-v1.0.0": "2020-01-01", // wrong prefix, ignored + "tracer-vNOTSEMVER": "2020-01-01", // unparseable, ignored + } + got := releaseDatesByVersion("tracer", in) + if got["2.1.0"] != "2026-06-18" { + t.Errorf("2.1.0 date = %q", got["2.1.0"]) + } + if got["2.2.0-beta.1"] != "2026-07-01" { + t.Errorf("pre-release date should still map by version string: %v", got) + } + if _, ok := got["1.0.0"]; ok { + t.Errorf("other-dir tag leaked: %v", got) + } +} + +func assertCycles(t *testing.T, got []struct { + cycle string + latest string + supported bool +}, want [][3]string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d cycles, want %d: %+v", len(got), len(want), got) + } + for i, w := range want { + sup := w[2] == "true" + if got[i].cycle != w[0] || got[i].latest != w[1] || got[i].supported != sup { + t.Errorf("cycle %d: got {%s %s %v}, want {%s %s %v}", + i, got[i].cycle, got[i].latest, got[i].supported, w[0], w[1], sup) + } + } +} + +func hasINFO(ws []Warning) bool { + for _, w := range ws { + if w.Severity == SevInfo { + return true + } + } + return false +} + +func assertNoINFO(t *testing.T, ws []Warning) { + t.Helper() + if hasINFO(ws) { + t.Errorf("unexpected INFO: %+v", ws) + } +} diff --git a/.github/scripts/generate-compatibility/run_test.go b/.github/scripts/generate-compatibility/run_test.go new file mode 100644 index 000000000..1c165a686 --- /dev/null +++ b/.github/scripts/generate-compatibility/run_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// seedRepo builds a minimal repo (charts/ + README.md) for run() tests. +func seedRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeChart(t, root, "matcher", `apiVersion: v2 +name: matcher-helm +type: application +version: 3.0.0 +`) + readme := "# Charts\n\n### Matcher\n\nprose\n" + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte(readme), 0o644); err != nil { + t.Fatalf("seed readme: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "docs"), 0o755); err != nil { + t.Fatalf("mkdir docs: %v", err) + } + return root +} + +func TestRun_ExitCodes(t *testing.T) { + t.Run("conflicting --write and --check => exit 2", func(t *testing.T) { + var out, errb bytes.Buffer + code := run([]string{"--write", "--check"}, &out, &errb) + if code != 2 { + t.Fatalf("exit = %d, want 2. stderr=%s", code, errb.String()) + } + }) + + t.Run("missing --root => exit 1", func(t *testing.T) { + var out, errb bytes.Buffer + code := run([]string{"--root", "/no/such/dir/xyz"}, &out, &errb) + if code != 1 { + t.Fatalf("exit = %d, want 1. stderr=%s", code, errb.String()) + } + }) + + t.Run("valid write => exit 0", func(t *testing.T) { + root := seedRepo(t) + var out, errb bytes.Buffer + code := run([]string{"--root", root, "--output", "docs/compatibility.json"}, &out, &errb) + if code != 0 { + t.Fatalf("exit = %d, want 0. stderr=%s", code, errb.String()) + } + if _, err := os.Stat(filepath.Join(root, "docs", "compatibility.json")); err != nil { + t.Fatalf("expected JSON written: %v", err) + } + }) +} diff --git a/.github/scripts/generate-compatibility/section_test.go b/.github/scripts/generate-compatibility/section_test.go new file mode 100644 index 000000000..2d04a5af8 --- /dev/null +++ b/.github/scripts/generate-compatibility/section_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "strings" + "testing" +) + +func TestSectionHeaderIndex(t *testing.T) { + doc := strings.Split(`# Charts + +### Midaz Helm Chart + +prose + +### Plugin Fees Helm Chart + +prose + +### Matcher + +prose`, "\n") + + tests := []struct { + chart string + want int // line index of the "### ..." header, -1 if absent + }{ + {"midaz-helm", 2}, + {"plugin-fees-helm", 6}, + {"matcher-helm", 10}, // normalizes to "matcher", matches "### Matcher" + {"br-spi-helm", -1}, // no section (ADR-5) + } + for _, tt := range tests { + t.Run(tt.chart, func(t *testing.T) { + got := sectionHeaderIndex(doc, tt.chart) + if got != tt.want { + t.Errorf("chart %q: got index %d, want %d", tt.chart, got, tt.want) + } + }) + } +} diff --git a/.github/scripts/generate-compatibility/state.go b/.github/scripts/generate-compatibility/state.go new file mode 100644 index 000000000..896ff1947 --- /dev/null +++ b/.github/scripts/generate-compatibility/state.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// ChartState is the normalized state read from one chart's Chart.yaml. +// Version is N, the absolute authority for the support window (ADR-3): +// it is NEVER derived from git tags. +type ChartState struct { + Name string // published chart name, e.g. "midaz-helm" + Dir string // directory under charts/, e.g. "midaz" (tag prefix) + Version string // N — from Chart.yaml.version + AppVersion string // informational (fills the N row's app-version cell) + ChartType string // lerian.studio/chart-type: single-service | multi-component | dependency-wrapper + Compat *CompatAnnotation +} + +// chartYAML is the minimal shape we parse out of Chart.yaml. +// Dependencies are not needed for the pilot; annotations carry compatibility. +type chartYAML struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + AppVersion string `yaml:"appVersion"` + Annotations map[string]string `yaml:"annotations"` +} + +// readChartState reads /charts//Chart.yaml and returns its +// normalized state. Returns an error if the file is missing or unparseable. +// A broken compatibility annotation is surfaced as a *badAnnotationError while +// still returning a usable state (name/version), so buildDoc downgrades it to a +// WARN and continues (ADR-4). +func readChartState(root, dir string) (ChartState, error) { + path := filepath.Join(root, "charts", dir, "Chart.yaml") + data, err := os.ReadFile(path) + if err != nil { + return ChartState{}, err + } + + var c chartYAML + if err := yaml.Unmarshal(data, &c); err != nil { + return ChartState{}, err + } + + chartType := c.Annotations[chartTypeAnnotationKey] + + compat, err := parseCompatAnnotation(c.Annotations[compatAnnotationKey]) + if err != nil { + // Broken embedded YAML (V1): surface as a tagged error so buildDoc can + // emit a WARN and continue. State is still usable (name/version known). + return ChartState{ + Name: c.Name, + Dir: dir, + Version: c.Version, + AppVersion: c.AppVersion, + ChartType: chartType, + }, &badAnnotationError{chart: c.Name, err: err} + } + + return ChartState{ + Name: c.Name, + Dir: dir, + Version: c.Version, + AppVersion: c.AppVersion, + ChartType: chartType, + Compat: compat, + }, nil +} + +// badAnnotationError marks a Chart.yaml whose compatibility annotation is +// unparseable (V1). The chart state is still returned; the caller downgrades +// this to a WARN rather than aborting (ADR-4). +type badAnnotationError struct { + chart string + err error +} + +func (e *badAnnotationError) Error() string { + return fmt.Sprintf("compatibility annotation is invalid YAML: %v", e.err) +} diff --git a/.github/scripts/generate-compatibility/state_test.go b/.github/scripts/generate-compatibility/state_test.go new file mode 100644 index 000000000..69c54b34e --- /dev/null +++ b/.github/scripts/generate-compatibility/state_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func writeChart(t *testing.T, root, dir, chartYAML string) { + t.Helper() + full := filepath.Join(root, "charts", dir) + if err := os.MkdirAll(full, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(full, "Chart.yaml"), []byte(chartYAML), 0o644); err != nil { + t.Fatalf("write Chart.yaml: %v", err) + } +} + +func TestReadChartState(t *testing.T) { + root := t.TempDir() + writeChart(t, root, "midaz", `apiVersion: v2 +name: midaz-helm +type: application +version: 8.6.0 +appVersion: "3.7.8" +`) + + got, err := readChartState(root, "midaz") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "midaz-helm" { + t.Errorf("Name = %q, want midaz-helm", got.Name) + } + if got.Dir != "midaz" { + t.Errorf("Dir = %q, want midaz", got.Dir) + } + if got.Version != "8.6.0" { + t.Errorf("Version = %q, want 8.6.0", got.Version) + } + if got.AppVersion != "3.7.8" { + t.Errorf("AppVersion = %q, want 3.7.8", got.AppVersion) + } +} + +func TestReadChartState_MissingFile(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "charts", "empty"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + _, err := readChartState(root, "empty") + if err == nil { + t.Fatal("expected error for missing Chart.yaml, got nil") + } +} + +func TestReadChartState_ParsesCompatAnnotation(t *testing.T) { + root := t.TempDir() + writeChart(t, root, "plugin-fees", `apiVersion: v2 +name: plugin-fees-helm +type: application +version: 7.2.0 +appVersion: "3.3.0" +annotations: + lerian.studio/chart-type: multi-component + lerian.studio/compatibility: | + requires: + midaz-helm: ">=8.4.0 <9.0.0" + testedWith: + midaz-helm: "8.6.0" +`) + + got, err := readChartState(root, "plugin-fees") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Compat == nil { + t.Fatal("Compat is nil, want parsed annotation") + } + if got.Compat.Requires["midaz-helm"] != ">=8.4.0 <9.0.0" { + t.Errorf("Requires[midaz-helm] = %q", got.Compat.Requires["midaz-helm"]) + } + if got.Compat.TestedWith["midaz-helm"] != "8.6.0" { + t.Errorf("TestedWith[midaz-helm] = %q", got.Compat.TestedWith["midaz-helm"]) + } +} + +func TestReadChartState_NoAnnotationIsNilCompat(t *testing.T) { + root := t.TempDir() + writeChart(t, root, "matcher", `apiVersion: v2 +name: matcher-helm +type: application +version: 3.0.0 +`) + got, err := readChartState(root, "matcher") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Compat != nil { + t.Errorf("Compat = %+v, want nil", got.Compat) + } +} diff --git a/.github/scripts/generate-compatibility/tags.go b/.github/scripts/generate-compatibility/tags.go new file mode 100644 index 000000000..256f22176 --- /dev/null +++ b/.github/scripts/generate-compatibility/tags.go @@ -0,0 +1,115 @@ +package main + +import ( + "os/exec" + "strings" + + "github.com/Masterminds/semver/v3" +) + +// tagLister abstracts the git tag lookup so tests can inject fixtures without +// touching a real repo. +type tagLister interface { + // listTags returns raw tag names matching the -v* glob for a chart dir. + listTags(dir string) ([]string, error) + // listTagDates returns a map of raw tag name -> release date (YYYY-MM-DD) + // for the -v* glob. The date is the tag's creatordate:short. Tags with + // no resolvable date are simply absent from the map. + listTagDates(dir string) (map[string]string, error) +} + +// gitTagLister is the real implementation, backed by `git tag --list`. +// It runs against the working tree at root. +type gitTagLister struct { + root string +} + +func (g gitTagLister) listTags(dir string) ([]string, error) { + cmd := exec.Command("git", "-C", g.root, "tag", "--list", dir+"-v*") + out, err := cmd.Output() + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + tags := make([]string, 0, len(lines)) + for _, l := range lines { + if s := strings.TrimSpace(l); s != "" { + tags = append(tags, s) + } + } + return tags, nil +} + +// listTagDates runs `git tag -l "-v*" --format='%(refname:short) %(creatordate:short)'` +// and returns tag name -> ISO date (YYYY-MM-DD). creatordate:short is already +// ISO-formatted. Lines without a date part are skipped. +func (g gitTagLister) listTagDates(dir string) (map[string]string, error) { + cmd := exec.Command("git", "-C", g.root, "tag", "-l", dir+"-v*", + "--format=%(refname:short) %(creatordate:short)") + out, err := cmd.Output() + if err != nil { + return nil, err + } + return parseTagDates(string(out)), nil +} + +// parseTagDates turns the " " lines of `git tag --format` output +// into a tag -> date map. Lines missing the date field are ignored. Kept as a +// pure function so it is unit-testable without a git repo. +func parseTagDates(raw string) map[string]string { + dates := map[string]string{} + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue // tag present but no date field + } + dates[fields[0]] = fields[1] + } + return dates +} + +// parseTags filters raw tags to those of the form "-v", strips the +// "-v" prefix, and parses each remainder with Masterminds/semver. Tags +// whose remainder is not valid semver are dropped. Pre-releases are KEPT here; +// segregation happens later (segregateStable). Output order mirrors input order. +func parseTags(dir string, rawTags []string) []*semver.Version { + prefix := dir + "-v" + out := make([]*semver.Version, 0, len(rawTags)) + for _, tag := range rawTags { + if !strings.HasPrefix(tag, prefix) { + continue + } + remainder := strings.TrimPrefix(tag, prefix) + v, err := semver.NewVersion(remainder) + if err != nil { + continue + } + out = append(out, v) + } + return out +} + +// releaseDatesByVersion maps a chart's tag dates (keyed by raw tag name) to a +// map keyed by semver .String() of the version, so resolveWindow can look up a +// cycle's release date by its latest version. Tags whose remainder is not valid +// semver are ignored. e.g. {"tracer-v2.1.0": "2026-06-18"} -> {"2.1.0": "2026-06-18"}. +func releaseDatesByVersion(dir string, tagDates map[string]string) map[string]string { + prefix := dir + "-v" + out := make(map[string]string, len(tagDates)) + for tag, date := range tagDates { + if !strings.HasPrefix(tag, prefix) { + continue + } + remainder := strings.TrimPrefix(tag, prefix) + v, err := semver.NewVersion(remainder) + if err != nil { + continue + } + out[v.String()] = date + } + return out +} diff --git a/.github/scripts/generate-compatibility/tags_test.go b/.github/scripts/generate-compatibility/tags_test.go new file mode 100644 index 000000000..3f459b8bd --- /dev/null +++ b/.github/scripts/generate-compatibility/tags_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "testing" +) + +func TestParseTags(t *testing.T) { + tests := []struct { + name string + dir string + raw []string + want []string // semver .String() values, order as returned + }{ + { + name: "filters by dir prefix and strips it", + dir: "midaz", + raw: []string{"midaz-v8.6.0", "midaz-v8.5.0", "plugin-fees-v7.2.0"}, + want: []string{"8.6.0", "8.5.0"}, + }, + { + name: "keeps semver pre-releases", + dir: "midaz", + raw: []string{"midaz-v8.6.0-beta.11", "midaz-v8.6.0"}, + want: []string{"8.6.0-beta.11", "8.6.0"}, + }, + { + name: "drops tags whose suffix is not parseable semver", + dir: "midaz", + raw: []string{"midaz-vLATEST", "midaz-v8.6.0", "midaz-vnightly"}, + want: []string{"8.6.0"}, + }, + { + name: "does not match a different dir that shares a prefix boundary", + dir: "plugin-fees", + raw: []string{"plugin-fees-v7.2.0", "plugin-fees-helm-v1.0.0"}, + want: []string{"7.2.0"}, + }, + { + name: "empty input", + dir: "br-spi", + raw: []string{}, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTags(tt.dir, tt.raw) + gotStr := make([]string, 0, len(got)) + for _, v := range got { + gotStr = append(gotStr, v.String()) + } + if len(gotStr) != len(tt.want) { + t.Fatalf("got %v (len %d), want %v (len %d)", gotStr, len(gotStr), tt.want, len(tt.want)) + } + for i := range tt.want { + if gotStr[i] != tt.want[i] { + t.Fatalf("index %d: got %q, want %q (full got %v)", i, gotStr[i], tt.want[i], gotStr) + } + } + }) + } +} diff --git a/.github/scripts/generate-compatibility/testdata/golden_two_products.json b/.github/scripts/generate-compatibility/testdata/golden_two_products.json new file mode 100644 index 000000000..41630026d --- /dev/null +++ b/.github/scripts/generate-compatibility/testdata/golden_two_products.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "generatedFrom": "test", + "products": { + "midaz-helm": { + "dir": "midaz", + "current": "8.6.0", + "cycles": [ + { + "cycle": "8.6", + "latest": "8.6.0", + "released": "2026-06-01", + "supported": true + }, + { + "cycle": "8.5", + "latest": "8.5.0", + "released": "2026-05-01", + "supported": true + }, + { + "cycle": "8.4", + "latest": "8.4.0", + "released": "2026-04-01", + "supported": true + }, + { + "cycle": "8.3", + "latest": "8.3.0", + "released": "2026-03-01", + "supported": true + }, + { + "cycle": "8.2", + "latest": "8.2.0", + "released": "2026-02-01", + "supported": false + } + ] + }, + "plugin-fees-helm": { + "dir": "plugin-fees", + "current": "7.2.0", + "cycles": [ + { + "cycle": "7.2", + "latest": "7.2.0", + "released": "2026-06-15", + "supported": true, + "requires": { + "midaz-helm": ">=8.4.0 <9.0.0" + }, + "testedWith": { + "midaz-helm": "8.6.0" + } + } + ] + } + } +} diff --git a/.github/scripts/generate-compatibility/testdata/readme_irregular_golden.md b/.github/scripts/generate-compatibility/testdata/readme_irregular_golden.md new file mode 100644 index 000000000..4eaf53200 --- /dev/null +++ b/.github/scripts/generate-compatibility/testdata/readme_irregular_golden.md @@ -0,0 +1,31 @@ +# Charts + +### Plugin Fees Helm Chart + +Fees prose. + +#### Application Version Mapping + + +| Chart Version | Fees Version | UI Version | Released | Support | Requer midaz-helm | +| :---: | :---: | :---: | :---: | :---: | :---: | +| `7.2.0` | 3.3.0 | — | — | 🟢 Full (N) | >=8.4.0 <9.0.0 | + + +----------------- + +### Matcher + +Matcher prose. + +#### Application Version Mapping + + +| Chart Version | Matcher Version | Released | Support | +| :---: | :---: | :---: | :---: | +| `3.0.0` | 1.0.0 | — | 🟢 Full (N) | + + +### Flowker + +Flowker prose. diff --git a/.github/scripts/generate-compatibility/testdata/readme_irregular_in.md b/.github/scripts/generate-compatibility/testdata/readme_irregular_in.md new file mode 100644 index 000000000..dd2efc4d0 --- /dev/null +++ b/.github/scripts/generate-compatibility/testdata/readme_irregular_in.md @@ -0,0 +1,27 @@ +# Charts + +### Plugin Fees Helm Chart + +Fees prose. + +#### Application Version Mapping + +| Chart Version | Fees Version | UI Version | +| :---: | :---: | :---: | +| `7.2.0` | 3.3.0 | `3.0.0` | + +----------------- + +### Matcher + +Matcher prose. + +#### Application Version Mapping + +| Chart Version | Matcher Version | +| :---: | :---: | +| `3.0.0` | 1.0.0 | + +### Flowker + +Flowker prose. diff --git a/.github/scripts/generate-compatibility/warn.go b/.github/scripts/generate-compatibility/warn.go new file mode 100644 index 000000000..9200ff384 --- /dev/null +++ b/.github/scripts/generate-compatibility/warn.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + "io" + "sort" +) + +// Severity is the stable, CI-parseable prefix (api-design §II.3). +type Severity string + +const ( + SevWarn Severity = "WARN" + SevInfo Severity = "INFO" +) + +// Warning is one diagnostic tied to a chart and a validation rule. +type Warning struct { + Severity Severity + Chart string + Rule string // e.g. "V3", "V4", "V6" + Detail string +} + +// Line renders the stable message contract: "WARN : ". +func (w Warning) Line() string { + return fmt.Sprintf("%s %s: %s — %s", w.Severity, w.Chart, w.Rule, w.Detail) +} + +// emitWarnings writes each warning as one line to stderr, sorted by chart then +// rule for deterministic output. Returns the count of SevWarn (not INFO). +func emitWarnings(stderr io.Writer, ws []Warning) int { + sorted := make([]Warning, len(ws)) + copy(sorted, ws) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].Chart != sorted[j].Chart { + return sorted[i].Chart < sorted[j].Chart + } + return sorted[i].Rule < sorted[j].Rule + }) + warnCount := 0 + for _, w := range sorted { + fmt.Fprintln(stderr, w.Line()) + if w.Severity == SevWarn { + warnCount++ + } + } + return warnCount +} diff --git a/.github/scripts/generate-compatibility/warn_test.go b/.github/scripts/generate-compatibility/warn_test.go new file mode 100644 index 000000000..bc7918648 --- /dev/null +++ b/.github/scripts/generate-compatibility/warn_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "testing" +) + +func rulesOf(ws []Warning) []string { + out := make([]string, 0, len(ws)) + for _, w := range ws { + out = append(out, w.Rule) + } + return out +} + +func containsRule(ws []Warning, rule string) bool { + for _, r := range rulesOf(ws) { + if r == rule { + return true + } + } + return false +} + +func TestValidateCompat(t *testing.T) { + known := map[string]bool{"midaz-helm": true, "plugin-fees-helm": true} + + tests := []struct { + name string + chartType string + ann *CompatAnnotation + wantRules []string // rules that MUST be present + absent []string // rules that must NOT be present + }{ + { + name: "single-service without compat => NO warnings (standalone)", + chartType: chartTypeSingleService, + ann: nil, + absent: []string{"V3", "V4", "V5", "V6", "CT"}, + }, + { + name: "multi-component without compat => V6 INFO reminder", + chartType: chartTypeMultiComponent, + ann: nil, + wantRules: []string{"V6"}, + }, + { + name: "dependency-wrapper without compat => V6 INFO reminder", + chartType: chartTypeDependencyWrapper, + ann: nil, + wantRules: []string{"V6"}, + }, + { + name: "missing chart-type without compat => CT WARN + V6 INFO (conservative)", + chartType: "", + ann: nil, + wantRules: []string{"CT", "V6"}, + }, + { + name: "single-service WITH requires => still validates V3/V4, no V6", + chartType: chartTypeSingleService, + ann: &CompatAnnotation{ + Requires: map[string]string{"midaz-ledger": "maior que 8"}, + }, + wantRules: []string{"V3", "V4"}, + absent: []string{"V6"}, + }, + { + name: "valid complete (multi-component) => no warnings", + chartType: chartTypeMultiComponent, + ann: &CompatAnnotation{ + Requires: map[string]string{"midaz-helm": ">=8.4.0 <9.0.0"}, + TestedWith: map[string]string{"midaz-helm": "8.6.0"}, + }, + absent: []string{"V3", "V4", "V5", "V6", "CT"}, + }, + { + name: "V3 unknown product in requires", + chartType: chartTypeMultiComponent, + ann: &CompatAnnotation{ + Requires: map[string]string{"midaz-ledger": ">=8.0.0"}, + }, + wantRules: []string{"V3"}, + }, + { + name: "V4 unparseable range", + chartType: chartTypeMultiComponent, + ann: &CompatAnnotation{ + Requires: map[string]string{"midaz-helm": "maior que 8"}, + }, + wantRules: []string{"V4"}, + }, + { + name: "V5 testedWith not an exact version", + chartType: chartTypeMultiComponent, + ann: &CompatAnnotation{ + TestedWith: map[string]string{"midaz-helm": ">=8.6.0"}, + }, + wantRules: []string{"V5"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validateCompat("plugin-fees-helm", tt.chartType, tt.ann, known) + for _, r := range tt.wantRules { + if !containsRule(got, r) { + t.Errorf("expected rule %s in %v", r, rulesOf(got)) + } + } + for _, r := range tt.absent { + if containsRule(got, r) { + t.Errorf("did not expect rule %s in %v", r, rulesOf(got)) + } + } + }) + } +} diff --git a/.github/scripts/generate-compatibility/window.go b/.github/scripts/generate-compatibility/window.go new file mode 100644 index 000000000..58a22174b --- /dev/null +++ b/.github/scripts/generate-compatibility/window.go @@ -0,0 +1,116 @@ +package main + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" +) + +// segregateStable drops every pre-release version (e.g. -beta.N, -HELM-N, +// -rc.N). Per TRD §2, pre-releases never become their own support cycle; they +// are removed before minors are computed. Input order is preserved for the +// survivors. +func segregateStable(vs []*semver.Version) []*semver.Version { + out := make([]*semver.Version, 0, len(vs)) + for _, v := range vs { + if v.Prerelease() != "" { + continue + } + out = append(out, v) + } + return out +} + +// minorKey renders the "MAJOR.MINOR" cycle key for a version. +func minorKey(v *semver.Version) string { + return fmt.Sprintf("%d.%d", v.Major(), v.Minor()) +} + +// groupByMinor buckets stable versions by their MAJOR.MINOR cycle, keeping the +// highest patch in each bucket. The result is deterministic regardless of input +// order because the winner is chosen by semver comparison, not by position. +func groupByMinor(vs []*semver.Version) map[string]*semver.Version { + latest := map[string]*semver.Version{} + for _, v := range vs { + key := minorKey(v) + if cur, ok := latest[key]; !ok || v.GreaterThan(cur) { + latest[key] = v + } + } + return latest +} + +// supportedWindowSize is the number of most-recent minor cycles that are marked +// supported (N..N-3). Cycles below that are supported=false. +const supportedWindowSize = 4 + +// resolveWindow builds the ordered support-window cycles for one chart. +// +// N = chartVersion (from Chart.yaml) is authoritative: its cycle is always +// present and supported, even when no matching tag exists (ADR-3, NFR-3). Tag +// history supplies N-1..N-3. Cycles strictly above N are discarded (a stray +// higher tag must never exceed the declared current version). The top +// supportedWindowSize distinct minors are supported=true; the rest false. +// +// Degradation (FR-7): 0 tags => only the N cycle (+ an INFO); <4 minors => only +// those that exist. +// +// releaseDates maps a version's semver .String() to its ISO release date +// (YYYY-MM-DD, from the tag). Each cycle's Released is set from the date of its +// latest version; a version with no known date leaves Released empty (""). +func resolveWindow(chartVersion string, tagVersions []*semver.Version, releaseDates map[string]string) ([]Cycle, []Warning) { + var warnings []Warning + + nVer, err := semver.NewVersion(chartVersion) + if err != nil { + // Chart.yaml version is unparseable: emit WARN, produce no cycles. + warnings = append(warnings, Warning{SevWarn, chartVersion, "N", fmt.Sprintf("Chart.yaml version %q is not valid semver", chartVersion)}) + return nil, warnings + } + nKey := minorKey(nVer) + + // Group stable tag versions by minor cycle. + byMinor := groupByMinor(segregateStable(tagVersions)) + + // Force the N cycle to exist, sourced from Chart.yaml (authority). If a tag + // for the N cycle also exists, keep the greater of the two as latest. + if existing, ok := byMinor[nKey]; !ok || nVer.GreaterThan(existing) { + byMinor[nKey] = nVer + } + + // Collect the "latest" version of each minor, drop any cycle above N, then + // sort descending by that latest version. + latests := make([]*semver.Version, 0, len(byMinor)) + for _, v := range byMinor { + if v.GreaterThan(nVer) { + continue // never exceed N + } + latests = append(latests, v) + } + sortSemverDesc(latests) + + cycles := make([]Cycle, 0, len(latests)) + for i, v := range latests { + cycles = append(cycles, Cycle{ + Cycle: minorKey(v), + Latest: v.String(), + Released: releaseDates[v.String()], // "" when the tag date is unknown + Supported: i < supportedWindowSize, + }) + } + + if len(tagVersions) == 0 { + warnings = append(warnings, Warning{SevInfo, chartVersion, "N", "no published tags; window = only N (Chart.yaml)"}) + } + + return cycles, warnings +} + +// sortSemverDesc performs a stable descending sort of semver versions in place. +func sortSemverDesc(vs []*semver.Version) { + for i := 1; i < len(vs); i++ { + for j := i; j > 0 && vs[j].GreaterThan(vs[j-1]); j-- { + vs[j], vs[j-1] = vs[j-1], vs[j] + } + } +} diff --git a/.github/scripts/generate-compatibility/window_test.go b/.github/scripts/generate-compatibility/window_test.go new file mode 100644 index 000000000..5f6effaf2 --- /dev/null +++ b/.github/scripts/generate-compatibility/window_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "sort" + "testing" + + "github.com/Masterminds/semver/v3" +) + +// mustVers parses a list of semver strings, failing the test on any error. +func mustVers(t *testing.T, ss ...string) []*semver.Version { + t.Helper() + out := make([]*semver.Version, 0, len(ss)) + for _, s := range ss { + v, err := semver.NewVersion(s) + if err != nil { + t.Fatalf("bad test version %q: %v", s, err) + } + out = append(out, v) + } + return out +} + +func TestSegregateStable(t *testing.T) { + tests := []struct { + name string + in []string + want []string + }{ + {"drops beta", []string{"8.6.0", "8.6.0-beta.11", "8.5.0"}, []string{"8.6.0", "8.5.0"}}, + {"drops HELM prerelease", []string{"1.0.0-HELM-94.1", "1.0.0"}, []string{"1.0.0"}}, + {"all stable kept", []string{"3.2.0", "3.1.0"}, []string{"3.2.0", "3.1.0"}}, + {"all prerelease => empty", []string{"2.0.0-rc.1", "2.0.0-beta.2"}, []string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := segregateStable(mustVers(t, tt.in...)) + gotStr := make([]string, 0, len(got)) + for _, v := range got { + gotStr = append(gotStr, v.String()) + } + if len(gotStr) != len(tt.want) { + t.Fatalf("got %v, want %v", gotStr, tt.want) + } + for i := range tt.want { + if gotStr[i] != tt.want[i] { + t.Fatalf("index %d: got %q want %q", i, gotStr[i], tt.want[i]) + } + } + }) + } +} + +func TestGroupByMinor(t *testing.T) { + tests := []struct { + name string + in []string + want map[string]string // "MAJOR.MINOR" -> latest patch .String() + }{ + { + name: "picks highest patch per minor", + in: []string{"8.6.0", "8.6.2", "8.6.1", "8.5.0", "8.5.3"}, + want: map[string]string{"8.6": "8.6.2", "8.5": "8.5.3"}, + }, + { + name: "single minor", + in: []string{"3.0.0"}, + want: map[string]string{"3.0": "3.0.0"}, + }, + { + name: "crosses majors", + in: []string{"9.0.0", "8.9.0", "8.9.5"}, + want: map[string]string{"9.0": "9.0.0", "8.9": "8.9.5"}, + }, + { + name: "empty", + in: []string{}, + want: map[string]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := groupByMinor(mustVers(t, tt.in...)) + if len(got) != len(tt.want) { + t.Fatalf("got %d cycles, want %d (%v)", len(got), len(tt.want), got) + } + for cycle, wantLatest := range tt.want { + v, ok := got[cycle] + if !ok { + t.Fatalf("missing cycle %q in %v", cycle, got) + } + if v.String() != wantLatest { + t.Errorf("cycle %q: got latest %q, want %q", cycle, v.String(), wantLatest) + } + } + }) + } +} + +// TestGroupByMinor_OrderIndependent proves grouping does not depend on input +// order (map iteration is random; the pick must be deterministic by value). +func TestGroupByMinor_OrderIndependent(t *testing.T) { + forward := mustVers(t, "8.6.0", "8.6.1", "8.6.2") + rev := mustVers(t, "8.6.2", "8.6.1", "8.6.0") + // shuffle-ish: sort rev descending to differ from forward + sort.Sort(sort.Reverse(semver.Collection(rev))) + a := groupByMinor(forward) + b := groupByMinor(rev) + if a["8.6"].String() != b["8.6"].String() { + t.Fatalf("order-dependent: %q vs %q", a["8.6"], b["8.6"]) + } + if a["8.6"].String() != "8.6.2" { + t.Fatalf("expected latest 8.6.2, got %q", a["8.6"]) + } +} diff --git a/.github/scripts/generate-compatibility/write_readme.go b/.github/scripts/generate-compatibility/write_readme.go new file mode 100644 index 000000000..485294966 --- /dev/null +++ b/.github/scripts/generate-compatibility/write_readme.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/LerianStudio/helm/.github/scripts/tableutil" +) + +// sortedProductNames returns the product keys in stable ascending order. +func sortedProductNames(doc CompatDoc) []string { + names := make([]string, 0, len(doc.Products)) + for name := range doc.Products { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// resolveAppVersions resolves the current (N-row) app version for each app +// column label of a chart, reading charts//values.yaml via the shared +// tableutil extractor (the same multi-column {component}.image.tag logic used by +// update-chart-version-readme, with a root image.tag fallback). It returns a +// map keyed by app LABEL (e.g. "Fees" -> "3.3.0") and a WARN per column whose +// tag could not be resolved, mirroring the sister tool's "Could not find" note. +// Only the current version is resolved; historical rows stay "—" (v1 reads the +// current state only — decision, not derived from tags). +func resolveAppVersions(root, dir string, appLabels []string) (map[string]string, []Warning) { + byLabel := map[string]string{} + if len(appLabels) == 0 { + return byLabel, nil + } + + // tableutil keys on the full "