Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c5bed4fc | ||
|
|
c1bd6dcf64 | ||
|
|
d4ff8a5f4f | ||
|
|
74859b966b |
+3
-1
@@ -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 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 保证
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user