diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed187baffd8..25a13c3d75a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,7 @@ jobs: # step. A missing or malformed filter output must fail this job instead # of silently making every expensive job skip. ci: ${{ steps.scope.outputs.ci }} + desktop: ${{ steps.scope.outputs.desktop }} native: ${{ steps.matrices.outputs.native }} # Matrix include lists for keyring-smoke and npm-global-smoke, built and # shape-checked by the same validation step as `native`. @@ -263,6 +264,20 @@ jobs: - '.github/workflows/ci.yml' gui: - 'gui/**' + # Building both Linux package formats and booting their real payloads is + # substantially heavier than the Rust-only desktop-shell check. Keep it + # scoped to inputs that can change the packaged shell, dashboard or + # standalone sidecar. The workflow names itself so edits to this lane + # cannot skip their own E2E. + desktop: + - 'desktop/**' + - 'gui/**' + - 'src/**' + - 'scripts/build-standalone.ts' + - 'scripts/standalone-targets.ts' + - 'package.json' + - 'bun.lock' + - '.github/workflows/ci.yml' # The docs site is built by nothing else on a pull request. `ci` above # deliberately omits `docs-site/**` -- a prose edit has no business # starting the cross-platform suite -- and `deploy-docs.yml` triggers @@ -345,6 +360,7 @@ jobs: env: CI_SCOPE: ${{ steps.filter.outputs.ci }} PRIVACY_SCOPE: ${{ steps.filter.outputs.privacy }} + DESKTOP_SCOPE: ${{ steps.filter.outputs.desktop }} run: | set -euo pipefail case "$CI_SCOPE" in @@ -365,6 +381,15 @@ jobs: exit 1 ;; esac + case "$DESKTOP_SCOPE" in + true|false) + printf 'desktop=%s\n' "$DESKTOP_SCOPE" >> "$GITHUB_OUTPUT" + ;; + *) + printf '::error::changes.outputs.desktop was %q, expected true or false\n' "$DESKTOP_SCOPE" + exit 1 + ;; + esac - name: Assert the native and matrix outputs are usable id: matrices @@ -1264,11 +1289,11 @@ jobs: desktop-shell: name: desktop shell needs: [changes, gates] - # Native-gated like platform-macos: the Rust shell is formatted, linted - # and tested only when native-capable paths changed. - if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.native == 'true') + # Native shell changes run the Rust checks; package-affecting changes also run the real Linux + # bundle acceptance. The aggregate gate below mirrors this union exactly. + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && (needs.changes.outputs.native == 'true' || needs.changes.outputs.desktop == 'true')) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -1278,7 +1303,11 @@ jobs: - name: Install Tauri Linux dependencies run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf dbus-x11 xvfb xauth xdotool openbox + + - name: Setup Bun for packaged E2E + if: needs.changes.outputs.desktop == 'true' + uses: ./.github/actions/setup-project-bun - name: Setup Rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master @@ -1304,6 +1333,79 @@ jobs: - name: Run Rust tests run: cargo test --manifest-path desktop/src-tauri/Cargo.toml + - name: Install packaged E2E dependencies + if: needs.changes.outputs.desktop == 'true' + run: | + bun install --frozen-lockfile + cd desktop + bun install --frozen-lockfile + + - name: Build dashboard and bundled sidecar + if: needs.changes.outputs.desktop == 'true' + run: | + bun run build:gui + bun desktop/scripts/prepare-sidecar.ts --target x86_64-unknown-linux-gnu + + # Build separately. One format failing must not delete or hide the other + # format's evidence, and neither verification artifact needs an updater key. + - name: Preserve the compiled Linux sidecar + if: needs.changes.outputs.desktop == 'true' + run: chmod +x desktop/scripts/appimage-patchelf.py + + - name: Build Linux AppImage + if: needs.changes.outputs.desktop == 'true' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target + PATCHELF: ${{ github.workspace }}/desktop/scripts/appimage-patchelf.py + run: bunx tauri build --ci --bundles appimage --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Build Linux deb + if: needs.changes.outputs.desktop == 'true' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target + run: bunx tauri build --ci --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Stage isolated Linux bundles + if: needs.changes.outputs.desktop == 'true' + env: + APPIMAGE_BUNDLE: ${{ runner.temp }}/opencodex-appimage-target/release/bundle/appimage + DEB_BUNDLE: ${{ runner.temp }}/opencodex-deb-target/release/bundle/deb + BUNDLE_ROOT: ${{ runner.temp }}/opencodex-linux-bundles + run: | + set -euo pipefail + mkdir -p "$BUNDLE_ROOT/appimage" "$BUNDLE_ROOT/deb" + cp -a "$APPIMAGE_BUNDLE/." "$BUNDLE_ROOT/appimage/" + cp -a "$DEB_BUNDLE/." "$BUNDLE_ROOT/deb/" + chmod -R a-w "$BUNDLE_ROOT" + + - name: Run Linux packaged-shell E2E + if: needs.changes.outputs.desktop == 'true' + env: + REPORT_PATH: ${{ runner.temp }}/opencodex-linux-e2e/report.json + run: | + set -euo pipefail + mkdir -p "$(dirname "$REPORT_PATH")" + dbus-run-session -- xvfb-run -a -s '-screen 0 1440x900x24' bash -lc ' + openbox >"$RUNNER_TEMP/opencodex-openbox.log" 2>&1 & + wm_pid=$! + trap '\''kill "$wm_pid" 2>/dev/null || true'\'' EXIT + bun desktop/scripts/linux-packaged-e2e.ts \ + --bundle-root "$RUNNER_TEMP/opencodex-linux-bundles" \ + --report "$REPORT_PATH" \ + --version "$(jq -r .version package.json)" + ' + + - name: Upload Linux packaged-shell E2E report + if: always() && needs.changes.outputs.desktop == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-packaged-shell-e2e + path: ${{ runner.temp }}/opencodex-linux-e2e/report.json + if-no-files-found: warn + retention-days: 7 + ci: name: ci if: always() @@ -1331,6 +1433,7 @@ jobs: CHANGES_DOCS: ${{ needs.changes.outputs.docs }} CHANGES_STRUCTURE: ${{ needs.changes.outputs.structure }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} + CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} CHANGES_PRIVACY: ${{ needs.changes.outputs.privacy }} GH_TOKEN: ${{ github.token }} run: | @@ -1352,8 +1455,8 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then scoped=not-requested fi - # platform-macos, widget and desktop-shell carry a compound - # condition: the ordinary scope gate AND the native path filter. + # platform-macos and widget carry the ordinary scope gate AND the native path filter. + # desktop-shell accepts that native set plus the package-E2E set. # This mirrors that expression exactly; where it disagrees with the # jobs' own `if:`, the gate fails by name instead of demanding # success from a job that was deliberately left unselected. @@ -1361,6 +1464,10 @@ jobs: if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && [ "$CHANGES_NATIVE" = "true" ]; }; then native=requested fi + desktop_shell=not-requested + if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && { [ "$CHANGES_NATIVE" = "true" ] || [ "$CHANGES_DESKTOP" = "true" ]; }; }; then + desktop_shell=requested + fi packaging=not-requested if [ "$CHANGES_PACKAGING" = "true" ]; then packaging=requested @@ -1407,8 +1514,9 @@ jobs: changes|select-windows-runner) echo requested ;; test|storage-policy|api-usage|gates|keyring-smoke|docker-smoke) echo "$scoped" ;; - platform-macos|widget|desktop-shell) + platform-macos|widget) echo "$native" ;; + desktop-shell) echo "$desktop_shell" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; structure-gate) echo "$structure" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6047c14d91c..899a9b0fdfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -370,6 +370,7 @@ jobs: # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. - name: Build desktop bundles + if: runner.os != 'Linux' working-directory: desktop env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -389,16 +390,58 @@ jobs: if: runner.os == 'Linux' run: bash desktop/scripts/verify-linux-sidecar.sh + # Tauri patches a bundle-type marker into the application binary for each Linux format. + # Keep each format in its own Cargo target so the deb cannot inherit the AppImage marker + # and linuxdeploy cannot mutate the binary later consumed by the deb build. + - name: Build Linux AppImage bundle + if: runner.os == 'Linux' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles appimage + + - name: Build Linux deb bundle + if: runner.os == 'Linux' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles deb + + - name: Stage isolated Linux release bundles + if: runner.os == 'Linux' + shell: bash + env: + DESKTOP_TARGET: ${{ matrix.target }} + APPIMAGE_TARGET: ${{ runner.temp }}/opencodex-appimage-target + DEB_TARGET: ${{ runner.temp }}/opencodex-deb-target + run: | + set -euo pipefail + bundle_root="$RUNNER_TEMP/opencodex-linux-release-bundles" + mkdir -p "$bundle_root/appimage" "$bundle_root/deb" + cp -a "$APPIMAGE_TARGET/$DESKTOP_TARGET/release/bundle/appimage/." "$bundle_root/appimage/" + cp -a "$DEB_TARGET/$DESKTOP_TARGET/release/bundle/deb/." "$bundle_root/deb/" + chmod -R a-w "$bundle_root" + echo "DESKTOP_BUNDLE_ROOT=$bundle_root" >> "$GITHUB_ENV" + - name: Rename release assets shell: bash env: RELEASE_VERSION: ${{ inputs.version }} DESKTOP_TARGET: ${{ matrix.target }} run: | - bun desktop/scripts/collect-release-assets.ts \ + args=( \ --version "$RELEASE_VERSION" \ --target "$DESKTOP_TARGET" \ - --out dist/release + --out dist/release \ + ) + if [[ -n "${DESKTOP_BUNDLE_ROOT:-}" ]]; then + args+=(--bundle-root "$DESKTOP_BUNDLE_ROOT") + fi + bun desktop/scripts/collect-release-assets.ts "${args[@]}" # After the bundle exists, not before: a sweep that runs first passes by finding nothing. - name: Verify every Mach-O in the bundle carries the release identity diff --git a/desktop/package.json b/desktop/package.json index 5966c9235f2..7e469873090 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,6 +5,7 @@ "dev": "tauri dev", "build": "tauri build", "build:local": "bun scripts/build-local.ts", + "e2e:linux-packaged": "bun scripts/linux-packaged-e2e.ts", "icons": "bun scripts/generate-icons.ts", "icons:check": "bun scripts/generate-icons.ts --check", "prepare-sidecar": "bun scripts/prepare-sidecar.ts", diff --git a/desktop/scripts/appimage-patchelf.py b/desktop/scripts/appimage-patchelf.py index 4c7e73cd930..63e78ef7416 100644 --- a/desktop/scripts/appimage-patchelf.py +++ b/desktop/scripts/appimage-patchelf.py @@ -5,17 +5,51 @@ import sys +APPDIR_SIDECAR_TAIL = ( + "release", + "bundle", + "appimage", + "OpenCodex.AppDir", + "usr", + "bin", + "ocx", +) + + +def prepared_sidecar(root, candidate, target_root): + """Return the one prepared Linux CLI that the AppDir sidecar exactly mirrors.""" + try: + relative = candidate.resolve().relative_to(target_root.resolve()) + except ValueError: + return None + if tuple(relative.parts[-len(APPDIR_SIDECAR_TAIL):]) != APPDIR_SIDECAR_TAIL: + return None + prefix = relative.parts[:-len(APPDIR_SIDECAR_TAIL)] + if len(prefix) > 1: + return None + + binaries = root / "desktop/src-tauri/binaries" + candidates = sorted(path for path in binaries.glob("ocx-*-linux-gnu") if path.is_file()) + if prefix: + candidates = [path for path in candidates if path.name == f"ocx-{prefix[0]}"] + matches = [path for path in candidates if path.read_bytes() == candidate.read_bytes()] + return matches[0] if len(matches) == 1 else None + + def main(args): root = Path(__file__).resolve().parents[2] - triple = "x86_64-unknown-linux-gnu" - original = root / "desktop/src-tauri/binaries" / f"ocx-{triple}" - sidecar = root / "desktop/src-tauri/target" / triple / "release/bundle/appimage/OpenCodex.AppDir/usr/bin/ocx" - if len(args) == 3 and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] and Path(args[2]).resolve() == sidecar.resolve(): + target_root = Path(os.environ.get("CARGO_TARGET_DIR", root / "desktop/src-tauri/target")) + sidecar = Path(args[2]) if len(args) == 3 else None + if ( + sidecar is not None + and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] + and prepared_sidecar(root, sidecar, target_root) is not None + ): # linuxdeploy's nested GTK pass runs ldd again after patching. Its # patchelf rewrite breaks the compiled Bun ELF. This sidecar depends # only on host glibc libraries; it needs no AppDir library search path. # Never bless an already-modified binary or a different executable. - if sidecar.is_symlink() or original.read_bytes() != sidecar.read_bytes(): + if sidecar.is_symlink(): raise RuntimeError("AppImage sidecar differs from the prepared CLI") print("Preserving compiled ocx bytes (no AppDir RPATH required)", file=sys.stderr) return diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts index 2c97de39d44..1d1266283e0 100644 --- a/desktop/scripts/collect-release-assets.ts +++ b/desktop/scripts/collect-release-assets.ts @@ -42,6 +42,7 @@ export interface CollectReleaseAssetsOptions { target: string; out: string; repoRoot?: string; + bundleRoot?: string; } function findBundle(directory: string, kind: BundleKind): string { @@ -61,13 +62,17 @@ export function collectReleaseAssets(options: CollectReleaseAssetsOptions): stri const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); const bundles = bundlesByTarget[options.target]; if (!bundles) throw new Error(`Unsupported desktop target: ${options.target}`); + const bundleRoot = resolve( + options.bundleRoot + ?? join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle"), + ); const output = resolve(options.out); mkdirSync(output, { recursive: true }); const written: string[] = []; for (const bundle of bundles) { const source = findBundle( - join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle", bundle.dir), + join(bundleRoot, bundle.dir), bundle.kind, ); const destinationName = `OpenCodex-${options.version}-${bundle.name}`; @@ -98,8 +103,11 @@ if (import.meta.main) { const version = argument("--version"); const target = argument("--target"); const out = argument("--out"); + const bundleRoot = argument("--bundle-root"); if (!version || !target || !out) { throw new Error("Usage: collect-release-assets.ts --version --target --out "); } - for (const path of collectReleaseAssets({ version, target, out })) console.log(`Wrote ${path}`); + const options: CollectReleaseAssetsOptions = { version, target, out }; + if (bundleRoot) options.bundleRoot = bundleRoot; + for (const path of collectReleaseAssets(options)) console.log(`Wrote ${path}`); } diff --git a/desktop/scripts/linux-packaged-e2e.ts b/desktop/scripts/linux-packaged-e2e.ts new file mode 100644 index 00000000000..cd957582009 --- /dev/null +++ b/desktop/scripts/linux-packaged-e2e.ts @@ -0,0 +1,532 @@ +#!/usr/bin/env bun +/** + * Hosted Linux packaged-shell acceptance. + * + * This is deliberately narrower than installed-gate.ts. It extracts, rather than + * installs, the AppImage and deb payloads so a hosted runner never mutates its package + * database or the runner account's real OpenCodex home. What it proves is the common + * packaged path: the real application executable and bundled resources can show a + * window in a session with no tray host, start their bundled sidecar, identify that + * runtime, and drain both processes when the only window closes. + * + * Real dpkg/AppImage installation, elevation, takeover, and in-place updates remain the + * responsibility of installed-gate.ts on an approved disposable GUI runner. + */ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { createServer } from "node:net"; + +export type LinuxBundleFormat = "appimage" | "deb"; + +export interface LinuxE2eOptions { + bundleRoot: string; + reportPath: string; + version: string; +} + +export interface BundleArtifacts { + appimage: string; + deb: string; +} + +interface RuntimeRecord { + pid: number; + port: number; +} + +interface HealthObservation { + status: number; + body: Record; +} + +interface ReservedLoopbackPort { + port: number; + release: () => Promise; +} + +interface FormatReport { + format: LinuxBundleFormat; + artifact: string; + ok: boolean; + durationMs: number; + windowId?: string; + appPid?: number; + runtimePid?: number; + runtimeVersion?: string; + configuredPort?: number; + readyMs?: number; + processTreeRssKiB?: number; + error?: string; + stdoutTail?: string[]; + stderrTail?: string[]; +} + +interface AcceptanceReport { + schema: "opencodex-linux-packaged-e2e/1"; + version: string; + startedAt: string; + finishedAt: string; + ok: boolean; + formats: FormatReport[]; +} + +const READY_DEADLINE_MS = 45_000; +const EXIT_DEADLINE_MS = 30_000; +const POLL_MS = 200; +const LOG_TAIL_LINES = 80; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +function argument(argv: string[], name: string): string | undefined { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +} + +export function parseArguments(argv: string[]): LinuxE2eOptions { + const bundleRoot = argument(argv, "--bundle-root"); + const reportPath = argument(argv, "--report"); + const version = argument(argv, "--version"); + if (!bundleRoot || !reportPath || !version) { + throw new Error("--bundle-root, --report and --version are required"); + } + if (!VERSION.test(version)) throw new Error("--version must be a strict semver"); + return { + bundleRoot: resolve(bundleRoot), + reportPath: resolve(reportPath), + version, + }; +} + +function files(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory) + .map(name => join(directory, name)) + .filter(path => statSync(path).isFile()); +} + +function exactlyOne(paths: string[], label: string): string { + if (paths.length !== 1) { + throw new Error(`expected exactly one ${label}, found ${paths.length}`); + } + return paths[0]!; +} + +export function locateArtifacts(bundleRoot: string): BundleArtifacts { + return { + appimage: exactlyOne( + files(join(bundleRoot, "appimage")).filter(path => path.endsWith(".AppImage")), + "AppImage", + ), + deb: exactlyOne( + files(join(bundleRoot, "deb")).filter(path => path.endsWith(".deb")), + "deb", + ), + }; +} + +function command( + file: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): void { + const result = spawnSync(file, args, { + cwd: options.cwd, + env: options.env, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "no output").trim(); + throw new Error(`${basename(file)} exited ${result.status ?? "without a status"}: ${detail}`); + } +} + +function executableFiles(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory) + .map(name => join(directory, name)) + .filter(path => { + const stat = statSync(path); + return stat.isFile() && (stat.mode & 0o111) !== 0; + }); +} + +export function extractedExecutable( + format: LinuxBundleFormat, + artifact: string, + destination: string, +): string { + mkdirSync(destination, { recursive: true }); + if (format === "appimage") { + command(artifact, ["--appimage-extract"], { cwd: destination }); + const appRun = join(destination, "squashfs-root", "AppRun"); + if (!existsSync(appRun)) throw new Error("AppImage extraction did not produce AppRun"); + return appRun; + } + + command("dpkg-deb", ["--extract", artifact, destination]); + const candidates = executableFiles(join(destination, "usr", "bin")); + return selectDebExecutable(candidates); +} + +export function selectDebExecutable(candidates: string[]): string { + // The package contains the desktop host and its `ocx` sidecar. The sidecar is deliberately + // executable, but it is not the process whose WebView/window lifecycle this acceptance owns. + return exactlyOne( + candidates.filter(candidate => basename(candidate) !== "ocx"), + "deb desktop executable under usr/bin", + ); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function reserveLoopbackPort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("could not reserve a temporary loopback port")); + return; + } + let released = false; + resolvePort({ + port: address.port, + release: async () => { + if (released) return; + released = true; + await new Promise((resolveClose, rejectClose) => { + server.close(error => error ? rejectClose(error) : resolveClose()); + }); + }, + }); + }); + }); +} + +async function waitFor(read: () => T | undefined | Promise, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await read(); + if (value !== undefined) return value; + await sleep(POLL_MS); + } + throw new Error(`condition did not settle within ${timeoutMs}ms`); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +export function readRuntimeRecord(path: string): RuntimeRecord | undefined { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record; + const pid = positiveInteger(parsed.pid); + const port = positiveInteger(parsed.port); + if (pid === undefined || port === undefined || port > 65_535) return undefined; + return { pid, port }; + } catch { + return undefined; + } +} + +export function assertRuntimeRecordPort(record: RuntimeRecord, configuredPort: number): RuntimeRecord { + if (record.port !== configuredPort) { + throw new Error( + `packaged runtime recorded port ${record.port}, expected isolated port ${configuredPort}`, + ); + } + return record; +} + +function processAlive(pid: number | undefined): boolean { + if (pid === undefined) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM"; + } +} + +function processRows(): Array<{ pid: number; ppid: number; rssKiB: number }> { + const result = spawnSync("ps", ["-e", "-o", "pid=,ppid=,rss="], { encoding: "utf8" }); + if (result.status !== 0) return []; + return result.stdout + .trim() + .split(/\r?\n/u) + .map(line => line.trim().split(/\s+/u).map(Number)) + .filter(parts => parts.length === 3 && parts.every(Number.isFinite)) + .map(parts => ({ pid: parts[0]!, ppid: parts[1]!, rssKiB: parts[2]! })); +} + +export function processTreeRssKiB(rootPid: number, rows = processRows()): number { + const selected = new Set([rootPid]); + let changed = true; + while (changed) { + changed = false; + for (const row of rows) { + if (selected.has(row.ppid) && !selected.has(row.pid)) { + selected.add(row.pid); + changed = true; + } + } + } + return rows.filter(row => selected.has(row.pid)).reduce((sum, row) => sum + row.rssKiB, 0); +} + +function xdotoolWindow(): string | undefined { + // WebKit exposes an auxiliary `opencodex-desktop` X11 window before the titled top-level + // `OpenCodex` window. A loose match selected that helper and `windowclose` merely destroyed the + // web process surface, never exercising Tauri's close/drain path. + const result = spawnSync( + "xdotool", + ["search", "--onlyvisible", "--name", "^OpenCodex$"], + { encoding: "utf8" }, + ); + if (result.status !== 0) return undefined; + return result.stdout.trim().split(/\r?\n/u).find(Boolean); +} + +async function health(record: RuntimeRecord): Promise { + try { + const response = await fetch(`http://127.0.0.1:${record.port}/healthz`, { + signal: AbortSignal.timeout(1_000), + cache: "no-store", + }); + const body = await response.json(); + return typeof body === "object" && body !== null + ? { status: response.status, body: body as Record } + : undefined; + } catch { + return undefined; + } +} + +function tail(path: string): string[] { + try { + return readFileSync(path, "utf8").split(/\r?\n/u).filter(Boolean).slice(-LOG_TAIL_LINES); + } catch { + return []; + } +} + +async function stopGroup(child: ChildProcess): Promise { + if (!child.pid || !processAlive(child.pid)) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + try { + await waitFor(() => processAlive(child.pid) ? undefined : true, 5_000); + return; + } catch { + // Escalate only inside the detached process group this test created. + } + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } +} + +async function runFormat( + format: LinuxBundleFormat, + artifact: string, + version: string, + root: string, +): Promise { + const started = Date.now(); + const directory = join(root, format); + const extracted = join(directory, "payload"); + const home = join(directory, "home"); + const opencodexHome = join(home, ".opencodex"); + const codexHome = join(home, ".codex"); + const configHome = join(home, ".config"); + const cacheHome = join(home, ".cache"); + const dataHome = join(home, ".local", "share"); + for (const path of [home, opencodexHome, codexHome, configHome, cacheHome, dataHome]) { + mkdirSync(path, { recursive: true, mode: 0o700 }); + } + const stdoutPath = join(directory, "stdout.log"); + const stderrPath = join(directory, "stderr.log"); + mkdirSync(directory, { recursive: true }); + const stdout = openSync(stdoutPath, "w", 0o600); + const stderr = openSync(stderrPath, "w", 0o600); + let child: ChildProcess | undefined; + let runtimePid: number | undefined; + let configuredPort: number | undefined; + let reservedPort: ReservedLoopbackPort | undefined; + try { + const executable = extractedExecutable(format, artifact, extracted); + reservedPort = await reserveLoopbackPort(); + configuredPort = reservedPort.port; + writeFileSync( + join(opencodexHome, "config.json"), + `${JSON.stringify({ port: configuredPort }, null, 2)}\n`, + { mode: 0o600 }, + ); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: configHome, + XDG_CACHE_HOME: cacheHome, + XDG_DATA_HOME: dataHome, + OPENCODEX_HOME: opencodexHome, + CODEX_HOME: codexHome, + NO_PROXY: "127.0.0.1,localhost", + no_proxy: "127.0.0.1,localhost", + WEBKIT_DISABLE_COMPOSITING_MODE: "1", + }; + // Hold the listener while preparing the isolated home so no unrelated process can claim the + // selected port. Release it only at the spawn boundary; the packaged runtime can then bind it. + await reservedPort.release(); + reservedPort = undefined; + child = spawn(executable, [], { + cwd: dirname(executable), + env, + detached: true, + stdio: ["ignore", stdout, stderr], + }); + if (!child.pid) throw new Error("desktop app did not report a pid"); + const appPid = child.pid; + const windowId = await waitFor(xdotoolWindow, READY_DEADLINE_MS); + const recordPath = join(opencodexHome, "runtime-port.json"); + const record = assertRuntimeRecordPort( + await waitFor(() => readRuntimeRecord(recordPath), READY_DEADLINE_MS), + configuredPort, + ); + runtimePid = record.pid; + let lastHealth: HealthObservation | undefined; + let ready: Record; + try { + ready = await waitFor(async () => { + const observed = await health(record); + if (!observed) return undefined; + lastHealth = observed; + const body = observed.body; + return observed.status >= 200 && observed.status < 300 + && body.service === "opencodex" + && body.pid === record.pid + && body.port === record.port + && body.version === version + ? body + : undefined; + }, READY_DEADLINE_MS); + } catch { + const observed = lastHealth + ? `status ${lastHealth.status}, body ${JSON.stringify(lastHealth.body)}` + : "no readable /healthz response"; + throw new Error(`packaged runtime health identity did not become ready (${observed})`); + } + const readyMs = Date.now() - started; + const rssKiB = processTreeRssKiB(appPid); + + command("xdotool", ["windowclose", windowId]); + await waitFor( + () => !processAlive(appPid) && !processAlive(runtimePid) ? true : undefined, + EXIT_DEADLINE_MS, + ); + return { + format, + artifact: basename(artifact), + ok: true, + durationMs: Date.now() - started, + windowId, + appPid, + runtimePid, + runtimeVersion: typeof ready.version === "string" ? ready.version : undefined, + configuredPort, + readyMs, + processTreeRssKiB: rssKiB, + stdoutTail: tail(stdoutPath), + stderrTail: tail(stderrPath), + }; + } catch (error) { + return { + format, + artifact: basename(artifact), + ok: false, + durationMs: Date.now() - started, + ...(child?.pid ? { appPid: child.pid } : {}), + ...(runtimePid ? { runtimePid } : {}), + ...(configuredPort ? { configuredPort } : {}), + error: error instanceof Error ? error.message : String(error), + stdoutTail: tail(stdoutPath), + stderrTail: tail(stderrPath), + }; + } finally { + await reservedPort?.release(); + if (child) await stopGroup(child); + closeSync(stdout); + closeSync(stderr); + } +} + +export async function runAcceptance(options: LinuxE2eOptions): Promise { + if (process.platform !== "linux") throw new Error("Linux packaged E2E runs only on Linux"); + for (const dependency of ["dpkg-deb", "ps", "xdotool"]) { + const probe = spawnSync("sh", ["-c", `command -v ${dependency}`]); + if (probe.status !== 0) throw new Error(`missing required command: ${dependency}`); + } + if (!process.env.DISPLAY) throw new Error("DISPLAY is required; run under Xvfb"); + + const artifacts = locateArtifacts(options.bundleRoot); + const root = mkdtempSync(join(tmpdir(), "opencodex-linux-e2e-")); + const startedAt = new Date().toISOString(); + let formats: FormatReport[] = []; + try { + formats = [ + await runFormat("appimage", artifacts.appimage, options.version, root), + await runFormat("deb", artifacts.deb, options.version, root), + ]; + } finally { + const report: AcceptanceReport = { + schema: "opencodex-linux-packaged-e2e/1", + version: options.version, + startedAt, + finishedAt: new Date().toISOString(), + ok: formats.length === 2 && formats.every(format => format.ok), + formats, + }; + mkdirSync(dirname(options.reportPath), { recursive: true }); + writeFileSync(options.reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + rmSync(root, { recursive: true, force: true }); + } + return JSON.parse(readFileSync(options.reportPath, "utf8")) as AcceptanceReport; +} + +async function main(): Promise { + const options = parseArguments(process.argv.slice(2)); + const report = await runAcceptance(options); + for (const format of report.formats) { + console.log(`${format.ok ? "PASS" : "FAIL"} ${format.format}: ${format.error ?? `${format.readyMs}ms ready, ${format.processTreeRssKiB} KiB RSS`}`); + } + process.exitCode = report.ok ? 0 : 1; +} + +if (import.meta.main) { + main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 726c72808c8..62b5502f6d4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -993,6 +993,7 @@ "legacy-shell-compat.test.ts": "responses", "live-call-bindings.test.ts": "server", "live-service-manager-guard.test.ts": "service", + "linux-desktop-packaged-e2e.test.ts": "ci-workflows", "local-aside-sync-capability.test.ts": "server", "local-destinations.test.ts": "lib", "local-management-attestation.test.ts": "server", diff --git a/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md b/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md new file mode 100644 index 00000000000..9c512cae17b --- /dev/null +++ b/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md @@ -0,0 +1,12 @@ +# ADR-5493 — decision recorded under "Linux packaged-shell acceptance" + +- Contract owner: [desktop-shell.md](../desktop-shell.md#linux-packaged-shell-acceptance) + +## Decision record + +- 목적과 의도: Make ordinary Linux pull requests prove the package users launch instead of proving only that the Rust shell compiles. +- 기존 구현 및 제약 조건: The Rust-only job used empty sidecar and resource stubs, while the real-install gate needs published releases, operator GUI hooks, and a protected self-hosted runner. Linux keeps Bun as Tauri's external binary through a byte-identity-checked patchelf wrapper, and sequential Linux formats must not share Tauri's patched release binary. +- 검토한 주요 대안: Install deb packages directly on hosted runners; require the privileged installed-artifact gate for every pull request; replace Linux externalBin with a separate resource launcher; or extract both package payloads and exercise their shared runtime path with format-local build roots. +- 선택한 방식: Preserve the existing verified externalBin packaging, build AppImage and deb under independent Cargo targets, stage both outputs read-only, and run the extracted payloads under isolated homes, a loopback port held until spawn, Xvfb, Openbox, and D-Bus. +- 다른 대안 대신 이 방식을 선택한 이유: The selected path covers bundle layout, WebKit startup, the real bundled sidecar, no-tray behavior, and coordinated exit without replacing the already-landed sidecar-integrity boundary, changing the hosted runner's package database, or granting workflow write permissions. +- 장점, 단점 및 영향: Desktop changes gain bounded Linux package acceptance and diagnostic evidence. The lane does not prove dpkg maintainer scripts, desktop integration, elevation, signed updates, or a physical compositor; those remain the installed-artifact gate's responsibility. diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 462406f7970..fee724af8cf 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -230,9 +230,38 @@ marker, which the GUI detects to identify the shell without using IPC. ## Release packaging and updater +### Linux packaged-shell acceptance + +The ordinary hosted Linux lane builds both AppImage and deb bundles with updater artifacts disabled, +extracts each payload into a disposable directory, and boots its real application executable under a +private Xvfb, Openbox, and D-Bus session. Openbox supplies only the window-manager close protocol; +it does not supply a tray host. `desktop/scripts/linux-packaged-e2e.ts` gives each format fresh +`HOME`, `XDG_*`, `CODEX_HOME`, and `OPENCODEX_HOME` roots plus a loopback port held until the app +spawn boundary, then requires a visible OpenCodex window, the bundled sidecar's matching `/healthz` +identity, port and version, and confirmed app plus runtime exit after closing the only window. Its +report records readiness time and whole app-process-tree RSS as evidence; those observations are not +pass/fail budgets until a reviewed cross-platform baseline exists. + +Extraction is intentional. A GitHub-hosted runner is disposable but its package database is still a +shared job resource, and a normal pull request does not need passwordless package installation or GUI +elevation to prove that the packaged executable and resources boot together. The separate +`desktop-installed-gate.yml` remains the authority for real installation, package-manager ownership, +takeover consent, elevation cancellation/acceptance, and in-place updater behavior on explicitly +approved disposable GUI runners. Passing the hosted lane must never be described as passing those +privileged installation flows. + +AppImage and deb are built with independent `CARGO_TARGET_DIR` roots in hosted acceptance and release +jobs, then copied into a read-only staging layout for verification and collection. Tauri patches a +per-format updater marker into the release binary while bundling; sharing one Cargo target lets one +format observe a binary mutated for the other. The isolated roots make the marker and every other +bundler mutation format-local. + +> Decision record: [ADR-5493](decisions/ADR-5493-linux-packaged-shell-acceptance.md) + Linux AppImage packaging uses `desktop/scripts/appimage-patchelf.py` to preserve the compiled Bun CLI when linuxdeploy sets the executable RPATH. Only the exact -AppDir sidecar, still byte-identical to the prepared CLI, is exempt; other ELF +AppDir sidecar under the active `CARGO_TARGET_DIR`, still byte-identical to the +prepared target-matching CLI, is exempt; other ELF operations use the system patchelf. `desktop/scripts/verify-linux-sidecar.sh` extracts the completed AppImage, compares its CLI bytes and runs its version command on the hosted runner before any release asset is collected. diff --git a/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts b/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts new file mode 100644 index 00000000000..5445a75fbd4 --- /dev/null +++ b/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertRuntimeRecordPort, + locateArtifacts, + parseArguments, + processTreeRssKiB, + readRuntimeRecord, + selectDebExecutable, +} from "../../desktop/scripts/linux-packaged-e2e"; +import { repoPath } from "../helpers/repo-root"; + +function temporaryDirectory(): string { + return mkdtempSync(join(tmpdir(), "opencodex-linux-e2e-test-")); +} + +describe("Linux packaged desktop E2E driver", () => { + test("requires an explicit bundle root, report and strict version", () => { + expect(() => parseArguments([])).toThrow("required"); + expect(() => parseArguments([ + "--bundle-root", "/bundles", + "--report", "/report.json", + "--version", "latest", + ])).toThrow("strict semver"); + expect(parseArguments([ + "--bundle-root", "/bundles", + "--report", "/report.json", + "--version", "2.61.0-preview.1", + ]).version).toBe("2.61.0-preview.1"); + }); + + test("requires exactly one AppImage and deb from their bundle directories", () => { + const root = temporaryDirectory(); + try { + mkdirSync(join(root, "appimage")); + mkdirSync(join(root, "deb")); + writeFileSync(join(root, "appimage", "OpenCodex.AppImage"), "appimage"); + writeFileSync(join(root, "deb", "OpenCodex.deb"), "deb"); + expect(locateArtifacts(root)).toEqual({ + appimage: join(root, "appimage", "OpenCodex.AppImage"), + deb: join(root, "deb", "OpenCodex.deb"), + }); + writeFileSync(join(root, "deb", "stale.deb"), "deb"); + expect(() => locateArtifacts(root)).toThrow("exactly one deb"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("selects the deb desktop host without mistaking the ocx sidecar for the app", () => { + expect(selectDebExecutable(["/payload/usr/bin/ocx", "/payload/usr/bin/opencodex-desktop"])) + .toBe("/payload/usr/bin/opencodex-desktop"); + expect(() => selectDebExecutable(["/payload/usr/bin/ocx"])) + .toThrow("expected exactly one deb desktop executable"); + }); + + test("accepts only a complete positive runtime record", () => { + const root = temporaryDirectory(); + try { + const record = join(root, "runtime-port.json"); + writeFileSync(record, JSON.stringify({ pid: 42, port: 10100 })); + expect(readRuntimeRecord(record)).toEqual({ pid: 42, port: 10100 }); + expect(assertRuntimeRecordPort({ pid: 42, port: 10100 }, 10100)).toEqual({ + pid: 42, + port: 10100, + }); + expect(() => assertRuntimeRecordPort({ pid: 42, port: 10101 }, 10100)) + .toThrow("recorded port 10101, expected isolated port 10100"); + for (const invalid of [ + { pid: 0, port: 10100 }, + { pid: 42, port: 0 }, + { pid: 42, port: 65_536 }, + { pid: "42", port: 10100 }, + ]) { + writeFileSync(record, JSON.stringify(invalid)); + expect(readRuntimeRecord(record)).toBeUndefined(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("measures only the selected process tree", () => { + const rows = [ + { pid: 10, ppid: 1, rssKiB: 100 }, + { pid: 11, ppid: 10, rssKiB: 50 }, + { pid: 12, ppid: 11, rssKiB: 25 }, + { pid: 20, ppid: 1, rssKiB: 1_000 }, + ]; + expect(processTreeRssKiB(10, rows)).toBe(175); + expect(processTreeRssKiB(20, rows)).toBe(1_000); + }); + + test("CI scopes the real package build and keeps the E2E unprivileged", () => { + const workflow = Bun.YAML.parse(readFileSync(repoPath(".github", "workflows", "ci.yml"), "utf8")) as { + permissions?: Record; + jobs?: Record; + steps?: Array<{ + name?: string; + uses?: string; + if?: string; + run?: string; + env?: Record; + with?: Record; + }>; + }>; + }; + expect(workflow.permissions).toEqual({ contents: "read" }); + const changes = workflow.jobs?.changes; + expect(changes?.outputs?.desktop).toBe("${{ steps.scope.outputs.desktop }}"); + const filter = changes?.steps?.find(step => step.name === "Detect changed areas"); + const filters = String(filter?.with?.filters ?? ""); + expect(filters).toContain("desktop:"); + expect(filters).toContain("'desktop/**'"); + expect(filters).toContain("'src/**'"); + expect(filters).toContain("'.github/workflows/ci.yml'"); + + const shell = workflow.jobs?.["desktop-shell"]; + expect(shell?.if).toContain("needs.changes.outputs.desktop == 'true'"); + const checkResources = shell?.steps?.find(step => step.name === "Prepare desktop check resources"); + expect(checkResources?.run).toContain("binaries/ocx-"); + expect(checkResources?.run).not.toContain("resources/sidecar/ocx"); + const preserve = shell?.steps?.find(step => step.name === "Preserve the compiled Linux sidecar"); + expect(preserve?.run).toContain("chmod +x desktop/scripts/appimage-patchelf.py"); + const appImageBuild = shell?.steps?.find(step => step.name === "Build Linux AppImage"); + const debBuild = shell?.steps?.find(step => step.name === "Build Linux deb"); + expect(appImageBuild?.env?.CARGO_TARGET_DIR).toContain("opencodex-appimage-target"); + expect(appImageBuild?.env?.PATCHELF).toContain("desktop/scripts/appimage-patchelf.py"); + expect(debBuild?.env?.CARGO_TARGET_DIR).toContain("opencodex-deb-target"); + expect(appImageBuild?.env?.CARGO_TARGET_DIR).not.toBe(debBuild?.env?.CARGO_TARGET_DIR); + const stage = shell?.steps?.find(step => step.name === "Stage isolated Linux bundles"); + expect(stage?.run).toContain("$APPIMAGE_BUNDLE/."); + expect(stage?.run).toContain("$DEB_BUNDLE/."); + expect(stage?.run).toContain('chmod -R a-w "$BUNDLE_ROOT"'); + + const aggregate = workflow.jobs?.ci?.steps?.find(step => step.name === "Assert every job this event requested succeeded"); + expect(aggregate?.env?.CHANGES_DESKTOP).toBe("${{ needs.changes.outputs.desktop }}"); + expect(aggregate?.run).toContain("desktop-shell) echo \"$desktop_shell\""); + + const e2e = shell?.steps?.find(step => step.name === "Run Linux packaged-shell E2E"); + expect(e2e?.if).toBe("needs.changes.outputs.desktop == 'true'"); + expect(e2e?.run).toContain("dbus-run-session -- xvfb-run"); + expect(e2e?.run).toContain("openbox"); + expect(e2e?.run).toContain("linux-packaged-e2e.ts"); + expect(e2e?.run).toContain("opencodex-linux-bundles"); + expect(e2e?.run).not.toContain("sudo"); + expect(e2e?.run).not.toContain("dpkg -i"); + + const upload = shell?.steps?.find(step => step.name === "Upload Linux packaged-shell E2E report"); + expect(upload?.uses).toMatch(/^actions\/upload-artifact@[0-9a-f]{40}$/u); + expect(upload?.if).toContain("always()"); + }); + + test("the driver isolates each package from a runtime already using the default port", () => { + const driver = readFileSync( + repoPath("desktop", "scripts", "linux-packaged-e2e.ts"), + "utf8", + ); + expect(driver).toContain('server.listen(0, "127.0.0.1"'); + expect(driver).toContain('join(opencodexHome, "config.json")'); + expect(driver).toContain("JSON.stringify({ port: configuredPort }"); + expect(driver).not.toContain('port: 10100'); + expect(driver).toContain('["search", "--onlyvisible", "--name", "^OpenCodex$"]'); + }); +}); diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 037b2f65ad3..f6a435f51b4 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -123,6 +123,34 @@ describe("desktop release scripts", () => { } }); + test("collects Linux formats from an explicitly staged isolated bundle root", () => { + const root = temporaryDirectory(); + try { + const bundleRoot = join(root, "isolated-linux-bundles"); + mkdirSync(join(bundleRoot, "appimage"), { recursive: true }); + mkdirSync(join(bundleRoot, "deb"), { recursive: true }); + writeFileSync(join(bundleRoot, "appimage", "OpenCodex.AppImage"), "appimage"); + writeFileSync(join(bundleRoot, "deb", "OpenCodex.deb"), "deb"); + + const files = collectReleaseAssets({ + version: "2.61.0", + target: "x86_64-unknown-linux-gnu", + out: join(root, "release"), + repoRoot: root, + bundleRoot, + }); + + expect(files.map(path => basename(path))).toEqual([ + "OpenCodex-2.61.0-linux-x86_64.AppImage", + "OpenCodex-2.61.0-linux-x86_64.AppImage.sha256", + "OpenCodex-2.61.0-linux-amd64.deb", + "OpenCodex-2.61.0-linux-amd64.deb.sha256", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("rejects ambiguous bundle matches", () => { const root = temporaryDirectory(); try { @@ -408,6 +436,31 @@ describe("the desktop build toolchain carries the bundle-type marker", () => { || (major === minimumCliWithBundlePatch.major && minor! >= minimumCliWithBundlePatch.minor), ).toBe(true); }); + + test("the release workflow gives AppImage and deb independent Cargo targets", () => { + const workflow = Bun.YAML.parse( + readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"), + ) as { + jobs?: Record }>; + }>; + }; + const steps = workflow.jobs?.["package-desktop"]?.steps ?? []; + const appImage = steps.find(step => step.name === "Build Linux AppImage bundle"); + const deb = steps.find(step => step.name === "Build Linux deb bundle"); + expect(appImage?.env?.CARGO_TARGET_DIR).toContain("opencodex-appimage-target"); + expect(deb?.env?.CARGO_TARGET_DIR).toContain("opencodex-deb-target"); + expect(appImage?.env?.CARGO_TARGET_DIR).not.toBe(deb?.env?.CARGO_TARGET_DIR); + expect(appImage?.run).toContain("--bundles appimage"); + expect(deb?.run).toContain("--bundles deb"); + + const stage = steps.find(step => step.name === "Stage isolated Linux release bundles"); + expect(stage?.run).toContain("$APPIMAGE_TARGET/$DESKTOP_TARGET/release/bundle/appimage/."); + expect(stage?.run).toContain("$DEB_TARGET/$DESKTOP_TARGET/release/bundle/deb/."); + expect(stage?.run).toContain('chmod -R a-w "$bundle_root"'); + const collect = steps.find(step => step.run?.includes("collect-release-assets.ts")); + expect(collect?.run).toContain('--bundle-root "$DESKTOP_BUNDLE_ROOT"'); + }); }); describe("widget extension signing", () => { @@ -453,6 +506,13 @@ describe("widget extension signing", () => { const verify = steps.find(step => step.name === "Verify the packaged Linux sidecar"); expect(preserve?.if).toBe("runner.os == 'Linux'"); expect(preserve?.run).toContain("PATCHELF=$GITHUB_WORKSPACE/desktop/scripts/appimage-patchelf.py"); + const wrapper = readFileSync( + repoPath("desktop", "scripts", "appimage-patchelf.py"), + "utf8", + ); + expect(wrapper).toContain('os.environ.get("CARGO_TARGET_DIR"'); + expect(wrapper).toContain("APPDIR_SIDECAR_TAIL"); + expect(wrapper).not.toContain('desktop/src-tauri/target" / triple'); expect(verify?.if).toBe("runner.os == 'Linux'"); expect(verify?.run).toBe("bash desktop/scripts/verify-linux-sidecar.sh"); expect(indexOfStep(preserve!.name!)).toBeLessThan(indexOfStep("Build desktop bundles")); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a62ab80eb1b..7b8e9168d92 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -825,6 +825,7 @@ "legacy-shell-compat.test.ts": "responses", "live-call-bindings.test.ts": "server", "live-service-manager-guard.test.ts": "service", + "linux-desktop-packaged-e2e.test.ts": "ci-workflows", "local-aside-sync-capability.test.ts": "server", "local-destinations.test.ts": "lib", "local-management-attestation.test.ts": "server",