diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 47e37bd8..c9e55bf3 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -132,9 +132,10 @@ jobs: --pattern 'latest-windows.json.sig' \ --pattern 'SHA256SUMS-windows.txt' \ --pattern 'EdgeEver-*-linux-x64.AppImage' \ + --pattern 'latest-linux.yml' \ --pattern 'SHA256SUMS-linux.txt' \ --dir "$RUNNER_TEMP/native-assets" - test "$(find "$RUNNER_TEMP/native-assets" -type f | wc -l | tr -d ' ')" = "16" + test "$(find "$RUNNER_TEMP/native-assets" -type f | wc -l | tr -d ' ')" = "17" gh release upload "$CURRENT_TAG" "$RUNNER_TEMP/native-assets/"* \ --repo "$GITHUB_REPOSITORY" \ --clobber @@ -618,6 +619,43 @@ jobs: - name: Build x64 Rust sidecar run: cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release + - name: Build Linux automatic update predecessor + env: + GH_TOKEN: ${{ github.token }} + PREVIOUS_TAG: ${{ needs.release-plan.outputs.previous_tag }} + run: | + if [[ -n "$PREVIOUS_TAG" ]]; then + git fetch --depth=1 origin "refs/tags/${PREVIOUS_TAG}:refs/tags/${PREVIOUS_TAG}" + if git show "${PREVIOUS_TAG}:apps/desktop/src/main/index.mjs" | grep -q 'linuxUpdateTestMode'; then + previous_directory="$RUNNER_TEMP/edgeever-previous-linux-x64" + mkdir -p "$previous_directory" + gh release download "$PREVIOUS_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern 'EdgeEver-*-linux-x64.AppImage' \ + --dir "$previous_directory" + previous_app_image="$(find "$previous_directory" -maxdepth 1 -name 'EdgeEver-*-linux-x64.AppImage' -type f -print -quit)" + test -n "$previous_app_image" + test "$(find "$previous_directory" -maxdepth 1 -name 'EdgeEver-*-linux-x64.AppImage' -type f | wc -l | tr -d ' ')" = "1" + mv "$previous_app_image" "$RUNNER_TEMP/EdgeEver-linux-update-source.AppImage" + chmod +x "$RUNNER_TEMP/EdgeEver-linux-update-source.AppImage" + exit 0 + fi + fi + + # The first release that enables Linux updates has no update-capable + # predecessor in the wild. Build the same packaged updater at an older + # version so the bootstrap release still exercises a real replacement. + package_backup="$RUNNER_TEMP/edgeever-desktop-package.json" + cp apps/desktop/package.json "$package_backup" + trap 'cp "$package_backup" apps/desktop/package.json' EXIT + node -e "const fs=require('fs');const path='apps/desktop/package.json';const value=JSON.parse(fs.readFileSync(path,'utf8'));value.version='0.0.1';fs.writeFileSync(path, JSON.stringify(value, null, 2)+'\n')" + EDGE_EVER_DESKTOP_ARCH=x64 EDGE_EVER_DESKTOP_TARGET=linux \ + bun scripts/run-desktop-builder.mjs --publish never + mv release/desktop/EdgeEver-0.0.1-linux-x64.AppImage \ + "$RUNNER_TEMP/EdgeEver-linux-update-source.AppImage" + cp "$package_backup" apps/desktop/package.json + trap - EXIT + - name: Package Linux AppImage env: EDGE_EVER_DESKTOP_ARCH: x64 @@ -667,12 +705,26 @@ jobs: sha256sum "EdgeEver-${version}-linux-x64.AppImage" > SHA256SUMS-linux.txt sha256sum --check SHA256SUMS-linux.txt + - name: Verify Linux automatic update metadata + run: | + version="$(node -p "JSON.parse(require('fs').readFileSync('apps/desktop/package.json', 'utf8')).version")" + node scripts/verify-linux-update-release.mjs release/desktop "$version" + + - name: Verify real Linux AppImage automatic update + run: | + version="$(node -p "JSON.parse(require('fs').readFileSync('apps/desktop/package.json', 'utf8')).version")" + xvfb-run -a bun scripts/verify-linux-appimage-update.mjs \ + "$RUNNER_TEMP/EdgeEver-linux-update-source.AppImage" \ + "release/desktop/EdgeEver-${version}-linux-x64.AppImage" \ + "$version" + - name: Stage Linux Preview assets uses: actions/upload-artifact@v6 with: name: edgeever-desktop-Linux-x64-staging path: | release/desktop/EdgeEver-*-linux-x64.AppImage + release/desktop/latest-linux.yml release/desktop/SHA256SUMS-linux.txt if-no-files-found: error retention-days: 14 @@ -750,7 +802,7 @@ jobs: run: | version="$(node -p "JSON.parse(require('fs').readFileSync('apps/desktop/package.json', 'utf8')).version")" node scripts/create-mac-update-metadata.mjs release/desktop "$version" - (cd release/desktop && sha256sum --check SHA256SUMS-linux.txt) + node scripts/verify-linux-update-release.mjs release/desktop "$version" asset_ready="$( find release/desktop -maxdepth 1 -type f -exec basename {} \; | node scripts/check-native-release-assets.mjs desktop true "v${version}" "$version" allow-missing-windows-signature @@ -837,12 +889,9 @@ jobs: gh release download "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --pattern 'EdgeEver-*-linux-x64.AppImage' \ + --pattern 'latest-linux.yml' \ --pattern 'SHA256SUMS-linux.txt' \ --dir release/desktop - - name: Verify Linux checksum - run: | - app_image="$(find release/desktop -maxdepth 1 -name 'EdgeEver-*-linux-x64.AppImage' -type f -print -quit)" - test -n "$app_image" - test "$(find release/desktop -maxdepth 1 -name 'EdgeEver-*-linux-x64.AppImage' -type f | wc -l | tr -d ' ')" = "1" - (cd release/desktop && sha256sum --check SHA256SUMS-linux.txt) + - name: Verify Linux automatic update release + run: node scripts/verify-linux-update-release.mjs release/desktop diff --git a/AGENTS.md b/AGENTS.md index a62fef82..d67b3f58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,13 +23,13 @@ 1. **Fork 工作流边界**:配置 GitHub Actions 时必须考虑大量用户会 Fork 仓库进行自部署;仅官方仓库需要的 Job 必须使用 `github.repository == 'tianma-if/edgeever'` 门禁,严禁在下游 Fork 中分配 Runner 或执行。 2. **版本号与基线**:`vX.Y.Z`(非 Draft/Prerelease)。发布须显式 `--bump patch|minor|major`(脚本不自动选级);按 SemVer 选择,**禁止因发版节奏把用户可感知的新能力或新平台压成 patch**。递增根目录 `package.json`;含移动端修改时同步 `apps/mobile/app.json` 的 `expo.version` 并递增 `android.versionCode`。上一个正式 Release 为审计基线。 -3. **跨平台 Release 资产**:每个正式 Release 页面必须同时包含 macOS arm64 DMG、macOS x64 DMG、Windows x64 Preview 安装包、Linux x64 AppImage Preview 和 Android arm64 APK。Windows Preview 未使用 Authenticode 签名时必须同时提供经仓库外 Ed25519 私钥签名的更新清单;Linux Preview 必须包含独立 SHA-256 清单;两者均须在发布前通过独立下载与摘要审计。若本次未修改对应原生运行时代码、依赖、配置或构建工具,直接复用上一个正式 Release 中已验证的原始资产,保留原文件名与校验和,禁止仅为匹配新版本号而重命名。官方仓库正式 Release 中的 Android APK 必须使用固定的 Google Play 应用签名证书;本地上传证书签名的 APK 只能作为 Draft 临时资产,未经 Play 签名替换和发布前门禁核验不得公开。 +3. **跨平台 Release 资产**:每个正式 Release 页面必须同时包含 macOS arm64 DMG、macOS x64 DMG、Windows x64 Preview 安装包、Linux x64 AppImage Preview 和 Android arm64 APK。Windows Preview 未使用 Authenticode 签名时必须同时提供经仓库外 Ed25519 私钥签名的更新清单;Linux Preview 必须包含 `latest-linux.yml` 更新元数据和独立 SHA-256 清单;两者均须在发布前通过独立下载与摘要审计。若本次未修改对应原生运行时代码、依赖、配置或构建工具,直接复用上一个正式 Release 中已验证的原始资产,保留原文件名与校验和,禁止仅为匹配新版本号而重命名。官方仓库正式 Release 中的 Android APK 必须使用固定的 Google Play 应用签名证书;本地上传证书签名的 APK 只能作为 Draft 临时资产,未经 Play 签名替换和发布前门禁核验不得公开。 4. **验证命令**:必须通过 `bun run typecheck`、`bun run typecheck:mobile` 和 `bun run build:web`。 5. **测试职责边界**:正式 Release 必须先在官方仓库及与下游一致的 Ubuntu 环境通过完整非 E2E 测试,严禁将上游自身的测试失败转嫁给下游 Fork 发现。只读部署 Fork 仅同步产品快照且不运行测试;只有显式保留定制改动的 Fork 才验证合并结果,失败时必须保持 `main` 与生产环境不变。 6. **原生资产构建与复用**:由 `scripts/plan-native-release.mjs` 决定重建或复用;桌面资产包含 `apps/web`。修改判定规则时同步更新测试。移动端重建使用 `bun run build:android:apk:local`,签名配置保存在仓库外。 7. **Draft 内准备资产**:通过带 `release_tag` 的 `workflow_dispatch` 在 Draft 中准备并验证资产;Android 重建时须在 Draft 阶段完成 Google Play 交付、用 Play 签名 APK 替换临时资产并通过独立签名门禁;`published` 事件只审计,禁止重新构建或上传,签名不符时恢复 Draft。 8. **桌面验证职责**:桌面 Release 工作流负责测试、包结构检查、签名与公证;代理不再重复下载 Draft 或执行本地首次启动验收,除非用户明确要求。 -9. **发布后更新**:正式发布后,发布流程默认不得下载、覆盖安装或启动 `/Applications/EdgeEver.app`;已安装的 macOS 与 Windows 桌面端通过应用内自动更新机制获取新版,Linux Preview 在完成真实 AppImage 跨版本验证前保持手动更新。仅在用户明确要求时使用 `--install-desktop` 执行原有安装验收,功能体验由用户在实际使用中验证。 +9. **发布后更新**:正式发布后,发布流程默认不得下载、覆盖安装或启动 `/Applications/EdgeEver.app`;已安装的 macOS、Windows 与 Linux 桌面端通过应用内自动更新机制获取新版,其中 Linux Preview 必须在 Draft 阶段通过真实 AppImage 跨版本更新门禁。仅在用户明确要求时使用 `--install-desktop` 执行原有安装验收,功能体验由用户在实际使用中验证。 10. **失败处理**:Release 阻塞工作流或资产审计失败时保持或恢复 Draft,修复后重跑;不得公开已知损坏的 Release。GHCR 镜像属于阻塞门禁;腾讯云 TCR 公共镜像由独立工作流在正式发布后异步同步和审计,其耗时或失败不得阻塞 GitHub Release 或将已发布版本恢复为 Draft。 11. **Release 说明结构**:使用中英文双语格式(正文禁止包含字面量 `\n`),只写用户可感知的变化、影响以及必要的升级或迁移提醒。类型检查、构建命令、签名、公证、资产复用等技术验证细节保留在 Actions 和关联 Issue 中,不写入公开 Release 正文。功能/修复关联对应 Issue 并标记 Label,发布后回链并关闭 Issue。正文结构: diff --git a/README.md b/README.md index 4d4e7e2a..258a0a6b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s

The iOS app requires an Apple ID from outside mainland China. -The Linux x86_64 AppImage is a Preview with manual updates; see the +The Linux x86_64 AppImage is a Preview with automatic updates; see the [Linux Preview guide](docs/linux-preview.md). ## Features diff --git a/README.zh-CN.md b/README.zh-CN.md index 2e961cbd..7ce9c1f3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,7 +46,7 @@ EdgeEver 是一款现代化的开源笔记与个人知识库工作区。它为

iOS 客户端需要使用非中国大陆区 Apple ID 下载。 -Linux x86_64 AppImage 当前为手动更新的预览版,详见 +Linux x86_64 AppImage 当前为支持自动更新的预览版,详见 [Linux 预览版说明](docs/linux-preview.zh-CN.md)。 ## 功能 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 74e33226..fc6a5214 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -86,8 +86,8 @@ Formal Releases also contain a Linux x64 AppImage Preview built on Ubuntu 22.04. The workflow verifies the Electron and Rust ELF architectures, caps the sidecar requirement at glibc 2.35, runs the packaged sidecar integration suite, performs an Xvfb first-launch smoke test, and publishes a matching -`SHA256SUMS-linux.txt`. Linux Preview updates are manual until a real -AppImage-to-AppImage update has passed the cross-version release gate. See +`SHA256SUMS-linux.txt` and `latest-linux.yml`. Linux Preview releases must pass +a real AppImage-to-AppImage automatic update through the cross-version gate. See [`docs/linux-preview.md`](../../docs/linux-preview.md). The desktop Settings page exposes the sidecar's local backup list. Restoring a diff --git a/apps/desktop/src/main/desktop-update-flow.test.mjs b/apps/desktop/src/main/desktop-update-flow.test.mjs index 4db93990..15052316 100644 --- a/apps/desktop/src/main/desktop-update-flow.test.mjs +++ b/apps/desktop/src/main/desktop-update-flow.test.mjs @@ -8,10 +8,11 @@ const systemInfoSource = readFileSync(new URL("../../../web/src/components/setti const notebookPaneSource = readFileSync(new URL("../../../web/src/components/NotebookPane.tsx", import.meta.url), "utf8"); describe("desktop update flow", () => { - test("keeps Linux Preview updates manual until a cross-version AppImage test passes", () => { - expect(mainSource).toContain('process.platform === "linux" || !app.isPackaged'); - expect(mainSource).toContain("Linux Preview updates stay manual"); - expect(mainSource).toContain('autoUpdateSupported: process.platform !== "linux"'); + test("enables automatic updates for packaged Linux AppImages", () => { + expect(mainSource).not.toContain('process.platform === "linux" || !app.isPackaged'); + expect(mainSource).toContain('autoUpdateSupported: true'); + expect(mainSource).toContain('process.env.GITHUB_ACTIONS === "true"'); + expect(mainSource).toContain("Linux update verification requires a loopback HTTP feed"); expect(systemInfoSource).toContain("desktopAutoUpdateSupported"); expect(systemInfoSource).toContain('t("systemInfo.desktopDownloadLatest")'); }); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index c21113d4..a068503d 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -54,7 +54,15 @@ import { const { autoUpdater } = electronUpdater; -const requestedUserDataDirectory = userDataDirectoryFromArguments(process.argv); +const linuxUpdateTestMode = process.platform === "linux" + && process.env.GITHUB_ACTIONS === "true" + && process.env.EDGE_EVER_DESKTOP_UPDATE_TEST === "1"; +const linuxUpdateTestFeedUrl = linuxUpdateTestMode + ? process.env.EDGE_EVER_DESKTOP_UPDATE_TEST_FEED_URL || "" + : ""; +const requestedUserDataDirectory = linuxUpdateTestMode + ? process.env.EDGE_EVER_DESKTOP_UPDATE_TEST_USER_DATA || userDataDirectoryFromArguments(process.argv) + : userDataDirectoryFromArguments(process.argv); if (requestedUserDataDirectory) app.setPath("userData", requestedUserDataDirectory); const currentDirectory = fileURLToPath(new URL(".", import.meta.url)); @@ -199,7 +207,7 @@ const writeDiagnostic = async (event, details = {}) => { const desktopRuntimeSystemInfo = () => ({ appVersion: app.getVersion(), - autoUpdateSupported: process.platform !== "linux", + autoUpdateSupported: true, platform: process.platform, architecture: process.arch, osVersion: process.getSystemVersion?.() || "unknown", @@ -857,7 +865,7 @@ const promptForDownloadedUpdate = async (version) => { }; const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } = {}) => { - if (process.platform === "linux" || !app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1" || updateState === "downloaded") { + if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1" || updateState === "downloaded") { return Promise.resolve(null); } if (updateCheckInFlight) { @@ -903,9 +911,18 @@ const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } = }; const configureAutoUpdater = () => { - // Linux Preview updates stay manual until a real AppImage-to-AppImage - // transition has passed the same cross-version gate as established clients. - if (process.platform === "linux" || !app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1") return; + if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1") return; + if (linuxUpdateTestMode) { + if (!/^http:\/\/127\.0\.0\.1:\d+\/$/.test(linuxUpdateTestFeedUrl)) { + throw new Error("Linux update verification requires a loopback HTTP feed"); + } + autoUpdater.setFeedURL({ provider: "generic", url: linuxUpdateTestFeedUrl }); + autoUpdater.disableDifferentialDownload = true; + void writeDiagnostic("update.test-started", { + version: app.getVersion(), + appImage: process.env.APPIMAGE || null, + }); + } autoUpdater.autoDownload = process.platform !== "win32"; autoUpdater.autoInstallOnAppQuit = process.platform !== "win32"; autoUpdater.autoRunAppAfterInstall = true; @@ -927,7 +944,10 @@ const configureAutoUpdater = () => { windowsDownloadedUpdateVerified = false; refreshTrayMenu(); publishDesktopUpdateStatus(); - void writeDiagnostic("update.not-available"); + const diagnosticWritten = writeDiagnostic("update.not-available"); + if (linuxUpdateTestMode) { + void diagnosticWritten.finally(() => setTimeout(() => app.quit(), 100)); + } }); autoUpdater.on("download-progress", (progress) => { void writeDiagnostic("update.download-progress", { percent: progress.percent }); }); autoUpdater.on("update-downloaded", (info) => { @@ -948,6 +968,10 @@ const configureAutoUpdater = () => { refreshTrayMenu(); publishDesktopUpdateStatus(); await writeDiagnostic("update.downloaded", { version: downloadedUpdateVersion }); + if (linuxUpdateTestMode) { + installDownloadedUpdate(); + return; + } await promptForDownloadedUpdate(downloadedUpdateVersion).catch((error) => { promptedUpdateVersion = null; void writeDiagnostic("update.prompt-failed", { message: error.message }); diff --git a/docs/linux-preview.md b/docs/linux-preview.md index 211b1b4f..eb8b0851 100644 --- a/docs/linux-preview.md +++ b/docs/linux-preview.md @@ -17,10 +17,17 @@ chmod +x EdgeEver-*-linux-x64.AppImage The checksum detects a damaged or mismatched download; it is not a code-signing identity. Do not download the Preview from third-party mirrors. -Automatic updates are intentionally disabled during the initial Preview. -Download the newer AppImage from the latest formal Release and replace the old -file manually. Automatic updates will only be enabled after a real -AppImage-to-AppImage upgrade has passed the cross-version release gate. +The Preview checks formal GitHub Releases for updates, downloads a newer +AppImage in the background, and replaces the current AppImage after you choose +to restart. Releases must pass a real AppImage-to-AppImage cross-version gate +before publication. Versions released before automatic updates were enabled +still require one final manual replacement. + +Automatic replacement requires write access to both the current AppImage and +its containing directory. An AppImage kept in a user-owned Downloads or +Applications directory normally satisfies this requirement. A system-wide copy +owned by an administrator, such as one under `/opt`, must be updated manually +or made writable by the administrator. An AppImage does not install a desktop launcher or file associations by itself. Desktop integration can be managed by the user's AppImage launcher or desktop diff --git a/docs/linux-preview.zh-CN.md b/docs/linux-preview.zh-CN.md index 63f122c5..b4f312e7 100644 --- a/docs/linux-preview.zh-CN.md +++ b/docs/linux-preview.zh-CN.md @@ -16,9 +16,13 @@ chmod +x EdgeEver-*-linux-x64.AppImage 校验和可发现下载损坏或文件版本不匹配,但不代表代码签名身份。请勿从第三方镜像 下载此预览版。 -初始预览阶段会明确关闭自动更新。请从最新正式 Release 下载新 AppImage,并手动 -替换旧文件。只有真实 AppImage 旧版本到新版本的升级通过跨版本发布门禁后,才会 -启用自动更新。 +预览版会检查正式 GitHub Release,在后台下载新版 AppImage,并在用户选择重启后 +替换当前 AppImage。每个正式版本发布前都必须通过真实 AppImage 旧版本到新版本的 +跨版本门禁。自动更新启用前发布的旧版本仍需最后手动替换一次。 + +自动替换要求用户对当前 AppImage 及其所在目录拥有写权限。保存在用户自己的下载或 +应用目录中通常可以正常更新;如果由管理员放在 `/opt` 等系统级只读位置,则仍需 +管理员手动替换或调整权限。 AppImage 本身不会安装桌面启动器或文件关联,可由用户使用的 AppImage 启动器或 桌面环境管理集成。如果系统无法挂载 AppImage,可以尝试: diff --git a/docs/release.md b/docs/release.md index 7fb5225d..4a0221a3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -41,9 +41,9 @@ contain only user-visible changes, impact, and necessary migration guidance. Use `--dry-run` to inspect commit coverage, the native rebuild plan, and notes. After publication, the command does not download, install, or launch the macOS -application. Existing macOS and Windows installations receive new versions -through the in-app automatic updater. Linux Preview updates remain manual until -a real AppImage-to-AppImage transition has passed the cross-version gate. Pass +application. Existing macOS, Windows, and Linux installations receive new +versions through the in-app automatic updater. Linux Preview releases must pass +a real AppImage-to-AppImage transition through the cross-version gate. Pass `--install-desktop` explicitly only when the previous installation check is actually needed. @@ -57,7 +57,7 @@ actually needed. iOS build numbers remain independent, monotonically increasing identifiers. - A formal Release contains macOS arm64 and x64 DMGs, architecture-specific updater ZIPs, an unsigned Windows x64 Preview installer with an independently - signed update manifest, a Linux x64 AppImage Preview with a SHA-256 checksum + signed update manifest, a Linux x64 AppImage Preview with updater metadata and a SHA-256 checksum file, and an Android arm64 APK. Unchanged native assets are reused with their original filenames, versions, and checksums. - Desktop and Android update checks use the version embedded in the applicable diff --git a/docs/release.zh-CN.md b/docs/release.zh-CN.md index bab1a755..e60a346c 100644 --- a/docs/release.zh-CN.md +++ b/docs/release.zh-CN.md @@ -39,8 +39,8 @@ bun run release -- \ 公开 Release 说明。公开说明只包含用户可感知的变化、影响和必要的迁移提醒。 使用 `--dry-run` 查看提交覆盖、原生端重建计划和说明。发布完成后不会下载、 -安装或启动 macOS 应用;已安装的 macOS 与 Windows 桌面端通过应用内自动更新 -机制获取新版。Linux 预览版在真实 AppImage 跨版本验证通过前保持手动更新。仅在 +安装或启动 macOS 应用;已安装的 macOS、Windows 与 Linux 桌面端通过应用内自动 +更新机制获取新版。Linux 预览版发布前必须通过真实 AppImage 跨版本更新验证。仅在 确实需要原有安装验收时显式传入 `--install-desktop`。 ## EdgeEver 特有规则 @@ -50,7 +50,7 @@ bun run release -- \ - 根版本表示整体产品 Release。只有对应原生运行时重建时,才更新原生展示版本。 Android `versionCode` 和 iOS Build Number 是相互独立且严格递增的标识。 - 每个正式 Release 包含 macOS arm64 与 x64 DMG、按架构区分的更新 ZIP、带独立 - 签名更新清单的未签名 Windows x64 预览版安装包、带 SHA-256 清单的 Linux x64 + 签名更新清单的未签名 Windows x64 预览版安装包、带更新元数据与 SHA-256 清单的 Linux x64 AppImage 预览版,以及 Android arm64 APK。 未变化的原生资产沿用原文件名、版本和校验和。 - 桌面端和 Android 更新检查使用对应 Release 资产中记录的版本,而不是整体 diff --git a/package.json b/package.json index 0789a96c..8945596a 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run prepare:desktop:icons && bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", "verify:web-performance": "bun scripts/verify-web-performance.mjs", - "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/windows-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/pe-imports.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", + "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/windows-update-metadata.test.mjs scripts/linux-update-release.test.mjs scripts/desktop-build-workflow.test.mjs scripts/pe-imports.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", diff --git a/scripts/check-native-release-assets.mjs b/scripts/check-native-release-assets.mjs index e59d0e41..10a3e6c0 100644 --- a/scripts/check-native-release-assets.mjs +++ b/scripts/check-native-release-assets.mjs @@ -68,6 +68,7 @@ export const nativeReleaseAssetsReady = ({ "latest.yml", "latest-windows.json", "SHA256SUMS-windows.txt", + "latest-linux.yml", "SHA256SUMS-linux.txt", ]) { if (assetNames.filter((assetName) => assetName === name).length !== 1) { diff --git a/scripts/check-native-release-assets.test.mjs b/scripts/check-native-release-assets.test.mjs index ccad19db..de1fcc5a 100644 --- a/scripts/check-native-release-assets.test.mjs +++ b/scripts/check-native-release-assets.test.mjs @@ -11,6 +11,7 @@ describe("native release asset readiness", () => { ]; const linuxAssets = (version) => [ `EdgeEver-${version}-linux-x64.AppImage`, + "latest-linux.yml", "SHA256SUMS-linux.txt", ]; diff --git a/scripts/desktop-build-workflow.test.mjs b/scripts/desktop-build-workflow.test.mjs index 2d4a9926..a2fc9d63 100644 --- a/scripts/desktop-build-workflow.test.mjs +++ b/scripts/desktop-build-workflow.test.mjs @@ -146,6 +146,12 @@ describe("desktop release workflow", () => { expect(workflow).toContain("xvfb-run -a bun run verify:packaged-desktop-startup"); expect(step("Verify packaged Linux private protocol file flows")).toContain("xvfb-run -a bun run verify:desktop-protocol-e2e"); expect(workflow).toContain("SHA256SUMS-linux.txt"); + expect(workflow).toContain("latest-linux.yml"); + expect(workflow).toContain("verify-linux-update-release.mjs"); + expect(step("Verify real Linux AppImage automatic update")).toContain("verify-linux-appimage-update.mjs"); + expect(step("Verify real Linux AppImage automatic update")).toContain("xvfb-run -a"); + expect(step("Build Linux automatic update predecessor")).toContain('gh release download "$PREVIOUS_TAG"'); + expect(step("Build Linux automatic update predecessor")).toContain("EdgeEver-linux-update-source.AppImage"); expect(workflow).toContain("name: Audit Linux Preview asset"); expect(workflow).toContain("needs: [release-plan, desktop, windows, linux]"); expect(desktopPackageVerifier).toContain("verifyGlibcBaseline(sidecar)"); diff --git a/scripts/linux-update-release.test.mjs b/scripts/linux-update-release.test.mjs new file mode 100644 index 00000000..486ad28a --- /dev/null +++ b/scripts/linux-update-release.test.mjs @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { verifyLinuxUpdateRelease } from "./verify-linux-update-release.mjs"; + +const directories = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +const fixture = async ({ metadataSha512, checksumSha256 } = {}) => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-linux-update-")); + directories.push(directory); + const version = "1.62.0"; + const name = `EdgeEver-${version}-linux-x64.AppImage`; + const content = Buffer.from("real-appimage-bytes"); + const sha512 = createHash("sha512").update(content).digest("base64"); + const sha256 = createHash("sha256").update(content).digest("hex"); + await writeFile(join(directory, name), content); + await writeFile(join(directory, "latest-linux.yml"), [ + `version: ${version}`, + "files:", + ` - url: ${name}`, + ` sha512: ${metadataSha512 || sha512}`, + ` size: ${content.length}`, + " blockMapSize: 123", + `path: ${name}`, + `sha512: ${metadataSha512 || sha512}`, + "releaseDate: '2026-09-09T00:00:00.000Z'", + "", + ].join("\n")); + await writeFile(join(directory, "SHA256SUMS-linux.txt"), `${checksumSha256 || sha256} ${name}\n`); + return { directory, version }; +}; + +describe("Linux automatic update release", () => { + test("accepts metadata and checksums that exactly match the AppImage", async () => { + const input = await fixture(); + expect(verifyLinuxUpdateRelease({ directory: input.directory, expectedVersion: input.version })) + .toMatchObject({ version: input.version }); + }); + + test("rejects stale updater metadata", async () => { + const input = await fixture({ metadataSha512: "stale" }); + expect(() => verifyLinuxUpdateRelease({ directory: input.directory, expectedVersion: input.version })) + .toThrow("does not match"); + }); + + test("rejects a checksum from another AppImage", async () => { + const input = await fixture({ checksumSha256: "0".repeat(64) }); + expect(() => verifyLinuxUpdateRelease({ directory: input.directory, expectedVersion: input.version })) + .toThrow("does not exactly match"); + }); + + test("rejects metadata that can select an extra update file", async () => { + const input = await fixture(); + const path = join(input.directory, "latest-linux.yml"); + const metadata = await Bun.file(path).text(); + await writeFile(path, metadata.replace("files:\n", "files:\n - url: unexpected.AppImage\n")); + expect(() => verifyLinuxUpdateRelease({ directory: input.directory, expectedVersion: input.version })) + .toThrow("exactly one"); + }); +}); diff --git a/scripts/plan-native-release.mjs b/scripts/plan-native-release.mjs index 86c5904e..cedfb692 100644 --- a/scripts/plan-native-release.mjs +++ b/scripts/plan-native-release.mjs @@ -53,6 +53,8 @@ export const planNativeRelease = (platform, changedFiles) => { "bun.lock", "scripts/create-mac-update-metadata.mjs", "scripts/create-windows-update-metadata.mjs", + "scripts/verify-linux-update-release.mjs", + "scripts/verify-linux-appimage-update.mjs", "scripts/desktop-icns.mjs", "scripts/prepare-desktop-icons.mjs", "scripts/pe-imports.mjs", diff --git a/scripts/release.mjs b/scripts/release.mjs index 228fa207..1f32f121 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -365,7 +365,7 @@ export const buildIssueBody = ({ changesEn, changesZh, commitCoverageAudit }) => "## Acceptance criteria", "", "- Required type checks, Web build, and native release planning tests pass.", - "- The Draft Release contains audited macOS arm64/x64 DMGs, an unsigned Windows x64 Preview with an independently signed update manifest, a Linux x64 AppImage Preview with a checksum, and a Play-signed Android arm64 APK.", + "- The Draft Release contains audited macOS arm64/x64 DMGs, an unsigned Windows x64 Preview with an independently signed update manifest, a Linux x64 AppImage Preview with verified updater metadata, checksum, and cross-version installation, and a Play-signed Android arm64 APK.", "- Post-publication native asset audits pass.", ].join("\n"); @@ -1153,11 +1153,12 @@ const assertDraftAssets = ({ "latest-windows.json", "latest-windows.json.sig", "SHA256SUMS-windows.txt", + "latest-linux.yml", "SHA256SUMS-linux.txt", ].includes(name) ); if ( - previousDesktopNames.length !== 16 || + previousDesktopNames.length !== 17 || !previousDesktopNames.every((name) => reusedAssetMatches(previousAssets, assets, name) ) diff --git a/scripts/verify-linux-appimage-update.mjs b/scripts/verify-linux-appimage-update.mjs new file mode 100644 index 00000000..d46a5b02 --- /dev/null +++ b/scripts/verify-linux-appimage-update.mjs @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createReadStream, existsSync, mkdtempSync, readFileSync, statSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; + +const [oldAppImageInput, newAppImageInput, expectedVersion] = process.argv.slice(2); +const oldAppImage = resolve(oldAppImageInput || ""); +const newAppImage = resolve(newAppImageInput || ""); +if (!existsSync(oldAppImage) || !statSync(oldAppImage).isFile()) { + throw new Error(`Old AppImage is missing: ${oldAppImage}`); +} +if (!existsSync(newAppImage) || !statSync(newAppImage).isFile()) { + throw new Error(`New AppImage is missing: ${newAppImage}`); +} +if (!/^\d+\.\d+\.\d+$/.test(expectedVersion || "")) { + throw new Error(`Expected version must be stable X.Y.Z: ${expectedVersion || ""}`); +} + +const releaseDirectory = dirname(newAppImage); +const allowedFiles = new Set([basename(newAppImage), "latest-linux.yml"]); +const server = createServer((request, response) => { + const name = decodeURIComponent(new URL(request.url || "/", "http://127.0.0.1").pathname.slice(1)); + if (!allowedFiles.has(name) || name.includes("/")) { + response.writeHead(404).end(); + return; + } + const path = join(releaseDirectory, name); + const size = statSync(path).size; + response.writeHead(200, { + "Accept-Ranges": "bytes", + "Content-Length": size, + "Content-Type": name.endsWith(".yml") ? "text/yaml" : "application/octet-stream", + }); + createReadStream(path).pipe(response); +}); +await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", resolveListen); +}); + +const testDirectory = mkdtempSync(join(tmpdir(), "edgeever-linux-appimage-update-")); +const logPath = join(testDirectory, "logs", "desktop.log"); +const output = []; +const child = spawn(oldAppImage, [], { + env: { + ...process.env, + EDGE_EVER_DESKTOP_UPDATE_TEST: "1", + EDGE_EVER_DESKTOP_UPDATE_TEST_FEED_URL: `http://127.0.0.1:${server.address().port}/`, + EDGE_EVER_DESKTOP_UPDATE_TEST_USER_DATA: testDirectory, + }, + stdio: ["ignore", "pipe", "pipe"], +}); +child.stdout.on("data", (chunk) => output.push(chunk.toString())); +child.stderr.on("data", (chunk) => output.push(chunk.toString())); + +const entries = () => { + if (!existsSync(logPath)) return []; + return readFileSync(logPath, "utf8").split(/\r?\n/).flatMap((line) => { + try { return [JSON.parse(line)]; } catch { return []; } + }); +}; +const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)); + +try { + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + const events = entries(); + const startedVersions = events + .filter((entry) => entry.event === "update.test-started") + .map((entry) => entry.version); + if ( + startedVersions.includes(expectedVersion) + && events.some((entry) => entry.event === "update.downloaded" && entry.version === expectedVersion) + && events.some((entry) => entry.event === "update.not-available") + ) { + const digest = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); + if (digest(oldAppImage) !== digest(newAppImage)) { + throw new Error("The installed AppImage does not match the downloaded release AppImage"); + } + process.stdout.write(`${JSON.stringify({ ok: true, startedVersions, installedPath: oldAppImage })}\n`); + process.exitCode = 0; + break; + } + await wait(200); + } + if (process.exitCode !== 0) { + throw new Error([ + "Linux AppImage did not complete the old-to-new automatic update.", + existsSync(logPath) && `Diagnostic log:\n${readFileSync(logPath, "utf8")}`, + output.length > 0 && `Process output:\n${output.join("")}`, + ].filter(Boolean).join("\n\n")); + } +} finally { + server.close(); + if (child.exitCode === null) child.kill("SIGTERM"); + await rm(testDirectory, { recursive: true, force: true }); +} diff --git a/scripts/verify-linux-update-release.mjs b/scripts/verify-linux-update-release.mjs new file mode 100644 index 00000000..18c6182b --- /dev/null +++ b/scripts/verify-linux-update-release.mjs @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const STABLE_VERSION = /^\d+\.\d+\.\d+$/; +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const hashFile = (path, algorithm, encoding) => + createHash(algorithm).update(readFileSync(path)).digest(encoding); + +export const verifyLinuxUpdateRelease = ({ directory, expectedVersion }) => { + const metadataPath = join(directory, "latest-linux.yml"); + const metadata = readFileSync(metadataPath, "utf8"); + const version = metadata.match(/^version:\s*([^\s]+)\s*$/m)?.[1]; + if (!version || !STABLE_VERSION.test(version)) { + throw new Error("latest-linux.yml must contain a stable X.Y.Z version"); + } + if (expectedVersion && version !== expectedVersion) { + throw new Error(`Linux update version ${version} does not match ${expectedVersion}`); + } + + const appImageName = `EdgeEver-${version}-linux-x64.AppImage`; + const appImagePath = join(directory, appImageName); + const stats = statSync(appImagePath); + if (!stats.isFile() || stats.size <= 0) { + throw new Error(`Linux AppImage is missing or empty: ${appImagePath}`); + } + const sha512 = hashFile(appImagePath, "sha512", "base64"); + const sha256 = hashFile(appImagePath, "sha256", "hex"); + const escapedName = escapeRegExp(appImageName); + const updateUrls = [...metadata.matchAll(/^ - url:\s*(.+)\s*$/gm)].map((match) => match[1]); + if (updateUrls.length !== 1 || updateUrls[0] !== appImageName) { + throw new Error("latest-linux.yml must select exactly one expected AppImage"); + } + const fileEntry = new RegExp( + `^ - url: ${escapedName}\\n sha512: ${escapeRegExp(sha512)}\\n size: ${stats.size}\\n blockMapSize: [1-9]\\d*$`, + "m", + ); + if (!fileEntry.test(metadata)) { + throw new Error("latest-linux.yml does not match the AppImage digest, size, and block map"); + } + if ((metadata.match(/^path:/gm) || []).length !== 1 || !new RegExp(`^path: ${escapedName}$`, "m").test(metadata)) { + throw new Error("latest-linux.yml path does not select the expected AppImage"); + } + if ((metadata.match(/^sha512:/gm) || []).length !== 1 || !new RegExp(`^sha512: ${escapeRegExp(sha512)}$`, "m").test(metadata)) { + throw new Error("latest-linux.yml top-level digest does not match the AppImage"); + } + + const checksum = readFileSync(join(directory, "SHA256SUMS-linux.txt"), "utf8"); + if (checksum !== `${sha256} ${appImageName}\n`) { + throw new Error("SHA256SUMS-linux.txt does not exactly match the AppImage"); + } + return { version, appImageName, size: stats.size, sha512, sha256 }; +}; + +const run = () => { + const [directoryValue, expectedVersion] = process.argv.slice(2); + if (!directoryValue) { + throw new Error("Usage: node scripts/verify-linux-update-release.mjs [version]"); + } + const result = verifyLinuxUpdateRelease({ + directory: resolve(directoryValue), + expectedVersion, + }); + process.stdout.write(`${basename(directoryValue)}: ${result.appImageName}\n`); +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + run(); +}