Skip to content

CI/CD Workflows

Mesh-Client uses GitHub Actions for continuous integration and deployment.


Workflows

Workflow Trigger Purpose
ci.yaml Push/PR/merge_group/workflow_dispatch Lint, typecheck, build, policy scanners, Flatpak manifest validation
tests.yaml Push/PR/merge_group/workflow_dispatch Vitest coverage + merge; Reticulum sidecar llvm-cov when sidecar paths change
buttonmash.yaml PR/merge_group/workflow_dispatch Browser-based chaos testing of the Vite renderer
e2e.yaml Daily on main + manual workflow_dispatch Playwright Electron E2E (unpackaged build, 3-OS; not a PR gate)
build.yaml Manual workflow_dispatch Native 3-OS packaging smoke build (+ schema compare vs last official)
reticulum-sidecar.yaml Path-filtered push/PR to main Sidecar fmt + Clippy (ubuntu); multi-OS matrix build/test
release.yaml Version tags (v*) Build & publish releases (AppImage/deb/rpm)
flatpak.yaml Version tags (v*), manual Build Flatpak (+ schema compare vs last official); publish to release on tags
cut-release.yaml Manual workflow_dispatch Primary release cut in Actions (needs admin RELEASE_PUSH_TOKEN)
docs.yml Push to main Deploy MkDocs to GitHub Pages
third-party-licenses.yaml Path-filtered push to main + dispatch Regenerate licenses doc and open a PR (needs RELEASE_PUSH_TOKEN)

CI Build (ci.yaml)

Runs on every push, pull request, and merge-queue merge_group for main (and workflow_dispatch). Independent lanes start concurrently; only Flatpak waits for change detection:

  • Code quality: format, markdownlint, license allowlist, actionlint, dependency audit, and yamllint
  • ESLint: full repository lint with two workers, using eslint.ci.config.mjs to avoid running Prettier again for extensions already covered by the required Code quality job. All type-aware rules and the zero-warning gate remain enabled; local lint still checks formatting.
  • Typecheck: pnpm run typecheck
  • Application build: pnpm run build
  • Flatpak checks: only when Flatpak inputs change; runs check:flatpak, check:flatpak-offline-pnpm, desktop-file-validate, and appstreamcli validate
  • Policy scanners: cheap always-on check:* set from pre-commit/release (check:electron-security, log/XSS/console/IPC/protocol-string gates, and the matching cheap scanners) so --no-verify and GitHub-only edits cannot skip them

Each Node lane uses the same pinned Node 22/pnpm setup action and frozen install. The final Build & Test job aggregates every lane so the existing required check name remains stable. Superseded runs for the same pull request or ref are cancelled.


Buttonmash (buttonmash.yaml)

Runs a bounded, deterministic Buttonmash crawl on pull requests, merge-queue refs, and manual dispatches. The job starts the Vite renderer in plain-browser development mode, which installs the repository's no-op electronAPI stub. This exercises the UI shell and browser-safe panel behavior without accessing radios, native dialogs, SQLite, MQTT, or other Electron-only services.

The workflow pins Buttonmash's action commit and npm version, refuses live billing, fails on high or critical findings, and uploads both the Buttonmash report and the Vite server log when a run fails. The detector config ignores the browser stub's expected no-peripheral BLE rejection and two exact third-party teardown races from lucide-react-motion and Leaflet. Native BLE behavior remains covered outside this stubbed lane, while all other high-severity browser errors remain blocking. Maps draw without a basemap here: the Electron main process serves the mesh-tiles: scheme, so the renderer skips tiles on the browser bridge instead of logging a failed request for each. The destructive guard may click the notification-sound Reset buttons (guardrails.destructive.safeNames), which only restore that sound's default; every other Reset and Delete control stays blocked. guardrails.blockMedia is off so the run draws text in the bundled IBM Plex fonts the app serves from its own origin; Buttonmash blocks font and media requests by default. The action and time budgets live in buttonmash.config.json.


Tests (tests.yaml)

Runs on every push, pull request, and merge-queue merge_group for main:

  1. Detect scope: compare a pull request head with its true merge base and reuse the local staged-test planner to select related paths and Vitest projects.
  2. Pull requests: run vitest related without coverage for the affected project lanes. Docs-only changes skip Vitest. Shared contracts select all projects.
  3. Safe fallback: test infrastructure, dependency manifests, deleted/renamed paths, oversized output, or detector failures run the full matrix.
  4. Protected events: merge_group, pushes to main, and manual runs always run full coverage across renderer-ui, renderer-logic, and main.
  5. Sharding: renderer-ui runs in three shards; renderer-logic and main each run once. This applies to both related tests and full coverage. Full runs upload blob reports; related runs write JUnit reports named junit-<project>-<shard>-<total>.xml and print results in each shard's log. The existing Coverage (...) required checks verify that detection and every shard succeeded.
  6. Merge job: collect related JUnit reports without checkout, Node/pnpm setup, or dependency installation. Full runs (protected events and PRs that cannot be safely scoped) still run pnpm run test:coverage:merge to enforce global thresholds.
  7. reticulum-sidecar-coverage: when sidecar paths change, clone the .rsstack/ workspace, run cargo llvm-cov --fail-under-lines 45, and upload lcov.info.
  8. Upload the vitest-report artifact (retained 7 days): separate JUnit files for related shards, including empty shards and available reports from failed runs; one merged junit.xml for full runs.

The three Coverage (...) job names and Merge coverage remain stable for the repository ruleset, including when a project or the whole test matrix has no relevant PR work. Superseded runs for the same pull request or ref are cancelled.

Static analysis on PRs is CodeQL (security) plus ESLint, Clippy, and pre-commit check:* scanners. AI PR review is CodeRabbit (see CodeRabbit below). SonarQube Cloud is not used.

Test results are available as a downloadable artifact from the workflow run.

Electron E2E (e2e.yaml)

Not a PR gate. Runs on a daily schedule (default branch only) and on manual workflow_dispatch:

  1. Checkout (persist-credentials: false), setup pnpm + Node 22, node scripts/check-environment.mjs --skip-node-modules, pnpm install --frozen-lockfile, then pnpm run check:environment (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1)
  2. Linux (ubuntu-24.04): install Electron runtime libraries (libatk-bridge2.0-0t64, libgtk-3-0t64, libasound2t64, …) + xvfb
  3. pnpm run build (unpackaged dist-electron + renderer)
  4. pnpm run test:e2e (Linux under xvfb-run -a; macOS/Windows plain) — Playwright launches the local Electron binary via resolveLocalElectronBin() with an isolated --user-data-dir
  5. On failure, upload test-results/ + playwright-report/ (7-day retention)

Local: pnpm run test:e2e:build. See development-environment.md.

Reticulum sidecar (reticulum-sidecar.yaml)

Path-filtered on reticulum-sidecar/** and related scripts:

  1. lint job (ubuntu-latest) — cargo fmt --check + cargo clippy with rns-stack,rns-ble,rns-rnode-tcp (-D warnings)
  2. Build matrix — stub + full-stack cargo test and release builds on Linux, macOS, and Windows. The WoA arm64 jobs cross-compile release binaries on Windows x64 runners; the Windows x64 matrix jobs run the corresponding host tests once.

The full-stack matrix and the two WoA jobs cache Cargo downloads and compiled dependencies with Swatinem/rust-cache, separately by job, target triple, and Rust toolchain. The cache is restored after cloning the current Ratspeak sources, applying overlays, and selecting the toolchain. The sidecar workspace crate is excluded, so every run still invokes Cargo to rebuild changed path dependencies and the executable. Host tests run on cache hits as well as misses; a cache miss performs a normal build. Release optimization and linking still run, so warm jobs retain some compilation cost.

CI and local dev clones float the .rsstack/ workspace via scripts/clone-ratspeak-stack.sh to origin/main (overlays must apply; optional RS_RETICULUM_REF / RS_LXMF_REF / RS_NOMAD_REF / RS_LXST_REF / RS_LRGP_REF for bisect only — CI never pins Ratspeak SHAs). Open upstream feature PRs needed before they land on main (e.g. rsReticulum#26 ReplyFile, rsLXMF#7 multi-file attachments) are carried as overlays under reticulum-sidecar/patches/ and tracked by RATSPEAK_PATCH_ENTRIES in scripts/update.sh. Release packaging (scripts/build-reticulum-sidecar-release.mjs) runs the same clone and records the resolved commit SHAs for all five crates in .rsstack/RESOLVED_SHAS.txt so artifacts retain the exact source revisions used — set RS_*_REF only when a release must not float.

Local parity: pnpm run reticulum:sidecar:clippy:full, pnpm run check:reticulum-sidecar (pre-commit full-feature). See development-environment.md.


CodeRabbit

PR review comments come from CodeRabbit via .coderabbit.yaml (quiet profile, path filters, auto-pause after two reviewed commits).

  • Prefer opening as a draft until the feature diff is ready, then mark ready for review.
  • Free plan: about 1 PR review per developer per hour; each auto-incremental push counts. After auto-pause, request another pass with @coderabbitai review.
  • Batch actionable findings via the Prompt for AI Agents block into one local commit (Autofix requires Pro).
  • Check remaining allowance with @coderabbitai rate limit.

Release (release.yaml)

Triggered by pushing a version tag (e.g., v1.2.3):

  1. schema-release-compare — first job; compares this SHA’s CURRENT_SCHEMA_VERSION to the last published GitHub Release (paginated Releases API; highest semver among non-draft/non-prerelease rows; recovers vX.Y.Z from release name only when tag_name is missing or untagged-*), writes the Actions step summary, and uploads a schema readme artifact. Job outputs feed installer notices and the draft release body.
  2. prepare-github-release — sole creator of the draft GitHub release for the tag (MESH_CLIENT_ALLOW_DRAFT_CREATE=1), exports release_id (reconstructed from validated digits before GITHUB_OUTPUT — CodeQL js/http-to-file-access), then prepends the schema compare note (via RELEASE_ID, not List Releases). On workflow_dispatch, the tag is resolved in the workflow from package.json and passed as RELEASE_TAG (not read inside the release API script — avoids CodeQL js/file-access-to-http). The schema note is rebuilt from schema-release-compare job outputs (MESH_CLIENT_SCHEMA_*), not from a downloaded markdown artifact (same CodeQL rule).
  3. Installs Linux build dependencies (libudev-dev, rpm, …) on ubuntu-latest runners
  4. Rebuilds native dependencies (pnpm run rebuild)
  5. Stamp CI build info — scripts/ci-write-build-info-env.mjs writes MESH_CLIENT_BUILD_INFO (buildChannel=release + tag + Actions runUrl) into $GITHUB_ENV before dist:* so support-bundle manifest.json and startup logs identify an official release build (see Build channel stamp).
  6. Builds for all three platforms in parallel (or a filtered subset on workflow_dispatch) with --publish never:
  7. macos-latest → pnpm run dist:mac
  8. ubuntu-latest → pnpm run dist:linux
  9. windows-latest → pnpm run dist:win
  10. ci-upload-release-assets.mjs attaches installers / update metadata to the prepare release_id (never POST /releases) via gh api --input path uploads (avoids CodeQL js/file-access-to-http from readFile → fetch). finalize-github-release still consolidates if anything external forked drafts.

Linux packaging smoke (verify-linux-packaging.mjs) asserts .deb Description metadata is ASCII-only. See Release Process.

See Release Process for the maintainer workflow.


Flatpak (flatpak.yaml)

Builds Flatpak bundles using flatpak/flatpak-github-actions.

Triggers: version tags (v*) and manual workflow_dispatch (Build Flatpak (no release)).

A matrix builds x86_64 and aarch64 in parallel. Both use the same privileged ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 container (Flathub remote, flatpak-builder, and system-scope runtime installs). x86_64 runs on ubuntu-latest; aarch64 runs on ubuntu-24.04-arm (native ARM runners — not QEMU on bare Ubuntu).

  1. schema-release-compare — same compare as Build Binaries / Release; uploads READ-ME-FIRST-flatpak.md and feeds write-schema-upgrade-notice.mjs so bumped schemas embed SCHEMA-UPGRADE.txt under Flatpak resources/
  2. Builds the Reticulum sidecar on bare Ubuntu runners, then generates flatpak/generated-sources.json via flatpak-node-generator
  3. Stamps CI build info (test on dispatch / release on tag), builds from org.coloradomesh.MeshClient.yml with offline pnpm sources
  4. Smoke-installs the unstamped local bundle; on dispatch only, renames to org.coloradomesh.MeshClient-run{N}.flatpak
  5. Uploads org.coloradomesh.MeshClient.flatpak-{x86_64,aarch64}.flatpak artifacts (file basename stamped on test builds) plus per-arch flatpak-schema-warning-*

On version tag pushes, a publish job waits for the Electron prepare-github-release draft (ci-wait-github-draft-release.mjs), then attaches both clean-named bundles with ci-upload-release-assets.mjs (never creates a release). aarch64 is the primary ARM Linux install path (release build.yaml only produces x86_64 AppImage/deb/rpm).

flatpak/generated-sources.json is generated automatically in CI by flatpak-node-generator before each build — it does not need to be committed to the repo. For local builds, generate it manually; see development-environment.md for steps. If submitting to Flathub's dedicated submission repo, the file must be committed there.


Third-party licenses (third-party-licenses.yaml)

After merges to main that change package.json, pnpm-lock.yaml, the generator script, or this workflow (and on workflow_dispatch):

  1. Checkout with persist-credentials: false (avoids Duplicate Authorization with create-pull-request)
  2. Setup pnpm + Node 22
  3. Install dependencies (pnpm install --frozen-lockfile)
  4. Audit licenses (pnpm run check:licenses)
  5. Regenerate docs/third-party-licenses.md (pnpm run docs:licenses)
  6. Open a PR via peter-evans/create-pull-request when the file changed (branch chore/third-party-licenses-<run_id>)

Secret: reuse RELEASE_PUSH_TOKEN (admin PAT with contents, workflows, and pull requests write). Default GITHUB_TOKEN PRs do not auto-run required checks, so they cannot enter the merge queue cleanly. Direct pushes to main remain blocked by the merge-queue ruleset. The workflow probes Contents write (create/delete a short-lived ref) before create-pull-request so a token missing write access fails with a clear error instead of a git 403.


Docs (docs.yml)

Deploys documentation to GitHub Pages on every push to main:

  1. Checkout code
  2. Setup Python 3.x
  3. Install MkDocs dependencies (docs/requirements.txt)
  4. Copy README.md → docs/index.md and CONTRIBUTING.md → docs/contributing.md
  5. Rewrite doc links for MkDocs
  6. Deploy with mkdocs gh-deploy --force

Dependabot

Automated dependency updates are configured in .github/dependabot.yml:

  • Schedule: Weekly on Saturdays
  • npm dependencies: Grouped PRs (Electron separate, all other deps together)
  • GitHub Actions: Grouped into one PR
  • Open PRs: open-pull-requests-limit: 0 — Dependabot scans but does not open PRs. Dependency bumps are applied manually via pnpm run update (scripts/update.sh), which also runs dedupe, Ratspeak overlay PR checks (rsReticulum#26 ReplyFile and rsLXMF#7 multi-file attachments are overlays on floated origin/main — see reticulum-sidecar/patches/README.md), and an upstream release / new-org-repo watch (rsLXST, lrgp-rs, Ratspeak Games-parity when a newer published release exists, LXMFace js/lxmface.js commit). Sibling rsReticulum / rsLXMF / rsNomad / rsLXST / lrgp-rs float to origin/main via clone-ratspeak-stack.sh (overlays must apply; no committed SHA pins). See AGENTS.md §6.

Testing Dependabot PRs locally

Use pnpm (not npm) to test dependabot PRs:

git checkout <dependabot-branch>
pnpm install --frozen-lockfile
pnpm run build
pnpm run test:run

Do not use npm install; it will create a package-lock.json and may not respect pnpm's lockfile format.


Running CI Locally with act

Optional tooling: You can run local CI in two ways:

Mode Command prefix Requires What it does
Container (default) pnpm run act:ci, pnpm run act:tests, … Docker-compatible engine + act Runs GitHub Actions jobs inside Linux containers (closest to CI)
Host / native pnpm run act:ci:native, pnpm run act:tests:native, … Node/pnpm only Runs the same pnpm/cargo steps directly on your machine (no container engine)

Container mode runs GitHub Actions jobs inside Linux containers using a Docker-compatible engine (Podman preferred). Host mode runs the same pnpm/cargo steps directly — use this when no container engine is available or act cannot reach the daemon. pnpm run check:environment warns if no container engine or act is missing but does not block commits. Use native scripts when no Docker-compatible engine is available or act cannot reach the daemon.

Docker pnpm run act:ci runs the real ci.yaml work jobs (quality, lint, typecheck, app-build, policy-scanners) in sequence. It does not invoke the Build & Test aggregator (build) — that job only checks sibling results on GitHub so the required check name stays stable. Path-filtered Flatpak checks are not part of container act:ci (native act:ci:native still runs them; or change Flatpak inputs and use the flatpak job / act:flatpak).

macOS note: Podman Desktop is the preferred Docker-compatible engine for local CI. When Docker compatibility is enabled, Podman exposes a Docker-compatible socket at /var/run/docker.sock; pass that path to act via ACT_DOCKER_SOCKET, or let act detect it automatically if Podman created the symlink. If you use Docker Desktop instead, its socket is typically under ~/.docker/run/docker.sock.

Install act (container mode only):

# macOS
brew install act

# Linux / Windows
# https://github.com/nektos/act/releases

On Windows, Podman Desktop with Docker compatibility is preferred. If you use Docker Desktop instead, use the WSL2 backend. On Apple Silicon, act uses --container-architecture linux/amd64 automatically for x86_64 CI parity.

Podman: scripts/run-act.mjs passes --container-daemon-socket to act (auto-detects /var/run/docker.sock on macOS). If act still cannot connect, set ACT_DOCKER_SOCKET to your socket path or use native mode. If you use Docker Desktop instead, its socket is typically under ~/.docker/run/docker.sock.

Package scripts

# One-time (container mode)
pnpm run act:pull-images

# List targets
pnpm run act:list

# PR parity — container (act + Podman/Docker)
# act:ci runs quality / lint / typecheck / app-build / policy-scanners
# (not the GitHub-only Build & Test aggregator job)
pnpm run act:ci
pnpm run act:tests
pnpm run act:pr

# PR parity — host (no container engine)
pnpm run act:ci:native
pnpm run act:tests:native
pnpm run act:pr:native

# Linux packaging
pnpm run act:build:linux        # container
pnpm run act:build:linux:native # host (best on Linux)

# Heavier workflows (container only unless noted)
pnpm run act:reticulum
pnpm run act:reticulum:native # stub sidecar cargo test/build on host
pnpm run act:flatpak          # docker only

# Override mode on one invocation
node scripts/run-act.mjs ci --native
node scripts/run-act.mjs ci --docker
MESH_CLIENT_ACT_MODE=native pnpm run act:ci

# Dry-run passthrough (container mode)
node scripts/run-act.mjs ci -- -n

What runs locally vs native OS only

Goal Container (act:*) Host (act:*:native) macOS host only Windows host only
PR checks (lint / test / build) act:ci + act:tests act:ci:native + :native same same
Linux installers (AppImage / deb / rpm) act:build:linux act:build:linux:native cross-build may differ cross-build may differ
macOS .dmg / .zip — — pnpm run dist:mac —
Windows .exe — — — pnpm run dist:win
Flatpak x86_64 act:flatpak use local Flatpak docs same same

Not run locally via act: docs.yml (mkdocs gh-deploy), release publish legs, macos-latest / windows-latest / windows-11-vs2026-arm matrix jobs, and ubuntu-24.04-arm Flatpak builds (no faithful local emulation).

Note: The test results artifact upload step is automatically skipped when running under act (detected by actor nektos/act in tests.yaml).


Pipeline status (issue #378)

Area Status
PR lint / typecheck / build / tests Done (ci.yaml, tests.yaml)
CodeQL / CodeRabbit Done (CodeQL default setup — PR/push/schedule; not merge-queue)
Tag → draft multi-OS + Flatpak + packaging smoke Done (release.yaml, flatpak.yaml, build.yaml)
pnpm run release preflight + bump/tag Done (scripts/release.sh; --yes for non-interactive)
Manual draft Publish on GitHub Intentional (human review of artifacts)
Dep bumps Manual (pnpm run update; Dependabot PRs disabled)
Merge queue + required status checks Done (ruleset 20821455 on main; see below)
E2E Daily / workflow_dispatch only — not a merge gate

Merge queue and rulesets

main is protected by a repository ruleset (not classic branch protection) that:

  1. Requires a pull request before merging
  2. Requires at least one approving review before the merge queue
  3. Requires a merge queue
  4. Requires strict status checks (must pass on the merge group / up-to-date tip)
  5. Blocks force-pushes and branch deletion on main

Required check names (always-on)

Only checks that report on every PR and every merge_group run are required:

Check name Workflow
Build & Test ci.yaml
Coverage (renderer-ui) tests.yaml
Coverage (renderer-logic) tests.yaml
Coverage (main) tests.yaml
Merge coverage tests.yaml

Each required check is pinned with integration_id 15368 (GitHub Actions) in .github/rulesets/main-merge-queue.json.

Do not add these as required (they skip or are not PR/merge_group gates and would stall the queue):

  • Reticulum sidecar coverage (path-filtered)
  • fmt + clippy / sidecar build matrix (reticulum-sidecar.yaml, path-filtered)
  • CodeQL Analyze (*) — default setup does not run on merge_group; CodeQL still runs on PRs/pushes. Requiring it would hang the merge queue until advanced setup + merge_group exists.
  • E2E, packaging smoke, Flatpak, release jobs

ci.yaml and tests.yaml both listen for merge_group so the queue’s temporary ref re-runs the same gates.

Bypass actors

  • Repository admins (RepositoryRole id 5) — emergency hotfixes and local pnpm run release (direct push of bump commit + tag to main)

GitHub Actions cannot be added as a bypass actor on this organization (“must be part of the ruleset source or owner organization”). third-party-licenses.yaml therefore opens a PR (via RELEASE_PUSH_TOKEN) instead of pushing to main.

Pull request gate: one approving review, dismiss stale reviews on push, and require last push approval so an approved PR cannot enter the merge queue after unreviewed follow-up commits.

Applying / updating the ruleset

Canonical JSON lives at .github/rulesets/main-merge-queue.json (live ruleset id 20821455). Vitest contract: scripts/main-merge-queue-ruleset.test.mjs (pinned checks + review gates). After changing the JSON, PUT the live ruleset or drift will remain until someone syncs:

# Update live ruleset from canonical JSON
gh api repos/Colorado-Mesh/mesh-client/rulesets/20821455 \
  --method PUT \
  --input .github/rulesets/main-merge-queue.json

gh api --input can hit HTTP/2 content-length issues on create; if that fails, POST the JSON body with Python urllib (same payload).

The ruleset is already active with merge_group triggers on ci.yaml / tests.yaml. Keep those triggers if you ever recreate the ruleset — enabling the queue without them leaves required checks pending forever.


Required Status Checks

All PRs (and merge-queue groups) for main must pass the required check names listed above. Those jobs cover:

  • Lint, format, markdown, licenses, actionlint, yamllint (pnpm run lint and related steps in ci.yaml)
  • Cheap always-on policy scanners (check:electron-security, log/XSS/console/IPC/protocol-string gates, and the matching cheap check:* set)
  • Typecheck and build (pnpm run typecheck, pnpm run build)
  • Affected Vitest tests on pull requests; full Vitest with global coverage thresholds on merge_group, main, and manual runs

Pre-commit Hook

The pre-commit hook (.githooks/pre-commit) runs checks beyond what GitHub Actions runs directly:

  • Staged-file Prettier + markdownlint (not a full-tree pnpm run format / lint:md)
  • pnpm dedupe when dependency manifests are staged
  • pnpm run i18n:auto-translate when en/translation.json is staged (fills new English keys vs HEAD) + re-stages locales
  • Staged ESLint (--cache) + full typecheck; path-gated typecheck:strict-shared when shared paths staged; always-on cheap check:* scanners; path-gated flatpak / DB / IPC / reticulum catalog / full-feature sidecar checks (sidecar also requires cargo on PATH when sidecar paths are staged; check:i18n when English locale staged, else check:i18n:branch)
  • Before PR: pnpm run check:pr (full lint + typecheck + strict-shared + test:run + path-aware sidecar)
  • pnpm audit only when dependency manifests staged; actionlint / yamllint only when relevant files are staged
  • pnpm run test:staged (scripts/precommit-tests.mjs: staged-only vitest related; full suite when vitest config/setup mocks or dependency manifests change; skip when no source/test staged)

PR CI (tests.yaml) selects merge-base-related Vitest work and fails closed to the full suite when scoping is unsafe. The merge queue and pnpm run release always run full Vitest; green pre-commit does not replace those gates.

CI focuses on lint, typecheck, build, cheap always-on policy scanners, Flatpak metadata validation, and coverage tests. i18n quality is enforced locally via pre-commit and indirectly in CI through Vitest (locale-quality.test.ts).


Troubleshooting

CI fails but passes locally

  • Ensure you're using Node 22 (same as CI)
  • Run pnpm install --frozen-lockfile to match CI's exact dependency versions
  • Check for platform-specific differences (paths, case sensitivity)

Release workflow fails

  • Verify the tag follows semantic versioning (v1.2.3)
  • Ensure GH_TOKEN secret is set in repository settings
  • Check that dist:* / dist:*:publish scripts exist in package.json (tag release CI uses dist:* + ci-upload-release-assets.mjs)

Docs deployment fails

  • Verify docs/requirements.txt dependencies are valid
  • Check MkDocs configuration in mkdocs.yml
  • Ensure all referenced doc files exist

Packaging smoke builds (build.yaml / flatpak.yaml / release.yaml)

build.yaml and release.yaml call packaging-sidecars.yaml to build the x64 and ARM64 Reticulum sidecars in parallel on separate runners (job UI: Stage Reticulum). Each OS runs the full-feature host tests once in its native architecture job. Release platform selection applies to both the sidecar and Electron matrices. Packaging starts after the selected sidecars succeed, so a failed build or test blocks the installers.

The sidecars reach the packaging jobs as tar archives from the same workflow run (ci-reticulum-staged-{platform}-{arch} — internal handoff only; not installer downloads). Tar preserves Unix executable permissions; each architecture also carries its own RESOLVED_SHAS.txt for the freshly cloned Ratspeak sources. The download action verifies that both platform binaries are staged before Electron packaging. Installer names, signing, updater metadata, and the four packaging smoke jobs remain the same. The existing reticulum-sidecar.yaml checks remain independent.

Actions artifact zip names (installer downloads): Release keeps mesh-client-{macos|linux|windows}-{sha}. Build Binaries (always workflow_dispatch) uses the same names with a test- prefix (test-mesh-client-…) so testers can tell them apart from official packaging runs. Filenames inside those zips still get -run{N} on Build Binaries (see below).

Local node scripts/build-reticulum-sidecar-release.mjs --platform win32|linux|darwin still runs host tests and builds both architectures. --arch x64|arm64 selects one target; CI uses --skip-tests only for the cross-build whose host tests run in the native job. Parallel jobs reduce elapsed build time but need more concurrent runners; queue delays can reduce the gain.

Packaging sidecars cache Cargo dependencies with Swatinem/rust-cache, separately by target triple, runner OS/architecture, and Rust toolchain. The packaging key is separate from validation jobs. The sidecar workspace crate and Cargo-installed tools are excluded. Every run still clones the current Ratspeak sources, applies overlays, runs the native host tests, and builds/stages the selected target; a cache hit never skips those steps. Missing caches fall back to a normal build.

To test sidecar staging or compare cold and warm cache timings without building installers or publishing a release, run Packaging sidecars manually and select all, mac, linux, or win. It uses the same jobs as Build Binaries and Release and uploads only the staged sidecar artifacts.

Build channel stamp (test vs release)

Build Binaries (build.yaml), Release (release.yaml), and Build Flatpak (flatpak.yaml) run scripts/ci-write-build-info-env.mjs before packaging. That writes a JSON MESH_CLIENT_BUILD_INFO blob into $GITHUB_ENV, which scripts/esbuild-main-build.mjs embeds via esbuild --define into the main process. Flatpak also writes flatpak/ci-build-info.json (gitignored) so the sandbox pnpm run build sees the same env.

Channel Workflow Support-bundle manifest.json
test Build Binaries (no release); Build Flatpak (no release) buildChannel: "test" + buildInfo.runUrl (Actions run)
release Build/Release Electron App; Build Flatpak (tag) buildChannel: "release" + tag + buildInfo.runUrl
local unmarked pnpm run dist / dev / local Flatpak buildChannel: "local" only

appVersion remains package.json semver (unchanged). Use buildChannel + buildInfo.runUrl when triaging Export for GitHub / Developer zips so a test binary is not mistaken for an official release. Startup logs include a compact fragment (buildChannel=… run=… runId=… sha=…).

Which binary am I running? If a tester says they downloaded Actions run N but the app reports a different run, open Export for GitHub → manifest.json → buildInfo.runUrl (authoritative), or the [Startup] runtime … run=… line in the app log. Same-semver test installers used to share identical filenames across runs; test builds now stamp -run{N} into downloadable basenames (see below).

Test-build installer filenames (-run{N})

Test / one-off only — never official GitHub Release assets:

Workflow When Filename stamp
build.yaml Always (dispatch-only) After dist:*, scripts/rename-test-build-artifacts.mjs renames AppImage/deb/rpm/DMG/ZIP/Setup under release/ to include -run{GITHUB_RUN_NUMBER} (e.g. Mesh-client-5.26.0-run214.AppImage, Mesh-client-Setup-5.26.0-run214.exe)
flatpak.yaml workflow_dispatch only After in-job smoke, rename to org.coloradomesh.MeshClient-run{N}.flatpak, then upload
flatpak.yaml tag v* (release publish) Clean org.coloradomesh.MeshClient.flatpak (no -run{N})
release.yaml tag publish Clean electron-builder names (no rename step)

packaging-smoke on Build Binaries downloads stamped names (Windows Setup matcher accepts default or -run{N}). Flatpak smoke always uses the unstamped local path before rename. Manual Flatpak runs use Actions run title Build Flatpak (no release); tag runs use Build Flatpak.

Schema compare vs last official release

Build Binaries, Build Flatpak, and Release start with a schema-release-compare job (scripts/ci-schema-release-compare.mjs) that:

  1. Labels Build Binaries / Build Flatpak (no release) runs as a test build (not an official release) in $GITHUB_STEP_SUMMARY
  2. Compares this tree’s CURRENT_SCHEMA_VERSION to the last published (non-draft) GitHub Release tag
  3. Uploads READ-ME-FIRST-test-build.md (build) / READ-ME-FIRST-flatpak.md (flatpak) / READ-ME-FIRST-schema.md (release). Build Binaries stages the note into release/ before platform uploads so upload-artifact’s least-common-ancestor stays under release/ (mixing release-warnings/ nests installers as release/release/*.exe and breaks packaging-smoke). Flatpak keeps a separate per-arch flatpak-schema-warning-* artifact beside the bundle
  4. Exposes schema_bumped / curr_schema / prev_schema / prev_tag for packaging

Packaging always runs scripts/write-schema-upgrade-notice.mjs. On a schema bump it writes the Windows NSIS MessageBox include and SCHEMA-UPGRADE.txt for macOS/Linux/Flatpak (electron-builder-before-pack.mjs / Flatpak resources/ copy). With no bump it still writes a no-op resources/schema-upgrade-notice.nsh stub — NSIS !include of a missing file is warning 7000, and electron-builder treats warnings as errors.

On first launch after a schema bump against an existing database, the app shows a blocking Quit / Upgrade dialog before mutating SQLite (see Release Process — Database schema upgrades).

Linux arm64 cross-builds on Ubuntu 24.04 runners use scripts/ci-setup-linux-arm64-apt.sh before dpkg --add-architecture arm64. The script pins Architectures: amd64 only on deb822 stanzas in ubuntu.sources that lack an Architectures field, writes arm64 ports mirrors as deb822 arm64.sources (not legacy .list), and is idempotent across workflow re-runs.

Reticulum sidecar staging before electron-builder:

  • scripts/build-reticulum-sidecar-release.mjs — compile/copy sidecar per target OS/arch
  • scripts/verify-reticulum-sidecar-staged.mjs — size/assert checks
  • scripts/electron-builder-before-pack.mjs — copy into resources/reticulum-sidecar/

Post-build smoke tests:

macOS packaging verify (verify-mac-packaging.mjs)

  • scripts/verify-mac-packaging.mjs — macOS packaging guard (runs after dist:mac / dist:mac:publish and in packaging-smoke on tag releases). Validates:
  • Both x64 and arm64 .dmg / .zip artifacts under release/ (path or file-name markers), each above minimum size
  • Bundle layout via direct .app (every complete on-disk bundle), ditto -xk ZIP extract for every ZIP, and hdiutil attach for every DMG (not only the largest archive)
  • DMG mount root includes an Applications → /Applications symlink and IMPORTANT-Read-Me.txt (7-Zip / bad ZIP extract warning; prefer DMG or Keka) — drag-to-install layout from electron-builder.yml dmg.contents
  • Electron Framework symlinks (Versions/Current, root Electron Framework) remain symlinks — upload-artifact dereferences them and breaks the bundle (~3× framework bloat)
  • Squirrel / Mantle / ReactiveObjC framework symlinks and binaries (7-Zip flattening breaks Squirrel at launch)
  • Staged 00-READ-ME-BEFORE-EXTRACTING-macOS-ZIP.txt uploaded beside macOS ZIP/DMG on GitHub Releases
  • Thin MacOS launcher + full Electron Framework binary sizes; bundled Reticulum sidecar present
  • Developer ID–signed builds only: codesign --verify --deep --strict on the finished .app (DMG mount / ZIP extract / on-disk), xcrun stapler validate (stapled notarization ticket), and codesign --verify --strict on the bundled Reticulum sidecar. Unsigned or ad-hoc (non–Developer ID) local dist:mac builds skip this gate.
  • CI uploads DMG/ZIP only — never raw Mesh-client.app (see comment in release.yaml Upload macOS Artifact)
  • Optional signing env (CSC_LINK, CSC_KEY_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, CSC_IDENTITY_AUTO_DISCOVERY) is passed through from workflow secrets on macos-latest; layout checks do not require them, but signed CI builds must pass the codesign/stapler gate above
  • scripts/test-linux-appimage-reticulum-sidecar.mjs — x64 uses --appimage-extract; arm64 on x64 runners uses unsquashfs for cross-arch extract
  • scripts/test-linux-appimage-launch.mjs — headless launch smoke that proves the packaged Linux AppImage actually boots and loads the renderer, not just that its files are well-formed or that the main process wrote a log line. Runs under xvfb-run in the packaging-smoke Linux leg (build.yaml / release.yaml), with a 5-minute step timeout. Extracts the AppImage (the file listing is kept out of the CI log unless extraction fails), launches its AppRun with MESH_CLIENT_DISABLE_GPU=1, --no-sandbox --disable-setuid-sandbox, and a throwaway --user-data-dir, then asserts the process stays alive past a startup threshold with no crash exit, that mesh-client.log contains a renderer-loaded signal ([Startup] renderer URL: or a [renderer line), and that the log and captured stderr have no renderer-gone, failed-load, or child-process-gone lines (those paths block on an error dialog, so the process can stay alive). Only the native-arch AppImage is launched (a cross-arch runtime cannot execute on the runner; that image is still covered by the sidecar extract smoke above). If no AppImage matches the host architecture, the smoke exits non-zero. The step gates build.yaml and release.yaml. Requires xvfb + Electron runtime libs (libnss3, libgtk-3-0t64, libgbm1, libasound2t64, …), installed by scripts/ci-install-linux-appimage-smoke-deps.sh from the Linux smoke step in both workflows.
  • scripts/test-win-nsis-install.mjs — NSIS + 7z sidecar probe on WoA

Local packaging parity: see development-environment.md.