release: use isolated changelog fragments
This commit is contained in:
@@ -9,9 +9,10 @@ import (
|
||||
)
|
||||
|
||||
type changelogGateRepo struct {
|
||||
root string
|
||||
gate string
|
||||
base string
|
||||
root string
|
||||
gate string
|
||||
fragmentPolicy string
|
||||
base string
|
||||
}
|
||||
|
||||
const changelogGateBase = `# Changelog
|
||||
@@ -69,8 +70,10 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
|
||||
}
|
||||
for _, path := range []string{
|
||||
"scripts/policy/check-changelog-pr.sh",
|
||||
"scripts/policy/check-release-fragments.sh",
|
||||
"scripts/policy/open-source-audit.sh",
|
||||
"scripts/release/release-lib.sh",
|
||||
"scripts/release/render-release-fragments.sh",
|
||||
} {
|
||||
data, err := os.ReadFile(filepath.Join(sourceRoot, path))
|
||||
if err != nil {
|
||||
@@ -91,12 +94,21 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
|
||||
changelogGateGit(t, root, "commit", "-m", "seed repository")
|
||||
|
||||
return &changelogGateRepo{
|
||||
root: root,
|
||||
gate: filepath.Join(root, "scripts", "policy", "check-changelog-pr.sh"),
|
||||
base: strings.TrimSpace(changelogGateGit(t, root, "rev-parse", "HEAD")),
|
||||
root: root,
|
||||
gate: filepath.Join(root, "scripts", "policy", "check-changelog-pr.sh"),
|
||||
fragmentPolicy: filepath.Join(root, "scripts", "policy", "check-release-fragments.sh"),
|
||||
base: strings.TrimSpace(changelogGateGit(t, root, "rev-parse", "HEAD")),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *changelogGateRepo) runFragmentPolicy(t *testing.T, base, head string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", r.fragmentPolicy, base, head)
|
||||
cmd.Dir = r.root
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func changelogGateWrite(t *testing.T, root, path, content string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
full := filepath.Join(root, path)
|
||||
@@ -203,22 +215,110 @@ func TestChangelogPRGateAcceptsTargetedChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogPRContentOnlyAllowsOtherFiles(t *testing.T) {
|
||||
func TestChangelogPRContentOnlyRejectsOrdinaryChangesAlongsideChangelog(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
|
||||
changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644)
|
||||
repo.commit(t, "change code with release notes")
|
||||
|
||||
output, err := repo.runMode(t, "--content-only")
|
||||
if err != nil {
|
||||
t.Fatalf("content-only gate error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "CHANGELOG PR check: ok (mode=content-only") {
|
||||
t.Fatalf("content-only gate output missing success marker:\n%s", output)
|
||||
if err == nil || !strings.Contains(output, "may accompany only release-fragment archival") {
|
||||
t.Fatalf("content-only gate accepted ordinary source change: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogPRContentOnlyStillValidatesContentWithOtherFiles(t *testing.T) {
|
||||
func TestChangelogPRContentOnlyAcceptsReleaseFragmentArchival(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
|
||||
repo.commit(t, "add release fragment")
|
||||
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- Chat reply mentions.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`, 0o644)
|
||||
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll archive: %v", err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1", "1234-chat.md")); err != nil {
|
||||
t.Fatalf("Rename release fragment: %v", err)
|
||||
}
|
||||
repo.commit(t, "seal release notes")
|
||||
|
||||
output, err := repo.runMode(t, "--content-only")
|
||||
if err != nil {
|
||||
t.Fatalf("content-only release seal error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "CHANGELOG PR check: ok (mode=content-only") {
|
||||
t.Fatalf("content-only release seal output missing success marker:\n%s", output)
|
||||
}
|
||||
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected matching seal: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
|
||||
t.Run("invalid active fragment", func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, ".changes/1234-invalid.md", "---\ncategory: Added\n---\n\n- TODO: fill this in.\n", 0o644)
|
||||
repo.commit(t, "add invalid fragment")
|
||||
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "must not contain TODO/TBD") {
|
||||
t.Fatalf("invalid active fragment passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("archive version differs from changelog", func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
|
||||
repo.commit(t, "add release fragment")
|
||||
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
|
||||
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.2-beta.1"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll archive: %v", err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.2-beta.1", "1234-chat.md")); err != nil {
|
||||
t.Fatalf("Rename release fragment: %v", err)
|
||||
}
|
||||
repo.commit(t, "archive under wrong release")
|
||||
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "unchanged R100 moves") {
|
||||
t.Fatalf("wrong archive version passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("archive notes differ from rendered fragment", func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
|
||||
repo.commit(t, "add release fragment")
|
||||
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
|
||||
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll archive: %v", err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1", "1234-chat.md")); err != nil {
|
||||
t.Fatalf("Rename release fragment: %v", err)
|
||||
}
|
||||
repo.commit(t, "seal mismatched release notes")
|
||||
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "does not exactly match") {
|
||||
t.Fatalf("mismatched release notes passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangelogPRContentOnlyStillValidatesReleaseNotes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changelog string
|
||||
@@ -250,7 +350,6 @@ func TestChangelogPRContentOnlyStillValidatesContentWithOtherFiles(t *testing.T)
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644)
|
||||
changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644)
|
||||
repo.commit(t, test.name)
|
||||
|
||||
output, err := repo.runMode(t, "--content-only")
|
||||
|
||||
@@ -73,6 +73,7 @@ type releaseTestRepo struct {
|
||||
remote string
|
||||
contract string
|
||||
prepare string
|
||||
render string
|
||||
releaseCmd string
|
||||
lib string
|
||||
verify string
|
||||
@@ -107,6 +108,7 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
||||
remote: remote,
|
||||
contract: filepath.Join(sourceRoot, "scripts", "release", "release-contract.sh"),
|
||||
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
|
||||
render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"),
|
||||
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
|
||||
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
||||
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
|
||||
@@ -1654,10 +1656,13 @@ func TestReleaseContractCIAllowsMainToAdvanceAfterTagSeal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
|
||||
func TestReleasePrepareChangelogRendersAndArchivesPrereleaseFragments(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
|
||||
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, ".changes", "1234-chat-reply.md"), []byte("---\ncategory: Added\n---\n\n- **Chat reply mentions** (#1234) — supports selected member mentions.\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, ".changes", "1235-chat-fix.md"), []byte("---\ncategory: Fixed\n---\n\n- **Chat reply fallback** (#1235) — keeps replies compatible with older clients.\n"), 0o644)
|
||||
r.commitAndPush(t, "install changelog preparation")
|
||||
|
||||
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
|
||||
@@ -1671,55 +1676,38 @@ func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(changelog), "## [1.0.1-beta.1] - 2026-07-11") || !strings.Contains(string(changelog), "TODO") {
|
||||
t.Fatalf("prepared changelog missing guarded template:\n%s", changelog)
|
||||
content := string(changelog)
|
||||
if !strings.Contains(content, "## [1.0.1-beta.1] - 2026-07-11") ||
|
||||
!strings.Contains(content, "### Added") ||
|
||||
!strings.Contains(content, "Chat reply mentions") ||
|
||||
!strings.Contains(content, "### Fixed") ||
|
||||
!strings.Contains(content, "Chat reply fallback") ||
|
||||
strings.Contains(content, "TODO") {
|
||||
t.Fatalf("prepared changelog did not render release fragments:\n%s", changelog)
|
||||
}
|
||||
|
||||
r.commitAndPush(t, "commit unfinished release notes")
|
||||
contractOutput, contractErr := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if contractErr == nil || !strings.Contains(contractOutput, "TODO/TBD") {
|
||||
t.Fatalf("unfinished template was not blocked: err=%v\noutput:\n%s", contractErr, contractOutput)
|
||||
for _, name := range []string{"1234-chat-reply.md", "1235-chat-fix.md"} {
|
||||
if _, err := os.Stat(filepath.Join(r.root, ".changes", name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unconsumed release fragment %s still present: %v", name, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1-beta.1", name)); err != nil {
|
||||
t.Fatalf("archived release fragment %s missing: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogKeepsUnreleasedContentAboveNewVersion(t *testing.T) {
|
||||
func TestReleasePrepareChangelogRejectsInvalidReleaseFragment(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte("# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- Keep this unreleased note.\n\n## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"), 0o644)
|
||||
r.commitAndPush(t, "add unreleased changelog note")
|
||||
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, ".changes", "invalid.md"), []byte("---\ncategory: Added\n---\n\n- TODO: write this later.\n"), 0o644)
|
||||
r.commitAndPush(t, "add invalid release fragment")
|
||||
|
||||
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
positions := []int{
|
||||
strings.Index(content, "## [Unreleased]"),
|
||||
strings.Index(content, "- Keep this unreleased note."),
|
||||
strings.Index(content, "## [1.0.1-beta.1] - 2026-07-11"),
|
||||
strings.Index(content, "## [1.0.0] - 2026-07-01"),
|
||||
}
|
||||
for index, position := range positions {
|
||||
if position < 0 {
|
||||
t.Fatalf("prepared changelog is missing expected marker %d:\n%s", index, content)
|
||||
}
|
||||
}
|
||||
for index := 1; index < len(positions); index++ {
|
||||
if positions[index-1] >= positions[index] {
|
||||
t.Fatalf("prepared changelog order is invalid: %v\n%s", positions, content)
|
||||
}
|
||||
if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "must not contain TODO/TBD") {
|
||||
t.Fatalf("invalid release fragment was accepted: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user