Snap package: strict core24 snap on every release, optional Snap Store upload (#413)
* Snap package: build, test and attach a strict snap to every release packaging/linux/snap/snapcraft.yaml (core24, strict, gnome extension) repackages the release's Linux tarball like the Flatpak does. The release workflow builds it for amd64 and arm64, installs it with --dangerous, models a box in the confined app on headless X11 and Wayland, and attaches solidcraft-<version>-linux-<arch>.snap plus the version-less copy. When SNAPCRAFT_STORE_CREDENTIALS is set, nightlies go to edge and release drafts to candidate, and snap-store.yml promotes candidate to stable when a release is published. The README gets a Snap badge and install line. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT * snap smoke: stop the app's whole process group, refuse a busy control port Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT * snap smoke: quit the app cleanly between backends Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
cddbfedb28
commit
daed2686bc
10 files changed
+484
-4
No files matched your search
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repackage a SolidCraft Linux tarball as a snap:
|
||||
#
|
||||
# $DIST/solidcraft-<version>-linux-<arch>.snap
|
||||
#
|
||||
# Usage: packaging/linux/snap-pack.sh [--destructive-mode] [TARBALL]
|
||||
#
|
||||
# TARBALL defaults to $DIST/solidcraft-<version>-linux-<arch>.tar.gz from package.sh. The snap is
|
||||
# built for the host architecture (x86_64 or aarch64), which must match the tarball's. Needs
|
||||
# snapcraft (`sudo snap install snapcraft --classic`) and, without --destructive-mode, LXD.
|
||||
# --destructive-mode builds on the host itself, which must be Ubuntu 24.04 (the core24 base),
|
||||
# as in CI. Install and smoke-test the result with:
|
||||
# sudo snap install --dangerous dist/release/solidcraft-<version>-linux-<arch>.snap
|
||||
# python3 packaging/linux/snap-smoke.py
|
||||
# Manifest: packaging/linux/snap/snapcraft.yaml.
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
HERE="$ROOT/packaging/linux"
|
||||
|
||||
MODE=(--use-lxd)
|
||||
TARBALL=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--destructive-mode) MODE=(--destructive-mode); shift ;;
|
||||
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
*) TARBALL="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) SNAP_ARCH=amd64 ;;
|
||||
aarch64 | arm64) ARCH=aarch64; SNAP_ARCH=arm64 ;;
|
||||
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
BASENAME="solidcraft-$VERSION-linux-$ARCH"
|
||||
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
|
||||
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
|
||||
command -v snapcraft >/dev/null || { echo "error: snapcraft not found (sudo snap install snapcraft --classic)" >&2; exit 1; }
|
||||
# Snap versions: at most 32 characters of [A-Za-z0-9.+~-].
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9.+~-]{0,31}$'; then
|
||||
echo "error: version $VERSION is not a valid snap version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> SolidCraft $VERSION snap for $SNAP_ARCH from $(basename "$TARBALL")"
|
||||
|
||||
WORK="$CARGO_TARGET_DIR/snap-pack"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/snap" "$WORK/stage"
|
||||
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
|
||||
[ -x "$WORK/stage/bin/solidcraft" ] || { echo "error: $TARBALL has no bin/solidcraft" >&2; exit 1; }
|
||||
cp "$HERE/snap/snapcraft.yaml" "$WORK/snap/snapcraft.yaml"
|
||||
printf '%s\n' "$VERSION" >"$WORK/version.txt"
|
||||
|
||||
(cd "$WORK" && snapcraft pack "${MODE[@]}" --build-for="$SNAP_ARCH" --output="$WORK/out.snap")
|
||||
|
||||
OUT="$DIST/$BASENAME.snap"
|
||||
mv "$WORK/out.snap" "$OUT"
|
||||
echo "wrote $OUT"
|
||||
ls -lh "$OUT"
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Start the installed SolidCraft snap on headless X11 and Wayland and model a box in it.
|
||||
|
||||
Runs the confined app (`snap install --dangerous` first) with its normal plugs, drives it over
|
||||
the control channel from the host (snaps share the host's network namespace), checks a
|
||||
30x20x10 box measures 6000 mm³ and that the GPU drew a non-blank frame, and copies each frame
|
||||
to --out (default: the current directory) as snap-<backend>.png. Needs xvfb-run and weston.
|
||||
Reuses the frame checks of flatpak-smoke.py.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_spec = importlib.util.spec_from_file_location("flatpak_smoke", HERE / "flatpak-smoke.py")
|
||||
flatpak_smoke = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(flatpak_smoke)
|
||||
|
||||
PORTS = {"x11": 18839, "wayland": 18840}
|
||||
REQUESTS = [
|
||||
{"id": 1, "method": "ui.inspect", "params": {}},
|
||||
{"id": 2, "method": "engine.execute",
|
||||
"params": {"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}},
|
||||
{"id": 3, "method": "document.inspect", "params": {"measure": True}},
|
||||
]
|
||||
|
||||
|
||||
def drive(app, port, frame):
|
||||
"""Connect to the app's control port, model the box and capture a frame."""
|
||||
deadline = time.monotonic() + 60
|
||||
while True:
|
||||
if app.poll() is not None:
|
||||
raise RuntimeError(f"the app exited with {app.returncode} before listening")
|
||||
try:
|
||||
conn = socket.create_connection(("127.0.0.1", port), timeout=60)
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("the control port never opened") from None
|
||||
time.sleep(.2)
|
||||
with conn, conn.makefile("rw") as io:
|
||||
replies = []
|
||||
for request in REQUESTS + [{"id": 4, "method": "ui.screenshot", "params": {"path": str(frame)}},
|
||||
{"id": 5, "method": "app.quit", "params": {"force": True}}]:
|
||||
io.write(json.dumps(request) + "\n")
|
||||
io.flush()
|
||||
replies.append(io.readline())
|
||||
renderer = flatpak_smoke.validate_replies("".join(replies[:3]))
|
||||
capture = json.loads(replies[3])
|
||||
if any(json.loads(reply).get("ok") is not True for reply in replies[4:]):
|
||||
raise ValueError(f"app.quit failed: {replies[4:]}")
|
||||
if capture.get("ok") is not True or renderer != "gpu":
|
||||
raise ValueError(f"GPU frame capture failed: renderer={renderer}, {capture}")
|
||||
return renderer, flatpak_smoke.frame_statistics(frame.read_bytes())
|
||||
|
||||
|
||||
def run_backend(backend, out):
|
||||
runtime = Path(f"/run/user/{os.getuid()}")
|
||||
if not runtime.is_dir():
|
||||
raise RuntimeError(f"{runtime} is missing: snaps expect the login session's runtime directory")
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime))
|
||||
for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET", "DISPLAY", "SOLIDCRAFT_WAYLAND", "SOLVECRAFT_WAYLAND"):
|
||||
env.pop(name, None)
|
||||
# The snap can write to the (non-hidden) home folder, not to the host's /tmp.
|
||||
work = Path(tempfile.mkdtemp(prefix="solidcraft-snap-smoke-", dir=Path.home()))
|
||||
compositor = None
|
||||
log = (work / "app.log").open("wb")
|
||||
try:
|
||||
# No private dbus-run-session bus: snapd moves the app into its own systemd scope through
|
||||
# the session's systemd, which a private bus doesn't have.
|
||||
prefix = []
|
||||
if backend == "x11":
|
||||
prefix += ["xvfb-run", "-a", "-s", "-screen 0 1600x1000x24 -nolisten tcp"]
|
||||
else:
|
||||
# The wayland plug only allows sockets named wayland-N in the runtime directory.
|
||||
env["WAYLAND_DISPLAY"] = "wayland-77"
|
||||
compositor = subprocess.Popen(["weston", "--backend=headless", "--renderer=pixman", "--no-config",
|
||||
"--socket=wayland-77", "--width=1600", "--height=1000"],
|
||||
env=env, stdout=log, stderr=subprocess.STDOUT)
|
||||
deadline = time.monotonic() + 20
|
||||
while not (runtime / "wayland-77").exists():
|
||||
if compositor.poll() is not None or time.monotonic() >= deadline:
|
||||
raise RuntimeError("headless Weston failed")
|
||||
time.sleep(.1)
|
||||
with socket.socket() as probe:
|
||||
if probe.connect_ex(("127.0.0.1", PORTS[backend])) == 0:
|
||||
raise RuntimeError(f"port {PORTS[backend]} is already in use (another SolidCraft?)")
|
||||
# Its own process group, so stopping it also stops the app under xvfb-run.
|
||||
app = subprocess.Popen(prefix + ["/snap/bin/solidcraft", "--control", str(PORTS[backend])],
|
||||
env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
renderer, stats = drive(app, PORTS[backend], work / "frame.png")
|
||||
# app.quit exits cleanly, so the next run doesn't report a crash.
|
||||
app.wait(timeout=30)
|
||||
finally:
|
||||
if app.poll() is None:
|
||||
os.killpg(app.pid, signal.SIGTERM)
|
||||
try:
|
||||
app.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(app.pid, signal.SIGKILL)
|
||||
app.wait()
|
||||
shutil.copy(work / "frame.png", out / f"snap-{backend}.png")
|
||||
print(f"snap {backend}: PASS renderer={renderer}, box=6000 mm³, frame={stats}")
|
||||
except BaseException:
|
||||
log.flush()
|
||||
sys.stderr.write((work / "app.log").read_text(errors="replace")[-8000:])
|
||||
raise
|
||||
finally:
|
||||
log.close()
|
||||
if compositor:
|
||||
compositor.terminate()
|
||||
compositor.wait(timeout=10)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--backend", choices=["x11", "wayland", "both"], default="both")
|
||||
parser.add_argument("--out", type=Path, default=Path.cwd())
|
||||
args = parser.parse_args()
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
for backend in (["x11", "wayland"] if args.backend == "both" else [args.backend]):
|
||||
run_backend(backend, args.out.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, KeyError, TypeError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
print(f"snap desktop smoke failed: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,81 @@
|
||||
# Snap package (solidcraft-<version>-linux-<arch>.snap on every GitHub release, and the Snap
|
||||
# Store when its credentials are configured). Build it with packaging/linux/snap-pack.sh.
|
||||
#
|
||||
# Like the Flatpak, it repackages the release's own Linux tarball (built on Ubuntu 22.04 by
|
||||
# package.sh) instead of compiling Rust: the same binaries as the AppImage/deb/rpm. snap-pack.sh
|
||||
# unpacks the tarball into stage/ next to snap/ and writes the package version to version.txt.
|
||||
# The binaries need glibc >= 2.35 (core24 has 2.39) and load X11, Wayland, xkbcommon and Vulkan
|
||||
# at runtime: the gnome extension supplies the desktop libraries and the GPU userspace (mesa,
|
||||
# through the gpu-2404 content interface) for the wgpu viewport.
|
||||
name: solidcraft
|
||||
title: SolidCraft
|
||||
summary: Open-source parametric 3D CAD
|
||||
description: |
|
||||
SolidCraft is an open-source parametric 3D CAD application written in Rust. Sketch on a
|
||||
plane, constrain and dimension the sketch, turn profiles into solids on a feature timeline,
|
||||
and change a parameter to watch the timeline rebuild.
|
||||
|
||||
- Sketches with a constraint solver and parameter-driven dimensions
|
||||
- Extrude, revolve, sweep, loft, fillet, chamfer, shell, holes and patterns
|
||||
- Components and joints, sheet metal, and a plastic-enclosure subset
|
||||
- STEP and IGES import and export; 3MF, STL and OBJ
|
||||
- Every action is a command, scriptable from a CLI, a JSON control channel and an MCP server
|
||||
|
||||
The command-line tool is `solidcraft.cli` (for example `solidcraft.cli mcp`).
|
||||
adopt-info: solidcraft
|
||||
license: MIT OR Apache-2.0
|
||||
contact: https://github.com/bherbruck/solidcraft/issues
|
||||
website: https://bherbruck.github.io/solidcraft/
|
||||
source-code: https://github.com/bherbruck/solidcraft
|
||||
issues: https://github.com/bherbruck/solidcraft/issues
|
||||
icon: stage/share/icons/hicolor/512x512/apps/ai.storyteller.solvecraft.png
|
||||
base: core24
|
||||
grade: stable
|
||||
confinement: strict
|
||||
# lzo starts faster than the default xz, at a somewhat larger download.
|
||||
compression: lzo
|
||||
platforms:
|
||||
amd64:
|
||||
arm64:
|
||||
|
||||
apps:
|
||||
solidcraft:
|
||||
command: usr/bin/solidcraft
|
||||
desktop: usr/share/applications/ai.storyteller.solvecraft.desktop
|
||||
# Ties the snap to the AppStream id (ai.storyteller.solvecraft, a compatibility shim kept by
|
||||
# every package; see docs/compatibility.md) so software centres treat it as the same app.
|
||||
common-id: ai.storyteller.solvecraft
|
||||
extensions: [gnome]
|
||||
plugs:
|
||||
- desktop
|
||||
- desktop-legacy
|
||||
- wayland
|
||||
- x11
|
||||
- opengl
|
||||
# Designs anywhere in the home folder (not hidden files) and on USB drives; Open/Save
|
||||
# also work through the desktop file-chooser portal.
|
||||
- home
|
||||
- removable-media
|
||||
# Opening links (Create Issue, documentation); the optional --control server listens
|
||||
# on 127.0.0.1 for agents and tests.
|
||||
- network
|
||||
- network-bind
|
||||
cli:
|
||||
command: usr/bin/solidcraft-cli
|
||||
plugs: [home, removable-media, network, network-bind]
|
||||
|
||||
parts:
|
||||
solidcraft:
|
||||
plugin: dump
|
||||
source: stage
|
||||
organize:
|
||||
bin/: usr/bin/
|
||||
share/: usr/share/
|
||||
override-pull: |
|
||||
craftctl default
|
||||
craftctl set version="$(cat "$CRAFT_PROJECT_DIR/version.txt")"
|
||||
override-prime: |
|
||||
craftctl default
|
||||
# Point the menu entry at the packaged icon (the hicolor name isn't on the host's path).
|
||||
sed -i 's|^Icon=.*|Icon=${SNAP}/usr/share/icons/hicolor/256x256/apps/ai.storyteller.solvecraft.png|' \
|
||||
usr/share/applications/ai.storyteller.solvecraft.desktop
|
||||
Reference in new issue
Block a user