feat(update): Stable/Preview update channels with opt-in toggle - #199
Conversation
Adds a user-selectable updater release channel (Settings -> About -> Update channel). Stable (default, every install + launch) tracks tagged vX.Y.Z releases; Preview tracks the latest main build via a rolling "preview" prerelease, falling back to stable if a stable release is ahead. Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and neither the JS check() nor the plugin's registration Builder can change them at runtime (verified against the 2.10.1 source). The only runtime-endpoint API is UpdaterExt::endpoints, so check+install move into two Rust commands that mirror the plugin's own check/download_and_install -- the Stable path behaves identically to the JS flow it replaces; only which manifest is consulted changes. Switching is instant (channel is read per check), no restart. backend (Rust): - config.rs: update_channel field (default "stable", VALID_CHANNELS) + get/set_update_channel commands. - updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) + check_update / install_update commands; install emits update://progress. frontend: - utils/updateChannel.js (+test): single source of truth, normalizeChannel. - utils/updater.js: routes the badge flow (#198) through the Rust commands via the same store contract -- UpdateBadge/App.jsx unchanged. - Settings About: Stable/Preview segmented toggle, channel-aware endpoint row + diagnostics; Check-for-updates honors the live channel. - i18n en + zh-CN. release.yml: additive, workflow_dispatch-guarded preview publish to a rolling "preview" prerelease. The v* tag-push stable path evaluates to its exact prior values (verified) and is never affected. Preview builds are manual -- no scheduled CI spend, nothing auto-published. docs/update-channels.md. Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard, release.yml YAML parses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughImplements a dual update-channel system allowing users to select between stable (latest tagged release) and preview (rolling ChangesDual Update Channels System
Sequence DiagramsequenceDiagram
participant User
participant Settings as Settings UI
participant TauriBackend as Tauri Backend
participant Updater as Update Service
participant ReleaseServer as Release Server
User->>Settings: select channel (stable/preview)
Settings->>TauriBackend: set_update_channel(channel)
TauriBackend->>TauriBackend: persist to config
User->>Settings: check for updates
Settings->>TauriBackend: check_update(channel)
TauriBackend->>TauriBackend: select endpoint by channel
TauriBackend->>Updater: build with channel endpoint
Updater->>ReleaseServer: fetch manifest
ReleaseServer-->>Updater: update metadata or none
Updater-->>TauriBackend: update info or none
TauriBackend-->>Settings: UpdateMeta or None
alt update available
Settings->>User: prompt install?
User->>Settings: confirm
Settings->>TauriBackend: install_update(channel)
TauriBackend->>Updater: download with channel endpoint
Updater->>ReleaseServer: download release
ReleaseServer-->>Updater: bytes + progress
Updater->>Updater: emit update://progress events
TauriBackend->>TauriBackend: track progress
TauriBackend->>Settings: progress updates
Settings->>User: show progress bar
Updater->>TauriBackend: install complete
TauriBackend-->>Settings: success
Settings->>User: relaunch app
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Filename | Overview |
|---|---|
| frontend/src/utils/updater.js | Rerouted update check/install through Rust commands; channel mismatch between launch check and badge install can leave UI in a broken state. |
| frontend/src-tauri/src/updater_channel.rs | New Rust module for channel-aware updater; install_update re-runs check() creating a double round-trip and TOCTOU; silent URL parse drop is low-risk but fragile. |
| frontend/src-tauri/src/config.rs | Adds update_channel field with stable default, validation against VALID_CHANNELS, and get/set Tauri commands — looks correct. |
| frontend/src/pages/Settings.jsx | Adds channel toggle UI and wires check/install to Rust commands; double check_update+install_update round-trip is the main concern. |
| .github/workflows/release.yml | Adds publish_preview boolean input and conditional expressions for tagName/releaseName/draft/prerelease; stable tag-push path evaluates to prior values correctly. |
| frontend/src/utils/updateChannel.js | Tiny utility exporting channel list and normalizeChannel(); clean and well-tested. |
| frontend/src/utils/updateChannel.test.ts | Three focused unit tests covering happy path and clamping behavior; no issues. |
Sequence Diagram
sequenceDiagram
participant App as App.jsx (launch)
participant Badge as UpdateBadge
participant Settings as Settings.jsx
participant JS as updater.js
participant Rust as updater_channel.rs
participant GH as GitHub Releases
Note over App,GH: Background check on launch (badge flow)
App->>JS: checkForUpdate(store)
JS->>Rust: "invoke('check_update', {channel})"
Rust->>GH: GET manifest (stable or preview)
GH-->>Rust: latest.json
Rust-->>JS: "UpdateMeta {version, notes}"
JS->>App: store.setUpdateAvailable(version, notes)
App->>Badge: renders badge
Note over Badge,GH: User clicks badge → install
Badge->>JS: installUpdate(store)
JS->>Rust: "invoke('install_update', {channel*})"
Note right of JS: channel* re-read from config
Rust->>GH: check() again (2nd request)
Rust->>GH: download binary
GH-->>Rust: binary chunks
Rust-->>JS: update://progress events
Rust-->>JS: Ok(())
JS->>App: relaunch()
Reviews (1): Last reviewed commit: "feat(update): Stable/Preview update chan..." | Re-trigger Greptile
| const channel = await currentChannel(); | ||
| store.setUpdateProgress(0); | ||
| await update.downloadAndInstall((ev) => { | ||
| if (ev.event === 'Started') total = ev.data?.contentLength || 0; | ||
| else if (ev.event === 'Progress') { | ||
| got += ev.data?.chunkLength || 0; | ||
| if (total > 0) store.setUpdateProgress(Math.min(99, (got / total) * 100)); | ||
| } else if (ev.event === 'Finished') { | ||
| store.setUpdateReady(); | ||
| } | ||
| unlisten = await listen('update://progress', (ev) => { | ||
| const { downloaded = 0, total = 0 } = ev?.payload || {}; | ||
| if (total > 0) store.setUpdateProgress(Math.min(99, (downloaded / total) * 100)); | ||
| }); | ||
| await invoke('install_update', { channel }); |
There was a problem hiding this comment.
Channel mismatch between background check and badge install
checkForUpdate (called at launch) reads the channel once and records the found version in the store, but installUpdate calls currentChannel() freshly at install time. If the user changes the channel in Settings between seeing the badge and clicking install, installUpdate runs against the new channel — which may have no update at all. The result is setUpdateError("No update available") while the badge still shows "update available", leaving the UI in a broken state. The fix is to store the channel alongside the version in the store when setUpdateAvailable is called, and pass it through to installUpdate.
| pub async fn install_update(app: AppHandle, channel: String) -> Result<(), String> { | ||
| let updater = app | ||
| .updater_builder() | ||
| .endpoints(channel_endpoints(&channel)) | ||
| .map_err(|e| format!("updater endpoints: {e}"))? | ||
| .build() | ||
| .map_err(|e| format!("updater build: {e}"))?; | ||
| let update = updater | ||
| .check() | ||
| .await | ||
| .map_err(|e| e.to_string())? | ||
| .ok_or_else(|| "No update available".to_string())?; |
There was a problem hiding this comment.
install_update re-runs check() — double network round-trip and TOCTOU in the Settings confirm flow
Settings.jsx::checkForUpdates calls check_update (network request #1), surfaces the version to the user in a native ask() dialog, then calls install_update which internally calls check() again (network request #2). In the window between the two checks a new release could land, causing the user to confirm "install 0.3.1" but actually install 0.3.2 — or, if the manifest briefly 404s, they'd get toast.error('Update check failed: No update available') after already clicking "yes". A more robust design would have install_update accept and re-use the pre-checked update object, or the Rust side could cache the update between the two calls.
| } else { | ||
| vec![STABLE_MANIFEST] | ||
| }; | ||
| raw.iter().filter_map(|u| u.parse().ok()).collect() |
There was a problem hiding this comment.
Silent URL-parse failures in
channel_endpoints will produce an empty endpoint list with no diagnostic. Since the URLs are compile-time constants this is currently safe, but if either constant is ever malformed the updater will silently do nothing instead of surfacing an error. Consider using an explicit expect to make the failure loud.
| raw.iter().filter_map(|u| u.parse().ok()).collect() | |
| raw.iter() | |
| .map(|u| u.parse().expect("BUG: hardcoded manifest URL is malformed")) | |
| .collect() |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
.github/workflows/release.yml (1)
85-88:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winPin action to a major version tag.
This action uses
@latestinstead of a pinned version. As per coding guidelines, GitHub Actions should be pinned to at least a major version tag to prevent unexpected behavior changes.📌 Suggested fix
Check the action's releases and pin to the appropriate major version:
- uses: awalsh128/cache-apt-pkgs-action@latest + uses: awalsh128/cache-apt-pkgs-action@v1🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/release.yml around lines 85 - 88, The workflow pins the GitHub Action to `@latest` (uses: awalsh128/cache-apt-pkgs-action@latest), which can introduce breaking changes; update the uses entry to a specific major version tag (e.g., change awalsh128/cache-apt-pkgs-action@latest to the action's current major release tag such as `@v1` or `@v1.x`) by consulting the action's releases and replacing the `@latest` reference with that major-version tag so the step (uses: awalsh128/cache-apt-pkgs-action@...) is stable and follows pinning guidelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src-tauri/src/updater_channel.rs`:
- Around line 21-31: The current channel_endpoints function returns
[PREVIEW_MANIFEST, STABLE_MANIFEST] which relies on tauri-plugin-updater's
sequential endpoint lookup (it stops at the first reachable manifest) and
therefore does not implement the intended "use preview but fall back to newer
stable" behavior; change the approach so the app explicitly fetches and compares
manifests from PREVIEW_MANIFEST and STABLE_MANIFEST (e.g., implement a new
helper like fetch_and_select_manifest or fetch_manifests_and_pick_newest), parse
the version fields from both manifests, choose the manifest with the
highest/newest version, and then pass only that chosen URL to
tauri-plugin-updater (or configure the updater with a single selected endpoint)
instead of returning both URLs from channel_endpoints; ensure channel_endpoints
or its callers use PREVIEW_MANIFEST and STABLE_MANIFEST only as sources to
compare rather than letting the plugin pick the first reachable one.
- Around line 40-44: The ProgressPayload currently serializes total: Option<u64>
which becomes null and breaks the JS consumer; update the emit path to always
send a concrete number (e.g. 0) instead of None — either change the
ProgressPayload field to total: u64 or, if keeping Option<u64>, ensure wherever
ProgressPayload is constructed (the chunk/progress callback that provides total)
you use total.unwrap_or(0) so the serialized payload never contains null;
reference ProgressPayload, its downloaded and total fields, and the chunk
callback/download_and_install emit code when making the change.
In `@frontend/src/pages/Settings.jsx`:
- Around line 1173-1175: Replace the hardcoded toast strings in Settings.jsx
with i18n keys: call the translation function t() for the downloading message
(pass update.version as the interpolation variable) when creating the loading
toast (tid) and use t('about.installed_relaunching') for the success toast after
invoke('install_update', { channel }) completes; also add the two keys
"about.downloading_version" with a "{{version}}…" placeholder and
"about.installed_relaunching" to the locales (e.g., locales/en.json) so the
t(...) lookups resolve.
---
Outside diff comments:
In @.github/workflows/release.yml:
- Around line 85-88: The workflow pins the GitHub Action to `@latest` (uses:
awalsh128/cache-apt-pkgs-action@latest), which can introduce breaking changes;
update the uses entry to a specific major version tag (e.g., change
awalsh128/cache-apt-pkgs-action@latest to the action's current major release tag
such as `@v1` or `@v1.x`) by consulting the action's releases and replacing the
`@latest` reference with that major-version tag so the step (uses:
awalsh128/cache-apt-pkgs-action@...) is stable and follows pinning guidelines.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 348112d3-de24-4a71-baf8-16fe68e8edcf
📒 Files selected for processing (11)
.github/workflows/release.ymldocs/update-channels.mdfrontend/src-tauri/src/config.rsfrontend/src-tauri/src/lib.rsfrontend/src-tauri/src/updater_channel.rsfrontend/src/i18n/locales/en.jsonfrontend/src/i18n/locales/zh-CN.jsonfrontend/src/pages/Settings.jsxfrontend/src/utils/updateChannel.jsfrontend/src/utils/updateChannel.test.tsfrontend/src/utils/updater.js
| /// Endpoints for a channel. Preview tries the rolling `preview` manifest first, | ||
| /// then falls back to stable so a preview user still receives a newer *stable* | ||
| /// release if one is ahead of the latest preview. Any unknown channel → stable. | ||
| fn channel_endpoints(channel: &str) -> Vec<tauri::Url> { | ||
| let raw = if channel == "preview" { | ||
| vec![PREVIEW_MANIFEST, STABLE_MANIFEST] | ||
| } else { | ||
| vec![STABLE_MANIFEST] | ||
| }; | ||
| raw.iter().filter_map(|u| u.parse().ok()).collect() | ||
| } |
There was a problem hiding this comment.
❓ Verification inconclusive
🌐 Web query:
tauri-plugin-updater v2 multiple endpoints behavior: does it try endpoints in order and use the first reachable manifest, or compare versions across all endpoints?
💡 Result:
In Tauri v2, when multiple endpoints are provided in the tauri-plugin-updater configuration, the updater attempts to reach them in the order they are defined [1][2]. It does not compare versions across all endpoints [2][3]. Specifically, the behavior is as follows: - Sequential Attempt: The updater tries to request the update manifest from the endpoints in the sequence provided in the array [1][2]. - Fallback Mechanism: If an endpoint returns a non-2XX status code (indicating it is not reachable or did not return a valid update manifest), the plugin will proceed to attempt the next URL in the list [2][3]. - Termination: The process stops and successfully proceeds as soon as it retrieves a valid update manifest from an endpoint [2]. It does not aggregate results or perform a "best version" comparison across multiple successful responses. This design is intended to provide a fallback mechanism for high availability, allowing developers to specify alternative mirrors or backup servers if the primary endpoint is unavailable [1].
Citations:
- 1: https://jonaskruckenberg.github.io/tauri-docs-wip/distributing/updater.html
- 2: https://v2.tauri.app/plugin/updater/
- 3: https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/updater/src/updater.rs
Fix updater fallback logic (major): tauri-plugin-updater with multiple endpoints tries them sequentially and stops at the first reachable/valid update manifest; it does not compare versions across endpoints. So [PREVIEW_MANIFEST, STABLE_MANIFEST] only falls back to stable when preview fails, not when stable is newer than preview—contradicting the documented “preview falls back to newer stable if one is ahead” requirement.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src-tauri/src/updater_channel.rs` around lines 21 - 31, The current
channel_endpoints function returns [PREVIEW_MANIFEST, STABLE_MANIFEST] which
relies on tauri-plugin-updater's sequential endpoint lookup (it stops at the
first reachable manifest) and therefore does not implement the intended "use
preview but fall back to newer stable" behavior; change the approach so the app
explicitly fetches and compares manifests from PREVIEW_MANIFEST and
STABLE_MANIFEST (e.g., implement a new helper like fetch_and_select_manifest or
fetch_manifests_and_pick_newest), parse the version fields from both manifests,
choose the manifest with the highest/newest version, and then pass only that
chosen URL to tauri-plugin-updater (or configure the updater with a single
selected endpoint) instead of returning both URLs from channel_endpoints; ensure
channel_endpoints or its callers use PREVIEW_MANIFEST and STABLE_MANIFEST only
as sources to compare rather than letting the plugin pick the first reachable
one.
| #[derive(Serialize, Clone)] | ||
| struct ProgressPayload { | ||
| downloaded: usize, | ||
| total: Option<u64>, | ||
| } |
There was a problem hiding this comment.
total: None serializes to null and stalls the JS progress bar.
When the server omits content-length, total is None → null in the emitted payload. The consumer in updater.js destructures const { downloaded = 0, total = 0 } = ..., but a JS default only applies to undefined, not null, so total stays null, total > 0 is false, and the progress bar never advances (jumps from 0% straight to ready). Consider emitting a concrete fallback (or fixing the guard on the JS side).
🛠️ One option: coerce missing total at emit
-struct ProgressPayload {
- downloaded: usize,
- total: Option<u64>,
-}
+struct ProgressPayload {
+ downloaded: usize,
+ total: u64,
+} move |chunk, total| {
downloaded += chunk;
let _ = app_for_chunk
- .emit("update://progress", ProgressPayload { downloaded, total });
+ .emit("update://progress", ProgressPayload { downloaded, total: total.unwrap_or(0) });
},(Alternatively, handle null explicitly in updater.js.)
Confirm the chunk callback signature emits Option<u64> for total in the version in use:
#!/bin/bash
rg -nP 'download_and_install' -g '*.rs' -A3
fd -t f Cargo.toml -x rg -nP 'tauri-plugin-updater' {}Also applies to: 88-100
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src-tauri/src/updater_channel.rs` around lines 40 - 44, The
ProgressPayload currently serializes total: Option<u64> which becomes null and
breaks the JS consumer; update the emit path to always send a concrete number
(e.g. 0) instead of None — either change the ProgressPayload field to total: u64
or, if keeping Option<u64>, ensure wherever ProgressPayload is constructed (the
chunk/progress callback that provides total) you use total.unwrap_or(0) so the
serialized payload never contains null; reference ProgressPayload, its
downloaded and total fields, and the chunk callback/download_and_install emit
code when making the change.
| const tid = toast.loading(`Downloading ${update.version}…`); | ||
| await invoke('install_update', { channel }); | ||
| toast.success('Installed — relaunching.', { id: tid }); |
There was a problem hiding this comment.
Hardcoded English strings violate i18n requirement.
Lines 1173 and 1175 contain user-facing text that should use the translation layer. As per coding guidelines, "All user-facing text in the UI must go through the i18n translation layer using t('...') keys in locales/*.json files."
🌐 Proposed fix to use i18n
- const tid = toast.loading(`Downloading ${update.version}…`);
+ const tid = toast.loading(t('about.downloading_version', { version: update.version }));
await invoke('install_update', { channel });
- toast.success('Installed — relaunching.', { id: tid });
+ toast.success(t('about.installed_relaunching'), { id: tid });Add to en.json:
"about.downloading_version": "Downloading {{version}}…",
"about.installed_relaunching": "Installed — relaunching."📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const tid = toast.loading(`Downloading ${update.version}…`); | |
| await invoke('install_update', { channel }); | |
| toast.success('Installed — relaunching.', { id: tid }); | |
| const tid = toast.loading(t('about.downloading_version', { version: update.version })); | |
| await invoke('install_update', { channel }); | |
| toast.success(t('about.installed_relaunching'), { id: tid }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/pages/Settings.jsx` around lines 1173 - 1175, Replace the
hardcoded toast strings in Settings.jsx with i18n keys: call the translation
function t() for the downloading message (pass update.version as the
interpolation variable) when creating the loading toast (tid) and use
t('about.installed_relaunching') for the success toast after
invoke('install_update', { channel }) completes; also add the two keys
"about.downloading_version" with a "{{version}}…" placeholder and
"about.installed_relaunching" to the locales (e.g., locales/en.json) so the
t(...) lookups resolve.
…202) PR #200 added 18 new locale files, but they predated #199 (auto-update badge + Stable/Preview channel toggle), so they were missing the `update.*` namespace (6 keys) and `about.channel_*` (5 keys) — those strings fell back to English in ar/de/es/fr/hi/id/it/ja/ko/nl/pl/pt/ru/sv/th/tr/uk/vi/zh-TW. Backfill all 11 keys in every one of those languages so the updater UI is fully localized. en.json / zh-CN.json already had them and are untouched. Placeholders ({{version}}, {{pct}}, {{channel}}) preserved verbatim; files re-emitted in the exact format scripts/translate_all.py writes (ensure_ascii=False, indent=2) so the diff is additions only (+13 lines/file, 0 deletions). Also fix scripts/translate_all.py: LOCALES_DIR was hardcoded to a contributor's absolute path (/Users/.../orca/...) — make it repo-relative so the generator actually runs for anyone. Verified: all 21 locales valid JSON + key-complete, placeholders intact; tsc clean; vitest 162/162; build OK; CJK guard passes (locales are the allowlisted translation layer). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the auto-update plan's follow-up: an opt-in Stable / Preview update channel toggle (Settings → About → Update channel), plus the release-pipeline path that feeds Preview.
What & why
vX.Y.Z(releases/latest/.../latest.json)mainbuild, a rollingpreviewprerelease (releases/download/preview/latest.json); falls back to stable if a stable release is aheadSwitching is instant — the channel is read per update check, no restart. Local-first preserved: same signed minisign key on both manifests, no accounts, no telemetry, no extra network calls.
The Rust requirement (discovered, not in the original offer)
I verified against
tauri-plugin-updater@2.10.1source: the plugin reads endpoints only fromtauri.conf.json, and neither the JScheck()nor the registrationBuilderexposes a runtime endpoint setter. The only runtime-endpoint API isUpdaterExt::endpoints. So check+install moved into two Rust commands (check_update/install_update) that mirror the plugin's owncheck/download_and_install— the Stable path behaves identically to the JS flow from #198; only which manifest is consulted changes.config.rs(update_channel, defaultstable, validated) +updater_channel.rs(channel endpoints,check_update,install_updateemittingupdate://progress).utils/updateChannel.js(+test),utils/updater.jsrerouted through the Rust commands via the same store contract (UpdateBadge/App.jsx untouched), Settings About toggle + channel-aware endpoint/diagnostics, i18n (en + zh-CN).Release pipeline — additive & guarded
release.ymlgains aworkflow_dispatchpublish_previewinput that publishes/updates a rollingpreviewprerelease with its own signedlatest.json. Thev*tag-push stable path evaluates to its exact prior values (tagName/releaseName/draft/prerelease) and is never touched. Preview builds are manual — no scheduled CI spend, nothing auto-published. To cut a preview: Actions → Desktop Release → Run workflow →publish_preview = true.Verification
cargo check✓ (compiles clean, local),tsc✓, vitest 162/162 (+updateChannel3),bun run build✓, CJK guard ✓ (zh-CN is the translation layer),release.ymlYAML parses (stable path unchanged).🤖 Generated with Claude Code
Summary by CodeRabbit