mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(desktop): unblock macOS and Linux desktop packaging (#11518)
* fix(build): stop bundling the OpenTUI library for the other libc The OpenTUI asset copier ships the native render library from every installed `@opentui/core-*` platform package. On Linux that is two packages, not one: the glibc and musl builds differ only by the `libc` field, so a glibc host installs both and the bundle carries both ELF libraries. That breaks AppImage packaging. linuxdeploy walks every ELF file in the AppDir to deploy its dependencies, and the musl library needs `libc.so`, which no glibc system resolves; `ldd` exits non-zero and linuxdeploy aborts with nothing but `failed to run linuxdeploy` reaching the log. Desktop release 0.2.3-preview.0 failed this way on Linux, because the desktop app copies this dist tree into its bundled runtime verbatim. Skip the Linux package built for the libc this host does not use. The remaining platforms are untouched: their libraries are not ELF, so the Linux bundler ignores them and the completeness gate keeps working. It also drops roughly 27 MB from every Linux bundle. * fix(desktop): sign every Mach-O binary in the bundled runtime The macOS signing step walks a list: the ripgrep vendor binaries and the Node.js runtime. That list covered everything the bundled runtime carried when it was written, but the runtime is a copy of the CLI's dist tree, so its native payload changes whenever a CLI dependency does — with nothing in this package changing. A renderer library arrived that way and shipped unsigned. Release 0.2.3-preview.0 built for twenty minutes on both macOS targets and then lost the archive to the notary service: "The binary is not signed with a valid Developer ID certificate." Enumerate the Mach-O files under the staged runtime and sign all of them, keeping the JIT entitlement on the Node.js binary alone. Verify the signatures in the same step so a gap fails the build in seconds instead of surfacing from the notary service after the build.
This commit is contained in:
3 files changed
+123
-26
No files matched your search
@@ -363,30 +363,50 @@ jobs:
|
||||
APPLE_SIGNING_IDENTITY: '${{ env.APPLE_SIGNING_IDENTITY }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign all native macOS executables in the bundled runtime so
|
||||
# notarization does not reject them. Tauri only signs the main
|
||||
# app binary; resources like ripgrep and the Node.js runtime are
|
||||
# embedded verbatim and must be signed beforehand.
|
||||
# Sign every native macOS binary in the bundled runtime so
|
||||
# notarization does not reject them. Tauri only signs the main app
|
||||
# binary; the runtime is embedded verbatim and must be signed
|
||||
# beforehand.
|
||||
#
|
||||
# Enumerate the Mach-O files instead of listing the known ones. The
|
||||
# runtime is a copy of the CLI's dist tree, so its native payload
|
||||
# changes whenever a CLI dependency does, without anything in this
|
||||
# package changing. An unsigned renderer library arrived exactly
|
||||
# that way and failed release 0.2.3-preview.0 in the notary service,
|
||||
# twenty minutes into the job, against a list that had covered every
|
||||
# binary the runtime carried when it was written.
|
||||
runtime_dir="runtime/qwen-code"
|
||||
# ripgrep vendor binaries
|
||||
rg_dir="$runtime_dir/lib/vendor/ripgrep"
|
||||
if [ -d "$rg_dir" ]; then
|
||||
find "$rg_dir" -type f -name 'rg' -path '*-darwin/*' -exec \
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" \
|
||||
--options runtime --timestamp \
|
||||
{} +
|
||||
else
|
||||
echo "::warning::Ripgrep vendor directory not found at $rg_dir; no ripgrep binaries signed."
|
||||
mach_o=()
|
||||
while IFS= read -r -d '' file; do
|
||||
if [ "$(file -b --mime-type "$file")" = 'application/x-mach-binary' ]; then
|
||||
mach_o+=("$file")
|
||||
fi
|
||||
done < <(find "$runtime_dir" -type f -print0)
|
||||
if [ "${#mach_o[@]}" -eq 0 ]; then
|
||||
echo "::error::No Mach-O binary found under $runtime_dir. The runtime is not staged as expected."
|
||||
exit 1
|
||||
fi
|
||||
# Node.js runtime binary
|
||||
# Node.js runs the CLI's JIT; nothing else in the runtime needs an
|
||||
# entitlement of its own, and libraries inherit the host process's.
|
||||
node_bin="$runtime_dir/node/bin/node"
|
||||
if [ -f "$node_bin" ]; then
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" \
|
||||
--options runtime --timestamp \
|
||||
--entitlements src-tauri/NodeEntitlements.plist "$node_bin"
|
||||
else
|
||||
echo "::warning::Node.js runtime binary not found at $node_bin; no Node.js binary signed."
|
||||
for file in "${mach_o[@]}"; do
|
||||
if [ "$file" = "$node_bin" ]; then
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" \
|
||||
--options runtime --timestamp \
|
||||
--entitlements src-tauri/NodeEntitlements.plist "$file"
|
||||
else
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" \
|
||||
--options runtime --timestamp "$file"
|
||||
fi
|
||||
done
|
||||
if [ ! -f "$node_bin" ]; then
|
||||
echo "::warning::Node.js runtime binary not found at $node_bin."
|
||||
fi
|
||||
# Verify here rather than learning it from the notary service.
|
||||
for file in "${mach_o[@]}"; do
|
||||
codesign --verify --strict "$file"
|
||||
done
|
||||
echo "Signed and verified ${#mach_o[@]} Mach-O binaries under $runtime_dir."
|
||||
|
||||
- name: 'Refresh bundled runtime checksums after signing (macOS)'
|
||||
if: "runner.os == 'macOS' && inputs.dry_run == false"
|
||||
|
||||
@@ -291,6 +291,36 @@ function stampReviewSourceDigest(root, distDir) {
|
||||
console.log(`Stamped the review source digest over ${count} files.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this host's Node.js links against musl rather than glibc. The
|
||||
* report header only carries `glibcVersionRuntime` on a glibc runtime.
|
||||
*/
|
||||
function hostUsesMusl() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
try {
|
||||
return !process.report?.getReport?.()?.header?.glibcVersionRuntime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an `@opentui/core-*` package holds a native library this host
|
||||
* cannot load: the Linux packages ship one library per libc, and npm
|
||||
* installs both flavors on a glibc host because only `libc` (not `os`/`cpu`)
|
||||
* separates them.
|
||||
*
|
||||
* Shipping the foreign one is not merely dead weight. Both are ELF files, so
|
||||
* AppImage packaging walks them with `ldd` while deploying dependencies; the
|
||||
* musl library needs `libc.so`, which no glibc system resolves, so `ldd`
|
||||
* exits non-zero and aborts linuxdeploy. That is what broke the Linux job of
|
||||
* desktop release 0.2.3-preview.0, which bundles this dist tree verbatim.
|
||||
*/
|
||||
function isForeignLibcPackage(entry, musl = hostUsesMusl()) {
|
||||
if (!entry.startsWith('core-linux-')) return false;
|
||||
return entry.endsWith('-musl') !== musl;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenTUI renderer runtime assets (tree-sitter grammars, the parser worker,
|
||||
* `web-tree-sitter` runtime and the native render library) relocated next to
|
||||
@@ -314,7 +344,11 @@ function stampReviewSourceDigest(root, distDir) {
|
||||
* callers/tests can assert on it. Never fails the bundle: a missing
|
||||
* @opentui/core (e.g. an install without the renderer) only warns.
|
||||
*/
|
||||
export function copyOpenTuiAssets({ root = defaultRoot, distDir } = {}) {
|
||||
export function copyOpenTuiAssets({
|
||||
root = defaultRoot,
|
||||
distDir,
|
||||
musl = hostUsesMusl(),
|
||||
} = {}) {
|
||||
distDir ??= join(root, 'dist');
|
||||
const destRoot = join(distDir, 'opentui-assets');
|
||||
// Start from a clean tree: the runtime gate below checks key existence
|
||||
@@ -402,17 +436,19 @@ export function copyOpenTuiAssets({ root = defaultRoot, distDir } = {}) {
|
||||
}
|
||||
|
||||
// 4. Native render libraries for every installed @opentui platform package
|
||||
// (core-darwin-arm64, core-linux-x64-musl, …). The runtime only needs
|
||||
// the current platform's, but shipping the installed set keeps the
|
||||
// completeness check satisfiable wherever the bundle runs next. The
|
||||
// platform packages live beside @opentui/core or hoisted at the repo
|
||||
// root; scan both scope directories.
|
||||
// (core-darwin-arm64, core-win32-x64, …), minus the Linux packages built
|
||||
// for the other libc (see isForeignLibcPackage). The runtime only needs
|
||||
// the current platform's, but shipping the rest of the installed set
|
||||
// keeps the completeness check satisfiable wherever the bundle runs
|
||||
// next. The platform packages live beside @opentui/core or hoisted at
|
||||
// the repo root; scan both scope directories.
|
||||
const scopeDirs = [dirname(coreDir), join(root, 'node_modules', '@opentui')];
|
||||
const seenPackages = new Set();
|
||||
for (const scopeDir of scopeDirs) {
|
||||
if (!existsSync(scopeDir)) continue;
|
||||
for (const entry of fs.readdirSync(scopeDir)) {
|
||||
if (!entry.startsWith('core-') || seenPackages.has(entry)) continue;
|
||||
if (isForeignLibcPackage(entry, musl)) continue;
|
||||
const packageDir = join(scopeDir, entry);
|
||||
if (!statSync(packageDir).isDirectory()) continue;
|
||||
let copiedFromPackage = false;
|
||||
|
||||
@@ -70,6 +70,18 @@ function seedOpentuiPackages(root) {
|
||||
writeFileSync(join(treeSitterDir, 'tree-sitter.wasm'), 'wasm');
|
||||
}
|
||||
|
||||
function seedLinuxPlatformPackages(root) {
|
||||
for (const name of ['core-linux-x64', 'core-linux-x64-musl']) {
|
||||
const platformDir = join(root, 'node_modules', '@opentui', name);
|
||||
mkdirSync(platformDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(platformDir, 'package.json'),
|
||||
JSON.stringify({ name: `@opentui/${name}` }),
|
||||
);
|
||||
writeFileSync(join(platformDir, 'libopentui.so'), 'native');
|
||||
}
|
||||
}
|
||||
|
||||
describe('copyOpenTuiAssets', () => {
|
||||
it('copies the runtime assets under the exact OTUI_ASSET_ROOT keys', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'opentui-bundle-assets-'));
|
||||
@@ -145,4 +157,33 @@ describe('copyOpenTuiAssets', () => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// npm installs both Linux flavors on a glibc host — only `libc` separates
|
||||
// them — and the musl library then aborts AppImage packaging, because
|
||||
// linuxdeploy runs `ldd` over every ELF in the tree and no glibc system
|
||||
// resolves its `libc.so`.
|
||||
it.each([
|
||||
{ musl: false, kept: 'core-linux-x64', dropped: 'core-linux-x64-musl' },
|
||||
{ musl: true, kept: 'core-linux-x64-musl', dropped: 'core-linux-x64' },
|
||||
])(
|
||||
'copies only the Linux library for this host libc (musl: $musl)',
|
||||
({ musl, kept, dropped }) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'opentui-bundle-assets-'));
|
||||
try {
|
||||
writeFileSync(join(root, 'package.json'), JSON.stringify({}));
|
||||
seedOpentuiPackages(root);
|
||||
seedLinuxPlatformPackages(root);
|
||||
|
||||
const copied = copyOpenTuiAssets({ root, musl });
|
||||
|
||||
expect(copied).toContain(`@opentui/${kept}/libopentui.so`);
|
||||
expect(copied).not.toContain(`@opentui/${dropped}/libopentui.so`);
|
||||
const dest = join(root, 'dist', 'opentui-assets', '@opentui');
|
||||
expect(existsSync(join(dest, kept, 'libopentui.so'))).toBe(true);
|
||||
expect(existsSync(join(dest, dropped))).toBe(false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user