diff --git a/.changes/README.md b/.changes/README.md new file mode 100644 index 0000000..c61c9f6 --- /dev/null +++ b/.changes/README.md @@ -0,0 +1,34 @@ +# Release notes + +Add one uniquely named Markdown file per user-visible change, for example +`.changes/20261019-support-timeout.md`: + +```md +## Changes + +- Explain the change in English from the user's perspective. +``` + +Keep each bullet on one line. Pure test/internal changes need no fragment. +Feature branches leave `CHANGELOG.md` alone. + +Commit fragments first. Run `make release-notes VERSION=X.Y.Z` from the repo +root to assemble committed fragments and any legacy Unreleased entries into a +dated changelog section. Review and commit the generated section, the +`.changes/releases/X.Y.Z.json` receipt and fragment deletions together, then +create the native `vX.Y.Z` tag. This command never tags or publishes. + +Preparation pins HEAD. Uncommitted/newer notes do not enter that release. +Cleanup removes only files whose content matches the included snapshot; +modified files remain pending. Receipts prevent duplication after interrupted +cleanup. Repeating a prepared version only retries its exact cleanup. Keep +receipts in Git; never edit them manually. If interrupted between the receipt +and changelog writes, retry restores the exact output when the original changelog +is intact; inconsistent output blocks cleanup. Cleanup retains private recovery +copies under `.changes/.recovery/`, including changes written through +a file handle opened before cleanup. These copies never enter Git. Coordinate +release preparation through one release operator per checkout. +The existing tag workflow reads only the version section committed at the tag, +so publication retries keep the same notes. + +`make check-release-notes` validates working fragments without consuming them. diff --git a/.changes/legacy-001.md b/.changes/legacy-001.md new file mode 100644 index 0000000..a267aa4 --- /dev/null +++ b/.changes/legacy-001.md @@ -0,0 +1,3 @@ +## Changes + +- Wait for the rendered support inventory before list and detail reads, and avoid false contract drift from frontend bundle filenames. diff --git a/.changes/legacy-002.md b/.changes/legacy-002.md new file mode 100644 index 0000000..1624b11 --- /dev/null +++ b/.changes/legacy-002.md @@ -0,0 +1,3 @@ +## Changes + +- Add stable CloudVPS backup status output and verify enable or disable postconditions without replaying ambiguous mutations. diff --git a/.changes/legacy-003.md b/.changes/legacy-003.md new file mode 100644 index 0000000..687b839 --- /dev/null +++ b/.changes/legacy-003.md @@ -0,0 +1,3 @@ +## Changes + +- Silently reconcile browser and CLI-egress public IPv4 addresses with the REG.API allowlist after portal login and refresh, with redacted non-blocking failure warnings. diff --git a/.changes/legacy-004.md b/.changes/legacy-004.md new file mode 100644 index 0000000..62bd5e6 --- /dev/null +++ b/.changes/legacy-004.md @@ -0,0 +1,3 @@ +## Changes + +- Show the authenticated REG.RU provider login separately from the local profile alias in human, JSON, and plain auth status output. diff --git a/.changes/legacy-005.md b/.changes/legacy-005.md new file mode 100644 index 0000000..7e0b8d3 --- /dev/null +++ b/.changes/legacy-005.md @@ -0,0 +1,3 @@ +## Changes + +- Reconcile interrupted support mutations with independent reads and wait for rendered ticket history before returning details. diff --git a/.changes/legacy-006.md b/.changes/legacy-006.md new file mode 100644 index 0000000..22fb2c4 --- /dev/null +++ b/.changes/legacy-006.md @@ -0,0 +1,3 @@ +## Changes + +- Enable experimental browser-backed support ticket list, detail, create, reply, and close operations with typed drift probes and one-attempt mutation reconciliation. diff --git a/.changes/legacy-007.md b/.changes/legacy-007.md new file mode 100644 index 0000000..342beeb --- /dev/null +++ b/.changes/legacy-007.md @@ -0,0 +1,3 @@ +## Changes + +- Preserve REG.Cloud S3 service and bucket sizes as provider-supplied strings in inventory output. diff --git a/.changes/legacy-008.md b/.changes/legacy-008.md new file mode 100644 index 0000000..62b798e --- /dev/null +++ b/.changes/legacy-008.md @@ -0,0 +1,3 @@ +## Changes + +- Keep interactive portal login alive when REG.RU navigates the staged browser during authentication. diff --git a/.changes/legacy-009.md b/.changes/legacy-009.md new file mode 100644 index 0000000..10615c8 --- /dev/null +++ b/.changes/legacy-009.md @@ -0,0 +1,3 @@ +## Changes + +- Scaffold the `regru` Go CLI with stable output, error, safety, authentication, timeout, completion, and capability-placeholder contracts. diff --git a/.changes/legacy-010.md b/.changes/legacy-010.md new file mode 100644 index 0000000..ff7c34f --- /dev/null +++ b/.changes/legacy-010.md @@ -0,0 +1,3 @@ +## Changes + +- Add provider-neutral multi-account profiles, strict user/project TOML boundaries, redacted account and capability commands, and lazy external credential-process helpers. diff --git a/.changes/legacy-011.md b/.changes/legacy-011.md new file mode 100644 index 0000000..d444b1a --- /dev/null +++ b/.changes/legacy-011.md @@ -0,0 +1,3 @@ +## Changes + +- Add isolated browser-backed portal login, refresh, status, logout, principal binding, and typed in-browser BFF/GraphQL execution. diff --git a/.changes/legacy-012.md b/.changes/legacy-012.md new file mode 100644 index 0000000..452f275 --- /dev/null +++ b/.changes/legacy-012.md @@ -0,0 +1,3 @@ +## Changes + +- Add typed CloudVPS v1/v2 clients and VPS, action, IP/PTR, SSH-key, snapshot, backup, plan, and image commands with safe mutation waiting. diff --git a/.changes/legacy-013.md b/.changes/legacy-013.md new file mode 100644 index 0000000..0d207d5 --- /dev/null +++ b/.changes/legacy-013.md @@ -0,0 +1,3 @@ +## Changes + +- Add REG.RU S3 service, bucket, configuration, quota, privacy, and key-set commands with split portal/S3 adapters, in-memory credentials, drift probes, and read-after-write reconciliation. diff --git a/.changes/legacy-014.md b/.changes/legacy-014.md new file mode 100644 index 0000000..c79c142 --- /dev/null +++ b/.changes/legacy-014.md @@ -0,0 +1,3 @@ +## Changes + +- Add source-discriminated REG.API and CloudVPS billing reads, invoice status and mutations, exact-decimal finance models, and fail-closed private checkout gates. diff --git a/.changes/legacy-015.md b/.changes/legacy-015.md new file mode 100644 index 0000000..3e33c6f --- /dev/null +++ b/.changes/legacy-015.md @@ -0,0 +1,3 @@ +## Changes + +- Add value-redacted portal invoice enrichment and a confirmed, non-shareable visible-browser checkout handoff while keeping unavailable bill-specific method enumeration gated. diff --git a/.changes/legacy-016.md b/.changes/legacy-016.md new file mode 100644 index 0000000..ebc7e95 --- /dev/null +++ b/.changes/legacy-016.md @@ -0,0 +1,3 @@ +## Changes + +- Add a typed experimental support command boundary with deferred message input, redacted dry runs, and precise fail-closed reasons for every uncaptured private ticket capability. diff --git a/.changes/legacy-017.md b/.changes/legacy-017.md new file mode 100644 index 0000000..319dfe1 --- /dev/null +++ b/.changes/legacy-017.md @@ -0,0 +1,3 @@ +## Changes + +- Add install, configuration, architecture, and capability documentation plus reproducible local checks, secret-scanning CI, cross-platform builds, and changelog-backed GitHub releases. diff --git a/.changes/legacy-018.md b/.changes/legacy-018.md new file mode 100644 index 0000000..026b387 --- /dev/null +++ b/.changes/legacy-018.md @@ -0,0 +1,3 @@ +## Changes + +- Add bounded, provider-facing capability probes with partial redacted results and stable reasons for missing credentials, authentication loss, contract drift, and unavailable adapters. diff --git a/.gitignore b/.gitignore index 4febd3b..0a885c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /bin/ /dist/ + +# Local fragment recovery, on the fragment filesystem for atomic rename. +.changes/.recovery/ diff --git a/AGENTS.md b/AGENTS.md index f413693..2bcd103 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,3 +2,6 @@ - The `regru` CLI must follow the `$create-cli` skill when designing, implementing, reviewing, or documenting its command-line interface. - Treat `.agents/skills/create-cli/SKILL.md` and its references as the canonical guidance for commands, arguments, flags, help, output, errors, exit codes, prompts, safety, configuration, signals, and scriptability. + +- User-visible changes add one uniquely named `.changes/*.md` fragment with `## Changes` and English one-line bullets. Internal/test-only changes need none. Never edit shared pending changelog entries on feature branches. +- Release preparation uses `make release-notes VERSION=X.Y.Z`; commit generated CHANGELOG.md, release receipts and consumed fragments before creating the native vX.Y.Z tag. See `.changes/README.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index a683d78..1512c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,3 @@ # Changelog ## Unreleased - -- Wait for the rendered support inventory before list and detail reads, and avoid false contract drift from frontend bundle filenames. -- Add stable CloudVPS backup status output and verify enable or disable postconditions without replaying ambiguous mutations. -- Silently reconcile browser and CLI-egress public IPv4 addresses with the REG.API allowlist after portal login and refresh, with redacted non-blocking failure warnings. -- Show the authenticated REG.RU provider login separately from the local profile alias in human, JSON, and plain auth status output. -- Reconcile interrupted support mutations with independent reads and wait for rendered ticket history before returning details. -- Enable experimental browser-backed support ticket list, detail, create, reply, and close operations with typed drift probes and one-attempt mutation reconciliation. -- Preserve REG.Cloud S3 service and bucket sizes as provider-supplied strings in inventory output. -- Keep interactive portal login alive when REG.RU navigates the staged browser during authentication. -- Scaffold the `regru` Go CLI with stable output, error, safety, authentication, timeout, completion, and capability-placeholder contracts. -- Add provider-neutral multi-account profiles, strict user/project TOML boundaries, redacted account and capability commands, and lazy external credential-process helpers. -- Add isolated browser-backed portal login, refresh, status, logout, principal binding, and typed in-browser BFF/GraphQL execution. -- Add typed CloudVPS v1/v2 clients and VPS, action, IP/PTR, SSH-key, snapshot, backup, plan, and image commands with safe mutation waiting. -- Add REG.RU S3 service, bucket, configuration, quota, privacy, and key-set commands with split portal/S3 adapters, in-memory credentials, drift probes, and read-after-write reconciliation. -- Add source-discriminated REG.API and CloudVPS billing reads, invoice status and mutations, exact-decimal finance models, and fail-closed private checkout gates. -- Add value-redacted portal invoice enrichment and a confirmed, non-shareable visible-browser checkout handoff while keeping unavailable bill-specific method enumeration gated. -- Add a typed experimental support command boundary with deferred message input, redacted dry runs, and precise fail-closed reasons for every uncaptured private ticket capability. -- Add install, configuration, architecture, and capability documentation plus reproducible local checks, secret-scanning CI, cross-platform builds, and changelog-backed GitHub releases. -- Add bounded, provider-facing capability probes with partial redacted results and stable reasons for missing credentials, authentication loss, contract drift, and unavailable adapters. diff --git a/Makefile b/Makefile index e94c61a..c4f2266 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || printf '%s' none) BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -s -w -X github.com/adinvadim/reg-ru-cli/internal/version.Version=$(VERSION) -X github.com/adinvadim/reg-ru-cli/internal/version.Commit=$(COMMIT) -X github.com/adinvadim/reg-ru-cli/internal/version.Date=$(BUILD_DATE) -.PHONY: build check dist fmt test test-race vet +.PHONY: build check dist fmt test test-race vet release-notes check-release-notes build: mkdir -p bin @@ -30,7 +30,7 @@ test: test-race: $(GO) test -race ./... -check: fmt vet test test-race +check: fmt vet test test-race check-release-notes dist: mkdir -p dist @@ -48,3 +48,9 @@ dist: else \ shasum -a 256 regru_$(VERSION)_* > checksums.txt; \ fi + +check-release-notes: + $(GO) run ./scripts/prepare-release --check + +release-notes: + $(GO) run ./scripts/prepare-release "$(VERSION)" diff --git a/README.md b/README.md index 37b1eae..527680c 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,15 @@ bash|zsh|fish|powershell`. ## Development and releases -`make check` runs formatting verification, vetting, unit tests, and the race -detector. CI additionally scans the repository for secrets and cross-compiles +`make check` runs formatting verification, vetting, unit tests, the race +detector, and read-only release-fragment validation. CI additionally scans the repository for secrets and cross-compiles every release target. Release tags matching `v*` must have a corresponding `## X.Y.Z - YYYY-MM-DD` section in `CHANGELOG.md`; the release workflow uses that section verbatim as the GitHub release notes and uploads the binaries and checksums. + +User-visible changes add independent [release fragments](.changes/README.md). +Before tagging, commit the fragments, run `make release-notes VERSION=X.Y.Z`, +review and commit the generated changelog, receipt, and fragment deletions, +then create the existing `vX.Y.Z` release tag. The tag workflow still extracts +its frozen version section; preparation does not publish or tag anything. diff --git a/scripts/prepare-release/main.go b/scripts/prepare-release/main.go new file mode 100644 index 0000000..cf6cb9f --- /dev/null +++ b/scripts/prepare-release/main.go @@ -0,0 +1,358 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +type fragment struct { + Path string + Digest string + Body string +} +type receipt struct { + Version string + Commit string + Fragments []fragment + Section string + SourceDigest string + Changelog string +} + +func git(root string, args ...string) ([]byte, error) { + c := exec.Command("git", args...) + c.Dir = root + b, err := c.Output() + if err != nil { + return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return b, nil +} + +func parse(path string, data []byte) (string, error) { + s := strings.TrimSpace(string(data)) + if !strings.HasPrefix(s, "## Changes\n") { + return "", fmt.Errorf("%s: expected ## Changes followed by English Markdown bullets", path) + } + body := strings.TrimSpace(strings.TrimPrefix(s, "## Changes\n")) + if body == "" { + return "", fmt.Errorf("%s: no changes", path) + } + for _, line := range strings.Split(body, "\n") { + if line != "" && !strings.HasPrefix(line, "- ") { + return "", fmt.Errorf("%s: each entry must be one Markdown bullet on one line", path) + } + } + return body, nil +} + +func digest(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } + +func capture(root string) (receipt, []byte, error) { + head, err := git(root, "rev-parse", "HEAD") + if err != nil { + return receipt{}, nil, err + } + r := receipt{Commit: strings.TrimSpace(string(head))} + changelog, err := git(root, "show", r.Commit+":CHANGELOG.md") + if err != nil { + return r, nil, err + } + paths, err := git(root, "ls-tree", "-r", "--name-only", "-z", r.Commit, "--", ".changes") + if err != nil { + return r, nil, err + } + for _, path := range strings.Split(string(paths), "\x00") { + if filepath.Dir(path) != ".changes" || filepath.Ext(path) != ".md" || path == ".changes/README.md" { + continue + } + data, err := git(root, "show", r.Commit+":"+path) + if err != nil { + return r, nil, err + } + body, err := parse(path, data) + if err != nil { + return r, nil, err + } + r.Fragments = append(r.Fragments, fragment{Path: path, Digest: digest(data), Body: body}) + } + return r, changelog, nil +} + +func syncDirectory(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +func mkdirDurable(path string) error { + if info, err := os.Stat(path); err == nil { + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + return nil + } else if !os.IsNotExist(err) { + return err + } + parent := filepath.Dir(path) + if err := mkdirDurable(parent); err != nil { + return err + } + if err := os.Mkdir(path, 0755); err != nil && !os.IsExist(err) { + return err + } + return syncDirectory(parent) +} + +func atomicWrite(path string, data []byte) error { + f, err := os.CreateTemp(filepath.Dir(path), ".release-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if err := f.Chmod(0644); err != nil { + f.Close() + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(f.Name(), path); err != nil { + return err + } + return syncDirectory(filepath.Dir(path)) +} + +func section(text, version string) string { + start := strings.Index(text, "\n## "+version+" - ") + if start < 0 { + return "" + } + tail := text[start+1:] + if end := strings.Index(tail, "\n## "); end >= 0 { + tail = tail[:end] + } + return strings.TrimSpace(tail) +} + +func cleanup(root string, r receipt) error { + recovery := filepath.Join(root, ".changes", ".recovery") + if err := os.MkdirAll(recovery, 0700); err != nil { + return err + } + for _, f := range r.Fragments { + path := filepath.Join(root, f.Path) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if digest(data) != f.Digest { + continue + } + dir, err := os.MkdirTemp(recovery, "fragment-") + if err != nil { + return err + } + backup := filepath.Join(dir, filepath.Base(f.Path)) + // Retain the renamed inode: even an already-open writer cannot lose its bytes. + if err := os.Rename(path, backup); err != nil { + if os.IsNotExist(err) { + continue + } + return err + } + moved, err := os.ReadFile(backup) + if err != nil { + return err + } + if digest(moved) != f.Digest { + // Link is exclusive: a new file at the original path is never overwritten. + if err := os.Link(backup, path); err != nil && !os.IsExist(err) { + return err + } + fmt.Fprintf(os.Stderr, "Preserved concurrently changed fragment at %s (recovery: %s)\n", path, backup) + } + } + return nil +} + +func prepare(root, version string, now time.Time) error { + if !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`).MatchString(version) { + return fmt.Errorf("version must be X.Y.Z without a v prefix") + } + recordPath := filepath.Join(root, ".changes", "releases", version+".json") + if _, err := os.Stat(recordPath); err == nil { + return assemble(root, receipt{Version: version}, nil, now) + } else if !os.IsNotExist(err) { + return err + } + r, source, err := capture(root) + if err != nil { + return err + } + r.Version = version + return assemble(root, r, source, now) +} + +func assemble(root string, r receipt, source []byte, now time.Time) error { + recordPath := filepath.Join(root, ".changes", "releases", r.Version+".json") + if data, err := os.ReadFile(recordPath); err == nil { + var old receipt + if err := json.Unmarshal(data, &old); err != nil { + return err + } + current, err := os.ReadFile(filepath.Join(root, "CHANGELOG.md")) + if err != nil { + return err + } + if old.Section == "" || section(string(current), r.Version) != old.Section { + if old.Changelog == "" || digest(current) != old.SourceDigest { + return fmt.Errorf("release receipt does not match the complete changelog section; reconcile before cleanup") + } + if err := atomicWrite(filepath.Join(root, "CHANGELOG.md"), []byte(old.Changelog)); err != nil { + return err + } + } + return cleanup(root, old) + } else if !os.IsNotExist(err) { + return err + } + current, err := os.ReadFile(filepath.Join(root, "CHANGELOG.md")) + if err != nil { + return err + } + if !bytes.Equal(current, source) { + return fmt.Errorf("CHANGELOG.md changed since %s; commit or reconcile it before preparing", r.Commit) + } + if regexp.MustCompile(`(?m)^## ` + regexp.QuoteMeta(r.Version) + `(?: |$)`).Match(source) { + return fmt.Errorf("release section already exists without a receipt") + } + used := map[string]bool{} + records, err := filepath.Glob(filepath.Join(root, ".changes", "releases", "*.json")) + if err != nil { + return err + } + for _, path := range records { + data, err := os.ReadFile(path) + if err != nil { + return err + } + var previous receipt + if err := json.Unmarshal(data, &previous); err != nil { + return err + } + if previous.Section == "" || section(string(source), previous.Version) != previous.Section { + return fmt.Errorf("release %s has an incomplete receipt; retry that version before preparing another", previous.Version) + } + for _, f := range previous.Fragments { + used[f.Path+":"+f.Digest] = true + } + } + var bodies []string + var included []fragment + for _, f := range r.Fragments { + if !used[f.Path+":"+f.Digest] { + bodies = append(bodies, f.Body) + included = append(included, f) + } + } + r.Fragments = included + const pending = "\n## Unreleased\n" + start := strings.Index(string(source), pending) + if start < 0 { + return fmt.Errorf("missing ## Unreleased section") + } + contentStart := start + len(pending) + tail := string(source[contentStart:]) + next := strings.Index(tail, "\n## ") + legacy := tail + history := "" + if next >= 0 { + legacy = tail[:next] + history = tail[next:] + } + if strings.TrimSpace(legacy) != "" { + bodies = append([]string{strings.TrimSpace(legacy)}, bodies...) + } + if len(bodies) == 0 { + return fmt.Errorf("no unreleased changes at %s", r.Commit) + } + assembled := string(source[:contentStart]) + "\n## " + r.Version + " - " + now.UTC().Format("2006-01-02") + "\n\n" + strings.Join(bodies, "\n") + "\n" + history + r.Section = section(assembled, r.Version) + r.SourceDigest = digest(source) + r.Changelog = assembled + // A receipt permits exact recovery if interrupted between the two atomic writes. + if err := mkdirDurable(filepath.Dir(recordPath)); err != nil { + return err + } + encoded, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + if err := atomicWrite(recordPath, append(encoded, '\n')); err != nil { + return err + } + if err := atomicWrite(filepath.Join(root, "CHANGELOG.md"), []byte(assembled)); err != nil { + return err + } + return cleanup(root, r) +} + +func check(root string) error { + paths, err := filepath.Glob(filepath.Join(root, ".changes", "*.md")) + if err != nil { + return err + } + for _, path := range paths { + if filepath.Base(path) == "README.md" { + continue + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if _, err := parse(path, data); err != nil { + return err + } + } + return nil +} + +func main() { + var err error + if len(os.Args) == 2 && os.Args[1] == "--check" { + err = check(".") + } else if len(os.Args) == 2 { + err = prepare(".", os.Args[1], time.Now()) + } else { + fmt.Fprintln(os.Stderr, "usage: go run ./scripts/prepare-release --check | X.Y.Z") + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/prepare-release/main_test.go b/scripts/prepare-release/main_test.go new file mode 100644 index 0000000..3c8752f --- /dev/null +++ b/scripts/prepare-release/main_test.go @@ -0,0 +1,271 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func run(t *testing.T, root string, args ...string) string { + t.Helper() + c := exec.Command("git", args...) + c.Dir = root + b, e := c.CombinedOutput() + if e != nil { + t.Fatalf("git %v: %v: %s", args, e, b) + } + return string(b) +} +func write(t *testing.T, root, path, body string) { + t.Helper() + p := filepath.Join(root, path) + if e := os.MkdirAll(filepath.Dir(p), 0755); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(p, []byte(body), 0644); e != nil { + t.Fatal(e) + } +} +func read(t *testing.T, root, path string) string { + t.Helper() + b, e := os.ReadFile(filepath.Join(root, path)) + if e != nil { + t.Fatal(e) + } + return string(b) +} +func repo(t *testing.T) string { + t.Helper() + r := t.TempDir() + run(t, r, "init", "-q") + run(t, r, "config", "user.name", "Release Test") + run(t, r, "config", "user.email", "release@example.invalid") + write(t, r, "CHANGELOG.md", "# Changelog\n\n## Unreleased\n\n- Legacy pending.\n\n## 0.1.0 - 2026-01-01\n\n- History.\n") + write(t, r, ".changes/first.md", "## Changes\n\n- First.\n") + run(t, r, "add", ".") + run(t, r, "commit", "-qm", "initial") + return r +} + +var date = time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC) + +func TestFrozenCommitAndConcurrentNotes(t *testing.T) { + root := repo(t) + r, source, e := capture(root) + if e != nil { + t.Fatal(e) + } + r.Version = "0.2.0" + write(t, root, ".changes/first.md", "## Changes\n\n- Changed after snapshot.\n") + write(t, root, ".changes/new.md", "## Changes\n\n- New after snapshot.\n") + run(t, root, "add", ".changes") + run(t, root, "commit", "-qm", "newer notes") + if e := assemble(root, r, source, date); e != nil { + t.Fatal(e) + } + got := read(t, root, "CHANGELOG.md") + for _, s := range []string{"## 0.2.0 - 2026-10-19", "- Legacy pending.", "- First.", "## 0.1.0 - 2026-01-01", "- History."} { + if !strings.Contains(got, s) { + t.Fatalf("missing %q: %s", s, got) + } + } + if strings.Contains(got, "after snapshot") { + t.Fatal("newer notes leaked") + } + if !strings.Contains(read(t, root, ".changes/first.md"), "Changed after") { + t.Fatal("modified fragment lost") + } + read(t, root, ".changes/new.md") +} + +func TestRetryAndMissedCleanup(t *testing.T) { + root := repo(t) + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + first := read(t, root, "CHANGELOG.md") + if _, e := os.Stat(filepath.Join(root, ".changes/first.md")); !os.IsNotExist(e) { + t.Fatal("included fragment not consumed") + } + write(t, root, ".changes/first.md", "## Changes\n\n- First.\n") // Interrupted cleanup. + write(t, root, ".changes/new.md", "## Changes\n\n- Next.\n") + if e := prepare(root, "0.2.0", date.Add(24*time.Hour)); e != nil { + t.Fatal(e) + } + if read(t, root, "CHANGELOG.md") != first { + t.Fatal("retry changed notes") + } + if _, e := os.Stat(filepath.Join(root, ".changes/first.md")); !os.IsNotExist(e) { + t.Fatal("retry did not clean exact identity") + } + read(t, root, ".changes/new.md") + // Even if cleanup was missed and the old fragment was committed, receipts exclude it. + write(t, root, ".changes/first.md", "## Changes\n\n- First.\n") + run(t, root, "add", ".") + run(t, root, "commit", "-qm", "release and next note") + if e := prepare(root, "0.3.0", date); e != nil { + t.Fatal(e) + } + got := read(t, root, "CHANGELOG.md") + if strings.Count(got, "- First.") != 1 || strings.Count(got, "- Next.") != 1 { + t.Fatal(got) + } +} + +func TestReadOnlyCheckAndInvalidFragment(t *testing.T) { + root := repo(t) + before := run(t, root, "status", "--porcelain") + if e := check(root); e != nil { + t.Fatal(e) + } + if run(t, root, "status", "--porcelain") != before { + t.Fatal("check changed files") + } + write(t, root, ".changes/bad.md", "invalid\n") + run(t, root, "add", ".") + run(t, root, "commit", "-qm", "invalid fixture") + original := read(t, root, "CHANGELOG.md") + if e := prepare(root, "0.2.0", date); e == nil { + t.Fatal("accepted invalid fragment") + } + if read(t, root, "CHANGELOG.md") != original { + t.Fatal("invalid input mutated changelog") + } +} + +func TestEmptyAndUncommittedInputs(t *testing.T) { + root := repo(t) + write(t, root, "CHANGELOG.md", "# Changelog\n\n## Unreleased\n") + if e := prepare(root, "0.2.0", date); e == nil { + t.Fatal("accepted uncommitted changelog") + } + run(t, root, "add", ".") + run(t, root, "commit", "-qm", "empty legacy") + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + run(t, root, "add", ".") + run(t, root, "commit", "-qm", "release") + if e := prepare(root, "0.3.0", date); e == nil { + t.Fatal("accepted empty release") + } + if e := prepare(root, "v0.3.0", date); e == nil { + t.Fatal("accepted invalid version") + } +} + +func TestNativeExtractor(t *testing.T) { + root := repo(t) + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + script, e := filepath.Abs("../release-notes.sh") + if e != nil { + t.Fatal(e) + } + c := exec.Command("sh", script, "0.2.0") + c.Dir = root + b, e := c.CombinedOutput() + if e != nil { + t.Fatalf("extract: %v %s", e, b) + } + if !strings.Contains(string(b), "- First.") || strings.Contains(string(b), "- History.") { + t.Fatal(string(b)) + } +} + +func TestInterruptedWriteAndIncompleteSection(t *testing.T) { + root := repo(t) + original := read(t, root, "CHANGELOG.md") + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + prepared := read(t, root, "CHANGELOG.md") + write(t, root, ".changes/first.md", "## Changes\n\n- First.\n") + write(t, root, "CHANGELOG.md", original) + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + if read(t, root, "CHANGELOG.md") != prepared { + t.Fatal("retry did not restore exact completed output") + } + write(t, root, ".changes/first.md", "## Changes\n\n- First.\n") + write(t, root, "CHANGELOG.md", "# Changelog\n\n## Unreleased\n\n## 0.2.0 - 2026-10-19\n") + if e := prepare(root, "0.2.0", date); e == nil { + t.Fatal("accepted incomplete section") + } + read(t, root, ".changes/first.md") +} + +func TestOpenWriterKeepsRecoveryInode(t *testing.T) { + root := repo(t) + f, e := os.OpenFile(filepath.Join(root, ".changes/first.md"), os.O_RDWR, 0644) + if e != nil { + t.Fatal(e) + } + defer f.Close() + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + if e := f.Truncate(0); e != nil { + t.Fatal(e) + } + if _, e := f.WriteAt([]byte("## Changes\n\n- Written during cleanup.\n"), 0); e != nil { + t.Fatal(e) + } + backups, e := filepath.Glob(filepath.Join(root, ".changes/.recovery/fragment-*/*.md")) + if e != nil { + t.Fatal(e) + } + if len(backups) != 1 { + t.Fatalf("backups: %v", backups) + } + b, e := os.ReadFile(backups[0]) + if e != nil { + t.Fatal(e) + } + if !strings.Contains(string(b), "Written during cleanup") { + t.Fatal("open writer data lost") + } +} + +func TestIncompletePriorReceiptCannotSuppressNextRelease(t *testing.T) { + root := repo(t) + original := read(t, root, "CHANGELOG.md") + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + write(t, root, "CHANGELOG.md", original) + write(t, root, ".changes/first.md", "## Changes\n\n- First.\n") + write(t, root, ".changes/new.md", "## Changes\n\n- New.\n") + run(t, root, "add", ".") + run(t, root, "commit", "-qm", "interrupted preparation plus next note") + if e := prepare(root, "0.3.0", date); e == nil { + t.Fatal("new version silently excluded incomplete prior receipt") + } + if read(t, root, "CHANGELOG.md") != original { + t.Fatal("changed notes despite incomplete prior receipt") + } + read(t, root, ".changes/first.md") +} + +func TestRetryDoesNotParseUnrelatedNewFragments(t *testing.T) { + root := repo(t) + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + write(t, root, ".changes/first.md", "## Changes\n\n- First.\n") + write(t, root, ".changes/bad.md", "unrelated invalid pending note\n") + run(t, root, "add", ".") + run(t, root, "commit", "-qm", "release plus unrelated pending note") + if e := prepare(root, "0.2.0", date); e != nil { + t.Fatal(e) + } + read(t, root, ".changes/bad.md") + if _, e := os.Stat(filepath.Join(root, ".changes/first.md")); !os.IsNotExist(e) { + t.Fatal("retry did not finish exact cleanup") + } +}