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.mjsto 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, andappstreamcli 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-verifyand 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:
- 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.
- Pull requests: run
vitest relatedwithout coverage for the affected project lanes. Docs-only changes skip Vitest. Shared contracts select all projects. - Safe fallback: test infrastructure, dependency manifests, deleted/renamed paths, oversized output, or detector failures run the full matrix.
- Protected events:
merge_group, pushes tomain, and manual runs always run full coverage acrossrenderer-ui,renderer-logic, andmain. - Sharding:
renderer-uiruns in three shards;renderer-logicandmaineach run once. This applies to both related tests and full coverage. Full runs upload blob reports; related runs write JUnit reports namedjunit-<project>-<shard>-<total>.xmland print results in each shard's log. The existingCoverage (...)required checks verify that detection and every shard succeeded. - 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:mergeto enforce global thresholds. reticulum-sidecar-coverage: when sidecar paths change, clone the.rsstack/workspace, runcargo llvm-cov --fail-under-lines 45, and uploadlcov.info.- Upload the
vitest-reportartifact (retained 7 days): separate JUnit files for related shards, including empty shards and available reports from failed runs; one mergedjunit.xmlfor 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:
- Checkout (
persist-credentials: false), setup pnpm + Node 22,node scripts/check-environment.mjs --skip-node-modules,pnpm install --frozen-lockfile, thenpnpm run check:environment(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1) - Linux (
ubuntu-24.04): install Electron runtime libraries (libatk-bridge2.0-0t64,libgtk-3-0t64,libasound2t64, …) +xvfb pnpm run build(unpackageddist-electron+ renderer)pnpm run test:e2e(Linux underxvfb-run -a; macOS/Windows plain) — Playwright launches the local Electron binary viaresolveLocalElectronBin()with an isolated--user-data-dir- 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:
lintjob (ubuntu-latest) —cargo fmt --check+cargo clippywithrns-stack,rns-ble,rns-rnode-tcp(-D warnings)- Build matrix — stub + full-stack
cargo testand 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):
schema-release-compare— first job; compares this SHA’sCURRENT_SCHEMA_VERSIONto the last published GitHub Release (paginated Releases API; highest semver among non-draft/non-prerelease rows; recoversvX.Y.Zfrom release name only whentag_nameis missing oruntagged-*), writes the Actions step summary, and uploads a schema readme artifact. Job outputs feed installer notices and the draft release body.prepare-github-release— sole creator of the draft GitHub release for the tag (MESH_CLIENT_ALLOW_DRAFT_CREATE=1), exportsrelease_id(reconstructed from validated digits beforeGITHUB_OUTPUT— CodeQLjs/http-to-file-access), then prepends the schema compare note (viaRELEASE_ID, not List Releases). Onworkflow_dispatch, the tag is resolved in the workflow frompackage.jsonand passed asRELEASE_TAG(not read inside the release API script — avoids CodeQLjs/file-access-to-http). The schema note is rebuilt fromschema-release-comparejob outputs (MESH_CLIENT_SCHEMA_*), not from a downloaded markdown artifact (same CodeQL rule).- Installs Linux build dependencies (
libudev-dev,rpm, …) onubuntu-latestrunners - Rebuilds native dependencies (
pnpm run rebuild) - Stamp CI build info —
scripts/ci-write-build-info-env.mjswritesMESH_CLIENT_BUILD_INFO(buildChannel=release+ tag + ActionsrunUrl) into$GITHUB_ENVbeforedist:*so support-bundlemanifest.jsonand startup logs identify an official release build (see Build channel stamp). - Builds for all three platforms in parallel (or a filtered subset on
workflow_dispatch) with--publish never: macos-latest→pnpm run dist:macubuntu-latest→pnpm run dist:linuxwindows-latest→pnpm run dist:winci-upload-release-assets.mjsattaches installers / update metadata to the preparerelease_id(neverPOST /releases) viagh api --inputpath uploads (avoids CodeQLjs/file-access-to-httpfromreadFile→fetch).finalize-github-releasestill 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).
schema-release-compare— same compare as Build Binaries / Release; uploadsREAD-ME-FIRST-flatpak.mdand feedswrite-schema-upgrade-notice.mjsso bumped schemas embedSCHEMA-UPGRADE.txtunder Flatpakresources/- Builds the Reticulum sidecar on bare Ubuntu runners, then generates
flatpak/generated-sources.jsonviaflatpak-node-generator - Stamps CI build info (
teston dispatch /releaseon tag), builds fromorg.coloradomesh.MeshClient.ymlwith offline pnpm sources - Smoke-installs the unstamped local bundle; on dispatch only, renames to
org.coloradomesh.MeshClient-run{N}.flatpak - Uploads
org.coloradomesh.MeshClient.flatpak-{x86_64,aarch64}.flatpakartifacts (file basename stamped on test builds) plus per-archflatpak-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):
- Checkout with
persist-credentials: false(avoids Duplicate Authorization with create-pull-request) - Setup pnpm + Node 22
- Install dependencies (
pnpm install --frozen-lockfile) - Audit licenses (
pnpm run check:licenses) - Regenerate
docs/third-party-licenses.md(pnpm run docs:licenses) - Open a PR via
peter-evans/create-pull-requestwhen the file changed (branchchore/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:
- Checkout code
- Setup Python 3.x
- Install MkDocs dependencies (
docs/requirements.txt) - Copy
README.md→docs/index.mdandCONTRIBUTING.md→docs/contributing.md - Rewrite doc links for MkDocs
- 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 viapnpm 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 floatedorigin/main— seereticulum-sidecar/patches/README.md), and an upstream release / new-org-repo watch (rsLXST, lrgp-rs, Ratspeak Games-parity when a newer published release exists, LXMFacejs/lxmface.jscommit). Sibling rsReticulum / rsLXMF / rsNomad / rsLXST / lrgp-rs float toorigin/mainviaclone-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:
- Requires a pull request before merging
- Requires at least one approving review before the merge queue
- Requires a merge queue
- Requires strict status checks (must pass on the merge group / up-to-date tip)
- 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 onmerge_group; CodeQL still runs on PRs/pushes. Requiring it would hang the merge queue until advanced setup +merge_groupexists. - 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 (
RepositoryRoleid 5) — emergency hotfixes and localpnpm run release(direct push of bump commit + tag tomain)
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 lintand related steps inci.yaml) - Cheap always-on policy scanners (
check:electron-security, log/XSS/console/IPC/protocol-string gates, and the matching cheapcheck:*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 dedupewhen dependency manifests are stagedpnpm run i18n:auto-translatewhenen/translation.jsonis staged (fills new English keys vsHEAD) + re-stages locales- Staged ESLint (
--cache) + fulltypecheck; path-gatedtypecheck:strict-sharedwhen shared paths staged; always-on cheapcheck:*scanners; path-gated flatpak / DB / IPC / reticulum catalog / full-feature sidecar checks (sidecar also requirescargoonPATHwhen sidecar paths are staged;check:i18nwhen English locale staged, elsecheck:i18n:branch) - Before PR:
pnpm run check:pr(full lint + typecheck + strict-shared +test:run+ path-aware sidecar) pnpm auditonly when dependency manifests staged;actionlint/yamllintonly when relevant files are stagedpnpm run test:staged(scripts/precommit-tests.mjs: staged-onlyvitest 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-lockfileto 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_TOKENsecret is set in repository settings - Check that
dist:*/dist:*:publishscripts exist inpackage.json(tag release CI usesdist:*+ci-upload-release-assets.mjs)
Docs deployment fails
- Verify
docs/requirements.txtdependencies 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:
- Labels Build Binaries / Build Flatpak (no release) runs as a test build (not an official release) in
$GITHUB_STEP_SUMMARY - Compares this tree’s
CURRENT_SCHEMA_VERSIONto the last published (non-draft) GitHub Release tag - 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 intorelease/before platform uploads soupload-artifact’s least-common-ancestor stays underrelease/(mixingrelease-warnings/nests installers asrelease/release/*.exeand breakspackaging-smoke). Flatpak keeps a separate per-archflatpak-schema-warning-*artifact beside the bundle - Exposes
schema_bumped/curr_schema/prev_schema/prev_tagfor 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/archscripts/verify-reticulum-sidecar-staged.mjs— size/assert checksscripts/electron-builder-before-pack.mjs— copy intoresources/reticulum-sidecar/
Post-build smoke tests:
macOS packaging verify (verify-mac-packaging.mjs)
scripts/verify-mac-packaging.mjs— macOS packaging guard (runs afterdist:mac/dist:mac:publishand inpackaging-smokeon tag releases). Validates:- Both x64 and arm64
.dmg/.zipartifacts underrelease/(path or file-name markers), each above minimum size - Bundle layout via direct
.app(every complete on-disk bundle),ditto -xkZIP extract for every ZIP, andhdiutil attachfor every DMG (not only the largest archive) - DMG mount root includes an
Applications→/Applicationssymlink andIMPORTANT-Read-Me.txt(7-Zip / bad ZIP extract warning; prefer DMG or Keka) — drag-to-install layout fromelectron-builder.ymldmg.contents - Electron Framework symlinks (
Versions/Current, rootElectron Framework) remain symlinks —upload-artifactdereferences 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.txtuploaded 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 --stricton the finished.app(DMG mount / ZIP extract / on-disk),xcrun stapler validate(stapled notarization ticket), andcodesign --verify --stricton the bundled Reticulum sidecar. Unsigned or ad-hoc (non–Developer ID) localdist:macbuilds skip this gate. - CI uploads DMG/ZIP only — never raw
Mesh-client.app(see comment inrelease.yamlUpload 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 onmacos-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 usesunsquashfsfor cross-arch extractscripts/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 underxvfb-runin thepackaging-smokeLinux 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 itsAppRunwithMESH_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, thatmesh-client.logcontains a renderer-loaded signal ([Startup] renderer URL:or a[rendererline), 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 gatesbuild.yamlandrelease.yaml. Requiresxvfb+ Electron runtime libs (libnss3,libgtk-3-0t64,libgbm1,libasound2t64, …), installed byscripts/ci-install-linux-appimage-smoke-deps.shfrom 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.