Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,30 @@ jobs:
path: dist
merge-multiple: true

- uses: actions/setup-node@v6
with:
node-version: 20

# Static contract on the build artifacts, before anything is attested
# or published: exact 5-artifact matrix, per-format magic bytes, and
# every checksum matches its file. Catches a corrupted/truncated/
# wrong-architecture artifact — fail_on_unmatched_files below only
# catches a totally missing one. See tests/releasecontract/README.md.
- name: Verify the release artifact contract
run: |
node tests/releasecontract/verify-release.mjs \
--directory dist \
--version "${GITHUB_REF_NAME}" \
--report artifacts/desktop-release-contract/report.json

- name: Upload the release contract report
if: always()
uses: actions/upload-artifact@v7
with:
name: release-contract-report
path: artifacts/desktop-release-contract/
if-no-files-found: warn

- name: Attest release artifacts
uses: actions/attest-build-provenance@v3
with:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ The dashboard opens immediately. If you don't have a ready Podman runtime yet, a
## Documentation

- [`DEVELOPMENT.md`](./DEVELOPMENT.md) — full dev environment setup, testing (including previewing onboarding screens without touching real Podman state), and the command reference
- [`TESTING.md`](./TESTING.md) — the full test-layer breakdown (source, release contract, native smoke, manual) and what's deliberately not built yet
- [`RELEASING.md`](./RELEASING.md) — tagging, the CI release pipeline, and the review-gate policy
- [`AGENT.md`](./AGENT.md) — architecture, non-negotiable rules, and current build status (the primary reference for contributing code)
- [`DESIGN.md`](./DESIGN.md) — screen-by-screen UI/UX reference
Expand Down
20 changes: 8 additions & 12 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,11 @@ to inspect or change the required reviewers.
developer" warning; Windows shows a SmartScreen "unknown publisher" prompt.
Fine for early alpha/internal use; worth fixing (via GitHub Environment
secrets + the relevant Tauri signing config) before pointing this at the
general public.
- **No release-artifact contract check.** Nothing yet asserts the expected
installer files actually exist with the expected names/checksums before
publishing — `release.yml`'s `fail_on_unmatched_files: true` catches a
totally missing platform, but not a subtly wrong one. See
`reference/desktop-hardening-migration-PLAN.md`'s Phase 7 for the sibling
repo's `tests/releasecontract` pattern if this becomes worth porting.
- **No `TESTING.md`.** This file assumes `ci.yml`'s green checkmark is
sufficient evidence; there's no separate document defining test layers,
promotion gates, or required manual sign-off yet. Fine at this repo's
current stage (single maintainer, no external users depending on
releases); revisit if that changes.
general public. `tests/manual/published-artifact.md` covers verifying
this is the *only* warning shown.

See [`TESTING.md`](TESTING.md) for the full test-layer breakdown and its
own "Deliberately not done yet" list (self-hosted hardware runners,
`run.ps1`, destructive host-reset tooling, post-publication
re-verification) — a release-artifact contract check and this doc both
used to be listed here as gaps; both are done now.
89 changes: 89 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Testing

The authoritative source for what test layers exist, what each one proves,
and what's deliberately not built yet. Modeled on the sibling repo
(`omnideck/desktop`)'s `TESTING.md`, scaled down to this repo's actual
scope and maturity — see "Deliberately not done yet" below for what was
cut and why.

A passing build proves only its stated layer. Compilation is not
installation, a cross-build is not native execution, and an unexecuted
manual procedure is `blocked` coverage, not a pass.

## Test layers

| Layer | Implementation | Where it runs | Proves |
|---|---|---|---|
| Source | `test.yml` (reusable): sidecar fetch/verify, policy tests, typecheck, `cargo fmt`/`test`/`clippy` | Every PR, push to `main`, and tag push | Code compiles, security-posture invariants hold, sidecar checksums match the pin |
| Release contract | `tests/releasecontract/verify-release.mjs` | `release.yml`'s `publish` job, before attestation/publish | The 5-artifact matrix is exactly right: correct filenames, per-format magic bytes, checksums match |
| Native packaged smoke | `tests/hardware/run.sh` + `validate-proof.mjs` | Manual, on a real machine with a display session (see `tests/hardware/README.md`) | A real installed/launched build actually reaches and correctly parses real CLI output on that OS — not a fixture |
| Manual journey | `tests/manual/*.md` | Manual, on a disposable VM or dedicated machine | First-run setup, recovery/interruption handling, published-artifact trust experience |

Hosted CI (source + release contract) runs on every relevant push/PR/tag.
Native packaged smoke and manual journeys are run by hand before a real
release goes out to actual users — see `RELEASING.md`.

## Automated security boundary

Source tests (`tests/policy.test.mjs`) keep the following invariants
release-blocking:

- the dashboard window's capability is an enumerated allowlist
(`dashboard-bridge`), never `core:default`;
- the onboarding window's capability (`onboarding-bridge`) is scoped to
`"windows": ["onboarding"]` only — never `"main"`, which is the dashboard
here (this exact mistake was caught once already; see the policy test's
own comment);
- every onboarding command re-checks `window.label() == "onboarding"`
server-side, not just the capability grant;
- `bootstrap()` only reveals the onboarding window when setup is actually
needed — never unconditionally (this was a real, caught-and-fixed
regression);
- the CLI sidecar is pinned by checksum for all 6 target triples, and the
runtime version check is a floor (`>= v0.10.0`), not an exact match;
- sidecar process output is bounded and every operation has a timeout.

## Release gating

`release.yml`'s `test` → `build` → `publish` chain (see `RELEASING.md`) is
the actual gate: a tag that fails any version-field check, any source
check, or the release-artifact contract never reaches `publish`. Stable
tags additionally require review in the `release` GitHub Environment;
alpha/beta/rc tags publish unattended via `release-preview`.

There is no formal alpha/beta/RC/stable promotion ladder with named gates
per channel (contrast the sibling's, which has one) — this repo doesn't
have the release volume or team size to justify that process yet. The tag
naming convention (`-alpha.N`/`-beta.N`/`-rc.N`/bare) already exists and
GitHub correctly treats non-bare tags as prereleases; formalize gates per
channel if/when that distinction needs to mean something more than "not
GA."

## Deliberately not done yet

- **Self-hosted hardware runners.** The sibling's opt-in native-smoke CI
workflow runs on dedicated, always-on self-hosted machines. Standing
that up is a real infrastructure/ops commitment (cost, machine
maintenance, security surface of a self-hosted runner), not something to
build speculatively — `tests/hardware/run.sh` exists and is verified
working; running it is manual for now.
- **`run.ps1`** (Windows equivalent of `tests/hardware/run.sh`). Cheap to
write, but not written — and critically, not *tested*, since there's no
Windows machine available while writing this. `run.sh` needed two real
fixes (a broken `pgrep -x` length limit, an AppImage process-reparenting
cleanup bug) despite being ported from already-working code; don't trust
a straight port of `run.ps1` without running it for real first.
- **`visual-platform.md`** (theme/DPI/multi-monitor/accessibility/platform
fit). Worth adding once this app has real usage on more than one
platform — right now only Linux has been built, run, and verified.
- **Destructive host-reset tooling** (the sibling's
`scripts/release-test/reset-host.*`, for resetting a test machine's
WSL/Podman state between manual runs). Real, useful, and genuinely
dangerous if done wrong (the sibling's version has real safety rails —
typed confirmation, dry-run, administrator checks) — not built
speculatively alongside everything else here.
- **Post-publication re-verification workflow** (downloading the *public*
release assets fresh and re-running the release contract + attestation
check, rather than trusting the build-time artifacts). `tests/manual/
published-artifact.md` covers this by hand; automating it is a
reasonable, cheap follow-up once it's worth not doing by hand every time.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"fetch:sidecars": "node scripts/fetch-sidecars.mjs",
"verify:sidecars": "node scripts/verify-sidecars.mjs",
"checksums": "node scripts/checksums.mjs",
"test:policy": "node --test tests/*.test.mjs",
"test:policy": "node --test tests/*.test.mjs tests/releasecontract/*.test.mjs tests/hardware/*.test.mjs",
"typecheck": "tsc --noEmit",
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml --locked",
"lint:rust": "cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets -- -D warnings",
Expand Down
59 changes: 40 additions & 19 deletions tests/hardware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,50 @@ Launches a real packaged build (not `tauri dev`) with
`run_packaged_smoke`/`record_packaged_smoke` in `src-tauri/src/lib.rs`.
`validate-proof.mjs` checks the pinned CLI version/commit against
`vendor-manifest.json`, runtime schema 4, the exact read-only operations,
and the application hash.
and the application hash. `validate-proof.test.mjs` unit-tests the
validator itself against synthetic fixtures.

Ported from the sibling repo's `tests/hardware`. Not yet ported: its
`run.sh`/`run.ps1`/`reset-host` harness scripts and the opt-in self-hosted
CI workflow — those assume real per-platform installers and a release
pipeline this repo doesn't have yet (Phase 6/7 of
`reference/desktop-hardening-migration-PLAN.md`). For now, run this by hand
against a real build:
Ported from the sibling repo's `tests/hardware`, now including `run.sh`
(build a real installer, run it with the smoke env var, wait for the proof,
validate, clean up):

```sh
npm run build:appimage
export OMNIDECK_DESKTOP_SMOKE_FILE=/tmp/omnideck-smoke-proof.json
./src-tauri/target/release/bundle/appimage/Omnideck_*.AppImage --appimage-extract-and-run &
sleep 5
kill %1
node tests/hardware/validate-proof.mjs \
--proof /tmp/omnideck-smoke-proof.json \
--application ./src-tauri/target/release/bundle/appimage/Omnideck_*.AppImage \
--report /tmp/omnideck-smoke-report.json \
npm run build:linux # or build:windows / build:macos
bash tests/hardware/run.sh \
--application src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Omnideck_*.AppImage \
--require-ready # only if the test machine's Podman runtime is already ready
```

Requires an actual display session (X11 or Wayland) — this launches the
real GUI process, not a headless check. A missing target or unexecuted step
is blocked coverage, never a pass. Generated evidence belongs under
`artifacts/desktop-hardware/` (not committed).
real GUI process, not a headless check. Verified directly against a real
build (not just written and assumed correct) — two real bugs were caught
and fixed doing that:

- `pgrep -x omnideck-desktop`'s "refuse to run if already running" check
silently never matched anything at all — the kernel truncates `comm` to
15 bytes, and `omnideck-desktop` is 16. Switched to `pgrep -f` matching
the full command line instead, which isn't length-limited.
- An AppImage launched via `--appimage-extract-and-run` reparents away from
the PID this script tracks during extraction, so cleanup only killing
that one PID left a fully running app + WebKit process tree behind.
`pkill -f` as a cleanup fallback catches it regardless of the actual
process-tree relationship.

Both fixes use the `[o]mnideck-desktop` bracket form, not
`omnideck-desktop` — the standard guard against `pgrep -f`/`pkill -f`
matching their own invocation (their own argv literally contains the
search string).

Not yet ported: `run.ps1` (Windows), `reset-host.sh`/`reset-host.ps1`, and
the opt-in self-hosted-runner CI workflow that drives all of this
automatically. `run.ps1` is a reasonable, cheap follow-up (same shape as
`run.sh`, just PowerShell) but hasn't been written *or tested* — don't
trust a straight port of it without running it on real Windows first, the
same way `run.sh` needed two real fixes despite being ported from
already-working code. The self-hosted-runner workflow is a real
infrastructure/ops decision (dedicated always-on test hardware), not
something to stand up speculatively — see `TESTING.md`.

A missing target or unexecuted step is blocked coverage, never a pass.
Generated evidence belongs under `artifacts/desktop-hardware/` (not
committed).
126 changes: 126 additions & 0 deletions tests/hardware/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# Launches a packaged desktop executable with OMNIDECK_DESKTOP_SMOKE_FILE
# set, waits for the read-only smoke proof lib.rs's run_packaged_smoke()
# writes, validates it, and terminates the host. Ported from the sibling
# repo's tests/hardware/run.sh, adapted to this repo's binary name
# (omnideck-desktop, not omnideck) and without OMNIDECK_DESKTOP_USER_DATA
# (this app has no isolated-user-data-directory feature — it uses the
# CLI's/Podman's normal shared state, per the multi-instance model).
set -euo pipefail

script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
desktop_root="$(CDPATH= cd -- "$script_dir/../.." && pwd)"
application=""
output_directory="${OMNIDECK_DESKTOP_SMOKE_OUTPUT_DIR:-}"
timeout_seconds=45
require_ready=false

usage() {
echo "Usage: $0 --application PATH [--output DIR] [--timeout SECONDS] [--require-ready]"
}

while [[ $# -gt 0 ]]; do
case "$1" in
--application) application="${2:?Missing application path}"; shift 2 ;;
--output) output_directory="${2:?Missing output directory}"; shift 2 ;;
--timeout) timeout_seconds="${2:?Missing timeout}"; shift 2 ;;
--require-ready) require_ready=true; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
done

[[ -n "$application" ]] || { usage >&2; exit 1; }
[[ -x "$application" ]] || { echo "Application is not executable: $application" >&2; exit 1; }
[[ "$timeout_seconds" =~ ^[0-9]+$ ]] || { echo "Timeout must be an integer." >&2; exit 1; }
(( timeout_seconds >= 5 && timeout_seconds <= 300 )) || { echo "Timeout must be between 5 and 300 seconds." >&2; exit 1; }
command -v node >/dev/null 2>&1 || { echo "Node.js is required to validate the packaged smoke proof." >&2; exit 1; }

# -f, not -x: the kernel truncates comm to 15 bytes, and "omnideck-desktop"
# is 16 — pgrep -x can never match it (confirmed directly: it prints "pattern
# ... longer than 15 characters will result in zero matches" and always
# exits 1, silently disabling this safety check entirely). -f matches
# against the full command line instead, which isn't length-limited.
# "[o]mnideck-desktop" (not "omnideck-desktop") is the standard pgrep
# self-match guard: pgrep's own argv literally contains the search string,
# so an unguarded pattern matches the pgrep/pkill invocation itself —
# confirmed directly, this pattern always "found" a match with none
# actually running. The bracket makes the *string* "[o]mnideck-desktop"
# (this command's own argv) not satisfy the *regex* `[o]mnideck-desktop`,
# while still matching a real process's plain "omnideck-desktop".
if pgrep -f '[o]mnideck-desktop' >/dev/null 2>&1; then
echo "Close every existing Omnideck process before running packaged smoke." >&2
exit 1
fi
if [[ "$(uname -s)" == "Linux" && -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then
echo "A real X11 or Wayland session is required for native desktop smoke." >&2
exit 1
fi

run_id="${GITHUB_RUN_ID:-local-$$}"
if [[ -z "$output_directory" ]]; then
output_directory="$desktop_root/../artifacts/desktop-hardware/$(uname -s | tr '[:upper:]' '[:lower:]')-$run_id"
fi
mkdir -p "$output_directory"
proof_path="$output_directory/smoke-proof.json"
report_path="$output_directory/report.json"
stdout_path="$output_directory/host.stdout.log"
stderr_path="$output_directory/host.stderr.log"
rm -f -- "$proof_path"

application="$(CDPATH= cd -- "$(dirname -- "$application")" && pwd)/$(basename -- "$application")"

# AppImage-specific: the plain FUSE-mount launch has been observed to exit
# silently within a few seconds on at least one real environment (see
# AGENT.md's AppImage runtime notes and scripts/run-appimage.sh, which
# always passes this too) — --appimage-extract-and-run sidesteps FUSE
# entirely. Harmless to always pass for an AppImage; irrelevant for the
# other platforms' installers, which don't take this flag at all.
application_args=()
if [[ "$application" == *.AppImage ]]; then
application_args+=(--appimage-extract-and-run)
fi

OMNIDECK_DESKTOP_SMOKE_FILE="$proof_path" \
"$application" "${application_args[@]}" >"$stdout_path" 2>"$stderr_path" &
application_pid=$!

# Confirmed directly: an AppImage launched via --appimage-extract-and-run
# reparents away from $application_pid during extraction (its real PPID ends
# up some unrelated adopting process, not this script) — killing only the
# tracked PID here left a fully running, un-cleaned-up omnideck-desktop +
# WebKit process tree behind in testing. pkill -f as a fallback catches it
# regardless of the process-tree relationship.
cleanup() {
if kill -0 "$application_pid" >/dev/null 2>&1; then
kill "$application_pid" >/dev/null 2>&1 || true
wait "$application_pid" >/dev/null 2>&1 || true
fi
pkill -f '[o]mnideck-desktop' >/dev/null 2>&1 || true
}
trap cleanup EXIT

deadline=$((SECONDS + timeout_seconds))
while [[ ! -f "$proof_path" ]]; do
if ! kill -0 "$application_pid" >/dev/null 2>&1; then
echo "The desktop host exited before writing a packaged smoke proof." >&2
exit 1
fi
if (( SECONDS >= deadline )); then
echo "The desktop host did not write a packaged smoke proof within $timeout_seconds seconds." >&2
exit 1
fi
sleep 0.25
done

validation=(
node "$script_dir/validate-proof.mjs"
--proof "$proof_path"
--application "$application"
--report "$report_path"
)
if [[ "$require_ready" == "true" ]]; then
validation+=(--require-ready)
fi
"${validation[@]}"
echo "Evidence: $report_path"
Loading
Loading