diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c05501aa..66003bfb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -164,7 +164,8 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf libssl-dev rpm + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf libssl-dev rpm rpm2cpio cpio + echo 'APPIMAGE_EXTRACT_AND_RUN=1' >> "$GITHUB_ENV" - run: npm --prefix web ci - name: Generate platform icons from provided transparent logo working-directory: desktop @@ -172,16 +173,29 @@ jobs: - run: python scripts/prepare_openapi_generator.py - run: python scripts/prepare_dubbo_worker.py - run: python scripts/prepare_java_runtime.py --target ${{ matrix.target }} + - name: Expose bundled JVM libraries to Linux AppImage dependency scan + if: runner.os == 'Linux' + run: | + java_lib="$GITHUB_WORKSPACE/vendor/java-runtime/current/payload/lib" + test -f "$java_lib/server/libjvm.so" + echo "LD_LIBRARY_PATH=$java_lib/server:$java_lib" >> "$GITHUB_ENV" - uses: tauri-apps/tauri-action@v0 with: projectPath: desktop tauriScript: ../web/node_modules/.bin/tauri - args: --config tauri.java.json --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} + args: --verbose --config tauri.java.json --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} + - name: Restore original bundled Java in Linux AppImage + if: runner.os == 'Linux' + run: python scripts/repair-linux-appimage-java.py target/${{ matrix.target }}/release/bundle - name: Verify desktop worker without opening a GUI run: python scripts/check-script-worker.py target/${{ matrix.target }}/release/moleapi - name: Verify actual bundled desktop JVM and original runtime files run: python scripts/check-bundled-java.py target/${{ matrix.target }}/release/moleapi --runtime-only + - name: Smoke extracted Linux deb, rpm and AppImage packages + if: runner.os == 'Linux' + run: python scripts/check-linux-desktop-packages.py target/${{ matrix.target }}/release/bundle - uses: actions/upload-artifact@v4 + if: ${{ success() || (failure() && runner.os == 'Linux') }} with: name: desktop-${{ matrix.target }} path: | @@ -191,7 +205,7 @@ jobs: target/${{ matrix.target }}/release/bundle/**/*.dmg target/${{ matrix.target }}/release/bundle/**/*.exe target/${{ matrix.target }}/release/bundle/**/*.msi - if-no-files-found: error + if-no-files-found: warn objc-qa: if: github.event_name == 'workflow_dispatch' && inputs.objc_qa == true timeout-minutes: 180 diff --git a/docs/BUILD-AND-RELEASE.md b/docs/BUILD-AND-RELEASE.md index ca0223bb..9ac5b369 100644 --- a/docs/BUILD-AND-RELEASE.md +++ b/docs/BUILD-AND-RELEASE.md @@ -47,6 +47,12 @@ npm --prefix web run desktop:build 读取对应资源,Docker 使用固定 /opt/moleapi/runtime/java/payload/bin/java。 MOLEAPI_CODEGEN_JAVA 与已有桌面用户选择可覆盖默认自带 JRE。 +Linux AppImage 的 `linuxdeploy` 会改写 JRE 内 ELF 文件,导致固定原始清单哈希失效。桌面构建在 Tauri 打包后调用 `scripts/repair-linux-appimage-java.py`:从固定源恢复完整 JRE,使用 SHA256 固定的 `linuxdeploy-plugin-appimage` 内 `appimagetool` 和原 AppImage runtime 重新封装,解包复核 JRE、worker 与 Dubbo。脚本拒绝修改已签名的 AppImage;启用签名时必须在重新封装后签名。旧构建 `38946817315` 的真实包已在本机按同一流程重新封装并通过这三项检查,当前分支的原生 CI 仍待结果。 + +CI 为无 FUSE 环境全局设置 `APPIMAGE_EXTRACT_AND_RUN=1`。修复脚本读取 AppImage runtime 的签名和偏移时临时移除该变量,避免把 runtime 查询误当成应用启动;实际解包步骤仍显式启用它。run `38963507351` 首次执行修复脚本时在签名查询处返回101,本地用相同环境复现并验证修复;该次打包结果不算通过。 + +分支构建 `38972262886` 已产出真实 Linux deb/rpm/AppImage,独立 deb 解包、JRE 原始清单与 Dubbo worker 自检通过,但 Ubuntu 22.04 上的 `rpm2cpio` 对该 RPM 返回1且没有错误正文,导致 RPM 与 AppImage 后续自检未执行。下载该次构建的原始 RPM 后,本地 `bsdtar` 可完整列出并解包;解包的 JRE 清单与 Dubbo worker 校验通过。本地 Arch 缺少 Ubuntu 打包程序所需的 WebKitGTK4.1 动态库,不能替代原生桌面 worker 检查。自检脚本已改为使用 `libarchive-tools` 的 `bsdtar` 提取真实 RPM;仅在 GitHub Linux runner 缺少该命令时,利用工作流先前更新的 apt 索引安装此工具。本地缺少工具会明确报错。新的原生 CI 结果仍待验证。 + 经典 Dubbo 使用同一份固定 Temurin JRE 和独立固定哈希的 Apache Dubbo worker JAR。构建前运行 `python scripts/prepare_dubbo_worker.py`(首次需要显式 Maven/JDK 或提供已构建且哈希匹配的 JAR);独立服务端压缩包和桌面资源均放在 `runtime/dubbo/engine.jar`,Docker 固定路径为 `/opt/moleapi/runtime/dubbo/engine.jar`。`scripts/check-bundled-dubbo.py` 检查 JAR/JRE 原始字节并实际启动 worker;真实业务调用、原生安装与容器 smoke 仍以对应平台流水线为准。开发者可显式设置绝对路径 `MOLEAPI_DUBBO_WORKER_JAR`,运行时不会下载或接受工作区提供的可执行文件路径。 发行验证用 scripts/check-bundled-java.py:在清空外部 Java 配置、使 PATH 不可用后, diff --git a/scripts/check-bundled-java.py b/scripts/check-bundled-java.py index 52354f56..54ae0d32 100644 --- a/scripts/check-bundled-java.py +++ b/scripts/check-bundled-java.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Run a packaged JVM and real project API with external Java configuration cleared.""" import argparse +from contextlib import ExitStack, contextmanager import json import os from pathlib import Path @@ -49,9 +50,27 @@ def generate(base, setup): return {'engine': artifact['engine'], 'files': len(artifact['files']), 'source_sha256': artifact['source_sha256']} +@contextmanager +def mounted_macos_resources(binary, temporary): + images = list(binary.parent.glob('bundle/dmg/*.dmg')) + if len(images) != 1: + raise ValueError('Expected exactly one packaged macOS DMG') + mount = Path(temporary) / 'mounted-dmg' + mount.mkdir() + subprocess.run(['hdiutil', 'attach', '-readonly', '-nobrowse', '-mountpoint', str(mount), str(images[0])], + timeout=90, check=True) + try: + applications = list(mount.glob('*.app/Contents/Resources')) + if len(applications) != 1: + raise ValueError('Expected exactly one application resource directory in the packaged DMG') + yield applications[0] + finally: + subprocess.run(['hdiutil', 'detach', str(mount), '-force'], timeout=90, check=True) + + def main(args): process = None - with tempfile.TemporaryDirectory(prefix='moleapi-bundled-java-') as temporary: + with tempfile.TemporaryDirectory(prefix='moleapi-bundled-java-') as temporary, ExitStack() as stack: if args.container: assert os.environ.get('GITHUB_ACTIONS') == 'true', 'Container verification requires the explicit CI fixture' version = subprocess.run(['docker', 'exec', args.container, 'env', '-i', @@ -69,10 +88,7 @@ def main(args): target = host_target() resource_root = args.resources.resolve() if args.resources else binary.parent if args.runtime_only and sys.platform == 'darwin' and not args.resources: - applications = list(binary.parent.glob('bundle/macos/*.app/Contents/Resources')) - if len(applications) != 1: - raise ValueError('Expected exactly one packaged macOS application resource directory') - resource_root = applications[0] + resource_root = stack.enter_context(mounted_macos_resources(binary, temporary)) resources = resource_root / 'runtime/java' verify_payload(resources, manifest, target) jvm = resources / manifest['assets'][target]['java'] diff --git a/scripts/check-linux-desktop-packages.py b/scripts/check-linux-desktop-packages.py new file mode 100644 index 00000000..ce03156d --- /dev/null +++ b/scripts/check-linux-desktop-packages.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Extract each Linux desktop package and smoke its actual bundled payload.""" +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def one_package(directory: Path, suffix: str) -> Path: + matches = list(directory.rglob(f"*{suffix}")) + if len(matches) != 1: + raise ValueError(f"Expected one {suffix} package, found {len(matches)}") + return matches[0].resolve(strict=True) + + +def extract_rpm(package: Path, destination: Path) -> None: + bsdtar = shutil.which("bsdtar") + if bsdtar is None and os.environ.get("GITHUB_ACTIONS") == "true" and os.environ.get("RUNNER_OS") == "Linux": + # The workflow already updated apt metadata before building this package. + subprocess.run(["sudo", "apt-get", "install", "-y", "libarchive-tools"], + check=True, timeout=180) + bsdtar = shutil.which("bsdtar") + if bsdtar is None: + raise RuntimeError("bsdtar (libarchive-tools) is required to inspect RPM payloads") + subprocess.run([bsdtar, "-xf", str(package)], cwd=destination, + check=True, timeout=180) + + +def verify_extracted(name: str, root: Path, executable: Path) -> None: + resources = root / "usr/lib/MoleAPI" + if not executable.is_file() or not resources.is_dir(): + raise ValueError(f"{name}: installed executable or resources missing") + subprocess.run([sys.executable, str(ROOT / "scripts/check-script-worker.py"), str(executable)], + check=True, timeout=45) + subprocess.run([sys.executable, str(ROOT / "scripts/check-bundled-java.py"), str(executable), + "--resources", str(resources), "--runtime-only"], check=True, timeout=45) + subprocess.run([sys.executable, str(ROOT / "scripts/check-bundled-dubbo.py"), str(executable), + "--resource-root", str(resources)], check=True, timeout=45) + print(f"PASS extracted {name}: worker, exact JRE inventory and offline Dubbo worker", flush=True) + + +def main(directory: Path) -> None: + directory = directory.resolve(strict=True) + deb = one_package(directory, ".deb") + rpm = one_package(directory, ".rpm") + appimage = one_package(directory, ".AppImage") + with tempfile.TemporaryDirectory(prefix="moleapi-linux-packages-") as temporary: + base = Path(temporary) + deb_root = base / "deb" + deb_root.mkdir() + subprocess.run(["dpkg-deb", "-x", str(deb), str(deb_root)], check=True, timeout=180) + verify_extracted("deb", deb_root, deb_root / "usr/bin/moleapi") + + rpm_root = base / "rpm" + rpm_root.mkdir() + extract_rpm(rpm, rpm_root) + verify_extracted("rpm", rpm_root, rpm_root / "usr/bin/moleapi") + + app_root = base / "appimage" + app_root.mkdir() + environment = {**os.environ, "APPIMAGE_EXTRACT_AND_RUN": "1"} + subprocess.run([str(appimage), "--appimage-extract"], cwd=app_root, + env=environment, check=True, timeout=180, + stdout=subprocess.DEVNULL) + extracted = app_root / "squashfs-root" + verify_extracted("AppImage", extracted, extracted / "AppRun") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bundle_directory", type=Path) + args = parser.parse_args() + main(args.bundle_directory) diff --git a/scripts/repair-linux-appimage-java.py b/scripts/repair-linux-appimage-java.py new file mode 100644 index 00000000..f1b577e2 --- /dev/null +++ b/scripts/repair-linux-appimage-java.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Restore the exact pinned JRE after linuxdeploy mutates AppImage ELF resources.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from urllib.request import Request, urlopen + +from prepare_java_runtime import verify_payload + +ROOT = Path(__file__).resolve().parents[1] +TARGET = "x86_64-unknown-linux-gnu" +PLUGIN_URL = ("https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/" + "download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage") +PLUGIN_SHA256 = "49d6a17160675a6bd1781699aae6bdf7692d98552e02a3671d2183d10547842e" +MAX_PLUGIN = 32 * 1024 * 1024 +MAX_RUNTIME = 2 * 1024 * 1024 + + +def checked_plugin(path: Path | None, temporary: Path) -> Path: + if path is None: + path = temporary / "linuxdeploy-plugin-appimage-x86_64.AppImage" + with urlopen(Request(PLUGIN_URL, headers={"User-Agent": "moleapi-build"}), timeout=60) as response: + total = 0 + with path.open("wb") as output: + while chunk := response.read(1024 * 1024): + total += len(chunk) + if total > MAX_PLUGIN: + raise ValueError("AppImage packaging plugin exceeds size limit") + output.write(chunk) + if hashlib.sha256(path.read_bytes()).hexdigest() != PLUGIN_SHA256: + raise ValueError("AppImage packaging plugin hash mismatch") + path.chmod(path.stat().st_mode | 0o100) + return path + + +def extract(image: Path, destination: Path) -> Path: + destination.mkdir() + subprocess.run([str(image), "--appimage-extract"], cwd=destination, + env={**os.environ, "APPIMAGE_EXTRACT_AND_RUN": "1"}, + stdout=subprocess.DEVNULL, check=True, timeout=180) + appdir = destination / "squashfs-root" + if not appdir.is_dir(): + raise ValueError("AppImage extraction did not create an AppDir") + return appdir + + +def verify_java(resources: Path) -> None: + manifest = json.loads((ROOT / "vendor/java-runtime/manifest.json").read_text()) + verify_payload(resources / "runtime/java", manifest, TARGET) + + +def repair(bundle_directory: Path, source_java: Path, plugin: Path | None) -> None: + images = list(bundle_directory.resolve(strict=True).rglob("*.AppImage")) + if len(images) != 1: + raise ValueError(f"Expected one Linux AppImage, found {len(images)}") + image = images[0] + if image.with_suffix(image.suffix + ".sig").exists(): + raise ValueError("Refusing to alter an already signed AppImage") + runtime_env = {key: value for key, value in os.environ.items() + if key != "APPIMAGE_EXTRACT_AND_RUN"} + signature = subprocess.run([str(image), "--appimage-signature"], + capture_output=True, check=True, timeout=15, env=runtime_env) + if signature.stdout.strip(): + raise ValueError("Refusing to alter an AppImage with an embedded signature") + source_java = source_java.resolve(strict=True) + manifest = json.loads((ROOT / "vendor/java-runtime/manifest.json").read_text()) + verify_payload(source_java, manifest, TARGET) + with tempfile.TemporaryDirectory(prefix="moleapi-appimage-repair-", dir=image.parent) as temporary: + base = Path(temporary) + appdir = extract(image, base / "original") + resources = appdir / "usr/lib/MoleAPI" + installed_java = resources / "runtime/java" + if not resources.is_dir() or not installed_java.is_dir(): + raise ValueError("AppImage bundled Java directory missing") + if not installed_java.samefile(source_java): + shutil.rmtree(installed_java) + shutil.copytree(source_java, installed_java, symlinks=True) + verify_java(resources) + + offset = int(subprocess.check_output([str(image), "--appimage-offset"], + text=True, timeout=15, env=runtime_env).strip()) + if not 0 < offset <= MAX_RUNTIME: + raise ValueError("AppImage runtime offset exceeds limit") + runtime = base / "runtime-x86_64" + with image.open("rb") as original, runtime.open("wb") as output: + output.write(original.read(offset)) + tool_image = checked_plugin(plugin, base) + tool = extract(tool_image, base / "tool") / "usr/bin/appimagetool" + if not tool.is_file(): + raise ValueError("Pinned AppImage plugin has no appimagetool") + repacked = base / image.name + subprocess.run([str(tool), "--no-appstream", "--runtime-file", str(runtime), + str(appdir), str(repacked)], env={**os.environ, "ARCH": "x86_64"}, + stdout=subprocess.DEVNULL, check=True, timeout=180) + verified = extract(repacked, base / "verified") + verify_java(verified / "usr/lib/MoleAPI") + subprocess.run([sys.executable, str(ROOT / "scripts/check-script-worker.py"), + str(verified / "AppRun")], check=True, timeout=45) + subprocess.run([sys.executable, str(ROOT / "scripts/check-bundled-dubbo.py"), + str(verified / "AppRun"), "--resource-root", + str(verified / "usr/lib/MoleAPI")], check=True, timeout=45) + os.replace(repacked, image) + print(f"PASS restored original JRE in {image.name}", flush=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bundle_directory", type=Path) + parser.add_argument("--java-source", type=Path, default=ROOT / "vendor/java-runtime/current") + parser.add_argument("--plugin", type=Path, help="Pinned plugin file already downloaded") + args = parser.parse_args() + repair(args.bundle_directory, args.java_source, args.plugin)