Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -42,6 +42,7 @@ jobs:
echo '::error::Release tag must equal v<VERSION>.'
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
Expand Down Expand Up @@ -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-*
Expand All @@ -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"
24 changes: 24 additions & 0 deletions docs/changelog.json
Original file line number Diff line number Diff line change
@@ -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."
]
}
}
]
}
21 changes: 20 additions & 1 deletion docs/打包与发布.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<VERSION>`,如 `v0.1.0`;暂不支持带 `-rc` 等后缀。已有 Release 不会被重跑覆盖,应使用新版本发布。此配置本身不会替你创建或推送 tag。
发布前修改 `VERSION`,并在 `docs/changelog.json` 顶部添加同版本条目、提交并合并,随后从已验证提交创建对应 tag。工作流要求 tag 严格等于 `v<VERSION>`,如 `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 <tag>` 生成双语 Release 正文,GitHub 自动生成的 PR 列表附在其后。[键影主页](https://keyshadow.aix.uy/) 会按最新 Release 的 tag 读取同一文件,发布后无需修改网站。

## 签名与公证配置

Expand Down
2 changes: 2 additions & 0 deletions docs/项目结构.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ KeyShadow/
│ ├── 构建与测试.md 开发环境与验证命令
│ ├── windows/、macos/ 安装及平台差异
│ ├── screenshots/ 实际窗口绘制与截图
│ ├── changelog.json 双语更新日志,生成 Release 正文并供主页读取
│ └── 其他文档 方案来源、项目结构、打包与发布
├── tools/changelog.py 更新日志校验与 Release 正文生成
├── README.md 下载入口、界面预览与通用操作
├── VERSION 三段数字版本
└── dist/ 构建及分发产物,不纳入版本控制
Expand Down
109 changes: 109 additions & 0 deletions tools/changelog.py
Original file line number Diff line number Diff line change
@@ -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 <version>")


if __name__ == "__main__":
main(sys.argv[1:])
Loading