diff --git a/.github/workflows/require-release-note.yml b/.github/workflows/require-release-note.yml index 611fb5acb..307cc3341 100644 --- a/.github/workflows/require-release-note.yml +++ b/.github/workflows/require-release-note.yml @@ -20,8 +20,13 @@ jobs: name: Require release note runs-on: ubuntu-latest steps: - - name: Require release-notes.md update + - name: Require release note uses: coreos/actions-lib/require-file-change@main with: - path: docs/release-notes.md + path-regex: '^docs/release-notes\.d/[a-z0-9]+(-[a-z0-9]+)*\.(breaking|feature|change|bugfix)\.md$' + status: added override-label: skip-notes + - name: Check out repository + uses: actions/checkout@v6 + - name: Validate release-note fragments + run: go run ./internal/util/tools/release-notes validate diff --git a/.opencode/skills/release_note_update/SKILL.md b/.opencode/skills/release_note_update/SKILL.md index 0db692e1b..baef6edc2 100644 --- a/.opencode/skills/release_note_update/SKILL.md +++ b/.opencode/skills/release_note_update/SKILL.md @@ -1,11 +1,15 @@ --- name: release_note_update -description: Automate release notes update and offer tagging. +description: Finalize a release from release-note fragments without committing. --- + + + # Release Note Update -This skill prepares release notes by running the codified script. +This skill finalizes release notes from validated fragments by running the +repository's release-note tool. ## Usage @@ -20,6 +24,7 @@ Then execute: .opencode/skills/release_note_update/run.sh [] ``` -The script handles everything: parsing the unreleased section, converting items to imperative mood, creating the versioned section, updating the unreleased header for the next version, and creating the commit. - -See `README.md` in this directory for detailed documentation. +The script validates and renders `docs/release-notes.d` fragments, creates the +versioned section, updates the Upcoming heading to the next minor version, and +deletes consumed fragments. It does not create a commit. Review and commit the +result separately. diff --git a/.opencode/skills/release_note_update/run.sh b/.opencode/skills/release_note_update/run.sh index a1bd075a0..6a2c3b437 100755 --- a/.opencode/skills/release_note_update/run.sh +++ b/.opencode/skills/release_note_update/run.sh @@ -1,202 +1,21 @@ #!/usr/bin/env bash +# Maintained in https://github.com/coreos/repo-templates +# Do not edit downstream. + set -euo pipefail usage() { - echo "Usage: $0 VERSION [DATE]" - echo "" - echo "Move unreleased items in docs/release-notes.md to a new version section." - echo "" - echo "Arguments:" - echo " VERSION Release version (e.g. 2.26.0)" - echo " DATE Release date in YYYY-MM-DD format (default: today)" + echo "Usage: $0 VERSION [DATE]" >&2 exit 1 } -if [[ $# -lt 1 ]]; then +if [[ $# -lt 1 || $# -gt 2 ]]; then usage fi VERSION="$1" DATE="${2:-$(date +%Y-%m-%d)}" - -if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: VERSION must be in X.Y.Z format, got: $VERSION" - exit 1 -fi - -if ! [[ "$DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then - echo "Error: DATE must be in YYYY-MM-DD format, got: $DATE" - exit 1 -fi - REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -cd "$REPO_ROOT" - -RELNOTES="docs/release-notes.md" - -if [[ ! -f "$RELNOTES" ]]; then - echo "Error: $RELNOTES not found" - exit 1 -fi - -# Find the unreleased section -UNRELEASED_LINE=$(grep -n '(unreleased)' "$RELNOTES" | head -1 | cut -d: -f1) -if [[ -z "$UNRELEASED_LINE" ]]; then - echo "Error: Could not find an '(unreleased)' section in $RELNOTES" - exit 1 -fi - -# Find the next release section (next "## " header after unreleased) -NEXT_RELEASE_LINE=$(awk "NR>$UNRELEASED_LINE && /^## /{print NR; exit}" "$RELNOTES") -if [[ -z "$NEXT_RELEASE_LINE" ]]; then - NEXT_RELEASE_LINE=$(wc -l < "$RELNOTES") -fi - -# Extract the unreleased content between the two headers -UNRELEASED_CONTENT=$(sed -n "$((UNRELEASED_LINE + 1)),$((NEXT_RELEASE_LINE - 1))p" "$RELNOTES") -# Parse sections and their items -declare -A SECTION_ITEMS -CURRENT_SECTION="" -SECTIONS_ORDER=() - -while IFS= read -r line; do - if [[ "$line" =~ ^###[[:space:]]+(.*) ]]; then - CURRENT_SECTION="${BASH_REMATCH[1]}" - SECTIONS_ORDER+=("$CURRENT_SECTION") - SECTION_ITEMS["$CURRENT_SECTION"]="" - elif [[ -n "$CURRENT_SECTION" && "$line" =~ ^-[[:space:]] ]]; then - if [[ -n "${SECTION_ITEMS[$CURRENT_SECTION]}" ]]; then - SECTION_ITEMS["$CURRENT_SECTION"]+=$'\n' - fi - SECTION_ITEMS["$CURRENT_SECTION"]+="$line" - fi -done <<< "$UNRELEASED_CONTENT" - -# Convert items to imperative mood -to_imperative() { - local line="$1" - # Strip leading "- " to get the first word - local rest="${line#- }" - local first_word="${rest%% *}" - local remainder="${rest#* }" - local lower_word=${first_word,,} - - local imperative="" - case "$lower_word" in - added|adding|adds) imperative="Add" ;; - fixed|fixing|fixes) imperative="Fix" ;; - updated|updating|updates) imperative="Update" ;; - removed|removing|removes) imperative="Remove" ;; - changed|changing|changes) imperative="Change" ;; - created|creating|creates) imperative="Create" ;; - supported|supporting|supports) imperative="Support" ;; - included|including|includes) imperative="Include" ;; - dropped|dropping|drops) imperative="Drop" ;; - marked|marking|marks) imperative="Mark" ;; - bumped|bumping|bumps) imperative="Bump" ;; - moved|moving|moves) imperative="Move" ;; - renamed|renaming|renames) imperative="Rename" ;; - stabilized|stabilizing|stabilizes) imperative="Stabilize" ;; - implemented|implementing|implements) imperative="Implement" ;; - enabled|enabling|enables) imperative="Enable" ;; - disabled|disabling|disables) imperative="Disable" ;; - allowed|allowing|allows) imperative="Allow" ;; - prevented|preventing|prevents) imperative="Prevent" ;; - deprecated|deprecating|deprecates) imperative="Deprecate" ;; - replaced|replacing|replaces) imperative="Replace" ;; - migrated|migrating|migrates) imperative="Migrate" ;; - resolved|resolving|resolves) imperative="Resolve" ;; - improved|improving|improves) imperative="Improve" ;; - refactored|refactoring|refactors) imperative="Refactor" ;; - *) - # Already imperative or unknown — leave as-is - echo "$line" - return - ;; - esac - - if [[ "$rest" == "$first_word" ]]; then - echo "- $imperative" - else - echo "- $imperative $remainder" - fi -} - -# Calculate next unreleased version (bump minor) -MAJOR=$(echo "$VERSION" | cut -d. -f1) -MINOR=$(echo "$VERSION" | cut -d. -f2) -NEXT_MINOR=$((MINOR + 1)) -NEXT_VERSION="${MAJOR}.${NEXT_MINOR}.0" - -# Build the new file content -{ - # Everything before the unreleased header - head -n "$((UNRELEASED_LINE - 1))" "$RELNOTES" - - # New unreleased section (empty) - echo "## Upcoming Ignition $NEXT_VERSION (unreleased)" - echo "" - echo "### Breaking changes" - echo "" - echo "### Features" - echo "" - echo "### Changes" - echo "" - echo "### Bug fixes" - echo "" - echo "" - - # New release section - echo "## Ignition $VERSION ($DATE)" - - # Only include sections that have items - for section in "${SECTIONS_ORDER[@]}"; do - items="${SECTION_ITEMS[$section]}" - if [[ -n "$items" ]]; then - echo "" - echo "### $section" - echo "" - while IFS= read -r item; do - to_imperative "$item" - done <<< "$items" - fi - done - echo "" - - # Everything from the next release onward - tail -n +"$NEXT_RELEASE_LINE" "$RELNOTES" -} > "$RELNOTES.tmp" -mv "$RELNOTES.tmp" "$RELNOTES" - -# Count items -TOTAL_ITEMS=0 -echo "Release notes updated for $VERSION ($DATE)" -echo "" -for section in "${SECTIONS_ORDER[@]}"; do - items="${SECTION_ITEMS[$section]}" - if [[ -n "$items" ]]; then - count=$(echo "$items" | wc -l) - TOTAL_ITEMS=$((TOTAL_ITEMS + count)) - echo " $count ${section,,}" - fi -done -echo "" - -if [[ $TOTAL_ITEMS -eq 0 ]]; then - echo " (no items in unreleased section)" -fi - -echo "New unreleased section created for $NEXT_VERSION" -echo "" - -# Commit -git add "$RELNOTES" -git commit -m "docs/release-notes: update for $VERSION" - -echo "Committed: docs/release-notes: update for $VERSION" -echo "" -echo "Next steps:" -echo " 1. Review changes: git show" -echo " 2. Push to remote when ready" -echo " 3. Create a GitHub release/tag if needed" +cd "$REPO_ROOT" +exec go run ./internal/util/tools/release-notes finalize "$VERSION" "$DATE" diff --git a/docs/release-notes.d/README.md b/docs/release-notes.d/README.md new file mode 100644 index 000000000..fcd8e5b47 --- /dev/null +++ b/docs/release-notes.d/README.md @@ -0,0 +1,48 @@ + + + +# Release-note fragments + +Every pull request with a user-visible change should add a release-note +fragment to this directory. Name it `..md`, +using a short descriptive slug made of lowercase letters, numbers, and single +hyphens. `` is one of: + +- `breaking` for breaking changes +- `feature` for new features +- `change` for other behavior or project changes +- `bugfix` for bug fixes + +If one pull request needs multiple notes in the same category, give each +fragment a different descriptive slug. + +Write the note as the body of a Markdown list item, without the leading `- `. +Multiline Markdown, including fenced code blocks, is supported. The +release-note tool adds the list marker and renders the pull request link on a +separate continuation line after the fragment body. +After the pull request is merged, finalization finds the fragment's adding +commit in Git first-parent history and derives the pull request link from the +merge or squash commit subject. It also verifies that the exact fragment is +present in `HEAD`, so uncommitted fragments or reused filenames cannot inherit +an older pull request. No PR number is needed in a normal fragment. + +For exceptional migrations or backports whose adding commit cannot identify +the original pull request, the first line may specify an override such as +``. Do not use this override in the normal +contributor workflow. + +Run these commands from the repository root: + +```console +go run ./internal/util/tools/release-notes validate +go run ./internal/util/tools/release-notes preview +go run ./internal/util/tools/release-notes finalize 2.29.0 2026-11-12 +``` + +`validate` and `preview` work before merge and do not modify fragments. Preview +omits links that still need Git history resolution and shows links from +explicit overrides. `finalize` requires at least one fragment, resolvable pull +request links, and the version in the current Upcoming heading. It replaces +that section with the rendered release, creates the next minor Upcoming +scaffold, and deletes the consumed fragments. It does not create a Git commit +or use network APIs. diff --git a/internal/util/tools/release-notes/main.go b/internal/util/tools/release-notes/main.go new file mode 100644 index 000000000..8a5282d6e --- /dev/null +++ b/internal/util/tools/release-notes/main.go @@ -0,0 +1,596 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Maintained in https://github.com/coreos/repo-templates +// Do not edit downstream. + +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const ( + fragmentsPath = "docs/release-notes.d" + releaseNotesPath = "docs/release-notes.md" + pullRequestURL = "https://github.com/coreos/ignition/pull/" +) + +type category struct { + name string + heading string +} + +var categories = []category{ + {name: "breaking", heading: "Breaking changes"}, + {name: "feature", heading: "Features"}, + {name: "change", heading: "Changes"}, + {name: "bugfix", heading: "Bug fixes"}, +} + +var ( + fragmentNamePattern = regexp.MustCompile(`^([a-z0-9]+(?:-[a-z0-9]+)*)\.(breaking|feature|change|bugfix)\.md$`) + metadataPattern = regexp.MustCompile(`^$`) + mergeSubjectPattern = regexp.MustCompile(`^Merge pull request #([1-9][0-9]*)\b`) + squashSubjectPattern = regexp.MustCompile(`\(#([1-9][0-9]*)\)$`) + versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) + upcomingPattern = regexp.MustCompile(`(?m)^## Upcoming Ignition ([0-9]+\.[0-9]+\.[0-9]+) \(unreleased\)\n`) +) + +type fragment struct { + path string + name string + slug string + category string + content string + data []byte + mode os.FileMode + pr int +} + +type filesystemOps struct { + remove func(string) error + beforeMutation func() error + resolvePR func(fragment) (int, error) +} + +type commandRunner func(dir, name string, args ...string) ([]byte, error) + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} + +func run(args []string, stdout io.Writer) error { + if len(args) == 0 { + return usageError() + } + + switch args[0] { + case "validate": + if len(args) != 1 { + return usageError() + } + fragments, err := loadFragments(fragmentsPath) + if err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, "Validated %d release-note fragment(s).\n", len(fragments)) + return nil + case "preview": + if len(args) != 1 { + return usageError() + } + fragments, err := loadFragments(fragmentsPath) + if err != nil { + return err + } + _, err = io.WriteString(stdout, renderFragments(fragments)) + return err + case "finalize": + if len(args) != 3 { + return usageError() + } + fragments, err := loadFragments(fragmentsPath) + if err != nil { + return err + } + if err := finalize(releaseNotesPath, fragments, args[1], args[2]); err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, "Finalized Ignition %s (%s) with %d release-note fragment(s).\n", args[1], args[2], len(fragments)) + return nil + default: + return usageError() + } +} + +func usageError() error { + return errors.New("usage: release-notes validate | preview | finalize VERSION YYYY-MM-DD") +} + +func loadFragments(dir string) ([]fragment, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading fragment directory: %w", err) + } + + var fragments []fragment + for _, entry := range entries { + if entry.Name() == "README.md" { + continue + } + if entry.Type() != 0 { + return nil, fmt.Errorf("invalid entry %q in fragment directory: only regular files are allowed", entry.Name()) + } + matches := fragmentNamePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + return nil, fmt.Errorf("invalid fragment name %q", entry.Name()) + } + path := filepath.Join(dir, entry.Name()) + data, mode, err := readFileSnapshot(path) + if err != nil { + return nil, fmt.Errorf("reading fragment %q: %w", entry.Name(), err) + } + if !mode.IsRegular() { + return nil, fmt.Errorf("invalid entry %q in fragment directory: only regular files are allowed", entry.Name()) + } + content, pr, err := parseContent(data) + if err != nil { + return nil, fmt.Errorf("invalid fragment %q: %w", entry.Name(), err) + } + fragments = append(fragments, fragment{ + path: path, name: entry.Name(), slug: matches[1], category: matches[2], content: content, data: data, mode: mode, pr: pr, + }) + } + + sort.Slice(fragments, func(i, j int) bool { + left, right := fragments[i], fragments[j] + if categoryIndex(left.category) != categoryIndex(right.category) { + return categoryIndex(left.category) < categoryIndex(right.category) + } + return left.slug < right.slug + }) + return fragments, nil +} + +func parseContent(data []byte) (string, int, error) { + if !utf8.Valid(data) { + return "", 0, errors.New("content is not valid UTF-8") + } + content := strings.TrimSuffix(string(data), "\n") + if strings.Contains(content, "\r") { + return "", 0, errors.New("content must use Unix line endings") + } + pr := 0 + if firstLine, remainder, found := strings.Cut(content, "\n"); found { + if matches := metadataPattern.FindStringSubmatch(firstLine); matches != nil { + var err error + pr, err = strconv.Atoi(matches[1]) + if err != nil { + return "", 0, errors.New("release-note PR metadata is too large") + } + content = remainder + } else if strings.HasPrefix(firstLine, "\nAdd the first feature") + writeFile(t, filepath.Join(dir, "remove-old-api.breaking.md"), "Remove the old API") + writeFile(t, filepath.Join(dir, "default-behavior.change.md"), "\nChange the default") + + fragments, err := loadFragments(dir) + mustNoError(t, err) + mustLen(t, fragments, 5) + assert.Equal(t, []string{ + "remove-old-api.breaking.md", "first-feature.feature.md", "second-feature.feature.md", "default-behavior.change.md", "later-fix.bugfix.md", + }, fragmentNames(fragments)) + + expected := `### Breaking changes + +- Remove the old API + +### Features + +- Add the first feature + ([#10](https://github.com/coreos/ignition/pull/10)) +- Add the second feature + +### Changes + +- Change the default + ([#5](https://github.com/coreos/ignition/pull/5)) + +### Bug fixes + +- Fix the later bug. + + This keeps a second paragraph. +` + assert.Equal(t, expected, renderFragments(fragments)) +} + +func TestRenderFragmentPreservesFencedCode(t *testing.T) { + note := fragment{ + category: "feature", + content: "Add an example:\n\n```go\nfmt.Println(\"hello\")\n```", + pr: 42, + } + expected := "### Features\n\n" + + "- Add an example:\n\n" + + " ```go\n" + + " fmt.Println(\"hello\")\n" + + " ```\n" + + " ([#42](https://github.com/coreos/ignition/pull/42))\n" + assert.Equal(t, expected, renderFragments([]fragment{note})) +} + +func TestLoadFragmentsOrdersSlugs(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "zebra.change.md"), "Zebra") + writeFile(t, filepath.Join(dir, "alpha.change.md"), "Alpha") + writeFile(t, filepath.Join(dir, "middle.change.md"), "Middle") + + fragments, err := loadFragments(dir) + mustNoError(t, err) + assert.Equal(t, []string{"alpha.change.md", "middle.change.md", "zebra.change.md"}, fragmentNames(fragments)) +} + +func TestLoadFragmentsRejectsMalformedNames(t *testing.T) { + for _, name := range []string{ + "missing-category.md", + "UPPERCASE.change.md", + "under_score.change.md", + "leading-.change.md", + "-leading.change.md", + "double--hyphen.change.md", + "sequence.1.change.md", + "valid-slug.docs.md", + "valid-slug.change.txt", + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, name), "A valid note") + _, err := loadFragments(dir) + mustError(t, err) + assert.Contains(t, err.Error(), "invalid fragment name") + }) + } +} + +func TestLoadFragmentsRejectsMalformedContent(t *testing.T) { + for name, content := range map[string]string{ + "empty": "", + "whitespace": " \n\t", + "leading blank": "\nA note", + "trailing blank": "A note\n\n", + "list marker": "- A note", + "Windows newlines": "A note\r\ncontinued", + "invalid UTF-8": string([]byte{0xff}), + "trailing whitespace": "A note ", + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "valid-slug.change.md"), content) + _, err := loadFragments(dir) + mustError(t, err) + assert.Contains(t, err.Error(), "invalid fragment") + }) + } +} + +func TestParseContentMetadata(t *testing.T) { + content, pr, err := parseContent([]byte("\nA migrated note\nwith details\n")) + mustNoError(t, err) + assert.Equal(t, 2343, pr) + assert.Equal(t, "A migrated note\nwith details", content) + + content, pr, err = parseContent([]byte("A normal note\n")) + mustNoError(t, err) + assert.Zero(t, pr) + assert.Equal(t, "A normal note", content) +} + +func TestParseContentRejectsMalformedMetadata(t *testing.T) { + for _, content := range []string{ + "\nA note", + "\nA note", + "", + } { + _, _, err := parseContent([]byte(content)) + mustError(t, err) + } +} + +func TestParsePRFromSubject(t *testing.T) { + for subject, expected := range map[string]int{ + "Merge pull request #123 from coreos/topic": 123, + "providers: add example support (#456)": 456, + } { + t.Run(subject, func(t *testing.T) { + actual, err := parsePRFromSubject(subject) + mustNoError(t, err) + assert.Equal(t, expected, actual) + }) + } + for _, subject := range []string{ + "providers: add example support", + "Revert merge pull request #123 from coreos/topic", + "providers: mention (#123) in the middle", + } { + _, err := parsePRFromSubject(subject) + mustError(t, err) + } +} + +func TestResolvePRFromGit(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "config", "user.name", "Test User") + runGit(t, repo, "config", "user.email", "test@example.com") + writeFile(t, filepath.Join(repo, "README.md"), "test repository") + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", "initial commit") + runGit(t, repo, "checkout", "-b", "topic") + fragmentPath := filepath.Join(repo, "docs", "release-notes.d", "add-example.feature.md") + mustNoError(t, os.MkdirAll(filepath.Dir(fragmentPath), 0o755)) + writeFile(t, fragmentPath, "Add an example") + runGit(t, repo, "add", "--", "docs/release-notes.d/add-example.feature.md") + runGit(t, repo, "commit", "-m", "docs: add example release note") + runGit(t, repo, "checkout", "main") + runGit(t, repo, "merge", "--no-ff", "topic", "-m", "Merge pull request #789 from coreos/topic") + data, err := os.ReadFile(fragmentPath) + mustNoError(t, err) + + pr, err := resolvePRFromGit(repo, fragmentPath, data, runCommand) + mustNoError(t, err) + assert.Equal(t, 789, pr) +} + +func TestResolvePRFromGitRejectsWorkingTreeContentMismatch(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "config", "user.name", "Test User") + runGit(t, repo, "config", "user.email", "test@example.com") + fragmentPath := filepath.Join(repo, "reused-name.change.md") + writeFile(t, fragmentPath, "Original note") + runGit(t, repo, "add", "reused-name.change.md") + runGit(t, repo, "commit", "-m", "change: original note (#123)") + writeFile(t, fragmentPath, "Uncommitted replacement note") + data, err := os.ReadFile(fragmentPath) + mustNoError(t, err) + + _, err = resolvePRFromGit(repo, fragmentPath, data, runCommand) + mustError(t, err) + assert.Contains(t, err.Error(), "does not match its exact contents in HEAD") +} + +func TestResolvePRFromGitReportsMissingHistory(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "config", "user.name", "Test User") + runGit(t, repo, "config", "user.email", "test@example.com") + writeFile(t, filepath.Join(repo, "README.md"), "test repository") + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", "initial commit") + fragmentPath := filepath.Join(repo, "uncommitted.change.md") + writeFile(t, fragmentPath, "An uncommitted note") + + data, readErr := os.ReadFile(fragmentPath) + mustNoError(t, readErr) + _, err := resolvePRFromGit(repo, fragmentPath, data, runCommand) + mustError(t, err) + assert.Contains(t, err.Error(), "must exist in HEAD") +} + +func TestResolvePRFromGitSeparatesPathFromOptions(t *testing.T) { + repo := t.TempDir() + path := filepath.Join(repo, "--dangerous.change.md") + var arguments []string + runner := func(_ string, name string, args ...string) ([]byte, error) { + assert.Equal(t, "git", name) + if args[0] == "show" { + return []byte("safe contents"), nil + } + arguments = append([]string(nil), args...) + return []byte("change: safe lookup (#321)\n"), nil + } + + pr, err := resolvePRFromGit(repo, path, []byte("safe contents"), runner) + mustNoError(t, err) + assert.Equal(t, 321, pr) + if !assert.Len(t, arguments, 7) { + return + } + assert.Equal(t, "--", arguments[5]) + assert.Equal(t, "--dangerous.change.md", arguments[6]) +} + +func TestFinalizeReplacesUpcomingAndDeletesFragments(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + writeFile(t, notesPath, `--- +nav_order: 9 +--- + +# Release Notes + +## Upcoming Ignition 2.29.0 (unreleased) +`+emptyUpcomingBody()+` +## Ignition 2.28.0 (2026-10-08) + +### Features + +- An older note +`) + fragmentPath := filepath.Join(fragmentsDir, "azure-tests.change.md") + writeFile(t, fragmentPath, "\nAdd Azure provider unit tests") + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + + mustNoError(t, finalize(notesPath, fragments, "2.29.0", "2026-11-12")) + data, err := os.ReadFile(notesPath) + mustNoError(t, err) + notes := string(data) + assert.Contains(t, notes, "## Upcoming Ignition 2.30.0 (unreleased)") + assert.Contains(t, notes, "## Ignition 2.29.0 (2026-11-12)\n\n### Changes\n\n- Add Azure provider unit tests\n ([#2345](https://github.com/coreos/ignition/pull/2345))") + assert.Contains(t, notes, "## Ignition 2.28.0 (2026-10-08)") + _, err = os.Stat(fragmentPath) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestFinalizeMismatchLeavesFilesUnchanged(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + original := "# Release Notes\n\n## Upcoming Ignition 2.29.0 (unreleased)\n\n## Ignition 2.28.0 (2026-10-08)\n" + writeFile(t, notesPath, original) + fragmentPath := filepath.Join(fragmentsDir, "a-note.change.md") + writeFile(t, fragmentPath, "\nA note") + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + + err = finalize(notesPath, fragments, "2.28.0", "2026-11-12") + mustError(t, err) + assert.Contains(t, err.Error(), "does not match") + data, readErr := os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, original, string(data)) + _, statErr := os.Stat(fragmentPath) + assert.NoError(t, statErr) +} + +func TestFinalizeWithoutFragmentsLeavesReleaseNotesUnchanged(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + original := emptyReleaseNotes("2.29.0") + writeFile(t, notesPath, original) + + err := finalize(notesPath, nil, "2.29.0", "2026-11-12") + mustError(t, err) + assert.Contains(t, err.Error(), "without release-note fragments") + data, readErr := os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, original, string(data)) +} + +func TestFinalizeUnresolvedHistoryLeavesInputsUnchanged(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + originalNotes := emptyReleaseNotes("2.29.0") + writeFile(t, notesPath, originalNotes) + fragmentPath := filepath.Join(fragmentsDir, "unmerged-change.change.md") + fragmentContent := "Describe an unmerged change" + writeFile(t, fragmentPath, fragmentContent) + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + + err = finalizeWithOps(notesPath, fragments, "2.29.0", "2026-11-12", filesystemOps{ + remove: os.Remove, + beforeMutation: func() error { return nil }, + resolvePR: func(fragment) (int, error) { + return 0, errors.New("no adding commit found; history may be shallow") + }, + }) + mustError(t, err) + assert.Contains(t, err.Error(), "history may be shallow") + data, readErr := os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, originalNotes, string(data)) + data, readErr = os.ReadFile(fragmentPath) + mustNoError(t, readErr) + assert.Equal(t, fragmentContent, string(data)) +} + +func TestFinalizePopulatedUpcomingLeavesFilesUnchanged(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + original := `# Release Notes + +## Upcoming Ignition 2.29.0 (unreleased) +` + strings.Replace(emptyUpcomingBody(), "### Changes\n\n", "### Changes\n\n- A manually maintained note\n\n", 1) + ` +## Ignition 2.28.0 (2026-10-08) +` + writeFile(t, notesPath, original) + fragmentPath := filepath.Join(fragmentsDir, "fragment-note.change.md") + writeFile(t, fragmentPath, "\nA fragment note") + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + + err = finalize(notesPath, fragments, "2.29.0", "2026-11-12") + mustError(t, err) + assert.Contains(t, err.Error(), "expected empty four-category scaffold") + data, readErr := os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, original, string(data)) + _, statErr := os.Stat(fragmentPath) + assert.NoError(t, statErr) +} + +func TestFinalizeChangedFragmentLeavesInputsUnchanged(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + originalNotes := emptyReleaseNotes("2.29.0") + writeFile(t, notesPath, originalNotes) + fragmentPath := filepath.Join(fragmentsDir, "original-fragment.change.md") + writeFile(t, fragmentPath, "\nOriginal fragment") + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + changedFragment := "Changed fragment" + writeFile(t, fragmentPath, changedFragment) + + err = finalize(notesPath, fragments, "2.29.0", "2026-11-12") + mustError(t, err) + assert.Contains(t, err.Error(), "changed before finalization") + data, readErr := os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, originalNotes, string(data)) + data, readErr = os.ReadFile(fragmentPath) + mustNoError(t, readErr) + assert.Equal(t, changedFragment, string(data)) +} + +func TestFinalizeChangedReleaseNotesLeavesInputsUnchanged(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + writeFile(t, notesPath, emptyReleaseNotes("2.29.0")) + fragmentPath := filepath.Join(fragmentsDir, "fragment-note.change.md") + fragmentContent := "\nA fragment note" + writeFile(t, fragmentPath, fragmentContent) + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + changedNotes := emptyReleaseNotes("2.29.0") + "\nConcurrent edit\n" + + err = finalizeWithOps(notesPath, fragments, "2.29.0", "2026-11-12", filesystemOps{ + remove: os.Remove, + beforeMutation: func() error { + return os.WriteFile(notesPath, []byte(changedNotes), 0o644) + }, + }) + mustError(t, err) + assert.Contains(t, err.Error(), "release notes changed before finalization") + data, readErr := os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, changedNotes, string(data)) + data, readErr = os.ReadFile(fragmentPath) + mustNoError(t, readErr) + assert.Equal(t, fragmentContent, string(data)) +} + +func TestFinalizeDeletionFailureRollsBack(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + originalNotes := emptyReleaseNotes("2.29.0") + writeFile(t, notesPath, originalNotes) + firstPath := filepath.Join(fragmentsDir, "first-note.change.md") + secondPath := filepath.Join(fragmentsDir, "second-note.bugfix.md") + writeFile(t, firstPath, "\nFirst note") + writeFile(t, secondPath, "\nSecond note") + mustNoError(t, os.Chmod(firstPath, 0o600)) + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + removeCalls := 0 + + err = finalizeWithOps(notesPath, fragments, "2.29.0", "2026-11-12", filesystemOps{ + remove: func(path string) error { + removeCalls++ + if removeCalls == 2 { + return errors.New("injected deletion failure") + } + return os.Remove(path) + }, + beforeMutation: func() error { return nil }, + }) + mustError(t, err) + assert.Contains(t, err.Error(), "injected deletion failure") + data, readErr := os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, originalNotes, string(data)) + data, readErr = os.ReadFile(firstPath) + mustNoError(t, readErr) + assert.Equal(t, "\nFirst note", string(data)) + info, statErr := os.Stat(firstPath) + mustNoError(t, statErr) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + data, readErr = os.ReadFile(secondPath) + mustNoError(t, readErr) + assert.Equal(t, "\nSecond note", string(data)) +} + +func TestFinalizeRevalidatesEachFragmentBeforeDeletion(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + originalNotes := emptyReleaseNotes("2.29.0") + writeFile(t, notesPath, originalNotes) + firstPath := filepath.Join(fragmentsDir, "first-note.change.md") + secondPath := filepath.Join(fragmentsDir, "second-note.bugfix.md") + writeFile(t, firstPath, "\nFirst note") + writeFile(t, secondPath, "\nSecond note") + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + newerSecond := "A newer edit to the second fragment" + removeCalls := 0 + + err = finalizeWithOps(notesPath, fragments, "2.29.0", "2026-11-12", filesystemOps{ + remove: func(path string) error { + removeCalls++ + if removeCalls == 1 { + if err := os.Remove(path); err != nil { + return err + } + return os.WriteFile(secondPath, []byte(newerSecond), 0o644) + } + return os.Remove(path) + }, + beforeMutation: func() error { return nil }, + }) + mustError(t, err) + assert.Contains(t, err.Error(), "changed before deletion") + data, readErr := os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, originalNotes, string(data)) + data, readErr = os.ReadFile(firstPath) + mustNoError(t, readErr) + assert.Equal(t, "\nFirst note", string(data)) + data, readErr = os.ReadFile(secondPath) + mustNoError(t, readErr) + assert.Equal(t, newerSecond, string(data)) +} + +func TestFinalizeRollbackRefusesToOverwriteNewerEdits(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + writeFile(t, notesPath, emptyReleaseNotes("2.29.0")) + firstPath := filepath.Join(fragmentsDir, "first-note.change.md") + secondPath := filepath.Join(fragmentsDir, "second-note.bugfix.md") + writeFile(t, firstPath, "\nFirst note") + writeFile(t, secondPath, "\nSecond note") + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + newerFragment := "A newer fragment created during deletion" + newerNotes := "A newer release-notes edit during deletion\n" + removeCalls := 0 + + err = finalizeWithOps(notesPath, fragments, "2.29.0", "2026-11-12", filesystemOps{ + remove: func(path string) error { + removeCalls++ + if removeCalls == 1 { + return os.Remove(path) + } + if err := os.WriteFile(firstPath, []byte(newerFragment), 0o644); err != nil { + return err + } + if err := os.WriteFile(notesPath, []byte(newerNotes), 0o644); err != nil { + return err + } + return errors.New("injected deletion failure") + }, + beforeMutation: func() error { return nil }, + }) + mustError(t, err) + assert.Contains(t, err.Error(), "restoration conflict: path contains newer") + assert.Contains(t, err.Error(), "release notes no longer contain the generated contents") + data, readErr := os.ReadFile(firstPath) + mustNoError(t, readErr) + assert.Equal(t, newerFragment, string(data)) + data, readErr = os.ReadFile(notesPath) + mustNoError(t, readErr) + assert.Equal(t, newerNotes, string(data)) +} + +func TestFinalizeRejectsInvalidInputsWithoutDeletingFragments(t *testing.T) { + for name, input := range map[string][2]string{ + "version": {"2.29", "2026-11-12"}, + "date": {"2.29.0", "2026-02-30"}, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + notesPath := filepath.Join(dir, "release-notes.md") + fragmentsDir := filepath.Join(dir, "fragments") + mustNoError(t, os.Mkdir(fragmentsDir, 0o755)) + writeFile(t, notesPath, "## Upcoming Ignition 2.29.0 (unreleased)\n\n## Ignition 2.28.0 (2026-10-08)\n") + fragmentPath := filepath.Join(fragmentsDir, "fix-a-bug.bugfix.md") + writeFile(t, fragmentPath, "\nFix a bug") + fragments, err := loadFragments(fragmentsDir) + mustNoError(t, err) + + err = finalize(notesPath, fragments, input[0], input[1]) + mustError(t, err) + _, statErr := os.Stat(fragmentPath) + assert.NoError(t, statErr) + }) + } +} + +func TestReplaceUpcomingRequiresExactlyOneHeading(t *testing.T) { + for _, notes := range []string{ + "## Ignition 2.28.0 (2026-10-08)\n", + "## Upcoming Ignition 2.29.0 (unreleased)\n\n## Upcoming Ignition 2.29.0 (unreleased)\n\n## Ignition 2.28.0 (2026-10-08)\n", + } { + _, err := replaceUpcoming(notes, nil, "2.29.0", "2026-11-12") + mustError(t, err) + } +} + +func TestLoadAndRenderDoNotDeleteFragments(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "add-feature.feature.md") + writeFile(t, path, "Add a feature") + fragments, err := loadFragments(dir) + mustNoError(t, err) + assert.NotEmpty(t, renderFragments(fragments)) + _, err = os.Stat(path) + assert.NoError(t, err) +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + mustNoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v: %s", strings.Join(args, " "), err, output) + } +} + +func fragmentNames(fragments []fragment) []string { + names := make([]string, len(fragments)) + for index, fragment := range fragments { + names[index] = fragment.name + } + return names +} + +func mustNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func mustError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected an error") + } +} + +func mustLen(t *testing.T, value []fragment, length int) { + t.Helper() + if len(value) != length { + t.Fatalf("got length %d, expected %d", len(value), length) + } +} + +func TestPreviewHasNoUnexpectedWhitespace(t *testing.T) { + note := fragment{category: "change", content: "First line\n \n nested Markdown", pr: 1} + preview := renderFragments([]fragment{note}) + assert.True(t, strings.HasSuffix(preview, "\n")) + assert.Contains(t, preview, "- First line\n\n nested Markdown\n ([#1](https://github.com/coreos/ignition/pull/1))") + assert.NotContains(t, preview, "\n \n") +} + +func emptyReleaseNotes(version string) string { + return "# Release Notes\n\n## Upcoming Ignition " + version + " (unreleased)\n" + emptyUpcomingBody() + "\n## Ignition 2.28.0 (2026-10-08)\n" +}