diff --git a/Makefile b/Makefile index 4a53fd06153..ad041b48ebc 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ K8S_VERSION = v1.17.2 REPO = github.com/operator-framework/operator-sdk BUILD_PATH = $(REPO)/cmd/operator-sdk PKGS = $(shell go list ./... | grep -v /vendor/) -TEST_PKGS = $(shell go list ./... | grep -v -E 'github.com/operator-framework/operator-sdk/(hack/|test/)') +TEST_PKGS = $(shell go list ./... | grep -v -E 'github.com/operator-framework/operator-sdk/test/') SOURCES = $(shell find . -name '*.go' -not -path "*/vendor/*") ANSIBLE_BASE_IMAGE = quay.io/operator-framework/ansible-operator @@ -98,6 +98,9 @@ gen-cli-doc: ## Generate CLI documentation gen-test-framework: build/operator-sdk ## Run generate commands to update test/test-framework ./hack/generate/gen-test-framework.sh +gen-changelog: ## Generate CHANGELOG.md and migration guide updates + ./hack/generate/gen-changelog.sh + generate: gen-cli-doc gen-test-framework ## Run all generate targets .PHONY: generate gen-cli-doc gen-test-framework diff --git a/changelog/fragments/00-template.yaml b/changelog/fragments/00-template.yaml new file mode 100644 index 00000000000..44f68b5ba65 --- /dev/null +++ b/changelog/fragments/00-template.yaml @@ -0,0 +1,36 @@ +# entries is a list of entries to include in +# release notes and/or the migration guide +entries: + - description: > + Description is the line that shows up in the CHANGELOG. This + should be formatted as markdown and be on a single line. Using + the YAML string '>' operator means you can write your entry + multiple lines and it will still be parsed as a single line. + + # kind is one of: + # - addition + # - change + # - deprecation + # - removal + # - bugfix + kind: "" + + # Is this a breaking change? + breaking: false + + # NOTE: ONLY USE `pull_request_override` WHEN ADDING THIS + # FILE FOR A PREVIOUSLY MERGED PULL_REQUEST! + # + # The generator auto-detects the PR number from the commit + # message in which this file was originally added. + # + # What is the pull request number (without the "#")? + # pull_request_override: 0 + + + # Migration can be defined to automatically add a section to + # the migration guide. This is required for breaking changes. + migration: + header: Header text for the migration section + body: > + Body of the migration section. diff --git a/doc/dev/release.md b/doc/dev/release.md index 243c4f8f8b0..4631ac02290 100644 --- a/doc/dev/release.md +++ b/doc/dev/release.md @@ -2,7 +2,7 @@ Making an Operator SDK release involves: -- Updating `CHANGELOG.md`. +- Updating `CHANGELOG.md` and migration guide. - Tagging and signing a git commit and pushing the tag to GitHub. - Building a release binary and signing the binary - Creating a release by uploading binary, signature, and `CHANGELOG.md` updates for the release to GitHub. @@ -193,7 +193,7 @@ $ git push origin release-v1.3.1 Create a PR from `release-v1.3.1` to `v1.3.x`. Once CI passes and your PR is merged, continue to step 1. -### 1. Create a PR for release version and CHANGELOG.md updates +### 1. Create a PR for release version, CHANGELOG.md, and migration guide updates Once all PR's needed for a release have been merged, branch from `master`: @@ -215,14 +215,22 @@ Create a new branch to push release commits: $ git checkout -b release-v1.3.0 ``` +Run the CHANGELOG and migration guide generator: + +```sh +$ GEN_CHANGELOG_TAG=v1.3.0 make gen-changelog +``` + Commit the following changes: - `version/version.go`: update `Version` to `v1.3.0`. - `internal/scaffold/go_mod.go`, change the `require` line version for `github.com/operator-framework/operator-sdk` from `master` to `v1.3.0`. - `internal/scaffold/helm/go_mod.go`: same as for `internal/scaffold/go_mod.go`. - `internal/scaffold/ansible/go_mod.go`: same as for `internal/scaffold/go_mod.go`. -- `CHANGELOG.md`: update the `## Unreleased` header to `## v1.3.0`. - `doc/user/install-operator-sdk.md`: update the linux and macOS URLs to point to the new release URLs. +- `CHANGELOG.md`: commit changes (updated by changelog generation). +- `website/content/en/docs/migration/v1.3.0.md`: commit changes (created by changelog generation). +- `changelog/fragments/*`: commit deleted fragment files (deleted by changelog generation). _(Non-patch releases only)_ Lock down the master branch to prevent further commits between this and step 4. See [this section](#locking-down-branches) for steps to do so. @@ -254,7 +262,7 @@ Once this tag passes CI, go to step 3. For more info on tagging, see the [releas **Note:** If CI fails for some reason, you will have to revert the tagged commit, re-commit, and make a new PR. -### 3. Create a PR for post-release version and CHANGELOG.md updates +### 3. Create a PR for post-release version updates Check out a new branch from master (or use your `release-v1.3.0` branch) and commit the following changes: @@ -262,21 +270,6 @@ Check out a new branch from master (or use your `release-v1.3.0` branch) and com - `internal/scaffold/go_mod.go`, change the `require` line version for `github.com/operator-framework/operator-sdk` from `v1.3.0` to `master`. - `internal/scaffold/helm/go_mod.go`: same as for `internal/scaffold/go_mod.go`. - `internal/scaffold/ansible/go_mod.go`: same as for `internal/scaffold/go_mod.go`. -- `CHANGELOG.md`: add the following as a new set of headers above `## v1.3.0`: - - ```markdown - ## Unreleased - - ### Added - - ### Changed - - ### Deprecated - - ### Removed - - ### Bug Fixes - ``` Create a new PR for this branch, targetting the `master` branch. Once this PR passes CI and is merged, `master` can be unfrozen. diff --git a/hack/generate/changelog/gen-changelog.go b/hack/generate/changelog/gen-changelog.go new file mode 100644 index 00000000000..fcdf4de705c --- /dev/null +++ b/hack/generate/changelog/gen-changelog.go @@ -0,0 +1,73 @@ +package main + +import ( + "flag" + "fmt" + "path/filepath" + "strings" + + "github.com/blang/semver" + log "github.com/sirupsen/logrus" + + "github.com/operator-framework/operator-sdk/hack/generate/changelog/util" +) + +const repo = "github.com/operator-framework/operator-sdk" + +func main() { + var ( + tag string + fragmentsDir string + changelogFile string + migrationDir string + validateOnly bool + ) + + flag.StringVar(&tag, "tag", "", + "Title for generated CHANGELOG and migration guide sections") + flag.StringVar(&fragmentsDir, "fragments-dir", filepath.Join("changelog", "fragments"), + "Path to changelog fragments directory") + flag.StringVar(&changelogFile, "changelog", "CHANGELOG.md", + "Path to CHANGELOG") + flag.StringVar(&migrationDir, "migration-guide-dir", + filepath.Join("website", "content", "en", "docs", "migration"), + "Path to migration guide directory") + flag.BoolVar(&validateOnly, "validate-only", false, + "Only validate fragments") + flag.Parse() + + if tag == "" && !validateOnly { + log.Fatalf("flag '-tag' is required without '-validate-only'") + } + + entries, err := util.LoadEntries(fragmentsDir, repo) + if err != nil { + log.Fatalf("failed to load fragments: %v", err) + } + if len(entries) == 0 { + log.Warnf("no entries found") + } + + if validateOnly { + return + } + + version, err := semver.Parse(strings.TrimPrefix(tag, "v")) + if err != nil { + log.Fatalf("flag '-tag' is not a valid semantic version: %v", err) + } + if len(version.Pre) > 0 || len(version.Build) > 0 { + log.Fatalf("flag '-tag' must not include a build number or pre-release identifiers") + } + + cl := util.ChangelogFromEntries(version, entries) + if err := cl.WriteFile(changelogFile); err != nil { + log.Fatalf("failed to update CHANGELOG: %v", err) + } + + mg := util.MigrationGuideFromEntries(version, entries) + mgFile := filepath.Join(migrationDir, fmt.Sprintf("v%s.md", version)) + if err := mg.WriteFile(mgFile); err != nil { + log.Fatalf("failed to create migration guide: %v", err) + } +} diff --git a/hack/generate/changelog/gen-changelog.sh b/hack/generate/changelog/gen-changelog.sh new file mode 100755 index 00000000000..f208cb400a8 --- /dev/null +++ b/hack/generate/changelog/gen-changelog.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +source ./hack/lib/common.sh +set -e +shopt -s extglob + +[[ -n "$GEN_CHANGELOG_TAG" ]] || fatal "Must set GEN_CHANGELOG_TAG (e.g. export GEN_CHANGELOG_TAG=v1.2.3)" +go run ./hack/generate/changelog/gen-changelog.go -tag="${GEN_CHANGELOG_TAG}" +rm ./changelog/fragments/!(00-template.yaml) diff --git a/hack/generate/changelog/util/changelog.go b/hack/generate/changelog/util/changelog.go new file mode 100644 index 00000000000..b04d8fca164 --- /dev/null +++ b/hack/generate/changelog/util/changelog.go @@ -0,0 +1,129 @@ +package util + +import ( + "bytes" + "errors" + "fmt" + "io/ioutil" + "os" + "strings" + "text/template" + + "github.com/blang/semver" +) + +type Changelog struct { + Version string + Additions []ChangelogEntry + Changes []ChangelogEntry + Removals []ChangelogEntry + Deprecations []ChangelogEntry + Bugfixes []ChangelogEntry + + Repo string +} + +type ChangelogEntry struct { + Description string + Link string +} + +const changelogTemplate = `## {{ .Version }} +{{- if or .Additions .Changes .Removals .Deprecations .Bugfixes -}} +{{- with .Additions }} + +### Additions +{{ range . }} +- {{ .Description }}{{ if .Link }} ({{ .Link }}){{ end }} +{{- end }}{{- end }} +{{- with .Changes }} + +### Changes +{{ range . }} +- {{ .Description }}{{ if .Link }} ({{ .Link }}){{ end }} +{{- end }}{{- end }} +{{- with .Removals }} + +### Removals +{{ range . }} +- {{ .Description }}{{ if .Link }} ({{ .Link }}){{ end }} +{{- end }}{{- end }} +{{- with .Deprecations }} + +### Deprecations +{{ range . }} +- {{ .Description }}{{ if .Link }} ({{ .Link }}){{ end }} +{{- end }}{{- end }} +{{- with .Bugfixes }} + +### Bug Fixes +{{ range . }} +- {{ .Description }}{{ if .Link }} ({{ .Link }}){{ end }} +{{- end }}{{- end }}{{- else }} + +No changes for this release!{{ end }} +` + +var changelogTmpl = template.Must(template.New("changelog").Parse(changelogTemplate)) + +func (c *Changelog) Template() ([]byte, error) { + w := &bytes.Buffer{} + if err := changelogTmpl.Execute(w, c); err != nil { + return nil, err + } + return w.Bytes(), nil +} + +func (c *Changelog) WriteFile(path string) error { + data, err := c.Template() + if err != nil { + return err + } + existingFile, err := ioutil.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if errors.Is(err, os.ErrNotExist) || len(existingFile) == 0 { + return ioutil.WriteFile(path, data, 0644) + } + + data = append(data, '\n') + data = append(data, existingFile...) + return ioutil.WriteFile(path, data, 0644) +} + +func ChangelogFromEntries(version semver.Version, entries []FragmentEntry) Changelog { + cl := Changelog{ + Version: fmt.Sprintf("v%s", version), + } + for _, e := range entries { + cle := e.toChangelogEntry() + switch e.Kind { + case Addition: + cl.Additions = append(cl.Additions, cle) + case Change: + cl.Changes = append(cl.Changes, cle) + case Removal: + cl.Removals = append(cl.Removals, cle) + case Deprecation: + cl.Deprecations = append(cl.Deprecations, cle) + case Bugfix: + cl.Bugfixes = append(cl.Bugfixes, cle) + } + } + return cl +} + +func (e *FragmentEntry) toChangelogEntry() ChangelogEntry { + cle := ChangelogEntry{} + desc := strings.TrimSpace(e.Description) + if e.Breaking { + desc = fmt.Sprintf("**Breaking change**: %s", desc) + } + if !strings.HasSuffix(desc, ".") && !strings.HasSuffix(desc, "!") { + desc = fmt.Sprintf("%s.", desc) + } + cle.Description = desc + cle.Link = e.PullRequestLink + return cle +} diff --git a/hack/generate/changelog/util/changelog_test.go b/hack/generate/changelog/util/changelog_test.go new file mode 100644 index 00000000000..a5d261a7272 --- /dev/null +++ b/hack/generate/changelog/util/changelog_test.go @@ -0,0 +1,536 @@ +package util + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + "github.com/blang/semver" + "github.com/stretchr/testify/assert" +) + +func getChangelogEntries(n int) []ChangelogEntry { + entries := make([]ChangelogEntry, n) + for i := 0; i < n; i++ { + entries[i] = ChangelogEntry{ + Description: fmt.Sprintf("Changelog entry description %d.", i), + Link: "[#999999](https://example.com/test/changelog/pulls/999999)", + } + } + return entries +} + +func TestChangelog_Template(t *testing.T) { + testCases := []struct { + name string + changelog Changelog + output string + }{ + { + name: "all with 1 entry", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(1), + Changes: getChangelogEntries(1), + Removals: getChangelogEntries(1), + Deprecations: getChangelogEntries(1), + Bugfixes: getChangelogEntries(1), + }, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "all with 2 entries", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(2), + Changes: getChangelogEntries(2), + Removals: getChangelogEntries(2), + Deprecations: getChangelogEntries(2), + Bugfixes: getChangelogEntries(2), + }, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "no additions", + changelog: Changelog{ + Version: "v999.999.999", + Additions: nil, + Changes: getChangelogEntries(1), + Removals: getChangelogEntries(1), + Deprecations: getChangelogEntries(1), + Bugfixes: getChangelogEntries(1), + }, + output: `## v999.999.999 + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "no changes", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(1), + Changes: nil, + Removals: getChangelogEntries(1), + Deprecations: getChangelogEntries(1), + Bugfixes: getChangelogEntries(1), + }, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "no removals", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(1), + Changes: getChangelogEntries(1), + Removals: nil, + Deprecations: getChangelogEntries(1), + Bugfixes: getChangelogEntries(1), + }, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "no deprecations", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(1), + Changes: getChangelogEntries(1), + Removals: getChangelogEntries(1), + Deprecations: nil, + Bugfixes: getChangelogEntries(1), + }, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "no bug fixes", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(1), + Changes: getChangelogEntries(1), + Removals: getChangelogEntries(1), + Deprecations: getChangelogEntries(1), + Bugfixes: nil, + }, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "entry with no link", + changelog: Changelog{ + Version: "v999.999.999", + Additions: []ChangelogEntry{ + { + Description: "Changelog entry description 0.", + }, + { + Description: "Changelog entry description 1.", + Link: "[#999999](https://example.com/test/changelog/pulls/999999)", + }, + }, + }, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "no entries", + changelog: Changelog{ + Version: "v999.999.999", + Additions: nil, + Changes: nil, + Removals: nil, + Deprecations: nil, + Bugfixes: nil, + }, + output: `## v999.999.999 + +No changes for this release! +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + d, err := tc.changelog.Template() + if err != nil { + t.Errorf("Got unexpected error: %v", err) + } + assert.Equal(t, tc.output, string(d)) + }) + } +} + +func TestChangelog_WriteFile(t *testing.T) { + + testCases := []struct { + name string + changelog Changelog + existingFile bool + existingFileContents string + output string + }{ + { + name: "non-existent file", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(2), + Changes: getChangelogEntries(2), + Removals: getChangelogEntries(2), + Deprecations: getChangelogEntries(2), + Bugfixes: getChangelogEntries(2), + }, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "empty file", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(2), + Changes: getChangelogEntries(2), + Removals: getChangelogEntries(2), + Deprecations: getChangelogEntries(2), + Bugfixes: getChangelogEntries(2), + }, + existingFile: true, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + { + name: "existing file", + changelog: Changelog{ + Version: "v999.999.999", + Additions: getChangelogEntries(2), + Changes: getChangelogEntries(2), + Removals: getChangelogEntries(2), + Deprecations: getChangelogEntries(2), + Bugfixes: getChangelogEntries(2), + }, + existingFileContents: `## v999.999.998 + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + output: `## v999.999.999 + +### Additions + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Changes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Removals + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Deprecations + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) + +## v999.999.998 + +### Bug Fixes + +- Changelog entry description 0. ([#999999](https://example.com/test/changelog/pulls/999999)) +- Changelog entry description 1. ([#999999](https://example.com/test/changelog/pulls/999999)) +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpFile, err := ioutil.TempFile("", "go-test-changelog") + assert.NoError(t, err) + assert.NoError(t, tmpFile.Close()) + defer assert.NoError(t, os.Remove(tmpFile.Name())) + + if tc.existingFile || len(tc.existingFileContents) > 0 { + assert.NoError(t, ioutil.WriteFile(tmpFile.Name(), []byte(tc.existingFileContents), 0644)) + } + + assert.NoError(t, tc.changelog.WriteFile(tmpFile.Name())) + + d, err := ioutil.ReadFile(tmpFile.Name()) + assert.NoError(t, err) + assert.Equal(t, tc.output, string(d)) + }) + } +} + +func TestChangelog_ChangelogFromEntries(t *testing.T) { + testCases := []struct { + name string + version semver.Version + entries []FragmentEntry + changelog Changelog + }{ + { + name: "no entries", + version: semver.MustParse("999.999.999"), + changelog: Changelog{Version: "v999.999.999"}, + }, + { + name: "add periods to descriptions and breaking change prefix", + version: semver.MustParse("999.999.999"), + entries: []FragmentEntry{ + { + Description: "Changelog entry description 0", + Kind: Addition, + Breaking: false, + PullRequestLink: "[#999999](https://example.com/test/changelog/pulls/999999)", + }, + { + Description: "Changelog entry description 0", + Kind: Change, + Breaking: true, + }, + { + Description: "Changelog entry description 0", + Kind: Removal, + Breaking: true, + }, + { + Description: "Changelog entry description 0", + Kind: Deprecation, + Breaking: false, + }, + { + Description: "Changelog entry description 0", + Kind: Bugfix, + Breaking: false, + }, + }, + changelog: Changelog{ + Version: "v999.999.999", + Additions: []ChangelogEntry{ + { + Description: "Changelog entry description 0.", + Link: "[#999999](https://example.com/test/changelog/pulls/999999)", + }, + }, + Changes: []ChangelogEntry{ + { + Description: "**Breaking change**: Changelog entry description 0.", + }, + }, + Removals: []ChangelogEntry{ + { + Description: "**Breaking change**: Changelog entry description 0.", + }, + }, + Deprecations: []ChangelogEntry{ + { + Description: "Changelog entry description 0.", + }, + }, + Bugfixes: []ChangelogEntry{ + { + Description: "Changelog entry description 0.", + }, + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cl := ChangelogFromEntries(tc.version, tc.entries) + assert.Equal(t, tc.changelog, cl) + }) + } +} diff --git a/hack/generate/changelog/util/fragment.go b/hack/generate/changelog/util/fragment.go new file mode 100644 index 00000000000..4a8b28980fe --- /dev/null +++ b/hack/generate/changelog/util/fragment.go @@ -0,0 +1,199 @@ +package util + +import ( + "errors" + "fmt" + "io/ioutil" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" +) + +type Fragment struct { + Entries []FragmentEntry `yaml:"entries"` +} + +func (f *Fragment) Validate() error { + for i, e := range f.Entries { + if err := e.Validate(); err != nil { + return fmt.Errorf("entry[%d] invalid: %v", i, err) + } + } + return nil +} + +type FragmentEntry struct { + Description string `yaml:"description"` + Kind EntryKind `yaml:"kind"` + Breaking bool `yaml:"breaking"` + Migration *EntryMigration `yaml:"migration,omitempty"` + PullRequest *uint `yaml:"pull_request_override,omitempty"` + + PullRequestLink string `yaml:"-"` +} + +func (e *FragmentEntry) Validate() error { + if err := e.Kind.Validate(); err != nil { + return fmt.Errorf("invalid kind: %v", err) + } + + if len(e.Description) == 0 { + return errors.New("missing description") + } + + if e.Breaking && e.Kind != Change && e.Kind != Removal { + return fmt.Errorf("breaking changes can only be kind %q or %q, got %q", Change, Removal, e.Kind) + } + + if e.Breaking && e.Migration == nil { + return fmt.Errorf("breaking changes require migration sections") + } + + if e.Migration != nil { + if err := e.Migration.Validate(); err != nil { + return fmt.Errorf("invalid migration: %v", err) + } + } + return nil +} + +func (e FragmentEntry) pullRequestLink(repo string) string { + if e.PullRequest == nil { + return "" + } + return fmt.Sprintf("[#%d](https://%s/pull/%d)", *e.PullRequest, repo, *e.PullRequest) +} + +type EntryKind string + +const ( + Addition EntryKind = "addition" + Change EntryKind = "change" + Removal EntryKind = "removal" + Deprecation EntryKind = "deprecation" + Bugfix EntryKind = "bugfix" +) + +func (k EntryKind) Validate() error { + for _, t := range []EntryKind{Addition, Change, Removal, Deprecation, Bugfix} { + if k == t { + return nil + } + } + return fmt.Errorf("%q is not a supported kind", k) +} + +type EntryMigration struct { + Header string `yaml:"header"` + Body string `yaml:"body"` +} + +func (m EntryMigration) Validate() error { + if len(m.Header) == 0 { + return errors.New("header not specified") + } + if len(m.Body) == 0 { + return errors.New("body not specified") + } + return nil +} + +func LoadEntries(fragmentsDir, repo string) ([]FragmentEntry, error) { + files, err := ioutil.ReadDir(fragmentsDir) + if err != nil { + return nil, fmt.Errorf("failed to read fragments directory: %w", err) + } + + var entries []FragmentEntry + for _, fragFile := range files { + if fragFile.Name() == "00-template.yaml" { + continue + } + if fragFile.IsDir() { + log.Warnf("Skipping directory %q", fragFile.Name()) + continue + } + if filepath.Ext(fragFile.Name()) != ".yaml" { + log.Warnf("Skipping non-YAML file %q", fragFile.Name()) + continue + } + path := filepath.Join(fragmentsDir, fragFile.Name()) + fragmentData, err := ioutil.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read fragment file %q: %w", fragFile.Name(), err) + } + + fragment := Fragment{} + if err := yaml.Unmarshal(fragmentData, &fragment); err != nil { + return nil, fmt.Errorf("failed to parse fragment file %q: %w", fragFile.Name(), err) + } + + if err := fragment.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate fragment file %q: %w", fragFile.Name(), err) + } + + prNum, err := prGetter.GetPullRequestNumberFor(path) + if err != nil { + log.Warn(err) + } + + if prNum != 0 { + for i, e := range fragment.Entries { + if e.PullRequest == nil { + fragment.Entries[i].PullRequest = &prNum + } + } + } + + for i, e := range fragment.Entries { + fragment.Entries[i].PullRequestLink = e.pullRequestLink(repo) + } + + entries = append(entries, fragment.Entries...) + } + return entries, nil +} + +var prGetter PullRequestNumberGetter = &gitPullRequestNumberGetter{} + +type PullRequestNumberGetter interface { + GetPullRequestNumberFor(file string) (uint, error) +} + +type gitPullRequestNumberGetter struct{} + +func (g *gitPullRequestNumberGetter) GetPullRequestNumberFor(filename string) (uint, error) { + msg, err := g.getCommitMessage(filename) + if err != nil { + return 0, err + } + return g.parsePRNumber(msg) +} + +func (g *gitPullRequestNumberGetter) getCommitMessage(filename string) (string, error) { + args := fmt.Sprintf("log --follow --pretty=format:%%s --diff-filter=A --find-renames=40%% %s", filename) + line, err := exec.Command("git", strings.Split(args, " ")...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to locate git commit for PR discovery: %v", err) + } + return string(line), nil +} + +var numRegex = regexp.MustCompile(`\(#(\d+)\)$`) + +func (g *gitPullRequestNumberGetter) parsePRNumber(msg string) (uint, error) { + matches := numRegex.FindAllStringSubmatch(msg, 1) + if len(matches) == 0 || len(matches[0]) < 2 { + return 0, fmt.Errorf("could not find PR number in commit message") + } + u64, err := strconv.ParseUint(matches[0][1], 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse PR number %q from commit message: %v", matches[0][1], err) + } + return uint(u64), nil +} diff --git a/hack/generate/changelog/util/fragment_test.go b/hack/generate/changelog/util/fragment_test.go new file mode 100644 index 00000000000..aa299b25554 --- /dev/null +++ b/hack/generate/changelog/util/fragment_test.go @@ -0,0 +1,372 @@ +package util + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type mockValidPRGetter struct{} + +var _ PullRequestNumberGetter = &mockValidPRGetter{} + +func (m *mockValidPRGetter) GetPullRequestNumberFor(file string) (uint, error) { + return 999998, nil +} + +func TestFragment_LoadEntries(t *testing.T) { + discoveredPRNum := uint(999998) + overriddenPRNum := uint(999999) + repoLink := "example.com/test/changelog" + + testCases := []struct { + name string + fragmentsDir string + prGetter PullRequestNumberGetter + expectedEntries []FragmentEntry + expectedErr string + }{ + { + name: "ignore non-fragments", + fragmentsDir: "testdata/ignore", + expectedEntries: nil, + }, + { + name: "invalid yaml", + fragmentsDir: "testdata/invalid_yaml", + expectedEntries: nil, + expectedErr: "unmarshal errors", + }, + { + name: "invalid entry", + fragmentsDir: "testdata/invalid_entry", + expectedEntries: nil, + expectedErr: `failed to validate fragment file`, + }, + { + name: "valid fragments", + fragmentsDir: "testdata/valid", + prGetter: &mockValidPRGetter{}, + expectedEntries: []FragmentEntry{ + { + Description: "Addition description 0", + Kind: Addition, + PullRequest: &discoveredPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", discoveredPRNum, repoLink, discoveredPRNum), + }, + { + Description: "Change description 0", + Kind: Change, + PullRequest: &discoveredPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", discoveredPRNum, repoLink, discoveredPRNum), + }, + { + Description: "Removal description 0", + Kind: Removal, + Breaking: true, + Migration: &EntryMigration{ + Header: "Header for removal migration 0", + Body: "Body for removal migration 0", + }, + PullRequest: &discoveredPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", discoveredPRNum, repoLink, discoveredPRNum), + }, + { + Description: "Deprecation description 0", + Kind: Deprecation, + PullRequest: &discoveredPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", discoveredPRNum, repoLink, discoveredPRNum), + }, + { + Description: "Bugfix description 0", + Kind: Bugfix, + PullRequest: &discoveredPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", discoveredPRNum, repoLink, discoveredPRNum), + }, + { + Description: "Addition description 1", + Kind: Addition, + PullRequest: &overriddenPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", overriddenPRNum, repoLink, overriddenPRNum), + }, + { + Description: "Change description 1", + Kind: Change, + PullRequest: &overriddenPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", overriddenPRNum, repoLink, overriddenPRNum), + }, + { + Description: "Removal description 1", + Kind: Removal, + Breaking: true, + Migration: &EntryMigration{ + Header: "Header for removal migration 1", + Body: "Body for removal migration 1", + }, + PullRequest: &overriddenPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", overriddenPRNum, repoLink, overriddenPRNum), + }, + { + Description: "Deprecation description 1", + Kind: Deprecation, + PullRequest: &overriddenPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", overriddenPRNum, repoLink, overriddenPRNum), + }, + { + Description: "Bugfix description 1", + Kind: Bugfix, + PullRequest: &overriddenPRNum, + PullRequestLink: fmt.Sprintf("[#%d](https://%s/pull/%d)", overriddenPRNum, repoLink, overriddenPRNum), + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + prGetter = tc.prGetter + entries, err := LoadEntries(tc.fragmentsDir, repoLink) + assert.Equal(t, tc.expectedEntries, entries) + if len(tc.expectedErr) > 0 { + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error to contain: %q, got %q", tc.expectedErr, err) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestFragmentEntry_Validate(t *testing.T) { + testCases := []struct { + name string + fragmentEntry FragmentEntry + expectedErr string + }{ + { + name: "invalid kind", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: "invalid", + Breaking: false, + }, + expectedErr: "invalid kind", + }, + { + name: "missing description", + fragmentEntry: FragmentEntry{ + Description: "", + Kind: Addition, + Breaking: false, + }, + expectedErr: "missing description", + }, + { + name: "breaking addition not allowed", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Addition, + Breaking: true, + }, + expectedErr: `breaking changes can only be kind "change" or "removal", got "addition"`, + }, + { + name: "breaking deprecation not allowed", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Deprecation, + Breaking: true, + }, + expectedErr: `breaking changes can only be kind "change" or "removal", got "deprecation"`, + }, + { + name: "breaking bugfix not allowed", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Bugfix, + Breaking: true, + }, + expectedErr: `breaking changes can only be kind "change" or "removal", got "bugfix"`, + }, + { + name: "migration missing", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Change, + Breaking: true, + }, + expectedErr: `breaking changes require migration sections`, + }, + { + name: "migration header missing", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Change, + Breaking: true, + Migration: &EntryMigration{ + Header: "", + Body: "migration body", + }, + }, + expectedErr: `invalid migration: header not specified`, + }, + { + name: "migration body missing", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Change, + Breaking: true, + Migration: &EntryMigration{ + Header: "migration header", + Body: "", + }, + }, + expectedErr: `invalid migration: body not specified`, + }, + { + name: "breaking change allowed", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Change, + Breaking: true, + Migration: &EntryMigration{ + Header: "migration header", + Body: "migration body", + }, + }, + expectedErr: ``, + }, + { + name: "breaking removal allowed", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Removal, + Breaking: true, + Migration: &EntryMigration{ + Header: "migration header", + Body: "migration body", + }, + }, + expectedErr: ``, + }, + { + name: "non-breaking migration allowed", + fragmentEntry: FragmentEntry{ + Description: "description", + Kind: Addition, + Breaking: false, + Migration: &EntryMigration{ + Header: "migration header", + Body: "migration body", + }, + }, + expectedErr: ``, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.fragmentEntry.Validate() + + if len(tc.expectedErr) == 0 { + assert.NoError(t, err) + } else if err == nil { + t.Errorf("expected error to contain %q, got no error", tc.expectedErr) + } else { + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error to contain: %q, got %q", tc.expectedErr, err) + } + } + }) + } +} + +func TestFragmentEntry_PullRequestLink(t *testing.T) { + prNum := uint(999999) + testCases := []struct { + name string + fragmentEntry FragmentEntry + repo string + link string + }{ + { + name: "no link", + fragmentEntry: FragmentEntry{}, + link: "", + }, + { + name: "link with repo 1", + repo: "example.com/test/repo1", + fragmentEntry: FragmentEntry{PullRequest: &prNum}, + link: "[#999999](https://example.com/test/repo1/pull/999999)", + }, + { + name: "link with repo 2", + repo: "example.com/test/repo2", + fragmentEntry: FragmentEntry{PullRequest: &prNum}, + link: "[#999999](https://example.com/test/repo2/pull/999999)", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + link := tc.fragmentEntry.pullRequestLink(tc.repo) + assert.Equal(t, tc.link, link) + }) + } +} + +func TestGitPullRequestNumberGetter_parsePRNumber(t *testing.T) { + prg := &gitPullRequestNumberGetter{} + testCases := []struct { + name string + msg string + prNum uint + expectedErr string + }{ + { + name: "valid message", + msg: "this is a message with a PR number at the end (#999999)", + prNum: uint(999999), + }, + { + name: "missing parentheses", + msg: "this is a message with a PR number at the end #999999", + expectedErr: `could not find PR number in commit message`, + }, + { + name: "not at the end", + msg: "this is a message with a PR number (#999999) in the middle", + expectedErr: `could not find PR number in commit message`, + }, + { + name: "no PR number", + msg: "this is a message without a PR number", + expectedErr: `could not find PR number in commit message`, + }, + { + name: "invalid PR number", + msg: "this is a message with a really big PR number (#99999999999999999999999999999999999999)", + expectedErr: `value out of range`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + prNum, err := prg.parsePRNumber(tc.msg) + + if len(tc.expectedErr) == 0 { + assert.NoError(t, err) + } else if err == nil { + t.Errorf("expected error to contain %q, got no error", tc.expectedErr) + } else { + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error to contain: %q, got %q", tc.expectedErr, err) + } + } + + assert.Equal(t, tc.prNum, prNum) + }) + } +} diff --git a/hack/generate/changelog/util/migration_guide.go b/hack/generate/changelog/util/migration_guide.go new file mode 100644 index 00000000000..b123c9df6de --- /dev/null +++ b/hack/generate/changelog/util/migration_guide.go @@ -0,0 +1,77 @@ +package util + +import ( + "bytes" + "fmt" + "io/ioutil" + "strings" + "text/template" + + "github.com/blang/semver" +) + +type MigrationGuide struct { + Version string + Weight uint64 + Migrations []Migration +} + +type Migration struct { + Header string + Body string + PullRequestLink string +} + +const migrationGuideTemplate = `--- +title: {{ .Version }} +weight: {{ .Weight }} +--- +{{ range .Migrations }} +## {{ .Header }} + +{{ .Body }} +{{ if .PullRequestLink }} +_See {{ .PullRequestLink }} for more details._ +{{ end }}{{ else }} +There are no migrations for this release! :tada: +{{ end }}` + +var migrationGuideTmpl = template.Must(template.New("migrationGuide").Parse(migrationGuideTemplate)) + +func (mg *MigrationGuide) Template() ([]byte, error) { + w := &bytes.Buffer{} + if err := migrationGuideTmpl.Execute(w, mg); err != nil { + return nil, err + } + return w.Bytes(), nil +} + +func (mg *MigrationGuide) WriteFile(path string) error { + data, err := mg.Template() + if err != nil { + return err + } + return ioutil.WriteFile(path, data, 0644) +} + +func MigrationGuideFromEntries(version semver.Version, entries []FragmentEntry) MigrationGuide { + mg := MigrationGuide{ + Version: fmt.Sprintf("v%s", version.String()), + Weight: versionToWeight(version), + } + for _, e := range entries { + if e.Migration == nil { + continue + } + mg.Migrations = append(mg.Migrations, Migration{ + Header: e.Migration.Header, + Body: strings.TrimSpace(e.Migration.Body), + PullRequestLink: e.PullRequestLink, + }) + } + return mg +} + +func versionToWeight(v semver.Version) uint64 { + return 1_000_000_000 - (v.Major * 1_000_000) - (v.Minor * 1_000) - v.Patch +} diff --git a/hack/generate/changelog/util/migration_guide_test.go b/hack/generate/changelog/util/migration_guide_test.go new file mode 100644 index 00000000000..3f08b666206 --- /dev/null +++ b/hack/generate/changelog/util/migration_guide_test.go @@ -0,0 +1,277 @@ +package util + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/blang/semver" + "github.com/stretchr/testify/assert" +) + +func TestMigrationGuide_Template(t *testing.T) { + testCases := []struct { + name string + mg MigrationGuide + output string + }{ + { + name: "link_then_no_link", + mg: MigrationGuide{ + Version: "v999.999.999", + Weight: 1, + Migrations: []Migration{ + { + Header: "Migration header 0", + Body: "Migration body 0", + PullRequestLink: "[#999999](https://example.com/test/changelog/pull/999999)", + }, + { + Header: "Migration header 1", + Body: "Migration body 1", + }, + }, + }, + output: `--- +title: v999.999.999 +weight: 1 +--- + +## Migration header 0 + +Migration body 0 + +_See [#999999](https://example.com/test/changelog/pull/999999) for more details._ + +## Migration header 1 + +Migration body 1 +`, + }, + { + name: "no_link_then_link", + mg: MigrationGuide{ + Version: "v999.999.999", + Weight: 2, + Migrations: []Migration{ + { + Header: "Migration header 0", + Body: "Migration body 0", + }, + { + Header: "Migration header 1", + Body: "Migration body 1", + PullRequestLink: "[#999999](https://example.com/test/changelog/pull/999999)", + }, + }, + }, + output: `--- +title: v999.999.999 +weight: 2 +--- + +## Migration header 0 + +Migration body 0 + +## Migration header 1 + +Migration body 1 + +_See [#999999](https://example.com/test/changelog/pull/999999) for more details._ +`, + }, + { + name: "no migrations", + mg: MigrationGuide{ + Version: "v999.999.999", + Weight: 3, + }, + output: `--- +title: v999.999.999 +weight: 3 +--- + +There are no migrations for this release! :tada: +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + d, err := tc.mg.Template() + if err != nil { + t.Errorf("Got unexpected error: %v", err) + } + assert.Equal(t, tc.output, string(d)) + }) + } +} + +func TestMigrationGuide_WriteFile(t *testing.T) { + + testCases := []struct { + name string + mg MigrationGuide + output string + }{ + { + name: "valid", + mg: MigrationGuide{ + Version: "v999.999.999", + Migrations: []Migration{ + { + Header: "Migration header 0", + Body: "Migration body 0", + PullRequestLink: "[#999999](https://example.com/test/changelog/pull/999999)", + }, + { + Header: "Migration header 1", + Body: "Migration body 1", + }, + }, + }, + output: `--- +title: v999.999.999 +weight: 0 +--- + +## Migration header 0 + +Migration body 0 + +_See [#999999](https://example.com/test/changelog/pull/999999) for more details._ + +## Migration header 1 + +Migration body 1 +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpFile, err := ioutil.TempFile("", "go-test-changelog") + assert.NoError(t, err) + assert.NoError(t, tmpFile.Close()) + defer assert.NoError(t, os.Remove(tmpFile.Name())) + + assert.NoError(t, tc.mg.WriteFile(tmpFile.Name())) + + d, err := ioutil.ReadFile(tmpFile.Name()) + assert.NoError(t, err) + assert.Equal(t, tc.output, string(d)) + }) + } +} + +func TestMigrationGuide_MigrationGuideFromEntries(t *testing.T) { + testCases := []struct { + name string + version semver.Version + entries []FragmentEntry + mg MigrationGuide + }{ + { + name: "no entries, weight 1", + version: semver.MustParse("999.999.999"), + mg: MigrationGuide{ + Version: "v999.999.999", + Weight: 1, + }, + }, + { + name: "no entries, weight 2", + version: semver.MustParse("999.999.998"), + mg: MigrationGuide{ + Version: "v999.999.998", + Weight: 2, + }, + }, + { + name: "no entries, weight 998_997_997", + version: semver.MustParse("1.2.3"), + mg: MigrationGuide{ + Version: "v1.2.3", + Weight: 998_997_997, + }, + }, + { + name: "no entries, weight 2", + version: semver.MustParse("3.2.1"), + mg: MigrationGuide{ + Version: "v3.2.1", + Weight: 996_997_999, + }, + }, + { + name: "some migrations", + version: semver.MustParse("999.999.999"), + entries: []FragmentEntry{ + { + Description: "Changelog entry description 0", + Kind: Addition, + Breaking: false, + PullRequestLink: "[#999999](https://example.com/test/changelog/pulls/999999)", + Migration: &EntryMigration{ + Header: "Migration header 0", + Body: "Migration body 0", + }, + }, + { + Description: "Changelog entry description 1", + Kind: Change, + Breaking: true, + Migration: &EntryMigration{ + Header: "Migration header 1", + Body: "Migration body 1", + }, + }, + { + Description: "Changelog entry description 2", + Kind: Removal, + Breaking: true, + Migration: &EntryMigration{ + Header: "Migration header 2", + Body: "Migration body 2", + }, + }, + { + Description: "Changelog entry description 0", + Kind: Deprecation, + Breaking: false, + }, + { + Description: "Changelog entry description 0", + Kind: Bugfix, + Breaking: false, + }, + }, + mg: MigrationGuide{ + Version: "v999.999.999", + Weight: 1, + Migrations: []Migration{ + { + Header: "Migration header 0", + Body: "Migration body 0", + PullRequestLink: "[#999999](https://example.com/test/changelog/pulls/999999)", + }, + { + Header: "Migration header 1", + Body: "Migration body 1", + }, + { + Header: "Migration header 2", + Body: "Migration body 2", + }, + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mg := MigrationGuideFromEntries(tc.version, tc.entries) + assert.Equal(t, tc.mg, mg) + }) + } +} diff --git a/hack/generate/changelog/util/testdata/ignore/00-template.yaml b/hack/generate/changelog/util/testdata/ignore/00-template.yaml new file mode 100644 index 00000000000..44f68b5ba65 --- /dev/null +++ b/hack/generate/changelog/util/testdata/ignore/00-template.yaml @@ -0,0 +1,36 @@ +# entries is a list of entries to include in +# release notes and/or the migration guide +entries: + - description: > + Description is the line that shows up in the CHANGELOG. This + should be formatted as markdown and be on a single line. Using + the YAML string '>' operator means you can write your entry + multiple lines and it will still be parsed as a single line. + + # kind is one of: + # - addition + # - change + # - deprecation + # - removal + # - bugfix + kind: "" + + # Is this a breaking change? + breaking: false + + # NOTE: ONLY USE `pull_request_override` WHEN ADDING THIS + # FILE FOR A PREVIOUSLY MERGED PULL_REQUEST! + # + # The generator auto-detects the PR number from the commit + # message in which this file was originally added. + # + # What is the pull request number (without the "#")? + # pull_request_override: 0 + + + # Migration can be defined to automatically add a section to + # the migration guide. This is required for breaking changes. + migration: + header: Header text for the migration section + body: > + Body of the migration section. diff --git a/hack/generate/changelog/util/testdata/ignore/more-fragments/ignored.yaml b/hack/generate/changelog/util/testdata/ignore/more-fragments/ignored.yaml new file mode 100644 index 00000000000..ec264e46281 --- /dev/null +++ b/hack/generate/changelog/util/testdata/ignore/more-fragments/ignored.yaml @@ -0,0 +1,19 @@ +entries: + - addition: Addition description + kind: addition + breaking: false + - description: Change description + kind: change + breaking: false + - description: Removal description + kind: removal + breaking: true + migration: + header: Header for removal migration + body: Body for removal migration + - description: Deprecation description + kind: deprecation + breaking: false + - description: Bugfix description + kind: bugfix + breaking: false \ No newline at end of file diff --git a/hack/generate/changelog/util/testdata/ignore/non-yaml.txt b/hack/generate/changelog/util/testdata/ignore/non-yaml.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/hack/generate/changelog/util/testdata/invalid_entry/fragment1.yaml b/hack/generate/changelog/util/testdata/invalid_entry/fragment1.yaml new file mode 100644 index 00000000000..4fe900e556a --- /dev/null +++ b/hack/generate/changelog/util/testdata/invalid_entry/fragment1.yaml @@ -0,0 +1,4 @@ +entries: + - description: Addition description 0 + kind: addition + breaking: true \ No newline at end of file diff --git a/hack/generate/changelog/util/testdata/invalid_yaml/fragment1.yaml b/hack/generate/changelog/util/testdata/invalid_yaml/fragment1.yaml new file mode 100644 index 00000000000..20d2807958b --- /dev/null +++ b/hack/generate/changelog/util/testdata/invalid_yaml/fragment1.yaml @@ -0,0 +1,18 @@ +- description: Addition description 0 + kind: addition + breaking: false +- description: Change description 0 + kind: change + breaking: false +- description: Removal description 0 + kind: removal + breaking: true + migration: + header: Header for removal migration 0 + body: Body for removal migration 0 +- description: Deprecation description 0 + kind: deprecation + breaking: false +- description: Bugfix description 0 + kind: bugfix + breaking: false \ No newline at end of file diff --git a/hack/generate/changelog/util/testdata/valid/fragment1.yaml b/hack/generate/changelog/util/testdata/valid/fragment1.yaml new file mode 100644 index 00000000000..4c8d7428153 --- /dev/null +++ b/hack/generate/changelog/util/testdata/valid/fragment1.yaml @@ -0,0 +1,19 @@ +entries: + - description: Addition description 0 + kind: addition + breaking: false + - description: Change description 0 + kind: change + breaking: false + - description: Removal description 0 + kind: removal + breaking: true + migration: + header: Header for removal migration 0 + body: Body for removal migration 0 + - description: Deprecation description 0 + kind: deprecation + breaking: false + - description: Bugfix description 0 + kind: bugfix + breaking: false \ No newline at end of file diff --git a/hack/generate/changelog/util/testdata/valid/fragment2.yaml b/hack/generate/changelog/util/testdata/valid/fragment2.yaml new file mode 100644 index 00000000000..00e1313a818 --- /dev/null +++ b/hack/generate/changelog/util/testdata/valid/fragment2.yaml @@ -0,0 +1,24 @@ +entries: + - description: Addition description 1 + kind: addition + breaking: false + pull_request_override: 999999 + - description: Change description 1 + kind: change + breaking: false + pull_request_override: 999999 + - description: Removal description 1 + kind: removal + breaking: true + pull_request_override: 999999 + migration: + header: Header for removal migration 1 + body: Body for removal migration 1 + - description: Deprecation description 1 + kind: deprecation + breaking: false + pull_request_override: 999999 + - description: Bugfix description 1 + kind: bugfix + breaking: false + pull_request_override: 999999 diff --git a/hack/generate/gen-cli-doc.go b/hack/generate/cli-doc/gen-cli-doc.go similarity index 100% rename from hack/generate/gen-cli-doc.go rename to hack/generate/cli-doc/gen-cli-doc.go diff --git a/hack/generate/cli-doc/gen-cli-doc.sh b/hack/generate/cli-doc/gen-cli-doc.sh new file mode 100755 index 00000000000..5f789624694 --- /dev/null +++ b/hack/generate/cli-doc/gen-cli-doc.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +go run ./hack/generate/cli-doc/gen-cli-doc.go diff --git a/hack/generate/gen-cli-doc.sh b/hack/generate/gen-cli-doc.sh deleted file mode 100755 index 5b716f55981..00000000000 --- a/hack/generate/gen-cli-doc.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -go run ./hack/generate/gen-cli-doc.go diff --git a/hack/generate/gen-test-framework.sh b/hack/generate/test-framework/gen-test-framework.sh similarity index 100% rename from hack/generate/gen-test-framework.sh rename to hack/generate/test-framework/gen-test-framework.sh diff --git a/hack/tests/sanity-check.sh b/hack/tests/sanity-check.sh index 63a2ab14fdc..b708c282936 100755 --- a/hack/tests/sanity-check.sh +++ b/hack/tests/sanity-check.sh @@ -8,8 +8,9 @@ go fmt ./... ./hack/check-license.sh ./hack/check-error-log-msg-format.sh ./hack/check-doc-diffs.sh -./hack/generate/gen-cli-doc.sh -./hack/generate/gen-test-framework.sh +./hack/generate/cli-doc/gen-cli-doc.sh +./hack/generate/test-framework/gen-test-framework.sh +go run ./hack/generate/changelog/gen-changelog.go -validate-only # Make sure repo is still in a clean state. git diff --exit-code diff --git a/website/content/en/docs/contribution-guidelines/release.md b/website/content/en/docs/contribution-guidelines/release.md index 20f183f9905..631768683ed 100644 --- a/website/content/en/docs/contribution-guidelines/release.md +++ b/website/content/en/docs/contribution-guidelines/release.md @@ -5,7 +5,7 @@ weight: 30 Making an Operator SDK release involves: -- Updating `CHANGELOG.md`. +- Updating `CHANGELOG.md` and migration guide. - Tagging and signing a git commit and pushing the tag to GitHub. - Building a release binary and signing the binary - Creating a release by uploading binary, signature, and `CHANGELOG.md` updates for the release to GitHub. @@ -196,7 +196,7 @@ $ git push origin release-v1.3.1 Create a PR from `release-v1.3.1` to `v1.3.x`. Once CI passes and your PR is merged, continue to step 1. -### 1. Create a PR for release version and CHANGELOG.md updates +### 1. Create a PR for release version, CHANGELOG.md, and migration guide updates Once all PR's needed for a release have been merged, branch from `master`: @@ -218,14 +218,22 @@ Create a new branch to push release commits: $ git checkout -b release-v1.3.0 ``` +Run the CHANGELOG and migration guide generator: + +```sh +$ GEN_CHANGELOG_TAG=v1.3.0 make gen-changelog +``` + Commit the following changes: - `version/version.go`: update `Version` to `v1.3.0`. - `internal/scaffold/go_mod.go`, change the `require` line version for `github.com/operator-framework/operator-sdk` from `master` to `v1.3.0`. - `internal/scaffold/helm/go_mod.go`: same as for `internal/scaffold/go_mod.go`. - `internal/scaffold/ansible/go_mod.go`: same as for `internal/scaffold/go_mod.go`. -- `CHANGELOG.md`: update the `## Unreleased` header to `## v1.3.0`. - `doc/user/install-operator-sdk.md`: update the linux and macOS URLs to point to the new release URLs. +- `CHANGELOG.md`: commit changes (updated by changelog generation). +- `website/content/en/docs/migration/v1.3.0.md`: commit changes (created by changelog generation). +- `changelog/fragments/*`: commit deleted fragment files (deleted by changelog generation). _(Non-patch releases only)_ Lock down the master branch to prevent further commits between this and step 4. See [this section](#locking-down-branches) for steps to do so. @@ -257,7 +265,7 @@ Once this tag passes CI, go to step 3. For more info on tagging, see the [releas **Note:** If CI fails for some reason, you will have to revert the tagged commit, re-commit, and make a new PR. -### 3. Create a PR for post-release version and CHANGELOG.md updates +### 3. Create a PR for post-release version updates Check out a new branch from master (or use your `release-v1.3.0` branch) and commit the following changes: @@ -265,21 +273,6 @@ Check out a new branch from master (or use your `release-v1.3.0` branch) and com - `internal/scaffold/go_mod.go`, change the `require` line version for `github.com/operator-framework/operator-sdk` from `v1.3.0` to `master`. - `internal/scaffold/helm/go_mod.go`: same as for `internal/scaffold/go_mod.go`. - `internal/scaffold/ansible/go_mod.go`: same as for `internal/scaffold/go_mod.go`. -- `CHANGELOG.md`: add the following as a new set of headers above `## v1.3.0`: - - ```markdown - ## Unreleased - - ### Added - - ### Changed - - ### Deprecated - - ### Removed - - ### Bug Fixes - ``` Create a new PR for this branch, targetting the `master` branch. Once this PR passes CI and is merged, `master` can be unfrozen.