From 74859b966b7fec242aae91a7fb485ee38832acce Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 15:59:48 +0800 Subject: [PATCH] fix(release): consume active fragments in stable seals --- .changes/README.md | 4 +- .changes/stable-active-fragment-seal.md | 5 ++ docs/releasing.md | 9 ++- scripts/policy/check-release-fragments.sh | 23 ++++++- scripts/release/prepare-changelog.sh | 44 +++++++++++-- test/scripts/changelog_pr_gate_test.go | 46 +++++++++++++ test/scripts/release_script_test.go | 79 +++++++++++++++++++++++ 7 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 .changes/stable-active-fragment-seal.md diff --git a/.changes/README.md b/.changes/README.md index 79b5868f..1ed1fbd3 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -25,7 +25,9 @@ category: Added 发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序, 将未归档 fragments 汇总为唯一的版本章节,并移动到 -`.changes/released//`。因此 release-seal PR 是唯一会修改 +`.changes/released//`。beta 发布后若有新 fragments 合入并直接准备 stable, +stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新 +fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改 `CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。 归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、 删除或重写已归档文件。 diff --git a/.changes/stable-active-fragment-seal.md b/.changes/stable-active-fragment-seal.md new file mode 100644 index 00000000..72958e9d --- /dev/null +++ b/.changes/stable-active-fragment-seal.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes. diff --git a/docs/releasing.md b/docs/releasing.md index c8d6c702..b6b57aa0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -140,8 +140,13 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 `.changes/.md` 中增加一个独立 fragment;格式和允许的分类见 [`.changes/README.md`](../.changes/README.md)。预发封板时 `scripts/release/prepare-changelog.sh prerelease ` 会稳定排序并汇总所有未归档 -fragment,写入唯一版本章节后移动到 `.changes/released//`。因此并发 PR 不会争用 -`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。 +fragment,写入唯一版本章节后移动到 `.changes/released//`。如果 beta 发布后又有 +带 fragment 的 PR 合入,而维护者决定直接发布 stable, +`scripts/release/prepare-changelog.sh stable --from-beta ` 会保留 beta 晋级摘要 +模板,并把这些 post-beta fragments 写到明确的 `Changes since ` 边界之后,再移动到 +`.changes/released//`。没有 active fragment 时,stable 仍只生成原有晋级摘要 +模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与 +归档移动,供审计复核。 ## CI/CD 保证 diff --git a/scripts/policy/check-release-fragments.sh b/scripts/policy/check-release-fragments.sh index a93f113a..a617831e 100755 --- a/scripts/policy/check-release-fragments.sh +++ b/scripts/policy/check-release-fragments.sh @@ -68,7 +68,28 @@ if [ "$archive_changed" = true ]; then index($0, heading) == 1 { found = 1; next } found && /^## / { exit } found { print } - ' >"$tmp_root/actual-notes" + ' >"$tmp_root/actual-section" + case "$release_version" in + *-beta.*) + cp "$tmp_root/actual-section" "$tmp_root/actual-notes" + ;; + *) + stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")" + [ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { + printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2 + exit 1 + } + . "$ROOT/scripts/release/release-lib.sh" + [ "$(release_core_tag "$stable_beta")" = "v$release_version" ] || { + printf 'error: stable post-beta fragment boundary %s does not match release v%s\n' "$stable_beta" "$release_version" >&2 + exit 1 + } + awk ' + found { print } + /^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 } + ' "$tmp_root/actual-section" >"$tmp_root/actual-notes" + ;; + esac normalize_notes() { awk ' /^[[:space:]]*$/ && !started { next } diff --git a/scripts/release/prepare-changelog.sh b/scripts/release/prepare-changelog.sh index 053a46b7..e7a7b25a 100755 --- a/scripts/release/prepare-changelog.sh +++ b/scripts/release/prepare-changelog.sh @@ -64,17 +64,49 @@ release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}" section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")" output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")" fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")" -cleanup() { rm -f "$section" "$output" "$fragments"; } +fragment_entries="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragment-entries.XXXXXX")" +cleanup() { rm -f "$section" "$output" "$fragments" "$fragment_entries"; } trap cleanup EXIT HUP INT TERM +render_fragments=false +if [ "$CHANNEL" = "prerelease" ]; then + render_fragments=true +else + if [ -e "$CHANGES_DIR" ] && [ ! -d "$CHANGES_DIR" ]; then + printf 'release fragments path is not a directory: %s\n' "$CHANGES_DIR" >&2 + exit 1 + fi + if [ -d "$CHANGES_DIR" ]; then + find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$fragment_entries" + while IFS= read -r entry; do + base="${entry##*/}" + if [ "$base" = 'README.md' ] && [ -f "$entry" ] && [ ! -L "$entry" ]; then + continue + fi + if [ "$base" = 'released' ] && [ -d "$entry" ] && [ ! -L "$entry" ]; then + continue + fi + render_fragments=true + break + done <"$fragment_entries" + fi +fi + +if [ "$render_fragments" = true ]; then + "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments" +fi + { printf '## [%s] - %s\n\n' "$semver" "$release_date" if [ "$CHANNEL" = "stable" ]; then printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA" printf '### Changed\n\n' printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA" + if [ -s "$fragments" ]; then + printf '\n### Changes since `%s`\n\n' "$FROM_BETA" + cat "$fragments" + fi else - "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments" cat "$fragments" fi } > "$section" @@ -106,14 +138,18 @@ fi [ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; } cp "$output" "$CHANGELOG" -if [ "$CHANNEL" = "prerelease" ]; then +if [ -s "$fragments" ]; then archive_dir="$CHANGES_DIR/released/$semver" mkdir -p "$archive_dir" find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -exec mv {} "$archive_dir"/ \; fi if [ "$CHANNEL" = "stable" ]; then - printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION" + if [ -s "$fragments" ]; then + printf 'Prepared CHANGELOG template and archived post-beta release fragments for %s. Replace TODO, review, commit, and merge the release-seal PR before release.\n' "$VERSION" + else + printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION" + fi else printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION" fi diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 5d8ef72b..a71385b5 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -305,6 +305,52 @@ func TestChangelogPRContentOnlyAcceptsReleaseFragmentArchival(t *testing.T) { } } +func TestReleaseFragmentPolicyAcceptsStablePostBetaArchival(t *testing.T) { + repo := newChangelogGateRepo(t) + changelogGateWrite(t, repo.root, ".changes/1236-stable-followup.md", "---\ncategory: Fixed\n---\n\n- Stable follow-up.\n", 0o644) + repo.commit(t, "add post-beta release fragment") + sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog + +## [Unreleased] + +## [1.0.1] - 2026-07-17 + +This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable. + +### Changed + +- Promote the complete beta release to stable. + +### Changes since `+"`v1.0.1-beta.1`"+` + +### Fixed + +- Stable follow-up. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +`, 0o644) + archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1") + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + t.Fatalf("MkdirAll stable archive: %v", err) + } + if err := os.Rename( + filepath.Join(repo.root, ".changes", "1236-stable-followup.md"), + filepath.Join(archiveDir, "1236-stable-followup.md"), + ); err != nil { + t.Fatalf("Rename stable release fragment: %v", err) + } + repo.commit(t, "seal stable release notes") + + if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil { + t.Fatalf("release fragment policy rejected stable seal: %v\noutput:\n%s", err, output) + } +} + func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) { t.Run("invalid active fragment", func(t *testing.T) { repo := newChangelogGateRepo(t) diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 7c8e98cc..a53c7910 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -1895,6 +1895,85 @@ func TestReleasePrepareChangelogRendersAndArchivesPrereleaseFragments(t *testing } } +func TestReleasePrepareChangelogRendersAndArchivesStableFragments(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", "1236-stable-followup.md"), []byte("---\ncategory: Fixed\n---\n\n- **Stable follow-up** (#1236) — includes a change merged after the beta.\n"), 0o644) + r.commitAndPush(t, "install stable changelog preparation") + + cmd := exec.Command( + "sh", + filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), + "stable", + "v1.0.1", + "--from-beta", + "v1.0.1-beta.1", + ) + cmd.Dir = r.root + cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("prepare stable changelog error = %v\noutput:\n%s", err, output) + } + changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) + } + content := string(changelog) + if !strings.Contains(content, "## [1.0.1] - 2026-07-11") || + !strings.Contains(content, "promotes the sealed `v1.0.1-beta.1` contents to stable") || + !strings.Contains(content, "### Changes since `v1.0.1-beta.1`") || + !strings.Contains(content, "### Fixed") || + !strings.Contains(content, "Stable follow-up") { + t.Fatalf("prepared stable changelog did not render post-beta fragments:\n%s", changelog) + } + name := "1236-stable-followup.md" + if _, err := os.Stat(filepath.Join(r.root, ".changes", name)); !os.IsNotExist(err) { + t.Fatalf("unconsumed stable release fragment %s still present: %v", name, err) + } + if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1", name)); err != nil { + t.Fatalf("archived stable release fragment %s missing: %v", name, err) + } +} + +func TestReleasePrepareChangelogStableWithoutFragmentsKeepsTemplate(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) + r.commitAndPush(t, "install stable changelog preparation") + + cmd := exec.Command( + "sh", + filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), + "stable", + "v1.0.1", + "--from-beta", + "v1.0.1-beta.1", + ) + cmd.Dir = r.root + cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("prepare stable changelog without fragments error = %v\noutput:\n%s", err, output) + } + changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) + } + content := string(changelog) + if !strings.Contains(content, "## [1.0.1] - 2026-07-11") || + !strings.Contains(content, "TODO: summarize the complete user-visible release") || + strings.Contains(content, "### Changes since") { + t.Fatalf("stable changelog without fragments changed template behavior:\n%s", changelog) + } + if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1")); !os.IsNotExist(err) { + t.Fatalf("stable preparation without fragments created an archive: %v", err) + } +} + func TestReleasePrepareChangelogRejectsInvalidReleaseFragment(t *testing.T) { r := newReleaseTestRepo(t) releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)