diff --git a/.changes/README.md b/.changes/README.md new file mode 100644 index 00000000..86c31701 --- /dev/null +++ b/.changes/README.md @@ -0,0 +1,29 @@ +# Release fragments + +普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的 +`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免 +并行 PR 争用同一文件。 + +文件名使用能唯一定位变更的短名,通常是 PR 号,例如 +`1234-chat-reply-mentions.md`。文件格式严格如下: + +```markdown +--- +category: Added +--- + +- **Chat reply mentions** (#1234) — supports mentioning selected members. +``` + +`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或 +`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。 + +发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序, +将未归档 fragments 汇总为唯一的版本章节,并移动到 +`.changes/released//`。因此 release-seal PR 是唯一会修改 +`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。 +归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、 +删除或重写已归档文件。 + +无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该 +例外是否成立。 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 391364b6..8b54e4f2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,8 +19,10 @@ repeat the entire CI suite locally only to fill this checklist: CI expands the selected tier from documentation checks, through affected-package tests, to the complete high-risk suite. -- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`): - `./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD` +- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`): + `.changes/.md`; ordinary PRs must not edit `CHANGELOG.md`. +- [ ] Release-seal validation (otherwise `N/A`): + `./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD` - [ ] Targeted test/check commands and results: - [ ] Behavior evidence (test name, CLI output shape, or before/after result): - [ ] Documentation links/content/rendering checked (documentation-only, otherwise diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bada42c2..06eb7ed5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1283,6 +1283,12 @@ jobs: ./scripts/policy/check-changelog-pr.sh \ "$mode" "$PR_BASE_SHA" HEAD + - name: Validate release fragment lifecycle + if: github.event_name == 'pull_request' + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD + - name: Validate trusted main CHANGELOG-only push if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true' env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96944724..08e83fd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2793,7 +2793,7 @@ jobs: fi if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then echo - echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish." + echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish." fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a17dcee..dc4ce719 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,7 +75,10 @@ coverage is additionally selected for platform-sensitive code. change. 6. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first). -7. Update docs and `CHANGELOG.md` for behavior/interface changes. +7. Update docs and add one `.changes/.md` release fragment for + behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR; + the release-seal workflow renders and archives fragments into the versioned + changelog section. ## Submission Flow diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 69f9a6e3..86ccd69d 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -48,8 +48,12 @@ It then runs: --fast-path "$PR_BASE_SHA" HEAD ``` -Because the verified PR diff contains only `CHANGELOG.md`, the validator and -its policy dependencies in that merge tree are byte-for-byte the current base +The exact fast path remains limited to historic one-file maintenance. A +release-seal PR uses `--content-only`, which permits the generated +`CHANGELOG.md` change together with archival moves from `.changes/` to +`.changes/released/`; it receives the normal scoped admission instead of this +fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone +release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base versions. Validation targets the synthetic merge tree, not the feature-branch tree, so a stale branch cannot supply an older validator or combine with newer base notes into an invalid final CHANGELOG. @@ -79,10 +83,12 @@ to the complete main admission suite. A source change can therefore never inherit the CHANGELOG-only result. Any PR that touches `CHANGELOG.md` but also changes another file runs the same -content contract in `Policy` with `--content-only`. That mode permits the -second file but still rejects invalid dates or versions, missing bullets, -placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes. -Adding a second file therefore cannot bypass CHANGELOG validation. +content contract in `Policy` with `--content-only`. That mode accepts only +fragment archival moves (`.changes/.md` to +`.changes/released//.md`) alongside the changelog; source and +documentation changes are rejected. It still rejects invalid dates or +versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section +changes, and unsafe tree modes. ## Risk tiers and downstream boundaries @@ -166,11 +172,11 @@ make mock-mcp-smoke go test -v -count=1 ./pkg/editiontest/... ``` -For an exact CHANGELOG-only branch: +For a release-seal branch that archives rendered fragments: ```sh base_ref=$(git merge-base HEAD origin/main) -./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD +./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD ``` `make coverage-gate` is an enforcement step, not a profile generator. For a diff --git a/docs/releasing.md b/docs/releasing.md index db12b52f..428cdbcc 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -17,7 +17,8 @@ 1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。 2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。 -3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。 +3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的 + release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。 4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。 `plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。 @@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG dws-release v1.2.3-beta.1 ``` -如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检: +如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的 +fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检: ```bash dws-release v1.2.3-beta.1 @@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。 +### Release fragments + +普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在 +`.changes/.md` 中增加一个独立 fragment;格式和允许的分类见 +[`.changes/README.md`](../.changes/README.md)。预发封板时 +`scripts/release/prepare-changelog.sh prerelease ` 会稳定排序并汇总所有未归档 +fragment,写入唯一版本章节后移动到 `.changes/released//`。因此并发 PR 不会争用 +`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。 + ## CI/CD 保证 - 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。 diff --git a/scripts/policy/check-changelog-pr.sh b/scripts/policy/check-changelog-pr.sh index b31af5cf..a88d4045 100755 --- a/scripts/policy/check-changelog-pr.sh +++ b/scripts/policy/check-changelog-pr.sh @@ -119,6 +119,17 @@ content-only) fi exit 1 fi + if ! awk -F ' ' ' + $1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { next } + $1 == "D" && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 2 { next } + $1 == "A" && $2 ~ /^\.changes\/released\/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 2 { next } + $1 ~ /^R[0-9]+$/ && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && $3 ~ /^\.changes\/released\/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 3 { next } + { invalid = 1 } + END { exit invalid } + ' "$TMP_ROOT/name-status"; then + printf '%s\n' 'error: CHANGELOG.md may accompany only release-fragment archival; ordinary PRs must add .changes/.md without editing CHANGELOG.md' >&2 + exit 1 + fi ;; esac diff --git a/scripts/policy/check-release-fragments.sh b/scripts/policy/check-release-fragments.sh new file mode 100755 index 00000000..2610f32b --- /dev/null +++ b/scripts/policy/check-release-fragments.sh @@ -0,0 +1,95 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" + +usage() { printf '%s\n' 'usage: check-release-fragments.sh BASE HEAD' >&2; } + +[ "$#" -eq 2 ] || { usage; exit 2; } +base="$(git -C "$ROOT" rev-parse --verify --quiet "$1^{commit}")" || { usage; exit 2; } +head="$(git -C "$ROOT" rev-parse --verify --quiet "$2^{commit}")" || { usage; exit 2; } +merge_base="$(git -C "$ROOT" merge-base "$base" "$head")" +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-fragment-policy.XXXXXX")" +cleanup() { rm -rf "$tmp_root"; } +trap cleanup EXIT HUP INT TERM + +git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status" + +archive_changed=false +if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then + archive_changed=true +fi + +if [ "$archive_changed" = true ]; then + release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')" + [ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { + printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2 + exit 1 + } + if ! awk -F '\t' -v version="$release_version" ' + $1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { changelog = 1; next } + $1 ~ /^R100$/ && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && $3 ~ ("^\\.changes/released/" version "/[a-z0-9][a-z0-9._-]*\\.md$") && NF == 3 { + source = $2; target = $3; sub(/^.*\//, "", source); sub(/^.*\//, "", target) + if (source != target) invalid = 1 + moved++; next + } + { invalid = 1 } + END { exit !(changelog && moved > 0 && !invalid) } + ' "$tmp_root/status"; then + printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/.md to .changes/released//.md in the matching release-seal PR' >&2 + exit 1 + fi + source_changes="$tmp_root/source-changes" + mkdir -p "$source_changes" + git -C "$ROOT" ls-tree -r --name-only "$merge_base" -- .changes | + while IFS= read -r path; do + case "$path" in + .changes/*.md) + name="${path#.changes/}" + mkdir -p "$(dirname "$source_changes/$name")" + git -C "$ROOT" show "$merge_base:$path" >"$source_changes/$name" + ;; + esac + done + "$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes" + git -C "$ROOT" show "$head:CHANGELOG.md" | + awk -v heading="## [$release_version] - " ' + index($0, heading) == 1 { found = 1; next } + found && /^## / { exit } + found { print } + ' >"$tmp_root/actual-notes" + normalize_notes() { + awk ' + /^[[:space:]]*$/ && !started { next } + { started = 1; lines[++count] = $0 } + END { + while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count-- + for (line_no = 1; line_no <= count; line_no++) print lines[line_no] + } + ' "$1" + } + normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized" + normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized" + if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then + printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2 + diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true + exit 1 + fi +else + if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) invalid = 1 } END { exit !invalid }' "$tmp_root/status"; then + printf '%s\n' 'error: archived release fragments are immutable outside their release-seal PR' >&2 + exit 1 + fi + if awk -F '\t' ' + $1 == "D" && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ { invalid = 1 } + $1 ~ /^R[0-9]+$/ && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ { invalid = 1 } + END { exit !invalid } + ' "$tmp_root/status"; then + printf '%s\n' 'error: active release fragments may be deleted or renamed only by the matching release-seal archival move' >&2 + exit 1 + fi +fi + +if git -C "$ROOT" diff --no-ext-diff --name-only "$merge_base" "$head" -- .changes | grep -Eq '^\.changes/[a-z0-9][a-z0-9._-]*\.md$'; then + "$ROOT/scripts/release/render-release-fragments.sh" "$ROOT/.changes" >/dev/null +fi diff --git a/scripts/release/prepare-changelog.sh b/scripts/release/prepare-changelog.sh index 0c718fa3..053a46b7 100755 --- a/scripts/release/prepare-changelog.sh +++ b/scripts/release/prepare-changelog.sh @@ -10,6 +10,7 @@ VERSION="${2:-}" FROM_BETA="" FROM_REF="" CHANGELOG="$ROOT/CHANGELOG.md" +CHANGES_DIR="$ROOT/.changes" usage() { cat >&2 <<'EOF' @@ -19,6 +20,7 @@ Options: --from-beta Required for stable release notes --from-ref Commit-list baseline for prerelease notes --changelog Override CHANGELOG.md path + --changes-dir Override release fragment directory EOF } @@ -29,6 +31,7 @@ while [ "$#" -gt 0 ]; do --from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;; --from-ref) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_REF="$2"; shift 2 ;; --changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;; + --changes-dir) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGES_DIR="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;; esac @@ -60,21 +63,19 @@ fi 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")" -cleanup() { rm -f "$section" "$output"; } +fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")" +cleanup() { rm -f "$section" "$output" "$fragments"; } trap cleanup EXIT HUP INT TERM { 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" - else - printf '\n\n' - fi - printf '### Changed\n\n' - if [ "$CHANNEL" = "stable" ]; then + printf '### Changed\n\n' printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA" else - printf -- '- TODO: summarize this beta candidate and its validation scope.\n' + "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments" + cat "$fragments" fi } > "$section" @@ -105,7 +106,17 @@ fi [ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; } cp "$output" "$CHANGELOG" -printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION" +if [ "$CHANNEL" = "prerelease" ]; 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" +else + printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION" +fi if [ -n "$FROM_REF" ] && git rev-parse --verify --quiet "$FROM_REF^{commit}" >/dev/null; then printf '\nCommits since %s:\n' "$FROM_REF" git log --oneline "$FROM_REF..HEAD" diff --git a/scripts/release/render-release-fragments.sh b/scripts/release/render-release-fragments.sh new file mode 100755 index 00000000..c6ed6796 --- /dev/null +++ b/scripts/release/render-release-fragments.sh @@ -0,0 +1,73 @@ +#!/bin/sh +set -eu + +CHANGES_DIR="${1:-.changes}" + +usage() { + printf '%s\n' 'usage: render-release-fragments.sh [changes-dir]' >&2 +} + +[ "$#" -le 1 ] || { usage; exit 2; } +[ -d "$CHANGES_DIR" ] || { printf 'release fragments directory not found: %s\n' "$CHANGES_DIR" >&2; exit 1; } + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-fragments.XXXXXX")" +cleanup() { rm -rf "$tmp_root"; } +trap cleanup EXIT HUP INT TERM + +find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -print | + LC_ALL=C sort >"$tmp_root/files" + +[ -s "$tmp_root/files" ] || { + printf '%s\n' 'no release fragments found; add .changes/.md before preparing a prerelease changelog' >&2 + exit 1 +} + +validate_fragment() { + fragment="$1" + base="$(basename "$fragment")" + case "$base" in + [a-z0-9]*.md) ;; + *) printf 'invalid release fragment filename: %s\n' "$fragment" >&2; return 1 ;; + esac + + [ "$(sed -n '1p' "$fragment")" = '---' ] && + [ "$(sed -n '3p' "$fragment")" = '---' ] || { + printf 'invalid release fragment header: %s\n' "$fragment" >&2 + return 1 + } + case "$(sed -n '2p' "$fragment")" in + 'category: Added'|'category: Changed'|'category: Deprecated'|'category: Removed'|'category: Fixed'|'category: Security') ;; + *) printf 'invalid release fragment category: %s\n' "$fragment" >&2; return 1 ;; + esac + + body="$(sed -n '4,$p' "$fragment")" + printf '%s\n' "$body" | grep -Eq '^- [^[:space:]].*' || { + printf 'release fragment must contain a non-empty Markdown list item: %s\n' "$fragment" >&2 + return 1 + } + if printf '%s\n' "$body" | grep -Eqi '(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)'; then + printf 'release fragment must not contain TODO/TBD: %s\n' "$fragment" >&2 + return 1 + fi +} + +while IFS= read -r fragment; do + validate_fragment "$fragment" +done <"$tmp_root/files" + +for category in Added Changed Deprecated Removed Fixed Security; do + category_files="$tmp_root/$category" + : >"$category_files" + while IFS= read -r fragment; do + if [ "$(sed -n '2p' "$fragment")" = "category: $category" ]; then + printf '%s\n' "$fragment" >>"$category_files" + fi + done <"$tmp_root/files" + [ -s "$category_files" ] || continue + + printf '### %s\n\n' "$category" + while IFS= read -r fragment; do + awk 'NR >= 4 { if ($0 ~ /[^[:space:]]/) started = 1; if (started) print }' "$fragment" + printf '\n' + done <"$category_files" +done diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 4f485999..00fea1ea 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -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") diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 3d3a43de..164b3ae6 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -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) } }