Compare commits

...
Author SHA1 Message Date
chichuan 305ccf0984 Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 13:53:37 +08:00
chichuan c2c1131079 fix: match the release archive directory literally, not as a regex
release_version was interpolated into an awk regex, where '.' matches any
character. Version 1.0.1-beta.1 therefore also admitted
.changes/released/1x0x1-betaX1/, letting the archive drift from the
CHANGELOG version while every other seal assertion still passed and
breaking the documented audit trail.

Compare the archive prefix with index() and split the basename off with
substr(), matching the literal-comparison idiom already used throughout
check-changelog-pr.sh. Only the basename, whose character class is fixed,
stays a pattern.
2026-08-12 13:52:25 +08:00
github-actions[bot] 38e387bcd6 Merge pull request #959 from DingTalk-Real-AI/codex/drive-shortcuts
feat(drive): harden and expand shortcut workflows
2026-08-12 12:18:58 +08:00
chichuan 12435e6e54 refactor: stage the .changes diff once for both fragment triggers
Both trigger predicates ran the same git diff, which the script already
avoids elsewhere by staging --name-status into $tmp_root/status. Write the
path list once and let each awk predicate read it, matching that idiom.
2026-08-12 12:07:07 +08:00
chichuan 1d8182bcfb Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 12:01:38 +08:00
chichuan 4243676739 fix: trigger release fragment tree validation on nested .changes paths
Git records no diff entry for a directory itself, so adding
.changes/foo/bar.md only surfaced the nested path, which the single-level
trigger regex skipped. The entry validation and the renderer were both
bypassed, letting a nested directory reach main and break every later
fragment render with 'unexpected directory'.

Trigger the top-level tree validation on any .changes change outside
.changes/released/ (which keeps its own immutability and release-seal
checks), and assert .changes itself is still a tree so replacing it with a
blob or symlink cannot empty the child listing unnoticed.

Re-rendering stays keyed on fragment changes so a README-only edit does
not fail on an empty fragment set.
2026-08-12 12:00:42 +08:00
chichuan e0dd800378 docs: state the release fragment filename and file-kind contract 2026-08-11 21:24:43 +08:00
chichuan 309c39a8e0 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 21:24:25 +08:00
chichuan 39d6caa24d fix: validate every top-level .changes entry in the fragment gate
The fragment gate only ran validation when the changed path matched the
legal fragment name pattern, so `.changes/Foo.md`, `.changes/notes.txt`
and a symlinked fragment slipped through untouched and then broke the
next PR that added a legal fragment. The trigger now fires on any
top-level `.changes/` change other than README.md and rejects every
entry that is not README.md, released/, or a 100644 blob named
^[a-z0-9][a-z0-9._-]*\.md$.

The renderer had the same hole from the other side: `find -type f`
is false for symlinks, so a symlinked fragment was silently dropped
from the rendered notes, and the `[a-z0-9]*.md` glob only constrained
the first character so `chat reply.md` passed. It now walks every
top-level entry and fails on symlinks, unexpected directories,
non-regular files and illegal names. Both scripts pin LC_ALL=C so the
ASCII ranges cannot match uppercase under a different collation.

Adds regression coverage for illegal names, non-markdown entries,
symlinks and executable modes on both the gate and the renderer.
2026-08-11 21:07:12 +08:00
chichuan afb25ae0e9 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 19:49:49 +08:00
chichuan 3af7adaad6 Merge branch 'main' into codex/release-fragments 2026-08-10 17:06:47 +08:00
chichuan 1f127881c9 Merge branch 'main' into codex/release-fragments 2026-08-07 10:24:51 +08:00
chichuan c52f2b6e05 release: use isolated changelog fragments 2026-08-06 10:49:46 +08:00
13 changed files with 924 additions and 73 deletions
+34
View File
@@ -0,0 +1,34 @@
# Release fragments
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
并行 PR 争用同一文件。
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
`1234-chat-reply-mentions.md`。文件名必须匹配
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
严格如下:
```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/<version>/`。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
例外是否成立。
+4 -2
View File
@@ -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/<unique-name>.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
+6
View File
@@ -1288,6 +1288,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:
+1 -1
View File
@@ -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"
+4 -1
View File
@@ -83,7 +83,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/<unique-name>.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
+14 -8
View File
@@ -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/<name>.md` to
`.changes/released/<version>/<name>.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
@@ -184,11 +190,11 @@ Schema,并让 candidate 对两份历史 contract 独立执行检查;它只
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
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
+13 -2
View File
@@ -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/<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 同时提交生成的章节与归档移动,供审计复核。
## 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 证据。
+11
View File
@@ -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/<unique-name>.md without editing CHANGELOG.md' >&2
exit 1
fi
;;
esac
+191
View File
@@ -0,0 +1,191 @@
#!/bin/sh
set -eu
# Fragment names are matched with ASCII ranges, so keep collation deterministic.
LC_ALL=C
export LC_ALL
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
}
# The archive directory is matched as a literal prefix, never as a regex:
# interpolating the version into one would make `.` match any character, so
# `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break
# the documented audit trail. Only the fragment basename, whose character class
# is fixed, stays a pattern.
if ! awk -F '\t' -v prefix=".changes/released/$release_version/" '
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { changelog = 1; next }
$1 == "R100" && NF == 3 && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && index($3, prefix) == 1 {
target = substr($3, length(prefix) + 1)
if (target !~ /^[a-z0-9][a-z0-9._-]*\.md$/) { invalid = 1; next }
source = $2; sub(/^.*\//, "", source)
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/<name>.md to .changes/released/<new-version>/<name>.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
# `.changes/released/**` carries its own immutability and release-seal checks
# above, so every other `.changes` change must revalidate the top-level tree.
# This trigger must not be narrowed to single-level paths or to the legal
# fragment name pattern: git records no diff entry for a directory itself, so
# adding `.changes/foo/bar.md` only ever shows the nested path, and an illegally
# named or non-regular entry only ever shows its own path. Either one would skip
# validation here and then break the next unrelated PR that adds a legal
# fragment.
git -C "$ROOT" diff --no-ext-diff --name-only "$merge_base" "$head" -- .changes >"$tmp_root/changes-paths"
changes_tree_changed=false
if awk '
$0 ~ /^\.changes\/released\// { next }
{ found = 1 }
END { exit !found }
' "$tmp_root/changes-paths"; then
changes_tree_changed=true
fi
# Re-rendering is driven by fragment changes only. `.changes/README.md` is the
# contributor contract rather than release content, and it may be edited while no
# fragment is pending, which the renderer would reject as an empty fragment set.
fragment_changed=false
if awk '
$0 ~ /^\.changes\/released\// { next }
$0 == ".changes/README.md" { next }
{ found = 1 }
END { exit !found }
' "$tmp_root/changes-paths"; then
fragment_changed=true
fi
if [ "$changes_tree_changed" = true ]; then
# `.changes` itself must stay a directory: replacing it with a blob or a
# symlink leaves the child listing below empty, which would report no invalid
# entries while silently discarding every fragment.
changes_root_type="$(git -C "$ROOT" ls-tree "$head" -- .changes | awk 'NR == 1 { print $2 }')"
if [ "$changes_root_type" != tree ]; then
printf '%s\n' 'error: .changes must remain a directory holding README.md, released/, and release fragments' >&2
exit 1
fi
git -C "$ROOT" ls-tree "$head" -- .changes/ >"$tmp_root/head-entries"
awk '
{
mode = $1
type = $2
path = $0
sub(/^[^\t]*\t/, "", path)
name = path
sub(/^.*\//, "", name)
if (path == ".changes/README.md") {
if (mode == "100644" && type == "blob") next
print path
next
}
if (path == ".changes/released") {
if (type == "tree") next
print path
next
}
if (mode != "100644" || type != "blob") { print path; next }
if (name !~ /^[a-z0-9][a-z0-9._-]*\.md$/) { print path; next }
}
' "$tmp_root/head-entries" >"$tmp_root/invalid-entries"
if [ -s "$tmp_root/invalid-entries" ]; then
printf '%s\n' 'error: .changes/ accepts only README.md, released/, and release fragments named <name>.md matching ^[a-z0-9][a-z0-9._-]*\.md$ stored as regular 100644 files' >&2
sed 's/^/ /' "$tmp_root/invalid-entries" >&2
exit 1
fi
# Only an ordinary fragment change is re-rendered here: a release-seal branch
# is already held to the stricter rendered-notes comparison above and its
# fragments have moved into the archive, and a README-only edit carries no
# release content to render.
if [ "$fragment_changed" = true ] && [ "$archive_changed" = false ]; then
head_changes="$tmp_root/head-changes"
mkdir -p "$head_changes"
awk '{ path = $0; sub(/^[^\t]*\t/, "", path); print path }' "$tmp_root/head-entries" |
while IFS= read -r path; do
case "$path" in
.changes/released) continue ;;
esac
git -C "$ROOT" show "$head:$path" >"$head_changes/${path#.changes/}"
done
"$ROOT/scripts/release/render-release-fragments.sh" "$head_changes" >/dev/null
fi
fi
+19 -8
View File
@@ -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 <tag> Required for stable release notes
--from-ref <ref> Commit-list baseline for prerelease notes
--changelog <path> Override CHANGELOG.md path
--changes-dir <path> 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 '<!-- Summarize what this beta validates. Remove every TODO before publishing. -->\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"
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
set -eu
# Fragment names are matched with ASCII ranges, so keep collation deterministic.
LC_ALL=C
export LC_ALL
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
# A fragment must be a regular file named ^[a-z0-9][a-z0-9._-]*\.md$. Anything
# else is rejected rather than skipped, so a symlink or an illegally named
# fragment can never be silently dropped from the rendered notes.
validate_fragment_name() {
name="$1"
path="$2"
case "$name" in
*.md) ;;
*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
case "$name" in
[a-z0-9]*) ;;
*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
case "$name" in
*[!a-z0-9._-]*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
}
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$tmp_root/entries"
: >"$tmp_root/files"
while IFS= read -r entry; do
base="${entry##*/}"
if [ "$base" = 'README.md' ]; then
continue
fi
if [ -L "$entry" ]; then
printf 'release fragment must be a regular file, not a symbolic link: %s\n' "$entry" >&2
exit 1
fi
if [ -d "$entry" ]; then
if [ "$base" = 'released' ]; then
continue
fi
printf 'unexpected directory in release fragments directory: %s\n' "$entry" >&2
exit 1
fi
if [ ! -f "$entry" ]; then
printf 'release fragment must be a regular file: %s\n' "$entry" >&2
exit 1
fi
validate_fragment_name "$base" "$entry"
printf '%s\n' "$entry" >>"$tmp_root/files"
done <"$tmp_root/entries"
[ -s "$tmp_root/files" ] || {
printf '%s\n' 'no release fragments found; add .changes/<unique-name>.md before preparing a prerelease changelog' >&2
exit 1
}
validate_fragment() {
fragment="$1"
validate_fragment_name "${fragment##*/}" "$fragment"
[ "$(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
+409 -14
View File
@@ -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
@@ -42,6 +43,26 @@ const changelogGateValidRelease = `# Changelog
- Initial release.
`
// A release-seal section whose 1.0.1-beta.1 notes are exactly the rendered form
// of changelogGateValidFragment. A seal PR built from this therefore satisfies
// the rendered-notes comparison, which isolates the archive-path assertions.
const changelogGateSealedRelease = `# Changelog
## [Unreleased]
## [1.0.1-beta.1] - 2026-07-17
### Added
- Chat reply mentions.
## [1.0.0] - 2026-07-01
### Added
- Initial release.
`
func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
t.Helper()
@@ -69,8 +90,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 +114,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 +235,386 @@ 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)
}
})
}
const changelogGateValidFragment = "---\ncategory: Added\n---\n\n- Chat reply mentions.\n"
// sealFragmentInto stages a release-seal PR that archives the single active
// fragment into .changes/released/<archiveDir>/, with a CHANGELOG section whose
// rendered notes already match. Only archiveDir varies, so a rejection can only
// come from the archive-path contract.
func (r *changelogGateRepo) sealFragmentInto(t *testing.T, archiveDir string) string {
t.Helper()
changelogGateWrite(t, r.root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
r.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, r.root, "rev-parse", "HEAD"))
changelogGateWrite(t, r.root, "CHANGELOG.md", changelogGateSealedRelease, 0o644)
target := filepath.Join(r.root, ".changes", "released", archiveDir)
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", target, err)
}
if err := os.Rename(filepath.Join(r.root, ".changes", "1234-chat.md"), filepath.Join(target, "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment error = %v", err)
}
r.commit(t, "seal release into "+archiveDir)
return sealBase
}
// The archive directory used to be matched by interpolating the version into an
// awk regex, where `.` matches any character. Version `1.0.1-beta.1` therefore
// also admitted `1x0x1-betaX1`, letting the archive drift from the CHANGELOG
// version while every other seal assertion still passed.
func TestReleaseFragmentPolicyRejectsArchiveDirectoryMatchedAsRegex(t *testing.T) {
for _, archiveDir := range []string{"1x0x1-betaX1", "1.0.1-betaX1", "1x0.1-beta.1"} {
t.Run(archiveDir, func(t *testing.T) {
repo := newChangelogGateRepo(t)
sealBase := repo.sealFragmentInto(t, archiveDir)
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
if err == nil {
t.Fatalf("archive directory %q passed:\n%s", archiveDir, output)
}
if !strings.Contains(output, "unchanged R100 moves") {
t.Fatalf("gate output missing the archive-move contract:\n%s", output)
}
})
}
}
// Guards the fix against over-blocking: the archive directory that exactly
// equals the CHANGELOG version must still seal cleanly.
func TestReleaseFragmentPolicyAcceptsArchiveDirectoryMatchingChangelogVersion(t *testing.T) {
repo := newChangelogGateRepo(t)
sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1")
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
t.Fatalf("matching archive directory rejected: %v\noutput:\n%s", err, output)
}
}
// A top-level `.changes/` entry that violates the naming rule or is not a
// regular 100644 blob must fail the gate. Selecting the trigger by the legal
// name pattern used to let these entries through untouched, which both bypassed
// validation here and broke the next PR that added a legal fragment.
func TestReleaseFragmentPolicyRejectsIllegalFragmentNamesAndModes(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, root string)
wantPath string
}{
{
name: "uppercase fragment name",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/Chat.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/Chat.md",
},
{
name: "fragment name with a space",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/chat reply.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/chat reply.md",
},
{
name: "fragment name starting with a dash",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/-chat.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/-chat.md",
},
{
name: "non-markdown entry",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/notes.txt", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/notes.txt",
},
{
name: "symlinked fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
if err := os.Symlink("1234-chat.md", filepath.Join(root, ".changes", "5678-alias.md")); err != nil {
t.Fatalf("Symlink release fragment: %v", err)
}
},
wantPath: ".changes/5678-alias.md",
},
{
name: "executable fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o755)
},
wantPath: ".changes/1234-chat.md",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newChangelogGateRepo(t)
test.seed(t, repo.root)
repo.commit(t, test.name)
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
if err == nil {
t.Fatalf("illegal release fragment entry passed:\n%s", output)
}
if !strings.Contains(output, ".changes/ accepts only") {
t.Fatalf("gate output missing the entry contract:\n%s", output)
}
if !strings.Contains(output, test.wantPath) {
t.Fatalf("gate output missing offending entry %q:\n%s", test.wantPath, output)
}
})
}
}
func TestReleaseFragmentPolicyAcceptsLegalFragmentAlongsideReadmeAndArchive(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
changelogGateWrite(t, repo.root, ".changes/released/1.0.0/0001-seed.md", changelogGateValidFragment, 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
repo.commit(t, "add release fragment")
if output, err := repo.runFragmentPolicy(t, base, "HEAD"); err != nil {
t.Fatalf("legal release fragment rejected: %v\noutput:\n%s", err, output)
}
}
// Git records no diff entry for a directory itself, so adding
// `.changes/foo/bar.md` only ever surfaces the nested path. Triggering the
// top-level validation on single-level paths let such a directory reach main
// untouched and then broke every later fragment render with
// `unexpected directory`.
func TestReleaseFragmentPolicyRejectsNestedChangesEntries(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, root string)
wantPath string
}{
{
name: "nested directory only",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/foo/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/foo",
},
{
name: "nested directory alongside a legal fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
changelogGateWrite(t, root, ".changes/foo/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/foo",
},
{
name: "nested directory shadowing the archive name",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/released.d/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/released.d",
},
{
name: "deeply nested directory",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/a/b/c/note.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/a",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newChangelogGateRepo(t)
test.seed(t, repo.root)
repo.commit(t, test.name)
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
if err == nil {
t.Fatalf("nested .changes entry passed:\n%s", output)
}
if !strings.Contains(output, ".changes/ accepts only") {
t.Fatalf("gate output missing the entry contract:\n%s", output)
}
if !strings.Contains(output, test.wantPath) {
t.Fatalf("gate output missing offending entry %q:\n%s", test.wantPath, output)
}
})
}
}
// Replacing `.changes` itself empties the child listing, so scanning entries
// alone would report nothing invalid while the whole fragment directory
// disappears. Seeded without an active fragment so the archival-move guard
// cannot mask the directory contract being asserted here.
func TestReleaseFragmentPolicyRejectsReplacedChangesDirectory(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
if err := os.RemoveAll(filepath.Join(repo.root, ".changes")); err != nil {
t.Fatalf("RemoveAll(.changes) error = %v", err)
}
if err := os.Symlink("docs", filepath.Join(repo.root, ".changes")); err != nil {
t.Fatalf("Symlink(.changes) error = %v", err)
}
repo.commit(t, "replace .changes with a symlink")
output, err := repo.runFragmentPolicy(t, base, "HEAD")
if err == nil {
t.Fatalf("replaced .changes directory passed:\n%s", output)
}
if !strings.Contains(output, ".changes must remain a directory") {
t.Fatalf("gate output missing the directory contract:\n%s", output)
}
}
// `.changes/README.md` is the contributor contract, not release content, so a
// documentation-only edit must pass even though no fragment is pending — the
// renderer rejects an empty fragment set.
func TestReleaseFragmentPolicyAcceptsReadmeOnlyChangeWithoutPendingFragments(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n\nName each fragment `<name>.md`.\n", 0o644)
repo.commit(t, "document the fragment naming rule")
if output, err := repo.runFragmentPolicy(t, base, "HEAD"); err != nil {
t.Fatalf("README-only change rejected: %v\noutput:\n%s", err, output)
}
}
// A README-only edit still revalidates the tree, so pollution that somehow
// reached the branch is reported by the PR that touches `.changes/` rather than
// deferred to whichever unrelated PR next adds a fragment.
func TestReleaseFragmentPolicyReadmeOnlyChangeStillValidatesTree(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
changelogGateWrite(t, repo.root, ".changes/Foo.md", changelogGateValidFragment, 0o644)
repo.commit(t, "seed polluted fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n\nUpdated contract.\n", 0o644)
repo.commit(t, "edit only the fragment readme")
output, err := repo.runFragmentPolicy(t, base, "HEAD")
if err == nil {
t.Fatalf("README-only change skipped tree validation:\n%s", output)
}
if !strings.Contains(output, ".changes/Foo.md") {
t.Fatalf("gate output missing the pre-existing illegal entry:\n%s", output)
}
}
func TestChangelogPRContentOnlyStillValidatesReleaseNotes(t *testing.T) {
tests := []struct {
name string
changelog string
@@ -250,7 +646,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")
+102 -37
View File
@@ -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"),
@@ -1658,10 +1660,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")
@@ -1675,55 +1680,115 @@ 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)
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)
}
data, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
if err != nil {
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
}
const releaseValidFragment = "---\ncategory: Added\n---\n\n- **Chat reply mentions** (#1234) — supports selected member mentions.\n"
// The renderer used to skip anything `find -type f -name '*.md'` did not match,
// so a symlinked or illegally named fragment was dropped from the notes without
// a word. Every unexpected top-level entry must fail the release instead.
func TestReleasePrepareChangelogRejectsUnsafeReleaseFragmentEntries(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, changesDir string)
wantMsg string
}{
{
name: "symlinked fragment",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "1234-chat.md"), []byte(releaseValidFragment), 0o644)
if err := os.Symlink("1234-chat.md", filepath.Join(changesDir, "5678-alias.md")); err != nil {
t.Fatalf("Symlink release fragment: %v", err)
}
},
wantMsg: "not a symbolic link",
},
{
name: "fragment name with a space",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "chat reply.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
name: "uppercase fragment name",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "Chat.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
name: "non-markdown entry",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "notes.txt"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
// The PR gate is what keeps a nested directory out of `.changes/`;
// this covers the release-side backstop that reports it instead of
// rendering notes from an unexpected tree shape.
name: "nested fragment directory",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "foo", "bar.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "unexpected directory in release fragments directory",
},
}
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)
}
for _, test := range tests {
t.Run(test.name, func(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)
test.seed(t, filepath.Join(r.root, ".changes"))
r.commitAndPush(t, "add unsafe release fragment entry")
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")
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("unsafe release fragment entry was accepted:\n%s", output)
}
if !strings.Contains(string(output), test.wantMsg) {
t.Fatalf("renderer output missing %q:\n%s", test.wantMsg, output)
}
})
}
}