feat(update): 将更新日志写入静态清单并打通 CI 生成

- 扩展 latest.json / UpdateInfo 支持 releaseNotes 字段
- latest 发版将 changelog 同步写入清单与 GitHub Release
- dev 构建按上一版基线自动生成相对更新日志
- 补充清单生成与后端映射测试
This commit is contained in:
Syngnat
2026-08-02 13:07:26 +08:00
parent 218e9388d2
commit 3fe41f3700
8 changed files with 295 additions and 55 deletions

View File

@@ -1103,6 +1103,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
# dev 更新日志需要 prev..HEAD 提交范围
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
@@ -1231,26 +1234,6 @@ jobs:
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
# 静态更新清单dev 通道读
# https://github.com/Syngnat/GoNavi/releases/download/dev-latest/latest-dev.json
- name: Generate static update manifest (latest-dev.json)
shell: bash
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
python3 tools/generate-update-latest-manifest.py \
--assets-dir release-assets \
--version "$DEV_VERSION" \
--tag dev-latest \
--channel dev \
--name "Dev Build (${DEV_VERSION})" \
--download-base-url "https://download.syngnat.top/gonavi/dev/releases/download" \
--download-tag "$DEV_VERSION" \
--output release-assets/latest-dev.json
test -s release-assets/latest-dev.json
echo "📄 latest-dev.json ready:"
head -n 40 release-assets/latest-dev.json
- name: Format Build Time
id: build_time
shell: bash
@@ -1265,6 +1248,115 @@ jobs:
print(f"display={formatted}")
PY
# 解析上一版 dev 基线(线上 latest-dev.json 的 version → 短 SHA用于生成相对变更日志。
- name: Resolve previous dev baseline
id: prev_dev
shell: bash
run: |
set -euo pipefail
PREV_SHA=""
for url in \
"https://download.syngnat.top/gonavi/dev/releases/latest/latest-dev.json" \
"https://github.com/${{ github.repository }}/releases/download/dev-latest/latest-dev.json"
do
if curl -fsSL --max-time 20 "$url" -o "$RUNNER_TEMP/prev-latest-dev.json"; then
PREV_VERSION="$(jq -r '.version // empty' "$RUNNER_TEMP/prev-latest-dev.json" 2>/dev/null || true)"
if [[ "$PREV_VERSION" =~ ^dev-([0-9a-fA-F]{7,40})$ ]]; then
CANDIDATE="${BASH_REMATCH[1]}"
if git rev-parse --verify "${CANDIDATE}^{commit}" >/dev/null 2>&1; then
PREV_SHA="$(git rev-parse "${CANDIDATE}^{commit}")"
echo "resolved previous dev baseline: version=${PREV_VERSION} sha=${PREV_SHA:0:12} (from ${url})"
break
fi
fi
fi
done
echo "sha=${PREV_SHA}" >> "$GITHUB_OUTPUT"
- name: Generate Dev Changelog
id: changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
BUILD_TIME="${{ steps.build_time.outputs.display }}"
PREV_SHA="${{ steps.prev_dev.outputs.sha }}"
CHANGELOG_FILE="$RUNNER_TEMP/dev-changelog.md"
BODY_FILE="$RUNNER_TEMP/dev-release-body.md"
NOTES_CORE="$RUNNER_TEMP/dev-changelog-core.md"
HEADER_FILE="$RUNNER_TEMP/dev-changelog-header.md"
cat > "$HEADER_FILE" <<EOF
## 🧪 测试版本 (Dev Build)
**版本**: \`${DEV_VERSION}\`
**分支**: \`dev\`
**提交**: [\`${{ github.sha }}\`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
**构建时间**: ${BUILD_TIME}
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
> 每次 push 到 \`dev\` 分支会自动覆盖此 release。
EOF
if [[ -n "$PREV_SHA" ]]; then
python3 tools/generate-release-notes.py \
--repo "${{ github.repository }}" \
--tag "${{ github.sha }}" \
--previous-tag "$PREV_SHA" \
--max-commits 100 \
--repository-url "${{ github.server_url }}/${{ github.repository }}" \
--output "$NOTES_CORE" || {
echo "warning: generate-release-notes failed; falling back to empty commit list" >&2
echo "(未能生成提交变更列表)" > "$NOTES_CORE"
}
else
cat > "$NOTES_CORE" <<EOF
## 📝 变更说明
- 未能解析上一测试版基线,本次仅发布当前构建。
- 当前提交:\`${{ github.sha }}\`
EOF
fi
{
cat "$HEADER_FILE"
echo "## 更新日志(相对上一测试版)"
echo
cat "$NOTES_CORE"
} > "$BODY_FILE"
# 应用内弹窗使用完整 body含 dev 警告头)
cp "$BODY_FILE" "$CHANGELOG_FILE"
echo "changelog_file=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT"
echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT"
echo "📄 dev changelog ready ($(wc -c < "$BODY_FILE") bytes)"
# 静态更新清单dev 通道读
# https://github.com/Syngnat/GoNavi/releases/download/dev-latest/latest-dev.json
- name: Generate static update manifest (latest-dev.json)
shell: bash
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
CHANGELOG_FILE="${{ steps.changelog.outputs.changelog_file }}"
python3 tools/generate-update-latest-manifest.py \
--assets-dir release-assets \
--version "$DEV_VERSION" \
--tag dev-latest \
--channel dev \
--name "Dev Build (${DEV_VERSION})" \
--download-base-url "https://download.syngnat.top/gonavi/dev/releases/download" \
--download-tag "$DEV_VERSION" \
--release-notes-file "$CHANGELOG_FILE" \
--output release-assets/latest-dev.json
test -s release-assets/latest-dev.json
jq -e '(.releaseNotes | type == "string" and length > 0)' release-assets/latest-dev.json >/dev/null
echo "📄 latest-dev.json ready:"
head -n 40 release-assets/latest-dev.json
# 删除旧的 dev pre-release保持只有最新一个
- name: Reset Previous Dev Release
uses: actions/github-script@v8
@@ -1393,16 +1485,7 @@ jobs:
files: release-assets/*
prerelease: true
draft: false
body: |
## 🧪 测试版本 (Dev Build)
**版本**: `${{ steps.version.outputs.version }}`
**分支**: `dev`
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
**构建时间**: ${{ steps.build_time.outputs.display }}
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
> 每次 push 到 `dev` 分支会自动覆盖此 release。
body_path: ${{ steps.changelog.outputs.body_file }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1291,6 +1291,27 @@ jobs:
--output "$CHANGELOG_FILE"
echo "changelog_file=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT"
# 将同一份 changelog 写入 latest.json供应用内「更新日志」弹窗使用静态清单优先路径
- name: Embed changelog into latest.json
shell: bash
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
CHANGELOG_FILE="${{ steps.changelog.outputs.changelog_file }}"
test -s "$CHANGELOG_FILE"
test -d release-assets
python3 tools/generate-update-latest-manifest.py \
--assets-dir release-assets \
--version "$VERSION" \
--tag "$TAG" \
--channel latest \
--download-base-url https://download.syngnat.top/gonavi/releases/download \
--release-notes-file "$CHANGELOG_FILE" \
--output release-assets/latest.json
jq -e '(.releaseNotes | type == "string" and length > 0)' release-assets/latest.json >/dev/null
echo "📄 latest.json releaseNotes embedded ($(jq -r '.releaseNotes | length' release-assets/latest.json) chars)"
- name: Create Release
uses: softprops/action-gh-release@v3
if: startsWith(github.ref, 'refs/tags/')

View File

@@ -79,16 +79,18 @@ type UpdateInfo struct {
ReleaseName string `json:"releaseName"`
ReleasePublishedAt string `json:"releasePublishedAt,omitempty"`
ReleaseNotesURL string `json:"releaseNotesUrl"`
AssetName string `json:"assetName"`
AssetURL string `json:"assetUrl"`
AssetAPIURL string `json:"assetApiUrl,omitempty"`
AssetSize int64 `json:"assetSize"`
SHA256 string `json:"sha256"`
Downloaded bool `json:"downloaded"`
DownloadPath string `json:"downloadPath,omitempty"`
InstallMode string `json:"installMode"`
PackageType string `json:"packageType,omitempty"`
AutoRelaunch bool `json:"autoRelaunch"`
// ReleaseNotes 为 Markdown 更新日志正文(来自 latest.json / GitHub release body
ReleaseNotes string `json:"releaseNotes,omitempty"`
AssetName string `json:"assetName"`
AssetURL string `json:"assetUrl"`
AssetAPIURL string `json:"assetApiUrl,omitempty"`
AssetSize int64 `json:"assetSize"`
SHA256 string `json:"sha256"`
Downloaded bool `json:"downloaded"`
DownloadPath string `json:"downloadPath,omitempty"`
InstallMode string `json:"installMode"`
PackageType string `json:"packageType,omitempty"`
AutoRelaunch bool `json:"autoRelaunch"`
}
type AppInfo struct {
@@ -167,6 +169,7 @@ type githubRelease struct {
Name string `json:"name"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
Body string `json:"body"`
Prerelease bool `json:"prerelease"`
Assets []githubAsset `json:"assets"`
}
@@ -699,6 +702,7 @@ func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool)
ReleaseName: release.Name,
ReleasePublishedAt: strings.TrimSpace(release.PublishedAt),
ReleaseNotesURL: release.HTMLURL,
ReleaseNotes: strings.TrimSpace(release.Body),
InstallMode: string(installMode),
PackageType: string(packageType),
AutoRelaunch: true,
@@ -737,6 +741,7 @@ func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool)
ReleaseName: release.Name,
ReleasePublishedAt: strings.TrimSpace(release.PublishedAt),
ReleaseNotesURL: release.HTMLURL,
ReleaseNotes: strings.TrimSpace(release.Body),
AssetName: asset.Name,
AssetURL: firstNonEmptyString(asset.BrowserDownloadURL, asset.URL),
AssetAPIURL: strings.TrimSpace(asset.URL),

View File

@@ -326,6 +326,50 @@ func TestCheckForUpdatesRestoresPersistedGlobalProxyRuntime(t *testing.T) {
}
}
func TestFetchLatestUpdateInfoMapsReleaseNotesFromStaticManifest(t *testing.T) {
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "1.2.3")
if err != nil {
t.Fatalf("expectedAssetName returned error: %v", err)
}
originalVersion := AppVersion
AppVersion = "1.0.0"
defer func() {
AppVersion = originalVersion
}()
const notes = "## ✨ 新功能\n\n- in-app release notes"
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return &githubRelease{
TagName: "v1.2.3",
Name: "v1.2.3",
HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v1.2.3",
PublishedAt: "2026-07-08T11:15:00Z",
Body: notes + "\n",
Assets: []githubAsset{
{
Name: assetName,
BrowserDownloadURL: "https://example.com/" + assetName,
Digest: "sha256:" + strings.Repeat("a", 64),
Size: 4096,
},
},
}, nil
})
defer restoreStatic()
info, err := fetchLatestUpdateInfo(updateChannelLatest)
if err != nil {
t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
}
if info.ReleaseNotes != notes {
t.Fatalf("expected release notes body, got %#v", info.ReleaseNotes)
}
if info.ReleaseNotesURL != "https://github.com/Syngnat/GoNavi/releases/tag/v1.2.3" {
t.Fatalf("unexpected release notes url: %#v", info.ReleaseNotesURL)
}
}
func TestFetchLatestUpdateInfoForDevChannelUsesReleaseBuildVersion(t *testing.T) {
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "dev-a1b2c3d")
if err != nil {

View File

@@ -42,9 +42,11 @@ type updateReleaseManifest struct {
Name string `json:"name,omitempty"`
HTMLURL string `json:"htmlUrl,omitempty"`
PublishedAt string `json:"publishedAt,omitempty"`
Assets []updateManifestAsset `json:"assets"`
FetchedAt time.Time `json:"fetchedAt,omitempty"` // 仅本地缓存写入
Source string `json:"source,omitempty"` // static | api | disk-cache
// ReleaseNotes 为 Markdown 更新日志(可选;旧清单无此字段时客户端走空态+外链)。
ReleaseNotes string `json:"releaseNotes,omitempty"`
Assets []updateManifestAsset `json:"assets"`
FetchedAt time.Time `json:"fetchedAt,omitempty"` // 仅本地缓存写入
Source string `json:"source,omitempty"` // static | api | disk-cache
}
type updateManifestAsset struct {
@@ -134,11 +136,12 @@ func releaseFromUpdateManifest(manifest *updateReleaseManifest) *githubRelease {
name = tagName
}
return &githubRelease{
TagName: tagName,
Name: name,
HTMLURL: strings.TrimSpace(manifest.HTMLURL),
PublishedAt: strings.TrimSpace(manifest.PublishedAt),
Assets: assets,
TagName: tagName,
Name: name,
HTMLURL: strings.TrimSpace(manifest.HTMLURL),
PublishedAt: strings.TrimSpace(manifest.PublishedAt),
Body: strings.TrimSpace(manifest.ReleaseNotes),
Assets: assets,
}
}
@@ -173,6 +176,7 @@ func updateManifestFromGitHubRelease(channel updateChannel, release *githubRelea
Name: strings.TrimSpace(release.Name),
HTMLURL: strings.TrimSpace(release.HTMLURL),
PublishedAt: strings.TrimSpace(release.PublishedAt),
ReleaseNotes: strings.TrimSpace(release.Body),
Assets: assets,
FetchedAt: time.Now().UTC(),
Source: "api",

View File

@@ -154,15 +154,18 @@ def warn_if_release_history_diverged(previous_tag: str, tag: str) -> None:
)
def read_commits(tag: str, previous_tag: str) -> list[Commit]:
def read_commits(tag: str, previous_tag: str, max_commits: int = 0) -> list[Commit]:
revision_range = f"{previous_tag}..{tag}" if previous_tag else tag
output = run_git(
git_args = [
"log",
revision_range,
"--no-merges",
"-z",
"--pretty=format:%H%x00%s%x00%an%x00%ae",
)
]
if max_commits > 0:
git_args.insert(2, f"-n{max_commits}")
output = run_git(*git_args)
if not output:
return []
fields = output.split("\x00")
@@ -416,7 +419,13 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument(
"--previous-tag",
default="",
help="Previous tag (default: preceding v* tag by creation date)",
help="Previous git ref/tag/commit (default: preceding v* tag by creation date)",
)
parser.add_argument(
"--max-commits",
type=int,
default=0,
help="Optional max commit count in the range (0 = unlimited; useful for dense dev builds)",
)
parser.add_argument("--repository-url", required=True, help="Repository web URL")
parser.add_argument(
@@ -428,6 +437,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
args = parser.parse_args(argv)
if not re.fullmatch(r"[^/\s]+/[^/\s]+", args.repo):
parser.error("--repo must use owner/name format")
if args.max_commits < 0:
parser.error("--max-commits must be >= 0")
return args
@@ -436,7 +447,7 @@ def main(argv: Sequence[str] | None = None) -> int:
previous_tag = args.previous_tag.strip() or resolve_previous_tag(args.tag)
warn_if_release_history_diverged(previous_tag, args.tag)
revision_range = f"{previous_tag}..{args.tag}" if previous_tag else args.tag
commits = read_commits(args.tag, previous_tag)
commits = read_commits(args.tag, previous_tag, max_commits=args.max_commits)
attributions = collect_attributions(
commits=commits,
repository=args.repo,

View File

@@ -33,6 +33,11 @@ from urllib.parse import quote
REPO = "Syngnat/GoNavi"
SCHEMA_VERSION = 1
# 客户端静态清单体积保护:超过则截断并附 GitHub 完整日志提示。
RELEASE_NOTES_MAX_BYTES = 64 * 1024
RELEASE_NOTES_TRUNCATE_SUFFIX = (
"\n\n---\n\n> 更新日志过长,已截断。完整内容请查看 GitHub Release 页面。\n"
)
SKIP_NAMES = {
"SHA256SUMS",
"LICENSE",
@@ -43,6 +48,25 @@ SKIP_NAMES = {
}
def load_release_notes(path: Path | None) -> str:
if path is None:
return ""
if not path.is_file():
raise SystemExit(f"release notes file not found: {path}")
text = path.read_text(encoding="utf-8", errors="replace").strip()
if not text:
return ""
encoded = text.encode("utf-8")
if len(encoded) <= RELEASE_NOTES_MAX_BYTES:
return text
# 按字节截断,避免切断多字节 UTF-8 字符
budget = RELEASE_NOTES_MAX_BYTES - len(RELEASE_NOTES_TRUNCATE_SUFFIX.encode("utf-8"))
if budget < 0:
budget = 0
truncated = encoded[:budget].decode("utf-8", errors="ignore").rstrip()
return truncated + RELEASE_NOTES_TRUNCATE_SUFFIX
def parse_sha256sums(path: Path) -> dict[str, str]:
if not path.is_file():
return {}
@@ -125,6 +149,7 @@ def build_manifest(
published_at: str | None,
download_base_url: str = "",
download_tag: str = "",
release_notes: str = "",
) -> dict:
hashes = parse_sha256sums(assets_dir / "SHA256SUMS")
tag = tag.strip() or f"v{normalize_version(version)}"
@@ -133,7 +158,7 @@ def build_manifest(
if not assets:
raise SystemExit(f"no release assets found under {assets_dir}")
return {
payload = {
"schemaVersion": SCHEMA_VERSION,
"channel": channel,
"tagName": tag,
@@ -143,6 +168,10 @@ def build_manifest(
"publishedAt": (published_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")),
"assets": assets,
}
notes = (release_notes or "").strip()
if notes:
payload["releaseNotes"] = notes
return payload
def main() -> int:
@@ -173,6 +202,11 @@ def main() -> int:
default="",
help="Output path (default: <assets-dir>/latest.json or latest-dev.json)",
)
parser.add_argument(
"--release-notes-file",
default="",
help="Optional Markdown file embedded into releaseNotes for in-app changelog",
)
args = parser.parse_args()
assets_dir = Path(args.assets_dir).resolve()
@@ -190,6 +224,8 @@ def main() -> int:
out_name = "latest-dev.json" if args.channel == "dev" else "latest.json"
output = Path(args.output).resolve() if args.output else assets_dir / out_name
notes_path = Path(args.release_notes_file).resolve() if args.release_notes_file.strip() else None
release_notes = load_release_notes(notes_path)
manifest = build_manifest(
channel=args.channel,
@@ -200,9 +236,11 @@ def main() -> int:
published_at=args.published_at or None,
download_base_url=args.download_base_url,
download_tag=args.download_tag,
release_notes=release_notes,
)
output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {output} ({len(manifest['assets'])} assets, version={manifest['version']})")
notes_hint = f", notes={len(manifest.get('releaseNotes', ''))} chars" if manifest.get("releaseNotes") else ""
print(f"wrote {output} ({len(manifest['assets'])} assets, version={manifest['version']}{notes_hint})")
return 0

View File

@@ -143,6 +143,40 @@ class GenerateUpdateLatestManifestTest(unittest.TestCase):
f"https://github.com/Syngnat/GoNavi/releases/download/dev-latest/{asset_name}",
)
def test_embeds_release_notes_from_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
assets = Path(tmp)
(assets / "GoNavi-1.2.3-Windows-Amd64-Portable.zip").write_bytes(b"fake")
(assets / "SHA256SUMS").write_text(
f"{'d' * 64} GoNavi-1.2.3-Windows-Amd64-Portable.zip\n",
encoding="utf-8",
)
notes = assets / "changelog.md"
notes.write_text("## ✨ 新功能\n\n- 示例变更\n", encoding="utf-8")
out = assets / "latest.json"
subprocess.check_call(
[
sys.executable,
str(SCRIPT),
"--assets-dir",
str(assets),
"--version",
"1.2.3",
"--tag",
"v1.2.3",
"--channel",
"latest",
"--release-notes-file",
str(notes),
"--output",
str(out),
],
cwd=str(ROOT),
)
data = json.loads(out.read_text(encoding="utf-8"))
self.assertIn("releaseNotes", data)
self.assertIn("示例变更", data["releaseNotes"])
if __name__ == "__main__":
unittest.main()