Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
305ccf0984 | ||
|
|
c2c1131079 | ||
|
|
38e387bcd6 | ||
|
|
12435e6e54 | ||
|
|
1d8182bcfb | ||
|
|
4243676739 | ||
|
|
e0dd800378 | ||
|
|
309c39a8e0 | ||
|
|
39d6caa24d | ||
|
|
afb25ae0e9 | ||
|
|
3af7adaad6 | ||
|
|
1f127881c9 | ||
|
|
c52f2b6e05 |
@@ -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。评审者根据改动是否可见来判断该
|
||||
例外是否成立。
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 证据。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+191
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Executable
+116
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user