3e076d4dd9
Deploy Worker / deploy (push) Failing after 1s
Shellcheck / Check shell scripts (push) Failing after 1s
CI / Build Packages (amd64 - appimage) (push) Has been skipped
CI / Build Packages (amd64 - deb) (push) Has been skipped
CI / Build Packages (amd64 - rpm) (push) Has been skipped
CI / Build Packages (arm64 - appimage) (push) Has been skipped
CI / Build Packages (arm64 - deb) (push) Has been skipped
CI / Test Flags Parsing (push) Failing after 1s
BATS Tests / BATS unit tests (push) Failing after 1s
CI / Build Packages (arm64 - rpm) (push) Has been skipped
CI / Test Build Artifacts (amd64) (push) Has been skipped
CI / Test Build Artifacts (arm64) (push) Has been skipped
CI / Update APT Repository (push) Has been cancelled
CI / Mirror Official .deb to Release (push) Has been cancelled
CI / Update DNF Repository (push) Has been cancelled
CI / Update AUR Package (push) Has been cancelled
CI / Create Release (push) Has been cancelled
411 lines
14 KiB
Bash
411 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
# Shared helpers for artifact validation tests
|
|
|
|
_pass_count=0
|
|
_fail_count=0
|
|
|
|
pass() {
|
|
printf '[PASS] %s\n' "$*"
|
|
((_pass_count++))
|
|
}
|
|
|
|
fail() {
|
|
printf '[FAIL] %s\n' "$*" >&2
|
|
((_fail_count++))
|
|
}
|
|
|
|
assert_file_exists() {
|
|
if [[ -f $1 ]]; then
|
|
pass "File exists: $1"
|
|
else
|
|
fail "File missing: $1"
|
|
fi
|
|
}
|
|
|
|
assert_dir_exists() {
|
|
if [[ -d $1 ]]; then
|
|
pass "Directory exists: $1"
|
|
else
|
|
fail "Directory missing: $1"
|
|
fi
|
|
}
|
|
|
|
assert_executable() {
|
|
if [[ -x $1 ]]; then
|
|
pass "Executable: $1"
|
|
else
|
|
fail "Not executable: $1"
|
|
fi
|
|
}
|
|
|
|
assert_setuid() {
|
|
if [[ -u $1 ]]; then
|
|
pass "Setuid bit set: $1"
|
|
else
|
|
fail "Setuid bit not set: $1"
|
|
fi
|
|
}
|
|
|
|
assert_contains() {
|
|
local file="$1" pattern="$2" desc="${3:-}"
|
|
if grep -q "$pattern" "$file" 2>/dev/null; then
|
|
pass "${desc:-"$file contains '$pattern'"}"
|
|
else
|
|
fail "${desc:-"$file does not contain '$pattern'"}"
|
|
fi
|
|
}
|
|
|
|
assert_command_succeeds() {
|
|
local desc="$1"
|
|
shift
|
|
if "$@" >/dev/null 2>&1; then
|
|
pass "$desc"
|
|
else
|
|
fail "$desc (exit code: $?)"
|
|
fi
|
|
}
|
|
|
|
# Validate app contents inside an Electron resources directory.
|
|
# Since the v3.0.0 patch-zero rebase the asar is the OFFICIAL bundle
|
|
# (byte-identical unless a survivor patch ran), so this asserts the
|
|
# upstream shape — no frame-fix files, no injected desktopName, no
|
|
# stubbed claude-native. See docs/decisions.md D-002.
|
|
# $1 = path to the resources/ dir containing app.asar
|
|
validate_app_contents() {
|
|
local resources_dir="$1"
|
|
|
|
assert_file_exists "$resources_dir/app.asar"
|
|
assert_dir_exists "$resources_dir/app.asar.unpacked"
|
|
|
|
# Official unpacked set: the real Rust native binding plus the
|
|
# node-pty prebuild (arch-dependent subdir, hence find). The 2.x
|
|
# unpacked stubs are gone by design; cowork-vm-service.js returned
|
|
# in #776 but lives at the resources/ root (asserted below), never
|
|
# in app.asar.unpacked.
|
|
local native_binding pty_prebuild
|
|
native_binding=$(find "$resources_dir/app.asar.unpacked" \
|
|
-name 'claude-native-binding.node' -type f | head -1)
|
|
if [[ -n $native_binding ]]; then
|
|
pass 'Unpacked: claude-native-binding.node present'
|
|
else
|
|
fail 'Unpacked: claude-native-binding.node missing'
|
|
fi
|
|
pty_prebuild=$(find "$resources_dir/app.asar.unpacked" \
|
|
-name 'pty.node' -type f | head -1)
|
|
if [[ -n $pty_prebuild ]]; then
|
|
pass 'Unpacked: node-pty prebuild present'
|
|
else
|
|
fail 'Unpacked: node-pty prebuild missing'
|
|
fi
|
|
|
|
# Cowork's bundled virtiofsd: the #771 un-gate patch makes it the
|
|
# universal fallback, and the client resolves it with X_OK — a
|
|
# repack that drops the exec bit silently kills Cowork on every
|
|
# host without a client-probed system virtiofsd (the mode-loss
|
|
# trap in docs/learnings/packaging-permissions.md).
|
|
if [[ -x $resources_dir/virtiofsd ]]; then
|
|
pass 'Bundled virtiofsd present and executable'
|
|
elif [[ -e $resources_dir/virtiofsd ]]; then
|
|
fail 'Bundled virtiofsd present but not executable'
|
|
else
|
|
fail 'Bundled virtiofsd missing from resources/'
|
|
fi
|
|
|
|
# The bwrap fallback daemon (#776): staged beside app.asar when
|
|
# patch_cowork_bwrap is active (it is, in every current build).
|
|
# The launcher spawns it via a system node, so presence is the
|
|
# contract — no exec bit required. Without it, an opt-in
|
|
# COWORK_VM_BACKEND=bwrap launch fails at spawn with doctor
|
|
# pointing at a reinstall.
|
|
if [[ -f $resources_dir/cowork-vm-service.js ]]; then
|
|
pass 'Bundled cowork-vm-service.js present (bwrap daemon)'
|
|
else
|
|
fail 'Bundled cowork-vm-service.js missing from resources/'
|
|
fi
|
|
|
|
# Extract app.asar for deeper inspection if tools available
|
|
local extract_dir
|
|
extract_dir=$(mktemp -d)
|
|
|
|
local extracted=false
|
|
if command -v asar &>/dev/null; then
|
|
asar extract "$resources_dir/app.asar" "$extract_dir/app" \
|
|
&& extracted=true
|
|
elif command -v npx &>/dev/null; then
|
|
npx --yes @electron/asar extract \
|
|
"$resources_dir/app.asar" "$extract_dir/app" 2>/dev/null \
|
|
&& extracted=true
|
|
fi
|
|
|
|
if [[ $extracted == true ]]; then
|
|
# Upstream entry point (main has shipped as index.js and
|
|
# index.pre.js across releases — assert the stable prefix,
|
|
# not the exact filename)
|
|
assert_contains "$extract_dir/app/package.json" \
|
|
'"main": ".vite/build/' \
|
|
'package.json main points into .vite/build/'
|
|
|
|
# productName drives WM_CLASS; the build guard asserts the
|
|
# same invariant at patch time (app-asar.sh)
|
|
assert_contains "$extract_dir/app/package.json" \
|
|
'"productName": "Claude"' \
|
|
'package.json productName is Claude'
|
|
|
|
# Main process bundle exists
|
|
local main_bundle
|
|
main_bundle=$(find "$extract_dir/app/.vite/build" \
|
|
-maxdepth 1 -name 'index*.js' -type f | head -1)
|
|
if [[ -n $main_bundle ]]; then
|
|
pass 'Main process bundle present in .vite/build/'
|
|
else
|
|
fail 'No index*.js in .vite/build/'
|
|
fi
|
|
else
|
|
pass "Skipping asar extraction (tool not available)"
|
|
fi
|
|
|
|
rm -rf "$extract_dir"
|
|
}
|
|
|
|
# Assert the launcher's --version fast-path (#775): it must print
|
|
# "<package_name> <version>" and exit 0. The fast-path exits before
|
|
# any launch, log-redirect, or sandbox logic, so unlike the launch
|
|
# smoke test it needs no display, D-Bus, or privilege handling — run
|
|
# the command directly. Closes the "deb/rpm static-verified only" gap
|
|
# from the #775 review.
|
|
#
|
|
# Usage: run_version_flag_test <label> <expected_prefix> <cmd> [args...]
|
|
# expected_prefix usually "<package_name> <version>"; matched as a
|
|
# prefix so an rpm caller can pass the %{VERSION}
|
|
# part and tolerate the raw hyphenated tail the
|
|
# launcher bakes in (see scripts/packaging/rpm.sh).
|
|
run_version_flag_test() {
|
|
local label="$1" expected="$2"
|
|
shift 2
|
|
# An empty metadata query (dpkg-deb -f / rpm -qp failure) would
|
|
# leave "name " as the expected prefix and make the match vacuous.
|
|
if [[ -z $expected || $expected == *' ' ]]; then
|
|
fail "$label --version: expected prefix '$expected' has no" \
|
|
'version component (metadata query returned empty?)'
|
|
return
|
|
fi
|
|
local out rc
|
|
out=$("$@" --version 2>&1)
|
|
rc=$?
|
|
if (( rc == 0 )) && [[ $out == "$expected"* ]]; then
|
|
pass "$label --version prints '$out' (exit 0)"
|
|
else
|
|
fail "$label --version: rc=$rc output='$out'" \
|
|
"(want prefix '$expected')"
|
|
fi
|
|
}
|
|
|
|
# Headless launch smoke test. Boots the packaged app under Xvfb + dbus
|
|
# and waits for the launcher's 'Executing:' log line (written
|
|
# immediately before exec'ing the official ELF), then requires the
|
|
# process group to survive a grace window. The 2.x frame-fix readiness
|
|
# marker died with the wrapper (patch-zero rebase) — the official
|
|
# bundle prints no deterministic startup line, so "reached exec and
|
|
# didn't crash within the grace period" is the honest contract now.
|
|
# Catches launcher breakage and immediate-exit startup regressions
|
|
# (bad patch anchors that yield a SyntaxError exit the main process
|
|
# within a second or two). Ref: #670 (deb/rpm), #646 (AppImage
|
|
# readiness-poll pattern this generalizes).
|
|
#
|
|
# Scope: main-process startup only. GPU/renderer crashes (#583-class)
|
|
# leave the main process alive and pass — Xvfb has no GPU, so Electron
|
|
# falls back to SwiftShader and that path isn't exercised here.
|
|
#
|
|
# Usage:
|
|
# run_launch_smoke_test <label> <pkill_match> <run_as> <cmd> [args...]
|
|
# label human name for pass/fail messages
|
|
# pkill_match pattern for the pkill -f child sweep (may be empty)
|
|
# run_as unprivileged user to drop to, or '' to run as-is.
|
|
# Electron aborts as root without --no-sandbox, and the
|
|
# launcher only adds that on Wayland/deb, so a root
|
|
# container (rpm) must drop privileges to exercise the
|
|
# real setuid-sandbox path.
|
|
# cmd [args] the launch command
|
|
#
|
|
# Tool absence (xvfb-run/dbus-run-session/setsid, or runuser when a
|
|
# run_as user is requested) is a skip, not a failure — matching
|
|
# validate_app_contents. Loud failure on missing tools belongs at the
|
|
# workflow layer.
|
|
|
|
# Module-scope state so the caller's trap can reap an interrupted launch.
|
|
_smoke_launch_pid=''
|
|
_smoke_cache_root=''
|
|
_smoke_xvfb_log=''
|
|
_smoke_pkill_match=''
|
|
|
|
_launch_smoke_cleanup() {
|
|
if [[ -n $_smoke_launch_pid ]]; then
|
|
# Negative PID targets the whole process group.
|
|
kill -KILL -- "-$_smoke_launch_pid" 2>/dev/null
|
|
[[ -n $_smoke_pkill_match ]] \
|
|
&& pkill -KILL -f "$_smoke_pkill_match" 2>/dev/null
|
|
fi
|
|
[[ -n $_smoke_cache_root ]] && rm -rf "$_smoke_cache_root"
|
|
[[ -n $_smoke_xvfb_log ]] && rm -rf "$_smoke_xvfb_log"
|
|
}
|
|
|
|
# True when any passed log file carries the sandbox-namespace-denied
|
|
# signature: the CI container forbidding Chromium's user/PID namespace
|
|
# sandbox. Matches `Failed to move to new namespace`,
|
|
# `zygote_host_impl_linux`, or `Operation not permitted` co-occurring
|
|
# with `namespace`. Missing files are skipped silently.
|
|
_smoke_sandbox_denied() {
|
|
local log
|
|
for log in "$@"; do
|
|
[[ -f $log ]] || continue
|
|
grep -qE 'Failed to move to new namespace|zygote_host_impl_linux' \
|
|
"$log" && return 0
|
|
grep -q 'Operation not permitted' "$log" \
|
|
&& grep -q 'namespace' "$log" && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
run_launch_smoke_test() {
|
|
local label="$1" pkill_match="$2" run_as="$3"
|
|
shift 3
|
|
|
|
local skip="Skipping launch smoke test for $label"
|
|
if ! { command -v xvfb-run && command -v dbus-run-session \
|
|
&& command -v setsid; } &>/dev/null; then
|
|
pass "$skip (xvfb-run/dbus-run-session/setsid missing)"
|
|
return
|
|
fi
|
|
if [[ -n $run_as ]] && ! command -v runuser &>/dev/null; then
|
|
pass "$skip (runuser missing)"
|
|
return
|
|
fi
|
|
|
|
local cache_root xvfb_log launcher_log
|
|
cache_root=$(mktemp -d)
|
|
xvfb_log=$(mktemp)
|
|
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
|
|
_smoke_cache_root="$cache_root"
|
|
_smoke_xvfb_log="$xvfb_log"
|
|
_smoke_pkill_match="$pkill_match"
|
|
|
|
# setsid puts xvfb-run + Xvfb + dbus + launcher + electron in a fresh
|
|
# process group; xvfb-run's own EXIT trap leaves Xvfb behind on TERM,
|
|
# so we reap via kill -- -PGID below. XDG_CACHE_HOME is redirected so
|
|
# the test owns the launcher log the readiness marker is written to
|
|
# (the launcher execs electron with stdout/stderr >> "$log_file").
|
|
local -a runner=(setsid)
|
|
if [[ -n $run_as ]]; then
|
|
# The unprivileged user must be able to write the redirected
|
|
# cache (and read the world-readable install + setuid sandbox).
|
|
chmod 0777 "$cache_root"
|
|
runner+=(runuser -u "$run_as" --)
|
|
fi
|
|
runner+=(env "XDG_CACHE_HOME=$cache_root"
|
|
xvfb-run -a -s '-screen 0 1280x720x24'
|
|
dbus-run-session -- "$@")
|
|
|
|
"${runner[@]}" >"$xvfb_log" 2>&1 &
|
|
_smoke_launch_pid=$!
|
|
|
|
# Poll for the launcher's pre-exec marker or early process death,
|
|
# up to 30s; then hold a grace window in which an immediate app
|
|
# crash (SyntaxError-class, bad ELF) still fails the test.
|
|
local readiness_marker='Executing: '
|
|
local readiness_timeout=30 grace=8 deadline saw_marker=0
|
|
deadline=$((SECONDS + readiness_timeout))
|
|
while ((SECONDS < deadline)); do
|
|
if [[ -f $launcher_log ]] \
|
|
&& grep -qF "$readiness_marker" "$launcher_log"; then
|
|
saw_marker=1
|
|
break
|
|
fi
|
|
kill -0 "$_smoke_launch_pid" 2>/dev/null || break
|
|
sleep 0.5
|
|
done
|
|
|
|
if ((saw_marker == 1)); then
|
|
# Grace window: the launcher exec'd the app — now require it
|
|
# to stay alive (the launcher logs the exit code if it dies).
|
|
deadline=$((SECONDS + grace))
|
|
while ((SECONDS < deadline)); do
|
|
if [[ -f $launcher_log ]] && grep -qF \
|
|
'Electron exited with code:' "$launcher_log"; then
|
|
saw_marker=0
|
|
break
|
|
fi
|
|
if ! kill -0 "$_smoke_launch_pid" 2>/dev/null; then
|
|
saw_marker=0
|
|
break
|
|
fi
|
|
sleep 0.5
|
|
done
|
|
fi
|
|
|
|
if ((saw_marker == 1)); then
|
|
pass "$label reached ready state under Xvfb"
|
|
else
|
|
# Build the failure detail message, but defer the fail/skip
|
|
# verdict until after we've dumped and scanned the logs below.
|
|
local detail exit_code
|
|
if kill -0 "$_smoke_launch_pid" 2>/dev/null; then
|
|
detail="$label did not reach ready state within"
|
|
detail+=" ${readiness_timeout}s"
|
|
else
|
|
wait "$_smoke_launch_pid" 2>/dev/null
|
|
exit_code=$?
|
|
detail="$label exited before reaching ready state"
|
|
detail+=" (exit: $exit_code)"
|
|
fi
|
|
if [[ -f $launcher_log ]]; then
|
|
echo '--- launcher.log (last 40 lines) ---' >&2
|
|
tail -40 "$launcher_log" >&2
|
|
echo '------------------------------------' >&2
|
|
fi
|
|
if [[ -s $xvfb_log ]]; then
|
|
echo '--- xvfb-run stderr (last 20 lines) ---' >&2
|
|
tail -20 "$xvfb_log" >&2
|
|
echo '---------------------------------------' >&2
|
|
fi
|
|
# Narrow skip: the GHA container's default seccomp/userns policy
|
|
# blocks Chromium's namespace sandbox, so the zygote aborts before
|
|
# the readiness marker. That's an environment limit, not an app
|
|
# defect (deb/appimage jobs prove the same code boots where the
|
|
# sandbox is allowed). Treat ONLY this signature as a skip; every
|
|
# other pre-marker exit stays a hard failure.
|
|
if _smoke_sandbox_denied "$launcher_log" "$xvfb_log"; then
|
|
pass "$label: SKIP — Chromium sandbox cannot initialize in this container (namespace creation denied by seccomp/userns policy); launch not exercised here. App boots where the sandbox is permitted (see deb/appimage jobs)."
|
|
else
|
|
fail "$detail"
|
|
fi
|
|
fi
|
|
|
|
kill -TERM -- "-$_smoke_launch_pid" 2>/dev/null || true
|
|
sleep 1
|
|
kill -KILL -- "-$_smoke_launch_pid" 2>/dev/null || true
|
|
wait "$_smoke_launch_pid" 2>/dev/null || true
|
|
# Sweep any electron child that escaped the group (e.g. zygote).
|
|
# Under the rpm runuser path PAM re-setsid()s the child into its own
|
|
# session/process group, so the negative-PID group kills above miss
|
|
# it entirely — this pkill -f sweep is the ACTUAL reaper there, not a
|
|
# belt-and-suspenders extra. Don't drop it.
|
|
if [[ -n $pkill_match ]]; then
|
|
pkill -KILL -f "$pkill_match" 2>/dev/null || true
|
|
fi
|
|
|
|
rm -rf "$cache_root" "$xvfb_log"
|
|
_smoke_launch_pid=''
|
|
_smoke_cache_root=''
|
|
_smoke_xvfb_log=''
|
|
}
|
|
|
|
print_summary() {
|
|
echo
|
|
echo '================================'
|
|
printf 'Results: %d passed, %d failed\n' "$_pass_count" "$_fail_count"
|
|
echo '================================'
|
|
if [[ $_fail_count -gt 0 ]]; then
|
|
exit 1
|
|
fi
|
|
}
|