Compare commits

...
9 changed files with 234 additions and 20 deletions
+3 -1
View File
@@ -25,7 +25,9 @@ category: Added
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
+5
View File
@@ -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.
+12 -4
View File
@@ -534,7 +534,8 @@ jobs:
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
# its job silently. The CrossPlatformCoverage-heavy C range is split again
# to retain headroom on runners reclaimed near the five-minute mark.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -542,7 +543,10 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -678,7 +682,8 @@ jobs:
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -686,7 +691,10 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
+7 -2
View File
@@ -140,8 +140,13 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
归档移动,供审计复核。
## CI/CD 保证
+20 -8
View File
@@ -14,7 +14,7 @@ usage() {
# Single source of truth for the partition set. CI runs one job per partition and
# pins its shard names to this list, so a name that appears here without a
# dispatch entry below fails closed rather than silently skipping tests.
APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz'
APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
mode="${1:-}"
partition=""
@@ -66,7 +66,10 @@ fi
schema_count=0
ab_count=0
c_count=0
cal_count=0
cmr_count=0
csz_count=0
cother_count=0
dr_count=0
sz_count=0
unmatched_count=0
@@ -75,7 +78,10 @@ while IFS= read -r test_name; do
case "$test_name" in
Test*Schema*) schema_count=$((schema_count + 1)) ;;
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
TestC*) c_count=$((c_count + 1)) ;;
TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
TestC*) cother_count=$((cother_count + 1)) ;;
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;;
*)
@@ -96,7 +102,10 @@ classified=''
for spec in \
"schema:$schema_count" \
"a-b:$ab_count" \
"c:$c_count" \
"c-a-l:$cal_count" \
"c-m-r:$cmr_count" \
"c-s-z:$csz_count" \
"c-other:$cother_count" \
"d-r:$dr_count" \
"s-z-example-fuzz:$sz_count"
do
@@ -133,14 +142,14 @@ for name in $classified; do
done
total_count="$(wc -l < "$tests" | tr -d ' ')"
assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count))
assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
if [ "$assigned_count" -ne "$total_count" ]; then
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
exit 1
fi
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \
"$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count"
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
if [ "$mode" = "verify" ]; then
exit 0
@@ -198,7 +207,10 @@ run_named_partition() {
case "$1" in
schema) run_partition schema no-race "$schema_pattern" ;;
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
c) run_partition c race '^TestC' "$schema_pattern" ;;
c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
s-z-example-fuzz)
run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern"
+22 -1
View File
@@ -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 }
+40 -4
View File
@@ -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
+46
View File
@@ -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)
+79
View File
@@ -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)