From 283c1d77b404dd968826b303671abd0e085be1ab Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Fri, 4 Sep 2026 10:43:45 +0800 Subject: [PATCH] fix: generate detailed markdown release notes --- .gitea/workflows/release.yml | 4 ++ docs/release.md | 2 + package.json | 2 +- scripts/publish-gitea-release.sh | 95 +++++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 066a863..ce95fb1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -17,6 +17,10 @@ jobs: steps: - name: Checkout tag uses: actions/checkout@v4 + with: + # Release notes are derived from the previous version tag. A shallow + # checkout would leave only the synthetic release commit available. + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v4 with: diff --git a/docs/release.md b/docs/release.md index 5b340e9..1889214 100644 --- a/docs/release.md +++ b/docs/release.md @@ -12,6 +12,8 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3` 2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。 3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。 +发布脚本会根据当前 tag 与上一个版本 tag 之间的真实 Git 提交自动生成 Release 正文,按“新增功能、问题修复、优化与重构、文档与测试”分类,并以 Markdown 写入 Gitea。Gitea 页面会渲染这些标题和列表;更新中心读取同一份正文后再进行安全的 Markdown 子集渲染,不会显示 Markdown 源代码。旧版本曾使用单行占位正文 `TallyNote <版本>`,新版本发布时不会再使用该占位内容。 + 在仓库的 Actions secrets 配置: - `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。 diff --git a/package.json b/package.json index 721165a..5548403 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.26", + "version": "1.1.27", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh index a30bf3f..7427488 100755 --- a/scripts/publish-gitea-release.sh +++ b/scripts/publish-gitea-release.sh @@ -24,6 +24,7 @@ DRY_RUN=0 AUTH_CONFIG='' SUMS_TMP='' SIG_TMP='' +RELEASE_NOTES_TMP='' SIGNATURE_GENERATED=0 usage() { @@ -43,6 +44,81 @@ EOF die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; } log() { printf 'release publisher: %s\n' "$*"; } +generate_release_notes() { + local current=${TAG#v} previous='' subject kind line count=0 + local -a commits + commits=() + + # A workflow checks out the tag with history. Prefer an explicitly supplied + # notes file for mirrors, then derive notes from the immutable tag range. + if [[ -n "${TALLYNOTE_RELEASE_NOTES_FILE:-}" && -f "$TALLYNOTE_RELEASE_NOTES_FILE" ]]; then + # Read at most the API's bounded notes size without a pipe that can turn a + # deliberately truncated input into a SIGPIPE failure under pipefail. + LC_ALL=C awk 'BEGIN { remaining = 65536 } { if (remaining <= 0) exit; line=$0; gsub(/[[:cntrl:]]/, "", line); bytes=length(line)+1; if (bytes > remaining) { print substr(line, 1, remaining); exit } print line; remaining-=bytes }' "$TALLYNOTE_RELEASE_NOTES_FILE" + return + fi + + if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + while IFS= read -r line; do + [[ -n "$line" ]] || continue + [[ "$line" == "v${current}" ]] && continue + previous="$line" + break + done < <(git tag --sort=-version:refname --list 'v*') + if [[ -n "$previous" && "$previous" != "v${current}" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] && commits+=("$line") + done < <(git log --format='%s' "${previous}..${TAG}") + else + while IFS= read -r line; do + [[ -n "$line" ]] && commits+=("$line") + done < <(git log -n 30 --format='%s' "$TAG") + fi + fi + + printf '# TallyNote %s\n\n' "$current" + if [[ -n "$previous" ]]; then + printf '> 从 `%s` 到 `%s` 的变更\n\n' "$previous" "v${current}" + else + printf '> 本版本变更\n\n' + fi + + local -a features fixes improvements docs other + features=(); fixes=(); improvements=(); docs=(); other=() + for subject in "${commits[@]-}"; do + # Do not expose merge noise or the synthetic release commit in user notes. + [[ "$subject" != Merge\ * && "$subject" != release:* ]] || continue + kind=${subject%%:*} + if [[ "$subject" == *:* ]]; then subject=${subject#*: }; fi + subject=${subject# } + [[ -n "$subject" ]] || continue + case "$kind" in + feat|feature) features+=("$subject") ;; + fix|bugfix) fixes+=("$subject") ;; + refactor|perf|style|improvement) improvements+=("$subject") ;; + docs|doc|test|tests) docs+=("$subject") ;; + *) other+=("$subject") ;; + esac + done + + print_group() { + local title=$1; shift + local item + (($# > 0)) || return 0 + printf '## %s\n\n' "$title" + for item in "$@"; do printf -- '- %s\n' "$item"; done + printf '\n' + } + print_group '新增功能' "${features[@]-}" + print_group '问题修复' "${fixes[@]-}" + print_group '优化与重构' "${improvements[@]-}" + print_group '文档与测试' "${docs[@]-}" + print_group '其他变更' "${other[@]-}" + if (( ${#features[@]} + ${#fixes[@]} + ${#improvements[@]} + ${#docs[@]} + ${#other[@]} == 0 )); then + printf '本版本包含内部维护更新。\n' + fi +} + validate_semver() { local value=$1 prerelease part [[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1 @@ -169,6 +245,7 @@ cleanup() { if [[ -n "$AUTH_CONFIG" ]]; then rm -f -- "$AUTH_CONFIG"; fi if [[ -n "$SUMS_TMP" ]]; then rm -f -- "$SUMS_TMP"; fi if [[ -n "$SIG_TMP" ]]; then rm -f -- "$SIG_TMP"; fi + if [[ -n "$RELEASE_NOTES_TMP" ]]; then rm -f -- "$RELEASE_NOTES_TMP"; fi } trap cleanup EXIT if [[ -n "$SIGNING_KEY_FILE" ]]; then @@ -204,6 +281,13 @@ command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing' write_auth_config unset TOKEN +# Keep the release body deterministic and human-readable. Gitea renders this +# Markdown in the Release page; the update API later exposes the same body as +# text for the safe client-side Markdown renderer. +RELEASE_NOTES_TMP=$(mktemp) +generate_release_notes > "$RELEASE_NOTES_TMP" +release_notes=$(<"$RELEASE_NOTES_TMP") + api_curl() { "$CURL_BIN" --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 15 --max-time 120 \ --config "$AUTH_CONFIG" "$@" @@ -221,8 +305,17 @@ release_json=$(mktemp) status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取 Gitea Release' if [[ "$status" == 200 ]]; then release_id=$(jq -r '.id // empty' "$release_json") + existing_body=$(jq -r '.body // ""' "$release_json") + # Older releases used a one-line placeholder. Upgrade that placeholder when + # a tag is republished, while leaving deliberately authored release notes + # untouched. + if [[ "$existing_body" == "TallyNote $TAG" || -z "$existing_body" ]]; then + patch_body=$(jq -cn --arg body "$release_notes" '{body:$body}') + patch_status=$(api_curl_status -X PATCH -H 'Content-Type: application/json' -d "$patch_body" -o /dev/null -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/$release_id") || die '无法更新 Gitea Release 日志' + [[ "$patch_status" == 2* ]] || die "无法更新 Gitea Release 日志(HTTP $patch_status)" + fi elif [[ "$status" == 404 ]]; then - body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "TallyNote $TAG" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}') + body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "$release_notes" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}') create_status=$(api_curl_status -H 'Content-Type: application/json' -d "$body" -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases") || die '无法创建 Gitea Release' if [[ "$create_status" == 2* ]]; then release_id=$(jq -r '.id // empty' "$release_json")