diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index bd5c564..dc3e26a 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - name: Validate package version and release tag + - name: Validate package version, release tag, and changelog shell: bash run: | version="$(tr -d '\r\n' < VERSION)" @@ -42,6 +42,7 @@ jobs: echo '::error::Release tag must equal v.' exit 1 fi + python3 tools/changelog.py check - name: Check Windows data parity and test core behavior run: apps/macos/tools/test.sh - name: Import Developer ID and configure notarization @@ -151,6 +152,9 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: actions/download-artifact@v8 with: pattern: KeyShadow-* @@ -162,8 +166,10 @@ jobs: GH_REPO: ${{ github.repository }} shell: bash run: | + # Bilingual notes from docs/changelog.json come first; GitHub appends the generated PR list. + python3 tools/changelog.py notes "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md" cd packages sha256sum --check ./*.sha256 gh release create "$GITHUB_REF_NAME" ./*.dmg ./*.exe ./*.sha256 \ --verify-tag --generate-notes --title "键影 $GITHUB_REF_NAME" \ - --notes 'macOS: Developer ID signed and Apple notarized universal DMG. Windows: unsigned standalone EXE. SHA256 checksums are included.' + --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/docs/changelog.json b/docs/changelog.json new file mode 100644 index 0000000..dff9093 --- /dev/null +++ b/docs/changelog.json @@ -0,0 +1,24 @@ +{ + "releases": [ + { + "version": "0.1.0", + "date": "2026-10-03", + "zh": { + "title": "首次开源发布", + "items": [ + "Windows 单文件 EXE 与 macOS 通用 DMG(已签名并公证)。", + "小鹤、自然码、微软、智能 ABC 四套方案,下一键与拼音提示。", + "自动跟随或固定位置、输入时或常驻显示、紧凑布局与四种配色。" + ] + }, + "en": { + "title": "First open-source release", + "items": [ + "Single-file Windows EXE and universal macOS DMG (signed and notarized).", + "Xiaohe, Ziranma, Microsoft, and Intelligent ABC schemes with next-key and pinyin hints.", + "Follow-input or fixed position, show-while-typing or always-on, compact layout, and four color themes." + ] + } + } + ] +} diff --git "a/docs/\346\211\223\345\214\205\344\270\216\345\217\221\345\270\203.md" "b/docs/\346\211\223\345\214\205\344\270\216\345\217\221\345\270\203.md" index b41cb73..4f87c85 100644 --- "a/docs/\346\211\223\345\214\205\344\270\216\345\217\221\345\270\203.md" +++ "b/docs/\346\211\223\345\214\205\344\270\216\345\217\221\345\270\203.md" @@ -27,7 +27,26 @@ Actions 页面运行详情底部的 `KeyShadow-macOS` / `KeyShadow-Windows` Arti 根目录 `VERSION` 是打包版本来源,使用三段数字 `X.Y.Z`,各段不超过 65534。macOS 构建时写入 app 的 `CFBundleShortVersionString`;Windows 在 `.build/` 生成临时 AssemblyInfo,将程序集和文件版本写为 `X.Y.Z.0`。构建不会修改两端源码中的版本声明。 -发布前修改 `VERSION`、提交并合并,随后从已验证提交创建对应 tag。工作流要求 tag 严格等于 `v`,如 `v0.1.0`;暂不支持带 `-rc` 等后缀。已有 Release 不会被重跑覆盖,应使用新版本发布。此配置本身不会替你创建或推送 tag。 +发布前修改 `VERSION`,并在 `docs/changelog.json` 顶部添加同版本条目、提交并合并,随后从已验证提交创建对应 tag。工作流要求 tag 严格等于 `v`,如 `v0.1.0`;暂不支持带 `-rc` 等后缀。已有 Release 不会被重跑覆盖,应使用新版本发布。此配置本身不会替你创建或推送 tag。 + +## 更新日志 + +`docs/changelog.json` 按新到旧排列,每个版本需要日期以及中英文标题和要点: + +```json +{ + "releases": [ + { + "version": "0.2.0", + "date": "2026-10-20", + "zh": { "title": "一句话概括", "items": ["面向用户的变化"] }, + "en": { "title": "One-line summary", "items": ["User-facing change"] } + } + ] +} +``` + +每次运行工作流都会执行 `python3 tools/changelog.py check`:格式错误、顺序颠倒或缺少当前 `VERSION` 的条目都会让打包失败,修改版本号时请一并补上。发布时 `python3 tools/changelog.py notes ` 生成双语 Release 正文,GitHub 自动生成的 PR 列表附在其后。[键影主页](https://keyshadow.aix.uy/) 会按最新 Release 的 tag 读取同一文件,发布后无需修改网站。 ## 签名与公证配置 diff --git "a/docs/\351\241\271\347\233\256\347\273\223\346\236\204.md" "b/docs/\351\241\271\347\233\256\347\273\223\346\236\204.md" index 5e9a822..a9217fb 100644 --- "a/docs/\351\241\271\347\233\256\347\273\223\346\236\204.md" +++ "b/docs/\351\241\271\347\233\256\347\273\223\346\236\204.md" @@ -20,7 +20,9 @@ KeyShadow/ │ ├── 构建与测试.md 开发环境与验证命令 │ ├── windows/、macos/ 安装及平台差异 │ ├── screenshots/ 实际窗口绘制与截图 +│ ├── changelog.json 双语更新日志,生成 Release 正文并供主页读取 │ └── 其他文档 方案来源、项目结构、打包与发布 +├── tools/changelog.py 更新日志校验与 Release 正文生成 ├── README.md 下载入口、界面预览与通用操作 ├── VERSION 三段数字版本 └── dist/ 构建及分发产物,不纳入版本控制 diff --git a/tools/changelog.py b/tools/changelog.py new file mode 100755 index 0000000..cf36c4d --- /dev/null +++ b/tools/changelog.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Validate docs/changelog.json and render GitHub release notes from it. + +The website (keyshadow.aix.uy) reads the same file at each release tag, so every +entry needs Chinese and English copy. + + python3 tools/changelog.py check # schema, order, and an entry for VERSION + python3 tools/changelog.py notes 0.2.0 # Markdown release notes for one version +""" +import datetime +import json +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +CHANGELOG = REPO / "docs" / "changelog.json" +VERSION_FILE = REPO / "VERSION" +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") +LANGUAGES = ("zh", "en") +PACKAGE_NOTE = ( + "macOS: Developer ID signed and Apple notarized universal DMG. " + "Windows: unsigned standalone EXE. SHA256 checksums are included." +) + + +def fail(message): + print(f"changelog: {message}", file=sys.stderr) + sys.exit(1) + + +def version_key(version): + return tuple(int(part) for part in version.split(".")) + + +def load(): + try: + data = json.loads(CHANGELOG.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + fail(f"cannot read {CHANGELOG.relative_to(REPO)}: {error}") + releases = data.get("releases") if isinstance(data, dict) else None + if not isinstance(releases, list) or not releases: + fail('expected {"releases": [...]} with at least one entry') + + previous = None + for index, release in enumerate(releases): + where = f"releases[{index}]" + if not isinstance(release, dict): + fail(f"{where} must be an object") + version = release.get("version") + if not isinstance(version, str) or not VERSION_PATTERN.match(version): + fail(f"{where}.version must look like 1.2.3") + try: + datetime.date.fromisoformat(release.get("date", "")) + except (TypeError, ValueError): + fail(f"{where}.date must be YYYY-MM-DD") + for language in LANGUAGES: + copy = release.get(language) + if not isinstance(copy, dict): + fail(f"{where}.{language} is required") + if not isinstance(copy.get("title"), str) or not copy["title"].strip(): + fail(f"{where}.{language}.title must be a non-empty string") + items = copy.get("items") + if not isinstance(items, list) or not items: + fail(f"{where}.{language}.items must be a non-empty list") + if not all(isinstance(item, str) and item.strip() for item in items): + fail(f"{where}.{language}.items must contain non-empty strings") + # Newest first, so the website and release notes can take the head of the list. + if previous and version_key(version) >= version_key(previous): + fail(f"{where}.version {version} must be older than {previous}") + previous = version + return releases + + +def find(releases, version): + return next((release for release in releases if release["version"] == version), None) + + +def check(releases): + version = VERSION_FILE.read_text(encoding="utf-8").strip() + if not find(releases, version): + fail(f"add an entry for VERSION {version} at the top of {CHANGELOG.relative_to(REPO)}") + print(f"changelog: {len(releases)} release(s) valid; VERSION {version} documented") + + +def notes(releases, version): + release = find(releases, version.lstrip("v")) + if not release: + fail(f"no entry for {version}") + sections = [] + for language in LANGUAGES: + copy = release[language] + lines = [f"## {copy['title']}", ""] + [f"- {item}" for item in copy["items"]] + sections.append("\n".join(lines)) + print("\n\n".join(sections + [PACKAGE_NOTE])) + + +def main(argv): + releases = load() + if argv[:1] == ["check"] and len(argv) == 1: + check(releases) + elif argv[:1] == ["notes"] and len(argv) == 2: + notes(releases, argv[1]) + else: + fail("usage: changelog.py check | changelog.py notes ") + + +if __name__ == "__main__": + main(sys.argv[1:])