A peer-to-peer git daemon with an issues board and cicd.
5
stars
90
commits
TypeScript
primary language
Aug 30, 2026
updated
:7979 (HTTPS by default; HTTP opt-in via transport.tls = false)./<repo>.git/..., POST /mesh/frame for signed
notifications, GET /status for the browser dashboard.git push.This was first built with Rust. But as an unsigned-compiled program, the company network security defense tooling and policy "dropped" inbound requests. Not technically dropped, but would essentially send "empty responses" back.
curl -fsSL https://raw.githubusercontent.com/kaizen-hq/mesh/main/install.sh | bash
Installs the latest source from main. Requires bun, unzip, and curl.
To install a specific release:
curl -fsSL https://raw.githubusercontent.com/kaizen-hq/mesh/main/install.sh | MESH_REF=v1.2.3 bash
After install, mesh is available at ~/.local/bin/mesh. Make sure that directory is on your PATH:
export PATH="$HOME/.local/bin:$PATH" # add to ~/.zshrc or ~/.bashrc
To update to the latest release at any time:
mesh update
To pin a specific release:
mesh update --ref v1.2.3
To update from a peer on your local network instead of GitHub:
mesh update --from alice
If a teammate already has mesh running, they can serve the installer directly:
# On the teammate's machine:
mesh serve-install # prints a curl line for you to run
This serves the same install flow over plain HTTP — no GitHub needed. Useful when you're on the same network and want the exact version your teammate is running.
git clone git@github.com:kaizen-hq/mesh.git
cd mesh
bun install
Requires Bun 1.1+. git must be on PATH.
Run directly without compiling:
bun src/main.ts start
Or compile to a standalone binary (no Bun required on the target machine):
bun run build # writes ./mesh
./mesh --help
mesh init # generate identity, seed mesh.toml
mesh pubkey # print your public key (share with teammates)
mesh start # run the daemon in the foreground
mesh stop # stop the running daemon
mesh status # show peers, repos, divergences
mesh peers # list configured peers + reachability
mesh repos # list repos + sync freshness
mesh reload # re-read mesh.toml and secrets.yml without restarting
mesh sync # force a reconciliation round
mesh add-peer <name> <pubkey> [addr] # manually add a peer
mesh add-address <peer> <addr> # bootstrap a known address for a peer
mesh invite --addr HOST:PORT [--ttl SECS] # generate a one-time pairing token (default TTL: 600s)
mesh join <token> # accept a pairing token
mesh url <repo> # print the local git URL for a repo
mesh update # update mesh to the latest release
mesh update --from <peer> # update from a peer running mesh serve-install
# CI/CD
mesh ci status [<repo>] # show recent pipeline runs
mesh ci run <repo> <ref> # manually trigger a pipeline
mesh ci logs <repo> <run_id> # print build log
mesh ci cancel <run_id> # cancel a running job
mesh ci runners # list peers and their CI capabilities
Mesh serves git smart-HTTP at https://localhost:7979/<repo>.git (or http://
if you started with transport.tls = false).
Because the TLS certificate is self-signed, tell git not to verify it for this
origin before cloning or pushing. This is scoped to localhost:7979 only and
does not affect other remotes:
git config --global http.https://localhost:7979.sslVerify false
Clone a repo from the local mesh node:
git clone https://localhost:7979/my-app.git
Or add it as a remote to an existing working copy and push:
git remote add mesh https://localhost:7979/my-app.git
git push mesh main
mesh url <repo> prints the full remote URL if you need it.
Mesh includes a distributed CI/CD system. Pipelines are defined per-repo and
executed across peers. Every node is a runner by default — runner configuration
lives in the [runner] section of ~/.mesh/mesh.toml.
Example repo:
kaizen-hq/hello-pipelineis a minimal working example — a Bun HTTP server with aDockerfile,docker-compose.yml, and.mesh/mesh-ci.ymlthat runs shell and Docker jobs. Clone it to have something to test against while setting up CI on your mesh.
Add .mesh/mesh-ci.yml to a repo to define its pipeline:
pipeline:
name: my-app
on:
push:
branches: [main, "release/*"]
schedule:
- cron: "0 2 * * *" # daily at 02:00 UTC
job: nightly
manual: true # allow mesh ci run
runner:
labels: [docker] # require a runner with this label
fallback: any # fall back to any capable peer if no labeled runner
jobs:
test:
image: oven/bun:1.1
commands:
- bun install --frozen-lockfile
- bun test
secrets: [NPM_TOKEN] # injected from ~/.mesh/secrets.yml on the runner node
build:
image: oven/bun:1.1
commands:
- bun run build
depends_on: [test] # topological ordering; build waits for test
nightly:
mode: shell # run commands directly on the runner host (no Docker)
commands:
- bun run audit
Jobs run in Docker by default (mode: docker). Set mode: shell to run
commands in a subprocess on the runner host.
depends_on supports arbitrary DAGs. Cycles are rejected at parse time.
Jobs whose upstream failed are automatically marked skipped.
runner.labels and runner.fallback)The runner: section in mesh-ci.yml controls which nodes in the mesh are
eligible to run the pipeline's jobs.
labels is matched against the labels list each node advertises in its
~/.mesh/mesh.toml [runner] section. A pipeline with labels: [docker] will
only be assigned to nodes that include "docker" in their runner labels.
An empty list [] means no label requirement — every enabled runner is eligible.
Use labels to route pipelines to dedicated build machines:
# On the dedicated build box — ~/.mesh/mesh.toml
[runner]
labels = ["docker", "linux"]
# In the repo — .mesh/mesh-ci.yml
runner:
labels: [docker] # only runs on nodes advertising "docker"
fallback defines what happens when no labeled runner is currently
available (offline, at capacity, or no nodes match the label):
| Value | Behavior |
|---|---|
any | Fall back to any enabled runner in the mesh, regardless of labels |
none | Do not run — the assignment is dropped; the pipeline will not execute until manually triggered again |
fallback: none is appropriate for jobs that must not run on general-purpose
machines (e.g. a deployment job that requires specific credentials or network
access). fallback: any is safe for most CI pipelines where any capable node
is fine.
Runner configuration lives in the [runner] section of ~/.mesh/mesh.toml,
seeded by mesh init:
[runner]
enabled = true
execution_modes = ["docker"] # permit "docker", "shell", or both
labels = [] # empty = matches any job; set for dedicated runners
max_concurrent_jobs = 2
workdir = "~/.mesh/ci/runs" # where worktrees are checked out
# Host environment variables that shell jobs are allowed to read.
# Shell jobs run in an isolated environment; only vars listed here (plus
# PATH, HOME, TMPDIR, LANG, LC_ALL) are passed through from the host.
# env_passthrough = ["GOPATH", "SSH_AUTH_SOCK", "DOCKER_HOST"]
# Tool auto-detection (docker, bun, node, python3, go, cargo)
# Override if auto-detect is wrong for your setup:
# tools = ["docker", "bun"]
# Tuning
log_stream_interval_ms = 500
max_worktree_age_minutes = 60
max_worktree_disk_mb = 2048
log_retention_runs = 50
To permit shell jobs (commands run directly on the host, not in Docker):
[runner]
execution_modes = ["docker", "shell"]
To opt a node out of running jobs entirely (it will still participate in runner selection and assign jobs to others):
[runner]
enabled = false
Repos are introduced by pushing to the local node — no registration step required:
git remote add mesh https://localhost:7979/my-app.git
git push mesh main
On first push to an unknown repo name, mesh auto-creates the bare mirror at
~/.mesh/repos/<name>.git and records which node introduced it. From that
point, the mirror is synced to every peer automatically via the normal
peer-to-peer reconciliation loop.
Runner nodes execute jobs directly from the bare mirror. No working copy or
mesh.toml entry is needed on any node — every peer that receives the mirror
(whether it is a runner or not) is immediately eligible to run the pipeline.
Node-local secrets (environment variables injected into jobs) live in
~/.mesh/secrets.yml — a separate file kept off version control:
secrets:
NPM_TOKEN: "${NPM_TOKEN}" # resolved from the daemon's environment at load time
DEPLOY_KEY: "literal-value"
Values can be literal strings or ${ENV_VAR} references — mesh reads the
named variable from the daemon's environment when secrets.yml is loaded (or
reloaded). This keeps plaintext secrets out of the file: only the variable
name is stored on disk; the value lives solely in the environment.
mesh reload picks up both mesh.toml and secrets.yml without restarting
the daemon.
Environment isolation. Shell jobs run with a minimal environment: only
PATH, HOME, TMPDIR, LANG, LC_ALL, variables listed in
[runner] env_passthrough, and the job's declared secrets. Docker jobs pass
the same limited set to the docker CLI so that compose variable substitution
only resolves declared secrets, not arbitrary host variables.
Trigger → assignment. A repo enters the mesh on first git push to any
node; the bare mirror is auto-created and synced to all peers. When a
subsequent push arrives on a branch that matches on.push.branches, the
receiving node selects the best available runner from the capability gossip it
has heard via heartbeats. It sends a CiAssignment frame to that peer. If no
peer has capacity, the job runs locally.
Runner selection. Dedicated runners (matching labels) are preferred.
Within a tier, the peer with the fewest running jobs and lowest CPU is chosen.
If fallback: any, any peer with capacity is eligible.
Execution. The runner checks out the commit into a git worktree under
workdir, then runs each job in topological order. Docker jobs pull the image
and exec commands inside a container. Shell jobs exec commands in a subprocess
on the host.
Log streaming. Stdout/stderr are captured in real time. Logs are stored
under ~/.mesh/ci/<repo>/<run_id>/log.txt and streamed live to the browser
via SSE.
Cron scheduling. Each runner-enabled node ([runner] enabled = true) runs
a cron loop. When a cron slot fires, all eligible nodes broadcast a
CiCronClaim frame. A deterministic sha256-based election picks one winner to
execute the job — so the job runs exactly once across the mesh even with many
peers online.
Gossip. CiStarted, CiCompleted, and CiLog frames propagate to all
peers so the browser dashboard stays in sync everywhere on the mesh.
The browser dashboard at /status links to per-repo views:
| URL | Description |
|---|---|
/repos/:name | Redirects to /repos/:name/ci |
/repos/:name/ci | Pipeline run list with status badges, manual trigger form |
/repos/:name/ci/:run_id | Run detail with per-job status and live log stream |
/repos/:name/issues | Issues board |
Both the pipelines page and the issues board have a shared nav bar: issues · pipelines.
Check reachability first.
mesh peers # shows each peer's last-seen time and address
mesh status # shows divergence count per peer
If a peer shows never or a stale timestamp:
mesh add-address <peer> <host:port> to add or correct it.curl -k https://HOST:7979/status should return HTML.transport.tls = true (default) or both set false.Check the peer's public key.
Frames are ed25519-signed and rejected if the signature doesn't match the configured pubkey. If you recently re-initialized a peer (mesh init on an existing node), its pubkey changes. Update it:
mesh add-peer <name> <new-pubkey>
mesh reload
on.push.branches in mesh-ci.yml matches the branch you pushed to. Glob patterns like "release/*" must be quoted in YAML.mesh ci runners to see which nodes are online and their capabilities. If the list is empty or all nodes have enabled = false, no assignment will be made.runner.labels in mesh-ci.yml specifies a label that no online node advertises, and fallback is none, the pipeline is silently dropped. Change to fallback: any or add the label to a node's mesh.toml.mesh-ci.yml not found. The file must be at .mesh/mesh-ci.yml relative to the repo root and committed — mesh reads it from the git object store, not the working tree.pendingmesh ci status <repo> — if the run shows pending indefinitely, the runner received the assignment but hasn't started. Check the runner node's daemon logs.mesh ci runners — verify the assigned runner shows runner: true and jobs_running below max_concurrent_jobs.execution_modes on the runner doesn't include the mode the job requires (docker or shell), the job will fail immediately with a log line like job "X" requires shell mode but "shell" is not in execution_modes.mode: docker jobs, docker must be on PATH on the runner node. Check with mesh ci runners — the tools list shows what was auto-detected.mesh ci logs <repo> <run_id>.PATH, HOME, TMPDIR, LANG, LC_ALL), add it to env_passthrough in mesh.toml and run mesh reload.secrets: list in mesh-ci.yml and defined in ~/.mesh/secrets.yml on the runner node (not the triggering node).${ENV_VAR} interpolation in secrets.yml, the variable must be set in the environment of the mesh daemon process itself — not just in your shell. Restart or reload after setting it: mesh reload.mesh ci logs <repo> <run_id> and look for WARN secrets.yml: env var "${X}" is not set lines emitted at daemon startup or reload.Worktrees are created under workdir (default ~/.mesh/ci/runs) and removed when a run completes. If the daemon crashed mid-run, orphaned worktrees may remain.
ls ~/.mesh/ci/runs # list worktrees
git worktree list # from inside the repo, see all registered worktrees
git worktree prune # remove stale registrations
max_worktree_age_minutes and max_worktree_disk_mb in mesh.toml control automatic cleanup of old runs. Lower these values on space-constrained nodes.
mesh reload re-reads mesh.toml and secrets.yml without restarting the daemon. It does not re-read mesh-ci.yml — that file lives in the repo and is read fresh on each pipeline trigger.
If mesh reload reports no error but behavior doesn't change, check that you edited the correct file. With multiple instances (--root), each instance has its own mesh.toml.
Releases follow semantic versioning with this convention:
| Change type | Version part bumped | Example |
|---|---|---|
| Breaking change | Major (first) | 1.0.0 → 2.0.0 |
| Fix | Minor (second) | 1.0.0 → 1.1.0 |
| Regular build | Patch (third) | 1.0.0 → 1.0.1 |
Use conventional commit prefixes so the release script can detect the bump type automatically:
fix: correct retry backoff after address change → fix (minor bump)
fix!: change wire protocol frame encoding → breaking (major bump)
feat!: drop support for legacy bincode format → breaking (major bump)
add invite expiry warning to status output → build (patch bump)
Write commits using the prefixes above.
Run the release script:
bun run release
This scans commits since the last tag, picks the bump type, updates package.json, commits release vX.Y.Z, and creates the git tag. Nothing is pushed yet.
To preview without making any changes:
bun run release --dry-run
To override the auto-detected bump type:
bun run release breaking
bun run release fix
bun run release build
Review, then push:
git push && git push --tags
Pushing the tag triggers the GitHub Actions release workflow, which validates the tag matches package.json, builds mesh-src.zip, and publishes a GitHub Release with it as a downloadable asset.
# Serve the installer over the local network:
mesh serve-install # plain HTTP, prints the curl line
# Or just build the zip manually:
bun run package # writes mesh-src.zip to repo root
The serve-install handler serves:
GET /install.sh — installs bun/unzip/git, downloads the zip, unpacks to
~/.local/share/mesh/, runs bun install, and writes a shim at
~/.local/bin/mesh.GET /mesh-src.zip — the source zip, rebuilt every 60 seconds so edits are
picked up without restarting.GET /mesh-src.zip.sig — ed25519 signature of the zip (verified by mesh update --from <peer>).GET /version — JSON with version, sha256, and signature.MIT.
90 commits
TypeScript
95.2%
HTML
2.0%
CSS
1.9%
A peer-to-peer git daemon with an issues board and cicd.
5
stars
90
commits
TypeScript
primary language
Aug 30, 2026
updated
:7979 (HTTPS by default; HTTP opt-in via transport.tls = false)./<repo>.git/..., POST /mesh/frame for signed
notifications, GET /status for the browser dashboard.git push.This was first built with Rust. But as an unsigned-compiled program, the company network security defense tooling and policy "dropped" inbound requests. Not technically dropped, but would essentially send "empty responses" back.
curl -fsSL https://raw.githubusercontent.com/kaizen-hq/mesh/main/install.sh | bash
Installs the latest source from main. Requires bun, unzip, and curl.
To install a specific release:
curl -fsSL https://raw.githubusercontent.com/kaizen-hq/mesh/main/install.sh | MESH_REF=v1.2.3 bash
After install, mesh is available at ~/.local/bin/mesh. Make sure that directory is on your PATH:
export PATH="$HOME/.local/bin:$PATH" # add to ~/.zshrc or ~/.bashrc
To update to the latest release at any time:
mesh update
To pin a specific release:
mesh update --ref v1.2.3
To update from a peer on your local network instead of GitHub:
mesh update --from alice
If a teammate already has mesh running, they can serve the installer directly:
# On the teammate's machine:
mesh serve-install # prints a curl line for you to run
This serves the same install flow over plain HTTP — no GitHub needed. Useful when you're on the same network and want the exact version your teammate is running.
git clone git@github.com:kaizen-hq/mesh.git
cd mesh
bun install
Requires Bun 1.1+. git must be on PATH.
Run directly without compiling:
bun src/main.ts start
Or compile to a standalone binary (no Bun required on the target machine):
bun run build # writes ./mesh
./mesh --help
mesh init # generate identity, seed mesh.toml
mesh pubkey # print your public key (share with teammates)
mesh start # run the daemon in the foreground
mesh stop # stop the running daemon
mesh status # show peers, repos, divergences
mesh peers # list configured peers + reachability
mesh repos # list repos + sync freshness
mesh reload # re-read mesh.toml and secrets.yml without restarting
mesh sync # force a reconciliation round
mesh add-peer <name> <pubkey> [addr] # manually add a peer
mesh add-address <peer> <addr> # bootstrap a known address for a peer
mesh invite --addr HOST:PORT [--ttl SECS] # generate a one-time pairing token (default TTL: 600s)
mesh join <token> # accept a pairing token
mesh url <repo> # print the local git URL for a repo
mesh update # update mesh to the latest release
mesh update --from <peer> # update from a peer running mesh serve-install
# CI/CD
mesh ci status [<repo>] # show recent pipeline runs
mesh ci run <repo> <ref> # manually trigger a pipeline
mesh ci logs <repo> <run_id> # print build log
mesh ci cancel <run_id> # cancel a running job
mesh ci runners # list peers and their CI capabilities
Mesh serves git smart-HTTP at https://localhost:7979/<repo>.git (or http://
if you started with transport.tls = false).
Because the TLS certificate is self-signed, tell git not to verify it for this
origin before cloning or pushing. This is scoped to localhost:7979 only and
does not affect other remotes:
git config --global http.https://localhost:7979.sslVerify false
Clone a repo from the local mesh node:
git clone https://localhost:7979/my-app.git
Or add it as a remote to an existing working copy and push:
git remote add mesh https://localhost:7979/my-app.git
git push mesh main
mesh url <repo> prints the full remote URL if you need it.
Mesh includes a distributed CI/CD system. Pipelines are defined per-repo and
executed across peers. Every node is a runner by default — runner configuration
lives in the [runner] section of ~/.mesh/mesh.toml.
Example repo:
kaizen-hq/hello-pipelineis a minimal working example — a Bun HTTP server with aDockerfile,docker-compose.yml, and.mesh/mesh-ci.ymlthat runs shell and Docker jobs. Clone it to have something to test against while setting up CI on your mesh.
Add .mesh/mesh-ci.yml to a repo to define its pipeline:
pipeline:
name: my-app
on:
push:
branches: [main, "release/*"]
schedule:
- cron: "0 2 * * *" # daily at 02:00 UTC
job: nightly
manual: true # allow mesh ci run
runner:
labels: [docker] # require a runner with this label
fallback: any # fall back to any capable peer if no labeled runner
jobs:
test:
image: oven/bun:1.1
commands:
- bun install --frozen-lockfile
- bun test
secrets: [NPM_TOKEN] # injected from ~/.mesh/secrets.yml on the runner node
build:
image: oven/bun:1.1
commands:
- bun run build
depends_on: [test] # topological ordering; build waits for test
nightly:
mode: shell # run commands directly on the runner host (no Docker)
commands:
- bun run audit
Jobs run in Docker by default (mode: docker). Set mode: shell to run
commands in a subprocess on the runner host.
depends_on supports arbitrary DAGs. Cycles are rejected at parse time.
Jobs whose upstream failed are automatically marked skipped.
runner.labels and runner.fallback)The runner: section in mesh-ci.yml controls which nodes in the mesh are
eligible to run the pipeline's jobs.
labels is matched against the labels list each node advertises in its
~/.mesh/mesh.toml [runner] section. A pipeline with labels: [docker] will
only be assigned to nodes that include "docker" in their runner labels.
An empty list [] means no label requirement — every enabled runner is eligible.
Use labels to route pipelines to dedicated build machines:
# On the dedicated build box — ~/.mesh/mesh.toml
[runner]
labels = ["docker", "linux"]
# In the repo — .mesh/mesh-ci.yml
runner:
labels: [docker] # only runs on nodes advertising "docker"
fallback defines what happens when no labeled runner is currently
available (offline, at capacity, or no nodes match the label):
| Value | Behavior |
|---|---|
any | Fall back to any enabled runner in the mesh, regardless of labels |
none | Do not run — the assignment is dropped; the pipeline will not execute until manually triggered again |
fallback: none is appropriate for jobs that must not run on general-purpose
machines (e.g. a deployment job that requires specific credentials or network
access). fallback: any is safe for most CI pipelines where any capable node
is fine.
Runner configuration lives in the [runner] section of ~/.mesh/mesh.toml,
seeded by mesh init:
[runner]
enabled = true
execution_modes = ["docker"] # permit "docker", "shell", or both
labels = [] # empty = matches any job; set for dedicated runners
max_concurrent_jobs = 2
workdir = "~/.mesh/ci/runs" # where worktrees are checked out
# Host environment variables that shell jobs are allowed to read.
# Shell jobs run in an isolated environment; only vars listed here (plus
# PATH, HOME, TMPDIR, LANG, LC_ALL) are passed through from the host.
# env_passthrough = ["GOPATH", "SSH_AUTH_SOCK", "DOCKER_HOST"]
# Tool auto-detection (docker, bun, node, python3, go, cargo)
# Override if auto-detect is wrong for your setup:
# tools = ["docker", "bun"]
# Tuning
log_stream_interval_ms = 500
max_worktree_age_minutes = 60
max_worktree_disk_mb = 2048
log_retention_runs = 50
To permit shell jobs (commands run directly on the host, not in Docker):
[runner]
execution_modes = ["docker", "shell"]
To opt a node out of running jobs entirely (it will still participate in runner selection and assign jobs to others):
[runner]
enabled = false
Repos are introduced by pushing to the local node — no registration step required:
git remote add mesh https://localhost:7979/my-app.git
git push mesh main
On first push to an unknown repo name, mesh auto-creates the bare mirror at
~/.mesh/repos/<name>.git and records which node introduced it. From that
point, the mirror is synced to every peer automatically via the normal
peer-to-peer reconciliation loop.
Runner nodes execute jobs directly from the bare mirror. No working copy or
mesh.toml entry is needed on any node — every peer that receives the mirror
(whether it is a runner or not) is immediately eligible to run the pipeline.
Node-local secrets (environment variables injected into jobs) live in
~/.mesh/secrets.yml — a separate file kept off version control:
secrets:
NPM_TOKEN: "${NPM_TOKEN}" # resolved from the daemon's environment at load time
DEPLOY_KEY: "literal-value"
Values can be literal strings or ${ENV_VAR} references — mesh reads the
named variable from the daemon's environment when secrets.yml is loaded (or
reloaded). This keeps plaintext secrets out of the file: only the variable
name is stored on disk; the value lives solely in the environment.
mesh reload picks up both mesh.toml and secrets.yml without restarting
the daemon.
Environment isolation. Shell jobs run with a minimal environment: only
PATH, HOME, TMPDIR, LANG, LC_ALL, variables listed in
[runner] env_passthrough, and the job's declared secrets. Docker jobs pass
the same limited set to the docker CLI so that compose variable substitution
only resolves declared secrets, not arbitrary host variables.
Trigger → assignment. A repo enters the mesh on first git push to any
node; the bare mirror is auto-created and synced to all peers. When a
subsequent push arrives on a branch that matches on.push.branches, the
receiving node selects the best available runner from the capability gossip it
has heard via heartbeats. It sends a CiAssignment frame to that peer. If no
peer has capacity, the job runs locally.
Runner selection. Dedicated runners (matching labels) are preferred.
Within a tier, the peer with the fewest running jobs and lowest CPU is chosen.
If fallback: any, any peer with capacity is eligible.
Execution. The runner checks out the commit into a git worktree under
workdir, then runs each job in topological order. Docker jobs pull the image
and exec commands inside a container. Shell jobs exec commands in a subprocess
on the host.
Log streaming. Stdout/stderr are captured in real time. Logs are stored
under ~/.mesh/ci/<repo>/<run_id>/log.txt and streamed live to the browser
via SSE.
Cron scheduling. Each runner-enabled node ([runner] enabled = true) runs
a cron loop. When a cron slot fires, all eligible nodes broadcast a
CiCronClaim frame. A deterministic sha256-based election picks one winner to
execute the job — so the job runs exactly once across the mesh even with many
peers online.
Gossip. CiStarted, CiCompleted, and CiLog frames propagate to all
peers so the browser dashboard stays in sync everywhere on the mesh.
The browser dashboard at /status links to per-repo views:
| URL | Description |
|---|---|
/repos/:name | Redirects to /repos/:name/ci |
/repos/:name/ci | Pipeline run list with status badges, manual trigger form |
/repos/:name/ci/:run_id | Run detail with per-job status and live log stream |
/repos/:name/issues | Issues board |
Both the pipelines page and the issues board have a shared nav bar: issues · pipelines.
Check reachability first.
mesh peers # shows each peer's last-seen time and address
mesh status # shows divergence count per peer
If a peer shows never or a stale timestamp:
mesh add-address <peer> <host:port> to add or correct it.curl -k https://HOST:7979/status should return HTML.transport.tls = true (default) or both set false.Check the peer's public key.
Frames are ed25519-signed and rejected if the signature doesn't match the configured pubkey. If you recently re-initialized a peer (mesh init on an existing node), its pubkey changes. Update it:
mesh add-peer <name> <new-pubkey>
mesh reload
on.push.branches in mesh-ci.yml matches the branch you pushed to. Glob patterns like "release/*" must be quoted in YAML.mesh ci runners to see which nodes are online and their capabilities. If the list is empty or all nodes have enabled = false, no assignment will be made.runner.labels in mesh-ci.yml specifies a label that no online node advertises, and fallback is none, the pipeline is silently dropped. Change to fallback: any or add the label to a node's mesh.toml.mesh-ci.yml not found. The file must be at .mesh/mesh-ci.yml relative to the repo root and committed — mesh reads it from the git object store, not the working tree.pendingmesh ci status <repo> — if the run shows pending indefinitely, the runner received the assignment but hasn't started. Check the runner node's daemon logs.mesh ci runners — verify the assigned runner shows runner: true and jobs_running below max_concurrent_jobs.execution_modes on the runner doesn't include the mode the job requires (docker or shell), the job will fail immediately with a log line like job "X" requires shell mode but "shell" is not in execution_modes.mode: docker jobs, docker must be on PATH on the runner node. Check with mesh ci runners — the tools list shows what was auto-detected.mesh ci logs <repo> <run_id>.PATH, HOME, TMPDIR, LANG, LC_ALL), add it to env_passthrough in mesh.toml and run mesh reload.secrets: list in mesh-ci.yml and defined in ~/.mesh/secrets.yml on the runner node (not the triggering node).${ENV_VAR} interpolation in secrets.yml, the variable must be set in the environment of the mesh daemon process itself — not just in your shell. Restart or reload after setting it: mesh reload.mesh ci logs <repo> <run_id> and look for WARN secrets.yml: env var "${X}" is not set lines emitted at daemon startup or reload.Worktrees are created under workdir (default ~/.mesh/ci/runs) and removed when a run completes. If the daemon crashed mid-run, orphaned worktrees may remain.
ls ~/.mesh/ci/runs # list worktrees
git worktree list # from inside the repo, see all registered worktrees
git worktree prune # remove stale registrations
max_worktree_age_minutes and max_worktree_disk_mb in mesh.toml control automatic cleanup of old runs. Lower these values on space-constrained nodes.
mesh reload re-reads mesh.toml and secrets.yml without restarting the daemon. It does not re-read mesh-ci.yml — that file lives in the repo and is read fresh on each pipeline trigger.
If mesh reload reports no error but behavior doesn't change, check that you edited the correct file. With multiple instances (--root), each instance has its own mesh.toml.
Releases follow semantic versioning with this convention:
| Change type | Version part bumped | Example |
|---|---|---|
| Breaking change | Major (first) | 1.0.0 → 2.0.0 |
| Fix | Minor (second) | 1.0.0 → 1.1.0 |
| Regular build | Patch (third) | 1.0.0 → 1.0.1 |
Use conventional commit prefixes so the release script can detect the bump type automatically:
fix: correct retry backoff after address change → fix (minor bump)
fix!: change wire protocol frame encoding → breaking (major bump)
feat!: drop support for legacy bincode format → breaking (major bump)
add invite expiry warning to status output → build (patch bump)
Write commits using the prefixes above.
Run the release script:
bun run release
This scans commits since the last tag, picks the bump type, updates package.json, commits release vX.Y.Z, and creates the git tag. Nothing is pushed yet.
To preview without making any changes:
bun run release --dry-run
To override the auto-detected bump type:
bun run release breaking
bun run release fix
bun run release build
Review, then push:
git push && git push --tags
Pushing the tag triggers the GitHub Actions release workflow, which validates the tag matches package.json, builds mesh-src.zip, and publishes a GitHub Release with it as a downloadable asset.
# Serve the installer over the local network:
mesh serve-install # plain HTTP, prints the curl line
# Or just build the zip manually:
bun run package # writes mesh-src.zip to repo root
The serve-install handler serves:
GET /install.sh — installs bun/unzip/git, downloads the zip, unpacks to
~/.local/share/mesh/, runs bun install, and writes a shim at
~/.local/bin/mesh.GET /mesh-src.zip — the source zip, rebuilt every 60 seconds so edits are
picked up without restarting.GET /mesh-src.zip.sig — ed25519 signature of the zip (verified by mesh update --from <peer>).GET /version — JSON with version, sha256, and signature.MIT.
90 commits
TypeScript
95.2%
HTML
2.0%
CSS
1.9%