Build orchestration
MESH uses go-task for build orchestration and task running. Two Taskfiles split responsibilities:
Taskfile.ymlis user-facing (deploy-time).Taskfile.dev.ymlis developer-facing (build-time).
Both auto-load .env and run silently by default.
Why Task?
Three constraints shaped the choice:
- Interactive prompts that work cross-platform. The setup flow asks for the control plane type, domain, and auth key, then writes them to
.env. Task'sprompt:andrequires.vars.enum:directives make this declarative.makewould need a custom shell wrapper per platform. - Embedded platform-specific tasks. Several tasks branch on OS (sed vs PowerShell, Linux sed vs macOS sed). Task exposes
platforms: [linux, darwin, windows]on individual commands, so one Taskfile serves all three. - Dotenv loading.
dotenv: [.env]means every task sees configuration variables without sourcing scripts.
Taskfile.yml: user-facing tasks
Path: Taskfile.yml. Default invocation target. All user-facing tasks live here.
Task reference
| Task | Purpose | Depends on |
|---|---|---|
build |
docker compose build |
|
controlPlane |
docker compose up -d headscale ui |
configureControlPlane |
down |
docker compose down |
|
apikey |
Calls headscale apikeys create --expiration 3h inside the running container. Prints a help message if the container isn't running locally. |
|
analyst |
Brings up the analyst service and opens an interactive bash shell via docker compose exec analyst /bin/bash. |
configureAnalyst |
setAuthKey |
Stops the analyst, removes its volume, and re-prompts for a new AUTH_KEY. Use when rotating keys or starting a fresh session. |
The configuration flow
configureControlPlane and configureAnalyst are high-level tasks that run the interactive setup prompts. Each prompt task is guarded by an if: so it only runs when the relevant .env variable is empty. Rerunning task controlPlane on a configured checkout skips the prompts.
controlPlane
└── configureControlPlane
├── controlPlaneType (prompts Ephemeral vs Persistent)
├── controlPlaneDomain (prompts domain name)
├── headscaleConfig (sed-substitutes config.example.yaml if config.yaml missing)
└── confirmDotEnv (cat .env, prompt "looks correct?")
Headscale config templating
headscaleConfig (internal) substitutes CONTROL_PLANE_DOMAIN in config.example.yaml and writes config.yaml, but only if the latter doesn't exist:
The task declares sources: and generates: so Task skips it if the source hasn't changed since the generate target's mtime. On Windows, a PowerShell -replace command does the same thing.
Key rotation: setAuthKey
cmds:
- docker compose down analyst && docker volume rm $(docker compose volumes -q | grep analyst-data) || true
- <prompt>
- task: promptAnalystAuthKey
Stopping the analyst and deleting its volume before asking for the new key ensures the container can't come up with stale state between the prompt and the restart.
Taskfile.dev.yml: developer-facing tasks
Path: Taskfile.dev.yml. Invoked via task -t Taskfile.dev.yml <task>. Holds every task that a contributor runs and an end-user should not.
Task reference
| Task | What it does |
|---|---|
tidy |
go mod tidy. Kept separate because it mutates go.sum and affects reproducibility. |
generate |
go generate ./.... Depends on submoduleInit. |
docs |
mkdocs build. |
serveDocs |
mkdocs serve with live reload on :8000. |
buildAnalyst |
Runs analyst/src/build.sh. Produces analyst/mesh. Depends on submoduleInit. |
buildAndroid |
make -C android-client apk. |
buildAndroidRelease |
make -C android-client release-apk. Unsigned. |
buildAndroidSigned |
make -C android-client release-apk-signed. Requires JKS_PATH, JKS_PASSWORD, JKS_ALIAS env vars. |
clean |
rm -f analyst/mesh and make -C android-client clean. |
tagRelease |
Calls scripts/tag-release.sh $VERSION. See CI and release. |
fdroidInit |
Initialise a local F-Droid repo for reproducibility testing. |
fdroidBuild |
cd fdroid && fdroid build -v -l com.barghest.mesh. |
pinGithubActions |
go tool github.com/stacklok/frizbee actions .github/workflows. |
Analyst build script
Path: analyst/src/build.sh. Called by the Dockerfile (stage 1) and by task buildAnalyst. Single source of truth for the mesh binary build.
What it does
- Locates the Go module directory via
go env GOMOD. - Assembles build tags, always adding
ts_omit_logtail(omits Tailscale telemetry) and any extra tags supplied through$TAGS. cdinto$GO_MOD_DIR/tailscale(the submodule).-
Runs:
Why a separate script rather than inline in the Dockerfile?
Two reasons:
task buildAnalystruns it for local dev without Docker. Contributors iterating on CLI changes rebuild in seconds instead of rebuilding the whole container.- Build flags live in one place. A change to build tags updates both the container image and local dev builds without drift.
Development container
Path: .devcontainer/Dockerfile, .devcontainer/devcontainer.json. Defines an ephemeral development environment compatible with VS Code's Remote Containers and GitHub Codespaces.
What the image installs
The image is built from a devcontainers Debian base image. It installs:
- Microsoft OpenJDK, Go, Node.js, and pnpm.
- The Android command-line tools and the SDK components needed for Android builds.
- Task CLI.
Java and the Android command-line tools are checksum-verified in the Dockerfile. Go is version-pinned via the GO_VERSION build arg. Android SDK licenses are auto-accepted with yes | sdkmanager --licenses.
devcontainer.json features
- Docker-outside-of-Docker so
docker composeinside the devcontainer drives the host Docker daemon. - MkDocs tooling so
task docsandtask serveDocswork without extra setup. - Gradle on
PATHfor Android builds. - Python plus
fdroidserverfor local F-Droid reproducibility work.
Mounts and environment
- Binds
~/.androidfrom the host to/root/.androidto persist the Android debug keystore across rebuilds. - Sets
LOCAL_WORKSPACE_FOLDER=${localWorkspaceFolder}so bind mounts incompose.ymlresolve to the host's repo path, not a path inside the devcontainer. - Forwards port
80so the UI is reachable from the host browser.
Dependencies and assumptions
- A
taskrelease withprompt:andrequires.vars.enum:support. goon PATH fortask tidy,task generate,task buildAnalyst. The CI workflow sets this up viaactions/setup-goreadinggo-version-file: go.mod.git submoduleinitialised before running any build task.submoduleInithandles this but requires network access on first run.- F-Droid work requires
fdroidserver, which requires Python, gradle, JDK, and the Android SDK. The devcontainer has all of this. - Signed releases require
JKS_PATH,JKS_PASSWORD, andJKS_ALIASenvironment variables and an existing.jkskeystore.task buildAndroidSignedfails if these are missing.
← Runtime stack | CI and release →