diff --git a/docs/dev/github-release-process.md b/docs/dev/github-release-process.md index fe3c274a58..aa366d7420 100644 --- a/docs/dev/github-release-process.md +++ b/docs/dev/github-release-process.md @@ -388,7 +388,9 @@ recover a failed release-note step without moving the immutable source tag. Automatic chart and stack pin updates skip releases with a lower major, minor, or patch than the selected pin. A patch for an older minor must not downgrade the default branch. Update maintenance stack pins through their own reviewed -Pull Requests after the component artifacts are verified. +Pull Requests after the component artifacts are verified. A stack inventory +renders a source chart from its release tag, so keep a chart maintenance +branch after tagging from it; the inventory looks for the tag there. Run the affected stack checks before merging its maintenance branch. These merges can create stable GitHub source releases and inventories independently @@ -463,6 +465,13 @@ source chart tag that is an ancestor of the stack commit or of the recorded commit. A snapshot cut before the trailer existed is matched to a commit in the checkout with the same tree. +A patch on the train can also pin a chart version released after the +cut. The inventory accepts a chart tag outside the stack's history when +`origin/main` or the chart's maintenance branch for that minor, +`origin/release-X.Y`, contains it. The tag workflow's +full-history checkout fetches every branch. Any other chart tag is +rejected, so the inventory still renders only reviewed chart source. + The `VERSION` bump pull request is a single commit on top of the selected default-branch commit, pushed to `chore/release-train//vX.Y-to-vX.Y+1`. It carries the diff --git a/tools/docs-version-sync/resolved_inventory_publish.go b/tools/docs-version-sync/resolved_inventory_publish.go index 9e3fe66723..13b7c730e1 100644 --- a/tools/docs-version-sync/resolved_inventory_publish.go +++ b/tools/docs-version-sync/resolved_inventory_publish.go @@ -858,7 +858,12 @@ func materializeResolvedInventorySourceCharts( return nil, fmt.Errorf("source chart %s: %w", name, err) } if !inSource { - return nil, fmt.Errorf("source chart %s tag %s is not an ancestor of stack commit %s%s", name, tag, stackSource.Commit, lineage.describeOrigins()) + branch, branches := chartReleaseBranchContaining(repoRoot, tag, sourceChart.TagPrefix, release.Version) + if branch == "" { + return nil, fmt.Errorf("source chart %s tag %s is not an ancestor of stack commit %s%s, nor of chart release branch %s", + name, tag, stackSource.Commit, lineage.describeOrigins(), strings.Join(branches, " or ")) + } + fmt.Fprintf(os.Stderr, "source chart %s tag %s is outside stack commit %s; using the release from %s\n", name, tag, stackSource.Commit, branch) } chartPath := filepath.Join(chartRoot, name, release.Version) if err := extractResolvedInventoryGitChart(repoRoot, tag, sourceChart.Path, chartPath); err != nil { @@ -982,6 +987,33 @@ func releaseSnapshotOrigins(repoRoot, stackCommit string) ([]string, error) { return origins, nil } +const ( + // chartReleaseRemote holds a remote-tracking ref for every branch in the + // release workflow's full-history checkout. + chartReleaseRemote = "origin" + chartReleaseDefaultBranch = "main" +) + +// chartReleaseBranchContaining returns the reviewed branch a chart tag was +// released from, or "" with the branches it checked. A chart releases from +// the default branch or from its maintenance branch for the tag's minor, +// release-X.Y. A stack patch can pin such a release after its +// train was cut, when the tag is not in the stack's history. +func chartReleaseBranchContaining(repoRoot, tag, tagPrefix, version string) (string, []string) { + major, rest, _ := strings.Cut(version, ".") + minor, _, _ := strings.Cut(rest, ".") + branches := []string{ + chartReleaseRemote + "/" + chartReleaseDefaultBranch, + chartReleaseRemote + "/release-" + tagPrefix + major + "." + minor, + } + for _, branch := range branches { + if _, err := gitOutput(repoRoot, "merge-base", "--is-ancestor", tag, "refs/remotes/"+branch); err == nil { + return branch, nil + } + } + return "", branches +} + func extractResolvedInventoryGitChart(repoRoot, tag, sourcePath, destination string) error { raw, err := gitOutput(repoRoot, "archive", "--format=tar", tag, "--", sourcePath) if err != nil { diff --git a/tools/docs-version-sync/resolved_inventory_publish_test.go b/tools/docs-version-sync/resolved_inventory_publish_test.go index 84cfc53eb2..0fc64dcb00 100644 --- a/tools/docs-version-sync/resolved_inventory_publish_test.go +++ b/tools/docs-version-sync/resolved_inventory_publish_test.go @@ -290,6 +290,161 @@ func TestMaterializeResolvedInventorySourceChartsFollowsReleaseSnapshot(t *testi } } +// TestMaterializeResolvedInventorySourceChartsAcceptsChartReleaseBranches +// covers a stack that pins a chart version released outside the stack's own +// history: from the default branch after the stack's train was cut, or from +// the chart's maintenance branch. The release workflow's full-history checkout +// holds a remote-tracking ref for every branch. +func TestMaterializeResolvedInventorySourceChartsAcceptsChartReleaseBranches(t *testing.T) { + repo := initTestGitRepo(t) + git := func(args ...string) string { + t.Helper() + output, err := gitOutput(repo, args...) + if err != nil { + t.Fatal(err) + } + return trimOutput(output) + } + commit := func(parent, message string, files map[string]string) string { + t.Helper() + if parent != "" { + git("checkout", "--quiet", "--detach", parent) + } + for name, body := range files { + writeFile(t, filepath.Join(repo, filepath.FromSlash(name)), body) + } + git("add", "--all") + git("commit", "--quiet", "-m", message) + return git("rev-parse", "HEAD") + } + remoteBranch := func(name, commit string) { + t.Helper() + git("update-ref", "refs/remotes/origin/"+name, commit) + } + values := func(replicas string) map[string]string { + return map[string]string{"deploy/helm/example/values.yaml": "replicas: " + replicas + "\n"} + } + + const statePath = "deploy/stacks/self-managed/helmfile.d/state.yaml.gotmpl" + stateBody := "releases:\n - name: example\n chart: nvcf/helm-example\n version: 1.0.0\n" + sourceCharts := map[string]resolvedInventorySourceChart{ + "helm-example": {TagPrefix: "deploy/helm/example/v", Path: "deploy/helm/example"}, + } + + seed := commit("", "chore: seed", map[string]string{"README.md": "seed\n"}) + chart := commit(seed, "feat: example chart", map[string]string{ + "deploy/helm/example/Chart.yaml": "apiVersion: v2\nname: helm-example\nversion: 0.0.0\n", + "deploy/helm/example/values.yaml": "replicas: 1\n", + "deploy/helm/other/Chart.yaml": "apiVersion: v2\nname: helm-other\nversion: 0.0.0\n", + }) + git("tag", "deploy/helm/example/v1.0.0") + base := commit(chart, "feat: pin example chart", map[string]string{statePath: stateBody}) + cut := git("commit-tree", git("rev-parse", base+"^{tree}"), "-p", seed, + "-m", releaseSnapshotSubject, "-m", releaseSnapshotTrailer+": "+base) + patch := git("commit-tree", git("rev-parse", cut+"^{tree}"), "-p", cut, "-m", "fix(stack): pin a newer chart") + + // Released from the default branch after the train was cut. + commit(base, "fix(chart): example 1.1.0", values("2")) + git("tag", "deploy/helm/example/v1.1.0") + git("tag", "deploy/helm/other/v1.5.0") + remoteBranch("main", commit("HEAD", "docs: later default-branch change", map[string]string{"README.md": "later\n"})) + + // Released from the chart's 1.0 maintenance branch, which also carries a + // tag that belongs to another minor. + commit(chart, "fix(chart): backport to 1.0", values("3")) + git("tag", "deploy/helm/example/v1.0.1") + commit("HEAD", "fix(chart): mislabeled release", values("4")) + git("tag", "deploy/helm/example/v1.4.0") + remoteBranch("release-deploy/helm/example/v1.0", git("rev-parse", "HEAD")) + + // Tags at commits that no release branch of the chart contains. + remoteBranch("feature/unreviewed", commit(base, "feat: unreviewed chart change", values("5"))) + git("tag", "deploy/helm/example/v1.2.0") + remoteBranch("release-deploy/helm/other/v1.3", commit(chart, "fix(other): another chart's backport", values("6"))) + git("tag", "deploy/helm/example/v1.3.0") + + materialize := func(t *testing.T, stackCommit, chartVersion, chartRoot string) ([]string, error) { + t.Helper() + stateFile := filepath.Join(t.TempDir(), "state.yaml.gotmpl") + writeFile(t, stateFile, stateBody) + fullRaw := mustJSON(t, []helmfileRelease{{Name: "example", Chart: "nvcf/helm-example", Version: chartVersion}}) + return materializeResolvedInventorySourceCharts( + repo, + stateFile, + chartRoot, + stackSourceRelease{Version: "1.1.1", Tag: "deploy/stacks/self-managed/v1.1.1", Commit: stackCommit}, + fullRaw, + sourceCharts, + false, + ) + } + + accepted := []struct { + name string + stackCommit string + chartVersion string + wantReplicas string + }{ + {name: "train patch pins a default-branch release after the cut", stackCommit: patch, chartVersion: "1.1.0", wantReplicas: "2"}, + {name: "train patch pins a chart maintenance release", stackCommit: patch, chartVersion: "1.0.1", wantReplicas: "3"}, + {name: "default-branch stack pins a chart maintenance release", stackCommit: base, chartVersion: "1.0.1", wantReplicas: "3"}, + } + for _, test := range accepted { + t.Run(test.name, func(t *testing.T) { + chartRoot := t.TempDir() + used, err := materialize(t, test.stackCommit, test.chartVersion, chartRoot) + if err != nil { + t.Fatalf("materialize source charts: %v", err) + } + if !reflect.DeepEqual(used, []string{"helm-example"}) { + t.Fatalf("used source charts = %v, want [helm-example]", used) + } + raw, err := os.ReadFile(filepath.Join(chartRoot, "helm-example", test.chartVersion, "values.yaml")) + if err != nil { + t.Fatal(err) + } + if got, want := string(raw), "replicas: "+test.wantReplicas+"\n"; got != want { + t.Fatalf("extracted values.yaml = %q, want %q from the pinned tag", got, want) + } + }) + } + + rejected := []struct { + name string + chartVersion string + wantErr string + }{ + { + name: "tag on an unmerged branch", + chartVersion: "1.2.0", + wantErr: "nor of chart release branch origin/main or origin/release-deploy/helm/example/v1.2", + }, + { + name: "tag only on another chart's maintenance branch", + chartVersion: "1.3.0", + wantErr: "nor of chart release branch origin/main or origin/release-deploy/helm/example/v1.3", + }, + { + name: "tag only on the maintenance branch of another minor", + chartVersion: "1.4.0", + wantErr: "nor of chart release branch origin/main or origin/release-deploy/helm/example/v1.4", + }, + { + name: "another chart's release tag at the pinned version", + chartVersion: "1.5.0", + wantErr: "is unavailable at deploy/helm/example/v1.5.0", + }, + } + for _, test := range rejected { + t.Run(test.name, func(t *testing.T) { + _, err := materialize(t, patch, test.chartVersion, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want %q", err, test.wantErr) + } + }) + } +} + func TestCollectResolvedStackInventoryBuildsConfiguredSelfManagedStates(t *testing.T) { t.Setenv("NVCF_RELEASE_NGC_API_KEY", "test-api-key") t.Setenv("NVCF_RELEASE_HELM_REGISTRY", "registry.example.test/release/charts")