Skip to content
Merged
20 changes: 17 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,24 +164,38 @@ 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
run: ../web/node_modules/.bin/tauri icon ../assets/brand/logo.png --output icons
- 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: |
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/BUILD-AND-RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不可用后,
Expand Down
26 changes: 21 additions & 5 deletions scripts/check-bundled-java.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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']
Expand Down
78 changes: 78 additions & 0 deletions scripts/check-linux-desktop-packages.py
Original file line number Diff line number Diff line change
@@ -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)
118 changes: 118 additions & 0 deletions scripts/repair-linux-appimage-java.py
Original file line number Diff line number Diff line change
@@ -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)
Loading