Files
dingtalk-workspace-cli/scripts/release/render-release-fragments.sh
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

117 lines
3.6 KiB
Bash
Executable File

#!/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