Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/dev/github-release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,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
Expand Down Expand Up @@ -404,6 +406,13 @@ release branch is a parallel history: its tags are not ancestors of
`main`, which is why `release_baseline_version` filters on
reachability.

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-<tag prefix>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.

Re-running `branch-cut` is safe. An existing release branch whose tree
matches the selected base is reused, one whose tree differs is an
error, and an open bump pull request is reported rather than replaced.
Expand Down
34 changes: 33 additions & 1 deletion tools/docs-version-sync/resolved_inventory_publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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-<tagPrefix>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 {
Expand Down
155 changes: 155 additions & 0 deletions tools/docs-version-sync/resolved_inventory_publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading