diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index d64a9f53..f7a40386 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -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" < ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。 + > 每次 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" < "$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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 380c8f58..0f2c9124 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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/') diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 8337d6ba..c3d5a657 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -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), diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index 842e6e7f..af5e4378 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -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 { diff --git a/internal/app/update_manifest.go b/internal/app/update_manifest.go index 7ea72344..47579681 100644 --- a/internal/app/update_manifest.go +++ b/internal/app/update_manifest.go @@ -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", diff --git a/tools/generate-release-notes.py b/tools/generate-release-notes.py index dc21784a..212efa53 100644 --- a/tools/generate-release-notes.py +++ b/tools/generate-release-notes.py @@ -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, diff --git a/tools/generate-update-latest-manifest.py b/tools/generate-update-latest-manifest.py index c57bc90f..be839bd9 100755 --- a/tools/generate-update-latest-manifest.py +++ b/tools/generate-update-latest-manifest.py @@ -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: /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 diff --git a/tools/generate-update-latest-manifest.test.py b/tools/generate-update-latest-manifest.test.py index a7af3fc9..d67f74f4 100644 --- a/tools/generate-update-latest-manifest.test.py +++ b/tools/generate-update-latest-manifest.test.py @@ -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()