CI/CD Workflows

September 7, 2026 · View on GitHub

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


Workflows

WorkflowTriggerPurpose
ci.yamlPush/PR/merge_group/workflow_dispatchLint, typecheck, build, Flatpak manifest validation
tests.yamlPush/PR/merge_group/workflow_dispatchVitest coverage + merge; Reticulum sidecar llvm-cov when sidecar paths change
buttonmash.yamlPR/merge_group/workflow_dispatchBrowser-based chaos testing of the Vite renderer
e2e.yamlDaily on main + manual workflow_dispatchPlaywright Electron E2E (unpackaged build, 3-OS; not a PR gate)
build.yamlManual workflow_dispatchNative 3-OS packaging smoke build (+ schema compare vs last official)
reticulum-sidecar.yamlPath-filtered push/PR to mainSidecar fmt + Clippy (ubuntu); multi-OS matrix build/test
release.yamlVersion tags (v*)Build & publish releases (AppImage/deb/rpm)
flatpak.yamlVersion tags (v*), manualBuild Flatpak (+ schema compare vs last official); publish to release on tags
cut-release.yamlManual workflow_dispatchPrimary release cut in Actions (needs admin RELEASE_PUSH_TOKEN)
docs.ymlPush to mainDeploy MkDocs to GitHub Pages
third-party-licenses.yamlPath-filtered push to main + dispatchRegenerate 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). After one change-detection job, independent lanes run concurrently:

  • Code quality: format, markdownlint, ESLint, license allowlist, actionlint, dependency audit, and yamllint
  • 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

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. 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. Merge job: combine scoped blob reports for PR feedback, or run pnpm run test:coverage:merge on protected events to enforce global thresholds.
  6. reticulum-sidecar-coverage: when sidecar paths change, clone the .rsstack/ workspace, run cargo llvm-cov --fail-under-lines 45, and upload lcov.info.
  7. Upload merged test results (retained 7 days).

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 (including WoA arm64 jobs)

CI and local dev clones float the .rsstack/ workspace via scripts/clone-ratspeak-stack.sh to origin/main (overlays must apply; override with RS_RETICULUM_REF / RS_LXMF_REF / RS_NOMAD_REF / RS_LXST_REF / RS_LRGP_REF for bisect). 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 — pin via RS_*_REF 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-releasesole 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 infoscripts/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:
    • macos-latestpnpm run dist:mac
    • ubuntu-latestpnpm run dist:linux
    • windows-latestpnpm run dist:win
  7. 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 readFilefetch). 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.mddocs/index.md and CONTRIBUTING.mddocs/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, 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). 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:

ModeCommand prefixRequiresWhat it does
Container (default)pnpm run act:ci, pnpm run act:tests, …Docker-compatible engine + actRuns GitHub Actions jobs inside Linux containers (closest to CI)
Host / nativepnpm run act:ci:native, pnpm run act:tests:native, …Node/pnpm onlyRuns 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.

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)
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

GoalContainer (act:*)Host (act:*:native)macOS host onlyWindows host only
PR checks (lint / test / build)act:ci + act:testsact:ci:native + :nativesamesame
Linux installers (AppImage / deb / rpm)act:build:linuxact:build:linux:nativecross-build may differcross-build may differ
macOS .dmg / .zippnpm run dist:mac
Windows .exepnpm run dist:win
Flatpak x86_64act:flatpakuse local Flatpak docssamesame

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)

AreaStatus
PR lint / typecheck / build / testsDone (ci.yaml, tests.yaml)
CodeQL / CodeRabbitDone (CodeQL default setup — PR/push/schedule; not merge-queue)
Tag → draft multi-OS + Flatpak + packaging smokeDone (release.yaml, flatpak.yaml, build.yaml)
pnpm run release preflight + bump/tagDone (scripts/release.sh; --yes for non-interactive)
Manual draft Publish on GitHubIntentional (human review of artifacts)
Dep bumpsManual (pnpm run update; Dependabot PRs disabled)
Merge queue + required status checksDone (ruleset 20821455 on main; see below)
E2EDaily / 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 nameWorkflow
Build & Testci.yaml
Coverage (renderer-ui)tests.yaml
Coverage (renderer-logic)tests.yaml
Coverage (main)tests.yaml
Merge coveragetests.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)
  • 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, 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 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.

ChannelWorkflowSupport-bundle manifest.json
testBuild Binaries (no release); Build Flatpak (no release)buildChannel: "test" + buildInfo.runUrl (Actions run)
releaseBuild/Release Electron App; Build Flatpak (tag)buildChannel: "release" + tag + buildInfo.runUrl
localunmarked pnpm run dist / dev / local FlatpakbuildChannel: "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 GitHubmanifest.jsonbuildInfo.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:

WorkflowWhenFilename stamp
build.yamlAlways (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.yamlworkflow_dispatch onlyAfter in-job smoke, rename to org.coloradomesh.MeshClient-run{N}.flatpak, then upload
flatpak.yamltag v* (release publish)Clean org.coloradomesh.MeshClient.flatpak (no -run{N})
release.yamltag publishClean 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$ \text{dereferences} \text{them} \text{and} \text{breaks} \text{the} \text{bundle} (~3 \times \text{framework} \text{bloat})
    • \text{Squirrel} / \text{Mantle} / \text{ReactiveObjC} \text{framework} \text{symlinks} \text{and} \text{binaries} (7-\text{Zip} \text{flattening} \text{breaks} \text{Squirrel} \text{at} \text{launch})
    • \text{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-win-nsis-install.mjs — NSIS + 7z sidecar probe on WoA

Local packaging parity: see development-environment.md.