fix(packaging): harden AppImage runtime lifecycle

This commit is contained in:
Ouroboros 2026-08-11 03:41:17 +03:00
parent 42170da034
commit 9e76636571
16 changed files with 260 additions and 48 deletions

View file

@ -705,8 +705,14 @@ jobs:
APPIMAGE="$PWD/dist/Ouroboros-${VERSION}-linux-x86_64.AppImage"
EXTRACT_ROOT="$RUNNER_TEMP/ouroboros-appimage-${GITHUB_RUN_ATTEMPT}"
HOME_DIR="$RUNNER_TEMP/ouroboros-appimage-home"
APP_ROOT="$HOME_DIR/Ouroboros"
DATA_DIR="$APP_ROOT/data"
PID_FILE="$APP_ROOT/ouroboros.pid"
PORT_FILE="$DATA_DIR/state/server_port"
CLI_LOG="$RUNNER_TEMP/ouroboros-appimage-cli.log"
LDD_LOG="$RUNNER_TEMP/ouroboros-appimage-ldd.log"
test -x "$APPIMAGE"
mkdir -p "$EXTRACT_ROOT" "$HOME_DIR"
mkdir -p "$EXTRACT_ROOT" "$HOME_DIR" "$DATA_DIR"
(cd "$EXTRACT_ROOT" && "$APPIMAGE" --appimage-extract >/dev/null)
APPDIR="$EXTRACT_ROOT/squashfs-root"
test -x "$APPDIR/AppRun"
@ -716,8 +722,81 @@ jobs:
test -f "$APPDIR/usr/lib/ouroboros/_internal/repo_bundle_manifest.json"
python scripts/fetch_claudexor_runtime.py --verify-only \
--output-dir "$APPDIR/usr/lib/ouroboros/_internal/claudexor-runtime"
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" APPDIR="$APPDIR" \
"$APPDIR/AppRun" --cli --help >/dev/null
VERSION_OUTPUT="$(APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE" --version)"
test "$VERSION_OUTPUT" = "Ouroboros $VERSION"
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" \
APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE" --cli --help >/dev/null
cleanup_appimage_smoke() {
if [ -s "$PID_FILE" ]; then
pid="$(tr -d '[:space:]' < "$PID_FILE")"
kill -TERM "$pid" 2>/dev/null || true
fi
}
trap cleanup_appimage_smoke EXIT
# `run --start` must launch a fresh outer AppImage runtime, not the
# raw PyInstaller binary inside the first temporary extraction. The
# deliberately empty prompt exits the CLI after readiness without
# enqueueing work; the desktop runtime must remain alive afterwards.
set +e
env -u DISPLAY -u WAYLAND_DISPLAY \
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" BROWSER=/bin/true \
OPENROUTER_API_KEY=ci-placeholder OUROBOROS_MAX_WORKERS=1 \
APPIMAGE_EXTRACT_AND_RUN=1 \
"$APPIMAGE" --cli run --start --detach >"$CLI_LOG" 2>&1
CLI_RC=$?
set -e
test "$CLI_RC" -eq 2
for _ in $(seq 1 180); do
if [ -s "$PID_FILE" ] && [ -s "$PORT_FILE" ]; then
PORT="$(tr -d '[:space:]' < "$PORT_FILE")"
if curl --fail --silent "http://127.0.0.1:$PORT/api/health" >/dev/null; then
break
fi
fi
sleep 0.5
done
test -s "$PID_FILE"
test -s "$PORT_FILE"
PORT="$(tr -d '[:space:]' < "$PORT_FILE")"
curl --fail --silent "http://127.0.0.1:$PORT/api/health" \
| jq -e --arg version "$VERSION" '.version == $version' >/dev/null
curl --fail --silent "http://127.0.0.1:$PORT/api/state" \
| jq -e '.supervisor_ready == true' >/dev/null
LAUNCHER_PID="$(tr -d '[:space:]' < "$PID_FILE")"
kill -0 "$LAUNCHER_PID"
LAUNCHER_EXE="$(readlink "/proc/$LAUNCHER_PID/exe")"
test -e "$LAUNCHER_EXE"
ldd "$LAUNCHER_EXE" >"$LDD_LOG"
if grep -F "not found" "$LDD_LOG"; then
echo "AppImage launcher has unresolved shared libraries" >&2
exit 1
fi
APPIMAGE_RUNTIME_ROOT="${LAUNCHER_EXE%/usr/lib/ouroboros/Ouroboros}"
kill -TERM "$LAUNCHER_PID"
for _ in $(seq 1 120); do
if ! kill -0 "$LAUNCHER_PID" 2>/dev/null; then
break
fi
sleep 0.5
done
if kill -0 "$LAUNCHER_PID" 2>/dev/null; then
echo "AppImage launcher did not shut down after SIGTERM" >&2
exit 1
fi
for _ in $(seq 1 60); do
[ ! -e "$APPIMAGE_RUNTIME_ROOT" ] && break
sleep 0.5
done
test ! -e "$APPIMAGE_RUNTIME_ROOT"
test ! -e "$PID_FILE"
trap - EXIT
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" \
OUROBOROS_DATA_DIR="$RUNNER_TEMP/ouroboros-appimage-claudexor-smoke" \
OUROBOROS_BUNDLE_DIR="$APPDIR/usr/lib/ouroboros/_internal" \
@ -786,7 +865,9 @@ jobs:
--commit "$GITHUB_SHA" --tag "$GITHUB_REF_NAME" \
--check embedded_repo_bundle --check embedded_claudexor_runtime \
--check packaged_cli_help --check appimage_extract_and_run \
--check appimage_metadata
--check appimage_metadata --check product_version \
--check browser_fallback_start --check gateway_readiness \
--check clean_shutdown --check shared_libraries
- name: Install digest-pinned Syft
id: syft

View file

@ -47,15 +47,16 @@ To run tasks, configure at least one supported remote provider API key or a loca
- **Debian / Ubuntu / Astra Linux x86_64:** when the selected release lists `ouroboros_<version>_amd64.deb`, download it and run `sudo apt install ./ouroboros_<version>_amd64.deb`. It installs Git as a package dependency, installs Ouroboros to `/opt/ouroboros`, puts `ouroboros` on `PATH`, and adds a desktop entry.
- **Fedora / RHEL x86_64:** when listed, download `ouroboros-<version>-1.x86_64.rpm` and run `sudo dnf install ./ouroboros-<version>-1.x86_64.rpm`. Same layout and Git dependency as the `.deb`.
- **RED OS 8 x86_64:** when listed, download `ouroboros-<version>-1.red80.x86_64.rpm` and run `sudo dnf install ./ouroboros-<version>-1.red80.x86_64.rpm`. It carries the `red80` release tag. CI attempts non-blocking install-and-run smokes on Astra Linux 1.8 and RED OS 8; inspect the tagged workflow run for their outcome.
- **Other Linux x86_64:** from the [latest stable release](https://github.com/razzant/ouroboros/releases/latest), download `Ouroboros-<version>-linux-x86_64.AppImage`, make it executable, and run it. Use `./Ouroboros-<version>-linux-x86_64.AppImage --cli <args>` for the bundled CLI. The tarball remains available for extraction-based installs.
- **Other Linux x86_64:** from the [selected release](https://github.com/razzant/ouroboros/releases), use `Ouroboros-<version>-linux-x86_64.AppImage` when listed: make it executable and run it, or pass `--cli <args>` for the bundled CLI. Git must already be installed. If that release does not list an AppImage, use the extraction-friendly tarball.
- **Windows x64:** from the [latest stable release](https://github.com/razzant/ouroboros/releases/latest), download `Ouroboros-<version>-windows-x64.zip`, extract it, and run `Ouroboros\Ouroboros.exe`. The optional CLI installer is `Ouroboros\bin\install-ouroboros-cli.cmd`.
Prerelease artifacts stay on their tag pages; `/releases/latest` points to the latest stable release. If bundled browser tools on Linux need host libraries, run `./Ouroboros/python-standalone/bin/python3 -m playwright install-deps chromium webkit`. See the [full install and verification guide](https://ouroboros-agent.ai/install/) for source setup and release proof files.
Prerelease artifacts stay on their tag pages; `/releases/latest` points to the latest stable release. If bundled browser tools on Linux need host libraries, run `./Ouroboros/_internal/python-standalone/bin/python3 -m playwright install-deps chromium webkit`. See the [full install and verification guide](https://ouroboros-agent.ai/install/) for source setup and release proof files.
#### Install the Linux AppImage
An AppImage is a portable executable, so user-level installation means copying
it to a stable path and making it executable; it does not need root access:
When a release lists the AppImage, user-level installation means copying the
portable executable to a stable path and making it executable; it does not need
root access. Ouroboros bootstrap still requires Git on the host:
```bash
VERSION=x.y.z
@ -86,7 +87,7 @@ your distribution:
```bash
"$HOME/Applications/Ouroboros.AppImage" --appimage-extract
./squashfs-root/usr/lib/ouroboros/python-standalone/bin/python3 \
./squashfs-root/usr/lib/ouroboros/_internal/python-standalone/bin/python3 \
-m playwright install-deps chromium webkit
```
@ -297,7 +298,7 @@ bash scripts/download_python_standalone.sh
bash build_linux.sh
```
Outputs: `dist/Ouroboros-<VERSION>-linux-<arch>.AppImage` and the extraction-friendly `dist/Ouroboros-<VERSION>-linux-<arch>.tar.gz`. Run the AppImage after `chmod +x`, or pass `--cli` to reach its bundled CLI. If FUSE is unavailable, set `APPIMAGE_EXTRACT_AND_RUN=1` when launching it. The tarball contains `Ouroboros/bin/install-ouroboros-cli`. If bundled browser tools need host libraries, run `./Ouroboros/python-standalone/bin/python3 -m playwright install-deps chromium webkit` from the extracted tarball.
Outputs: `dist/Ouroboros-<VERSION>-linux-<arch>.AppImage` and the extraction-friendly `dist/Ouroboros-<VERSION>-linux-<arch>.tar.gz`. The AppImage needs host Git; run it after `chmod +x`, or pass `--cli` to reach its bundled CLI. If FUSE is unavailable, set `APPIMAGE_EXTRACT_AND_RUN=1` when launching it. The tarball contains `./Ouroboros/bin/install-ouroboros-cli`. If bundled browser tools need host libraries, run `./Ouroboros/_internal/python-standalone/bin/python3 -m playwright install-deps chromium webkit` from the extracted tarball.
On a build host where system packages are managed separately, set `OUROBOROS_SKIP_PLAYWRIGHT_INSTALL_DEPS=1`; Chromium and WebKit are still downloaded and bundled, but the build does not invoke `sudo` to install host libraries.

View file

@ -273,6 +273,8 @@ scripts/claudexor_platform_smoke.py ← the platform gate's smoke runner (daemon
scripts/fetch_claudexor_runtime.py ← release-build fetcher for the pinned managed Claudexor runtime archive (claudexor_runtime_pin.json is the SSOT)
build.sh ← macOS build (PyInstaller → .dmg)
build_linux.sh ← Linux build (portable-Python PyInstaller → .AppImage + .tar.gz)
scripts/build_appimage.sh ← AppDir assembly plus digest-pinned appimagetool and embedded type-2 runtime
packaging/appimage/ ← AppRun desktop/CLI dispatch and Linux desktop metadata
scripts/build_linux_packages.sh ← Wraps the x86_64 Linux payload into dependency-declaring `.deb`, generic `.rpm`, and RED OS 8 `.red80.rpm` assets
scripts/smoke_linux_packages.sh ← Release-gating `apt`/`dnf` CLI + desktop-launcher smoke on Ubuntu 22.04/Fedora plus a non-blocking vendor-registry lane for Astra Linux/RED OS
build_windows.ps1 ← Windows build (PyInstaller → .zip)
@ -1947,7 +1949,7 @@ Claude Runtime Status appears when an Anthropic key exists or when backend/runti
The local `ouroboros-stable` ref also remains a recovery fallback maintained by explicit promotion; that local role does not select the official QA feed. Colab seeds it from the already validated shared Stable release when possible and otherwise from the selected validated channel, then leaves it pinned until promotion. Launcher metadata describes bootstrap provenance only. Runtime status, preflight, Colab bootstrap, and apply resolve the selected channel and exact fetched SHA themselves, so an older frozen launcher cannot silently redirect updates. Stable additionally requires the shared plain release tag; QA and Development do not use version comparison as an admission gate.
The main CI workflow has five roles: fork-safe quick checks with no provider secrets; the full cross-platform matrix; provider integration when secrets exist; official-skill install, preflight, review, dependency, and keyless execution smoke; and tag-triggered build/release. Secret-bearing skill review runs before any step that imports downloaded plugin code, and a missing required key is red rather than skipped. Release jobs build three platform archives plus three native Linux package assets, verify each final asset, produce checksums, SBOMs and source-bound attestations, and recheck the remote annotated tag against the event SHA before draft creation and publication.
The main CI workflow has five roles: fork-safe quick checks with no provider secrets; the full cross-platform matrix; provider integration when secrets exist; official-skill install, preflight, review, dependency, and keyless execution smoke; and tag-triggered build/release. Secret-bearing skill review runs before any step that imports downloaded plugin code, and a missing required key is red rather than skipped. Release jobs build three platform archives, an AppImage, and three native Linux package assets, verify each final asset, produce checksums, SBOMs and source-bound attestations, and recheck the remote annotated tag against the event SHA before draft creation and publication.
Quick pull-request jobs are read-only and never use `pull_request_target`. The live catalog skill lane is intentionally a release dependency: it proves that the published payloads still install on this runtime, while keeping provider credentials out of every process that imports payload code. This external-service dependency is an explicit owner trade-off rather than an accidental source of flaky authority.
@ -1955,11 +1957,11 @@ The separate Scorecard workflow runs on `main` pushes and weekly. It pins every
### Build scripts
`build.sh`, `build_linux.sh`, `scripts/build_linux_packages.sh`, `scripts/smoke_linux_packages.sh`, `build_windows.ps1`, and `scripts/build_repo_bundle.py` are release-invariant owners. Linux PyInstaller runs under the same pinned portable Python shipped in the payload, so its bundled libpython keeps the payload's glibc floor instead of inheriting the release runner's newer ABI. The native Linux builder wraps that already-built x86_64 payload without replacing its runtime: package metadata declares the external Git required by bootstrap, while the bundled Python, Node, and browser remain under `/opt/ouroboros`. It prefers hardlinks in an output-local stage and falls back to one payload copy when hardlinking is unavailable. The release-gating smoke installs through `apt` or `dnf`, proves Git resolution, the desktop files, the real packaged CLI, and a bounded desktop-launcher start on Ubuntu 22.04/Fedora 42; Astra Linux and RED OS vendor-image runs remain explicit informational evidence because third-party registry availability cannot block publication. The macOS image keeps the explicit app, Applications symlink, and optional CLI installer layout; final-image verification checks the real symlink target. Release tag prerequisite: `scripts/build_repo_bundle.py` is the release-tag SSOT and verifies the annotated `v$(cat VERSION)` tag points at `HEAD` before packaging.
`build.sh`, `build_linux.sh`, `scripts/build_appimage.sh`, `scripts/build_linux_packages.sh`, `scripts/smoke_linux_packages.sh`, `build_windows.ps1`, and `scripts/build_repo_bundle.py` are release-invariant owners. Linux PyInstaller runs under the same pinned portable Python shipped in the payload, so its bundled libpython keeps the payload's glibc floor instead of inheriting the release runner's newer ABI. The AppImage builder wraps that payload with digest-pinned tool and runtime bytes; the native Linux builder wraps the same x86_64 payload without replacing its runtime. Native package metadata declares the external Git required by bootstrap, while the bundled Python, Node, and browser remain under `/opt/ouroboros`. It prefers hardlinks in an output-local stage and falls back to one payload copy when hardlinking is unavailable. The release-gating smoke installs through `apt` or `dnf`, proves Git resolution, the desktop files, the real packaged CLI, and a bounded desktop-launcher start on Ubuntu 22.04/Fedora 42; Astra Linux and RED OS vendor-image runs remain explicit informational evidence because third-party registry availability cannot block publication. The macOS image keeps the explicit app, Applications symlink, and optional CLI installer layout; final-image verification checks the real symlink target. Release tag prerequisite: `scripts/build_repo_bundle.py` is the release-tag SSOT and verifies the annotated `v$(cat VERSION)` tag points at `HEAD` before packaging.
Platform builds precompile bundled Python with unchecked-hash bytecode. Sealing valid bytecode prevents runtime `__pycache__` writes from invalidating a macOS signature; runtime children also route caches outside the bundle. When signing is enabled, hardened runtime, notarization, xattr hygiene, and strict verification remain part of the stable-release path. Prerelease artifacts may intentionally be unsigned, and their evidence must report the actual signing state rather than imply notarization. Linux and Windows use the same precompile for startup parity without a macOS seal.
The release proof begins with the final DMG, AppImage, tarball, or ZIP rather than its staging directory. Each platform shard checks the embedded repository bundle, packaged CLI, and managed Claudexor seed plus Node by starting the owned daemon, completing a fixture task, and verifying an identity-bound stop. The Linux AppImage is extracted without FUSE for metadata, CLI, runtime, and SBOM checks, while its mounted payload remains read-only at runtime. The macOS smoke also verifies its Finder installation surfaces. On Linux, the proven tarball payload is also wrapped into the three native packages; each receives its own digest-bound package-manager smoke receipt, including the bounded desktop-launcher start, and provenance attestation. A digest-pinned Syft build produces CycloneDX inventories from extracted payload bytes; the tarball inventory is reused for the three native wrappers instead of rescanning identical bytes. GitHub SBOM attestations bind the relevant payload inventory to each final asset. The release job accepts only the seven expected release assets, recalculates their digests, verifies both predicate types, writes the checksum/evidence capsule, and rechecks the remote tag immediately before publication. Signing credentials remain step-scoped and are absent from SBOM and attestation steps.
The release proof begins with the final DMG, AppImage, tarball, or ZIP rather than its staging directory. Each platform shard checks the embedded repository bundle, packaged CLI, and managed Claudexor seed plus Node by starting the owned daemon, completing a fixture task, and verifying an identity-bound stop. The Linux AppImage is extracted for metadata and SBOM inspection, then run through the FUSE-independent runtime to prove exact version output, CLI dispatch, browser-fallback readiness, payload lifetime after CLI-started relaunch, main-executable libraries, and clean shutdown; its payload remains read-only at runtime. This is browser-fallback evidence, not a claim that the packaged payload contains a native GTK/Qt backend. The macOS smoke also verifies its Finder installation surfaces. On Linux, the proven tarball payload is also wrapped into the three native packages; each receives its own digest-bound package-manager smoke receipt, including the bounded desktop-launcher start, and provenance attestation. A digest-pinned Syft build produces CycloneDX inventories from extracted payload bytes; the tarball inventory is reused for the three native wrappers instead of rescanning identical bytes. GitHub SBOM attestations bind the relevant payload inventory to each final asset. The release job accepts only the seven expected release assets, recalculates their digests, verifies both predicate types, writes the checksum/evidence capsule, and rechecks the remote tag immediately before publication. Signing credentials remain step-scoped and are absent from SBOM and attestation steps.
### Docker

View file

@ -1657,8 +1657,12 @@ floating Claudexor npm package. The macOS check also requires the
payload, and an arm64 app executable.
Linux additionally emits an AppImage built by a version- and digest-pinned
`appimagetool`. CI extracts it without FUSE and independently verifies its
desktop metadata, embedded repository, CLI, and managed Claudexor runtime.
`appimagetool` with a separately SHA-pinned embedded type-2 runtime. CI extracts
it for metadata and SBOM inspection, then uses real extract-and-run invocations
to verify product version, CLI dispatch, the browser-fallback launcher, gateway
readiness, payload lifetime after `run --start`, shared libraries, and graceful
shutdown. This smoke deliberately makes no native GTK/Qt claim: packaged native
webview coverage remains a separate Linux distribution contract.
`OUROBOROS_SKIP_PLAYWRIGHT_INSTALL_DEPS=1` is only a local-builder escape hatch
for hosts whose system packages are managed separately: it skips Playwright's
interactive host-library installation, not browser-binary bundling, and a build

View file

@ -32,6 +32,13 @@
"format": "tar.gz",
"nameTemplate": "Ouroboros-{version}-linux-x86_64.tar.gz"
},
{
"platform": "linux",
"architecture": "x86_64",
"format": "AppImage",
"availability": "per-release",
"nameTemplate": "Ouroboros-{version}-linux-x86_64.AppImage"
},
{
"platform": "linux",
"architecture": "x86_64",

View file

@ -60,11 +60,11 @@
<h2>Linux and Windows</h2>
<div class="download-grid download-grid-two">
<article><h2>Linux</h2><p>x86_64 Linux.</p><a class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/latest">Open latest release</a><p>Use a native <code>.deb</code> or <code>.rpm</code> when listed. Otherwise copy the AppImage to <code>~/Applications/Ouroboros.AppImage</code>, make it executable, and run it; no root install is needed. Pass <code>--cli</code> for the bundled CLI, or set <code>APPIMAGE_EXTRACT_AND_RUN=1</code> when FUSE is unavailable. The tarball remains available for extraction-based installs.</p></article>
<article><h2>Linux</h2><p>x86_64 Linux.</p><a class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/latest">Open latest release</a><p>When that release lists native packages, use its <code>.deb</code> or <code>.rpm</code>. If it does not list a native package, use the AppImage when listed: copy it to <code>~/Applications/Ouroboros.AppImage</code>, make it executable, and run it. Git must already be installed. Pass <code>--cli</code> for the bundled CLI, or set <code>APPIMAGE_EXTRACT_AND_RUN=1</code> when FUSE is unavailable. If that release lists neither option, download the tarball.</p></article>
<article><h2>Windows</h2><p>64-bit Windows.</p><a class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/latest">Open latest release</a><p>Download the Windows ZIP, extract it, and run <code>Ouroboros.exe</code>. The CLI installer is in <code>Ouroboros\bin\</code>.</p></article>
</div>
<p>Prerelease artifacts stay on their tag pages; the latest-release page points to the latest stable release. The AppImage bundles Chromium and WebKit binaries, but distro shared libraries remain host dependencies. Prefer the native package where available; otherwise extract the AppImage and run <code>./squashfs-root/usr/lib/ouroboros/python-standalone/bin/python3 -m playwright install-deps chromium webkit</code> to identify and install the required host packages.</p>
<p>Prerelease artifacts stay on their tag pages; the latest-release page points to the latest stable release. The AppImage bundles Chromium and WebKit binaries, but distro shared libraries remain host dependencies. Prefer the native package where available; otherwise extract the AppImage and run <code>./squashfs-root/usr/lib/ouroboros/_internal/python-standalone/bin/python3 -m playwright install-deps chromium webkit</code> to identify and install the required host packages.</p>
<h2>Run from source</h2>
<p>Source mode needs Python 3.10 or newer and Git.</p>

View file

@ -401,6 +401,15 @@ def _launch_desktop_app(runtime: PackagedRuntime) -> None:
def _desktop_app_path(bundle_root: pathlib.Path) -> pathlib.Path | None:
if not IS_MACOS and not IS_WINDOWS:
# Inside an AppImage, the inner PyInstaller launcher lives on a mount (or
# temporary extract tree) owned by the outer runtime. Starting that raw
# binary would let the CLI exit and release the payload underneath the
# detached desktop process. Relaunch the stable AppImage path instead so
# its own runtime owns the payload for the launcher's whole lifetime.
appimage = pathlib.Path(os.environ.get("APPIMAGE", "").strip())
if appimage.is_file():
return appimage
for parent in (bundle_root, *bundle_root.parents):
if IS_MACOS and parent.suffix == ".app":
return parent

View file

@ -9,6 +9,18 @@ PAYLOAD="$APPDIR/usr/lib/ouroboros"
# and cache roots; expose the same explicit bundle root to child processes.
export OUROBOROS_BUNDLE_DIR="$PAYLOAD/_internal"
if [ "${1:-}" = "--version" ]; then
VERSION_FILE="$OUROBOROS_BUNDLE_DIR/VERSION"
if [ ! -s "$VERSION_FILE" ]; then
echo "Ouroboros version metadata is missing" >&2
exit 1
fi
VERSION=
IFS= read -r VERSION < "$VERSION_FILE" || true
printf 'Ouroboros %s\n' "$VERSION"
exit 0
fi
if [ "${1:-}" = "--cli" ]; then
shift
exec "$PAYLOAD/bin/ouroboros" "$@"

View file

@ -2,27 +2,11 @@
set -euo pipefail
VERSION=$(tr -d '[:space:]' < VERSION)
ARCH=$(uname -m)
DIST_DIR=${OUROBOROS_DIST_DIR:-dist}
PAYLOAD_DIR="$DIST_DIR/Ouroboros"
APPDIR=${OUROBOROS_APPDIR:-$DIST_DIR/Ouroboros.AppDir}
OUTPUT="$DIST_DIR/Ouroboros-${VERSION}-linux-${ARCH}.AppImage"
TOOL_VERSION=1.9.1
case "$ARCH" in
x86_64)
TOOL_ARCH=x86_64
TOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
;;
aarch64)
TOOL_ARCH=aarch64
TOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158
;;
*)
echo "Unsupported AppImage architecture: $ARCH" >&2
exit 1
;;
esac
RUNTIME_VERSION=20251108
if [ ! -x "$PAYLOAD_DIR/Ouroboros" ]; then
echo "ERROR: PyInstaller payload not found at $PAYLOAD_DIR/Ouroboros" >&2
@ -49,24 +33,59 @@ if [ "${1:-}" = "--appdir-only" ]; then
exit 0
fi
ARCH=$(uname -m)
OUTPUT="$DIST_DIR/Ouroboros-${VERSION}-linux-${ARCH}.AppImage"
case "$ARCH" in
x86_64)
TOOL_ARCH=x86_64
TOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d
;;
aarch64)
TOOL_ARCH=aarch64
TOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158
RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444
;;
*)
echo "Unsupported AppImage architecture: $ARCH" >&2
exit 1
;;
esac
TOOL_CACHE=${APPIMAGE_TOOL_CACHE_DIR:-${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/ouroboros/appimage}
TOOL="$TOOL_CACHE/appimagetool-${TOOL_VERSION}-${TOOL_ARCH}.AppImage"
TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/${TOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage"
RUNTIME="$TOOL_CACHE/type2-runtime-${RUNTIME_VERSION}-${TOOL_ARCH}"
RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/${RUNTIME_VERSION}/runtime-${TOOL_ARCH}"
mkdir -p "$TOOL_CACHE"
if [ ! -f "$TOOL" ]; then
curl --fail --location --silent --show-error "$TOOL_URL" --output "$TOOL.tmp"
mv "$TOOL.tmp" "$TOOL"
fi
ACTUAL_SHA256=$(sha256sum "$TOOL" | awk '{print $1}')
if [ "$ACTUAL_SHA256" != "$TOOL_SHA256" ]; then
echo "appimagetool SHA256 mismatch: expected $TOOL_SHA256, got $ACTUAL_SHA256" >&2
exit 1
fi
fetch_verified() {
local destination="$1" url="$2" expected="$3" actual tmp
if [ -f "$destination" ]; then
actual=$(sha256sum "$destination" | awk '{print $1}')
if [ "$actual" = "$expected" ]; then
return 0
fi
fi
tmp="${destination}.tmp.$$"
rm -f "$tmp"
curl --fail --location --silent --show-error "$url" --output "$tmp"
actual=$(sha256sum "$tmp" | awk '{print $1}')
if [ "$actual" != "$expected" ]; then
echo "SHA256 mismatch for $(basename "$destination"): expected $expected, got $actual" >&2
rm -f "$tmp"
return 1
fi
mv -f "$tmp" "$destination"
}
fetch_verified "$TOOL" "$TOOL_URL" "$TOOL_SHA256"
fetch_verified "$RUNTIME" "$RUNTIME_URL" "$RUNTIME_SHA256"
chmod +x "$TOOL"
rm -f "$OUTPUT"
ARCH="$TOOL_ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" "$APPDIR" "$OUTPUT"
ARCH="$TOOL_ARCH" APPIMAGE_EXTRACT_AND_RUN=1 \
"$TOOL" --runtime-file "$RUNTIME" "$APPDIR" "$OUTPUT"
chmod +x "$OUTPUT"
rm -rf "$APPDIR"

View file

@ -62,7 +62,17 @@ REQUIRED_SMOKE_CHECKS = {
),
"linux-x86_64": COMMON_SMOKE_CHECKS,
"linux-appimage-x86_64": COMMON_SMOKE_CHECKS
| frozenset({"appimage_extract_and_run", "appimage_metadata"}),
| frozenset(
{
"appimage_extract_and_run",
"appimage_metadata",
"browser_fallback_start",
"clean_shutdown",
"gateway_readiness",
"product_version",
"shared_libraries",
}
),
"linux-deb-amd64": PACKAGE_SMOKE_CHECKS,
"linux-rpm-x86_64": PACKAGE_SMOKE_CHECKS,
"linux-rpm-red80-x86_64": PACKAGE_SMOKE_CHECKS,
@ -269,7 +279,8 @@ def _release_notes(
"",
"On Linux, `sudo apt install ./ouroboros_*_amd64.deb` or `sudo dnf install ./ouroboros-*.x86_64.rpm`"
" installs to `/opt/ouroboros` and puts `ouroboros` on `PATH`; RED OS 8 has its own"
" `.red80.x86_64.rpm`, and the `.tar.gz` stays available for distributions without dpkg or rpm.",
" `.red80.x86_64.rpm`. The AppImage runs from a user-writable path when listed and host Git"
" is installed; the `.tar.gz` stays available for extraction-based installs.",
"",
"## Release proof",
"",

View file

@ -60,11 +60,11 @@
<h2>Linux and Windows</h2>
<div class="download-grid download-grid-two">
<article><h2>Linux</h2><p>x86_64 Linux.</p><a class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/latest">Open latest release</a><p>Use a native <code>.deb</code> or <code>.rpm</code> when listed. Otherwise copy the AppImage to <code>~/Applications/Ouroboros.AppImage</code>, make it executable, and run it; no root install is needed. Pass <code>--cli</code> for the bundled CLI, or set <code>APPIMAGE_EXTRACT_AND_RUN=1</code> when FUSE is unavailable. The tarball remains available for extraction-based installs.</p></article>
<article><h2>Linux</h2><p>x86_64 Linux.</p><a class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/latest">Open latest release</a><p>When that release lists native packages, use its <code>.deb</code> or <code>.rpm</code>. If it does not list a native package, use the AppImage when listed: copy it to <code>~/Applications/Ouroboros.AppImage</code>, make it executable, and run it. Git must already be installed. Pass <code>--cli</code> for the bundled CLI, or set <code>APPIMAGE_EXTRACT_AND_RUN=1</code> when FUSE is unavailable. If that release lists neither option, download the tarball.</p></article>
<article><h2>Windows</h2><p>64-bit Windows.</p><a class="btn btn-primary" href="https://github.com/razzant/ouroboros/releases/latest">Open latest release</a><p>Download the Windows ZIP, extract it, and run <code>Ouroboros.exe</code>. The CLI installer is in <code>Ouroboros\bin\</code>.</p></article>
</div>
<p>Prerelease artifacts stay on their tag pages; the latest-release page points to the latest stable release. The AppImage bundles Chromium and WebKit binaries, but distro shared libraries remain host dependencies. Prefer the native package where available; otherwise extract the AppImage and run <code>./squashfs-root/usr/lib/ouroboros/python-standalone/bin/python3 -m playwright install-deps chromium webkit</code> to identify and install the required host packages.</p>
<p>Prerelease artifacts stay on their tag pages; the latest-release page points to the latest stable release. The AppImage bundles Chromium and WebKit binaries, but distro shared libraries remain host dependencies. Prefer the native package where available; otherwise extract the AppImage and run <code>./squashfs-root/usr/lib/ouroboros/_internal/python-standalone/bin/python3 -m playwright install-deps chromium webkit</code> to identify and install the required host packages.</p>
<h2>Run from source</h2>
<p>Source mode needs Python 3.10 or newer and Git.</p>

View file

@ -32,6 +32,13 @@
"format": "tar.gz",
"nameTemplate": "Ouroboros-{version}-linux-x86_64.tar.gz"
},
{
"platform": "linux",
"architecture": "x86_64",
"format": "AppImage",
"availability": "per-release",
"nameTemplate": "Ouroboros-{version}-linux-x86_64.AppImage"
},
{
"platform": "linux",
"architecture": "x86_64",

View file

@ -4,8 +4,13 @@ import os
import pathlib
import subprocess
import pytest
REPO = pathlib.Path(__file__).resolve().parents[1]
pytestmark = pytest.mark.skipif(
os.name == "nt", reason="AppImage packaging is POSIX-only"
)
def test_appdir_layout_wraps_the_existing_pyinstaller_payload(tmp_path: pathlib.Path):
@ -18,6 +23,10 @@ def test_appdir_layout_wraps_the_existing_pyinstaller_payload(tmp_path: pathlib.
internal = payload / "_internal"
internal.mkdir()
(internal / "repo.bundle").write_bytes(b"bundle")
(internal / "VERSION").write_text("6.96.2\n", encoding="utf-8")
embedded_python = internal / "python-standalone/bin/python3"
embedded_python.parent.mkdir(parents=True)
embedded_python.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
appdir = tmp_path / "Ouroboros.AppDir"
env = os.environ.copy()
@ -37,10 +46,22 @@ def test_appdir_layout_wraps_the_existing_pyinstaller_payload(tmp_path: pathlib.
assert (appdir / "ouroboros.png").is_file()
assert (appdir / "usr/lib/ouroboros/Ouroboros").is_file()
assert (appdir / "usr/lib/ouroboros/_internal/repo.bundle").is_file()
assert (
appdir / "usr/lib/ouroboros/_internal/python-standalone/bin/python3"
).is_file()
desktop = (appdir / "ouroboros.desktop").read_text(encoding="utf-8")
assert "Exec=Ouroboros" in desktop
assert "Icon=ouroboros" in desktop
version = subprocess.run(
[str(appdir / "AppRun"), "--version"],
env={**os.environ, "APPDIR": str(appdir)},
check=True,
capture_output=True,
text=True,
)
assert version.stdout.strip() == "Ouroboros 6.96.2"
def test_apprun_exposes_cli_without_writing_to_the_mount(tmp_path: pathlib.Path):
appdir = tmp_path / "AppDir"
@ -63,3 +84,14 @@ def test_apprun_exposes_cli_without_writing_to_the_mount(tmp_path: pathlib.Path)
text=True,
)
assert result.stdout.strip() == str(appdir / "usr/lib/ouroboros/_internal")
def test_appimage_builder_pins_tool_and_embedded_runtime():
script = (REPO / "scripts/build_appimage.sh").read_text(encoding="utf-8")
assert "RUNTIME_VERSION=20251108" in script
assert "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d" in script
assert "00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444" in script
assert "releases/download/${RUNTIME_VERSION}/runtime-${TOOL_ARCH}" in script
assert 'fetch_verified "$RUNTIME" "$RUNTIME_URL" "$RUNTIME_SHA256"' in script
assert '"$TOOL" --runtime-file "$RUNTIME" "$APPDIR" "$OUTPUT"' in script

View file

@ -55,6 +55,20 @@ def test_packaged_cli_run_start_launches_desktop_and_strips_start(tmp_path, monk
assert inner["args"] == ["run", "2+2?"]
def test_packaged_cli_linux_relaunches_outer_appimage(tmp_path, monkeypatch):
from ouroboros import packaged_cli
appimage = tmp_path / "Ouroboros.AppImage"
appimage.write_bytes(b"appimage")
bundle_root = tmp_path / "mount" / "usr" / "lib" / "ouroboros" / "_internal"
monkeypatch.setattr(packaged_cli, "IS_MACOS", False)
monkeypatch.setattr(packaged_cli, "IS_WINDOWS", False)
monkeypatch.setenv("APPIMAGE", str(appimage))
assert packaged_cli._desktop_app_path(bundle_root) == appimage
def test_packaged_cli_does_not_treat_prompt_start_text_as_option(tmp_path, monkeypatch):
from ouroboros import packaged_cli

View file

@ -134,6 +134,7 @@ def test_install_manifest_discovers_releases_without_future_asset_hashes():
expected = {
"Ouroboros-{version}.dmg": ("macos", "arm64"),
"Ouroboros-{version}-linux-x86_64.tar.gz": ("linux", "x86_64"),
"Ouroboros-{version}-linux-x86_64.AppImage": ("linux", "x86_64"),
"ouroboros_{version}_amd64.deb": ("linux", "x86_64"),
"ouroboros-{version}-1.x86_64.rpm": ("linux", "x86_64"),
"ouroboros-{version}-1.red80.x86_64.rpm": ("linux", "x86_64"),
@ -152,6 +153,9 @@ def test_install_manifest_discovers_releases_without_future_asset_hashes():
]
assert native_packages
assert all(row["availability"] == "per-release" for row in native_packages)
appimages = [row for row in manifest["artifacts"] if row["format"] == "AppImage"]
assert len(appimages) == 1
assert appimages[0]["availability"] == "per-release"
verification = manifest["verification"]
assert verification["availability"] == "per-release"
assert verification["githubAttestations"] == {
@ -174,6 +178,7 @@ def test_install_page_does_not_promise_native_packages_on_older_releases():
html = (SITE / "install" / "index.html").read_text(encoding="utf-8")
assert "When that release lists native packages" in html
assert "does not list a native package" in html
assert "usr/lib/ouroboros/_internal/python-standalone/bin/python3" in html
def test_benchmark_assets_expose_status_and_accessible_text():

View file

@ -118,6 +118,7 @@ def test_assemble_binds_every_asset_smoke_and_sbom(tmp_path: Path):
assert len(checksum_lines) == 3 * len(release_proof.PROOF_IDS)
assert checksum_lines == sorted(checksum_lines, key=lambda line: line.split(" ", 1)[1])
assert "A clear release note." in notes.read_text()
assert "The AppImage runs from a user-writable path" in notes.read_text()
assert "v6.87.4...v6.87.5" in notes.read_text()
commands = evidence["verification"]["attestationCommands"]
assert len(commands) == 2
@ -340,6 +341,13 @@ def test_release_workflow_orders_smoke_sbom_attestation_and_draft_verification()
assert "Ouroboros-*-linux-x86_64.AppImage" in workflow
assert "--check appimage_extract_and_run" in workflow
assert "--check appimage_metadata" in workflow
assert "--check product_version" in workflow
assert "--check browser_fallback_start" in workflow
assert "--check gateway_readiness" in workflow
assert "--check clean_shutdown" in workflow
assert "--check shared_libraries" in workflow
assert 'APP_ROOT="$HOME_DIR/Ouroboros"' in workflow
assert 'OUROBOROS_APP_ROOT="$APP_ROOT"' not in workflow
assert 'test -x "$MOUNT/Install CLI.command"' in workflow
assert 'test -L "$MOUNT/Applications"' in workflow
assert 'test "$(readlink "$MOUNT/Applications")" = "/Applications"' in workflow