Cross-Platform Release and Operations Implementation Plan
Cross-Platform Release and Operations Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Deliver reproducible TunnelMesh binary archives and container images for the supported platforms, together with version reporting, health probes, Nginx WSS guidance, process-manager definitions, safe upgrade/rollback procedures, and tag-gated GitHub releases.
Architecture: Release metadata is injected into the three existing Cobra binaries through internal/build; a server healthcheck command consumes the health endpoints produced by subproject B. GoReleaser cross-compiles each binary independently with CGO_ENABLED=0, while deployment templates remain version-controlled under deploy/ and Docker uses a non-root, non-privileged internal listener. Local make release only prepares and verifies artifacts; only the explicit v* tag workflow may publish a GitHub release after all tests and archive checks pass.
Tech Stack: Go 1.23+, Cobra, GoReleaser v2.18.1, GNU/BSD Make, Vue 3/Vite, Nginx 1.27, systemd, launchd, WinSW v2.12.0, PowerShell 7/Windows PowerShell 5.1, Docker BuildKit/Buildx, Docker Compose, GitHub Actions.
Spec: docs/superpowers/specs/2026-09-06-security-observability-release-design.md
Global Constraints
- This plan implements only subproject C: release and operations. Token persistence, scoped authorization, runtime metrics, structured logging,
/health/live,/health/ready, and WebSocket lifecycle behavior belong to subprojects A and B. - Start this plan only after subproject A has stabilized Agent and Client Bearer-token semantics and subproject B exposes
GET /health/live,GET /health/ready, graceful GOAWAY drain, and systemd readiness/watchdog notifications. - Public traffic remains HTTP/HTTPS/WebSocket on ports 80/443. Nginx terminates edge TLS and proxies to the Server on a non-privileged loopback port.
- Supported binary targets are
linux/amd64,linux/arm64,darwin/amd64,darwin/arm64,windows/amd64, andwindows/arm64for each oftunnelmesh-server,tunnelmesh-agent, andtunnelmesh-client. - Every release build sets
CGO_ENABLED=0,-trimpath, version, commit, and UTC build time. Unix archives use.tar.gz; Windows archives use.zip. make release VERSION=v0.1.0validates and builds local artifacts only. It must not create a tag, commit, push, publish, or mutate tracked embedded Web assets.- A release archive contains its binary and non-secret release documentation. It must not contain runtime YAML/TOML/JSON configuration,
.env, Token values, passwords, DSNs, private keys, certificates, SQLite files, MySQL dumps, or logs. - WinSW is pinned to
v2.12.0. The only downloaded executable isWinSW-x64.exe, whose SHA256 is05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0da. - Never use
sc.exe createto point Windows SCM directly at a TunnelMesh console executable. PowerShell installs WinSW first, then usessc.exe failureonly to configure recovery actions on the WinSW-created service. - systemd units use
Type=notifyandWatchdogSeconly after subproject B has implementedREADY=1,STOPPING=1, and periodicWATCHDOG=1notifications. - Docker runtime processes run as non-root and bind to port
8080inside the container. Nginx or host port publishing owns ports 80/443. - Nginx must preserve
Host, explicitly forwardAuthorizationand trustedX-Forwarded-*values, disable WebSocket buffering, and use WebSocket timeouts longer than three 30-second heartbeat periods. - Runtime configuration and secrets remain external to binaries, archives, images, unit files, plists, and WinSW XML. Examples use paths and environment-file references, never credential values.
- Each code or script change follows RED, GREEN, refactor: add a failing focused test, record the expected failure, implement the minimum behavior, then run focused and full verification.
- Do not commit, tag, push, merge, or publish while executing this plan unless the user separately and explicitly authorizes that Git action.
File Structure
Build and release core
internal/build/build.go: single source of version, commit, build time, binary names, and formatted version output.internal/build/build_test.go: defaults and ldflag-compatible metadata formatting contract.internal/cli/root.go: attaches version output to all three roots and registers the Server healthcheck command.internal/cli/root_test.go: CLI-level--versionand command-surface tests.internal/cli/healthcheck.go: bounded HTTP liveness probe used by operators and the Server container.internal/cli/healthcheck_test.go: status, timeout, body-closing, and URL validation tests.internal/config/config.go: changes the default Server HTTP listener from:80to:8080.internal/config/config_test.go: protects the non-privileged default..goreleaser.yaml: three builds, six target tuples, three archive families, and one checksum file.scripts/verify-web-embed.sh: provesweb/distandinternal/server/web_distcontain the same production bundle without modifying tracked files.scripts/verify-release.sh: validates archive matrix, filenames, checksums, content exclusions, and version strings.Makefile: release preflight and local artifact orchestration.
Deployment assets
deploy/nginx/tunnelmesh.conf: complete HTTP-to-HTTPS redirect, TLS server, API, SPA/managed-host, Agent WS, and Client WS proxy configuration.deploy/nginx/proxy-headers.conf: one trusted upstream-header policy shared by HTTP and WebSocket locations.deploy/systemd/tunnelmesh-server.service: Server configuration validation, notify/watchdog, restart, hardening, and state directory.deploy/systemd/tunnelmesh-agent.service: Agent validation, notify/watchdog, restart, and restricted filesystem access.deploy/launchd/io.tunnelmesh.server.plist: macOS Server daemon definition.deploy/launchd/io.tunnelmesh.agent.plist: macOS Agent daemon definition.deploy/windows/tunnelmesh-server.xml: WinSW Server service definition.deploy/windows/tunnelmesh-agent.xml: WinSW Agent service definition.deploy/windows/install-service.ps1: architecture check, pinned WinSW download, SHA256 verification, installation, and recovery actions.deploy/windows/uninstall-service.ps1: stop/uninstall wrappers without deleting operator configuration or data.
Containers, documentation, and CI
Dockerfile: non-root port 8080, build metadata, three runtime targets, and Server healthcheck.docker-compose.local.yml: correct Agent WS path, external Token injection, health-gated startup, and non-privileged container port.docker-compose.cluster.yml: MySQL health, Server readiness, external secrets, and non-privileged ports.docs/deployment/binary-release.md: download, checksum, install, first start, logs, and uninstall.docs/deployment/nginx.md: TLS/WSS deployment and smoke checks.docs/deployment/linux-systemd.md: unit installation, user/directory ownership, watchdog, logs, and removal.docs/deployment/macos-launchd.md: plist installation, bootstrap/bootout, logs, and removal.docs/deployment/windows-service.md: WinSW installation, recovery, logs, architecture limitation, and removal.docs/operations/upgrade-rollback.md: preflight, backups, canary, database compatibility, rollback, and Token rotation.docs/deployment/docker.md: immutable image tags, health, backup, upgrade, and rollback commands.docs/operations/configuration.md: port 8080 and service-manager configuration paths.docs/operations/troubleshooting.md: health, proxy, service-manager, and release checksum diagnostics.docs/README.mdandREADME.md: navigation to the release and operations guides..github/workflows/release.yml: tag-only quality gate, local-style artifact generation, explicit GitHub publication, and native smoke jobs.
Task 1: Version Metadata and --version
Files:
- Modify:
internal/build/build.go - Modify:
internal/build/build_test.go - Modify:
internal/cli/root.go - Modify:
internal/cli/root_test.go
Interfaces:
- Consumes: Go linker
-X github.com/tunnelmesh/tunnelmesh/internal/build.Version=...,-X .../build.Commit=..., and-X .../build.BuildTime=.... -
Produces:
build.Info{Version string, Commit string, BuildTime string},build.Current() build.Info,build.String() string, and identical Cobra--versionoutput on all three binaries. - Step 1: Write failing build metadata tests
Add tests that save and restore the package variables so the suite is order-independent:
func TestCurrentReturnsLinkerMetadata(t *testing.T) {
originalVersion, originalCommit, originalBuildTime := build.Version, build.Commit, build.BuildTime
t.Cleanup(func() {
build.Version, build.Commit, build.BuildTime = originalVersion, originalCommit, originalBuildTime
})
build.Version = "v0.1.0"
build.Commit = "0123456789abcdef"
build.BuildTime = "2026-09-06T10:00:00Z"
want := build.Info{Version: "v0.1.0", Commit: "0123456789abcdef", BuildTime: "2026-09-06T10:00:00Z"}
if got := build.Current(); got != want {
t.Fatalf("Current() = %#v, want %#v", got, want)
}
}
func TestStringIsStableAndSingleLine(t *testing.T) {
originalVersion, originalCommit, originalBuildTime := build.Version, build.Commit, build.BuildTime
t.Cleanup(func() {
build.Version, build.Commit, build.BuildTime = originalVersion, originalCommit, originalBuildTime
})
build.Version, build.Commit, build.BuildTime = "v0.1.0", "0123456789abcdef", "2026-09-06T10:00:00Z"
if got, want := build.String(), "v0.1.0 commit=0123456789abcdef built=2026-09-06T10:00:00Z"; got != want {
t.Fatalf("String() = %q, want %q", got, want)
}
}
- Step 2: Run the focused tests and record RED
Run:
go test ./internal/build -run 'Test(CurrentReturnsLinkerMetadata|StringIsStableAndSingleLine)' -count=1
Expected: compilation fails because build.Version, build.Commit, build.BuildTime, build.Info, build.Current, and build.String do not exist.
- Step 3: Implement the metadata contract
Keep BinaryNames() unchanged and add:
var (
Version = "dev"
Commit = "unknown"
BuildTime = "unknown"
)
type Info struct {
Version string
Commit string
BuildTime string
}
func Current() Info {
return Info{Version: Version, Commit: Commit, BuildTime: BuildTime}
}
func String() string {
info := Current()
return fmt.Sprintf("%s commit=%s built=%s", info.Version, info.Commit, info.BuildTime)
}
Import fmt; do not read Git state or the wall clock at runtime.
- Step 4: Add failing CLI version tests
Add a table-driven test for all roots:
func TestRootsExposeBuildVersion(t *testing.T) {
for name, factory := range map[string]func() *cobra.Command{
"tunnelmesh-server": cli.NewServerRoot,
"tunnelmesh-agent": cli.NewAgentRoot,
"tunnelmesh-client": cli.NewClientRoot,
} {
t.Run(name, func(t *testing.T) {
root := factory()
root.SetArgs([]string{"--version"})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
for _, part := range []string{name, "commit=", "built="} {
if !strings.Contains(out.String(), part) {
t.Fatalf("version output %q does not contain %q", out.String(), part)
}
}
})
}
}
- Step 5: Run the CLI test and record RED
Run:
go test ./internal/cli -run TestRootsExposeBuildVersion -count=1
Expected: FAIL because the Cobra roots have no version string and reject or ignore --version.
- Step 6: Attach the version to every root
In newRoot, import internal/build and set:
Version: build.String(),
Set a stable template once:
root.SetVersionTemplate(" \n")
- Step 7: Run focused and package tests and record GREEN
Run:
go test ./internal/build ./internal/cli -count=1
Expected: PASS; each root prints one line containing the command name, version, commit, and UTC build time.
Task 2: Server Healthcheck CLI and Non-Privileged Default Port
Files:
- Create:
internal/cli/healthcheck.go - Create:
internal/cli/healthcheck_test.go - Modify:
internal/cli/root.go - Modify:
internal/cli/root_test.go - Modify:
internal/config/config.go - Modify:
internal/config/config_test.go
Interfaces:
- Consumes: subproject B
GET /health/live, which returns HTTP 200 only while the Server process is live; readiness remains available separately atGET /health/ready. -
Produces:
newHealthcheckCommand(defaultURL string) *cobra.Command,checkHealth(ctx context.Context, client *http.Client, endpoint string) error, andtunnelmesh-server healthcheck --url URL --timeout DURATION. - Step 1: Write failing healthcheck unit tests
Cover 200, non-200, invalid scheme, and timeout with httptest.Server:
func TestCheckHealthAcceptsOnlyHTTP200(t *testing.T) {
for _, status := range []int{http.StatusOK, http.StatusServiceUnavailable} {
t.Run(strconv.Itoa(status), func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
}))
defer srv.Close()
err := checkHealth(context.Background(), srv.Client(), srv.URL)
if status == http.StatusOK && err != nil {
t.Fatalf("checkHealth() error = %v", err)
}
if status != http.StatusOK && err == nil {
t.Fatal("checkHealth() succeeded for non-200 response")
}
})
}
}
func TestCheckHealthRejectsNonHTTPURL(t *testing.T) {
if err := checkHealth(context.Background(), http.DefaultClient, "file:///etc/passwd"); err == nil {
t.Fatal("checkHealth() accepted file URL")
}
}
func TestHealthcheckCommandTimesOut(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cmd := newHealthcheckCommand("http://127.0.0.1:8080/health/live")
cmd.SetArgs([]string{"--url", srv.URL, "--timeout", "10ms"})
if err := cmd.ExecuteContext(context.Background()); err == nil {
t.Fatal("healthcheck succeeded after timeout")
}
}
- Step 2: Run healthcheck tests and record RED
Run:
go test ./internal/cli -run 'Test(CheckHealth|HealthcheckCommand)' -count=1
Expected: compilation fails because the healthcheck functions do not exist.
- Step 3: Implement the bounded probe
Implement a GET request with no redirects and a bounded context:
func checkHealth(ctx context.Context, client *http.Client, endpoint string) error {
u, err := url.Parse(endpoint)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("healthcheck: invalid HTTP URL %q", endpoint)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return fmt.Errorf("healthcheck: create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("healthcheck: request failed: %w", err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("healthcheck: unexpected status %d", resp.StatusCode)
}
return nil
}
newHealthcheckCommand defaults to http://127.0.0.1:8080/health/live, defaults timeout to 3s, uses an http.Client with redirects rejected, and prints healthy only after a successful 200 response.
- Step 4: Register the command and test the public surface
Add newHealthcheckCommand("http://127.0.0.1:8080/health/live") to serverCommands. Extend TestServerRootExposesConfigurationCommands to require healthcheck.
Run:
go test ./internal/cli -run 'Test(CheckHealth|HealthcheckCommand|ServerRootExposesConfigurationCommands)' -count=1
Expected: PASS.
- Step 5: Write the failing non-privileged default test
Add:
func TestDefaultServerHTTPAddressIsNonPrivileged(t *testing.T) {
cfg, err := config.Load(context.Background(), config.ConfigOptions{})
if err != nil {
t.Fatal(err)
}
if got, want := cfg.Server.HTTPAddr, ":8080"; got != want {
t.Fatalf("Server.HTTPAddr = %q, want %q", got, want)
}
}
- Step 6: Run the default test and record RED
Run:
go test ./internal/config -run TestDefaultServerHTTPAddressIsNonPrivileged -count=1
Expected: FAIL with Server.HTTPAddr = ":80", want ":8080".
- Step 7: Change the default and record GREEN
Change only the default server.http_addr to :8080; keep public 80/443 in Nginx and host port mappings.
Run:
go test ./internal/config ./internal/cli -count=1
Expected: PASS.
Task 3: Deterministic Web Embed, GoReleaser, and Local Release Verification
Files:
- Create:
.goreleaser.yaml - Create:
scripts/verify-web-embed.sh - Create:
scripts/verify-release.sh - Modify:
Makefile - Test:
internal/build/build_test.go
Interfaces:
- Consumes: Task 1 linker variables and the tracked
internal/server/web_distbundle. -
Produces:
make release VERSION=vX.Y.Z, thedist/tunnelmesh-server_vX.Y.Z_<os>_<arch>.<ext>,dist/tunnelmesh-agent_vX.Y.Z_<os>_<arch>.<ext>, anddist/tunnelmesh-client_vX.Y.Z_<os>_<arch>.<ext>archive families, plusdist/tunnelmesh_vX.Y.Z_checksums.txt. - Step 1: Add a failing configuration contract test
Extend internal/build/build_test.go with a repository-root test that reads .goreleaser.yaml and checks all commands and metadata variables:
func TestGoReleaserConfigCoversEveryBinaryAndMetadataField(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", ".goreleaser.yaml"))
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, want := range append(build.BinaryNames(),
"internal/build.Version", "internal/build.Commit", "internal/build.BuildTime",
"linux", "darwin", "windows", "amd64", "arm64", "CGO_ENABLED=0") {
if !strings.Contains(text, want) {
t.Fatalf(".goreleaser.yaml does not contain %q", want)
}
}
}
- Step 2: Run the contract test and record RED
Run:
go test ./internal/build -run TestGoReleaserConfigCoversEveryBinaryAndMetadataField -count=1
Expected: FAIL because .goreleaser.yaml does not exist.
- Step 3: Create the GoReleaser v2 configuration
Use three explicit build IDs. Each build contains:
version: 2
builds:
- id: tunnelmesh-server
main: ./cmd/tunnelmesh-server
binary: tunnelmesh-server
env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
flags: [-trimpath]
ldflags:
- >-
-s -w
-X github.com/tunnelmesh/tunnelmesh/internal/build.Version=
-X github.com/tunnelmesh/tunnelmesh/internal/build.Commit=
-X github.com/tunnelmesh/tunnelmesh/internal/build.BuildTime=
Repeat the complete build block for Agent and Client with their own id, main, and binary. Define three archive stanzas, each restricted to one build ID, with this naming and format policy:
archives:
- id: tunnelmesh-server
ids: [tunnelmesh-server]
name_template: >-
tunnelmesh-server___
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
Repeat for Agent and Client. Add:
checksum:
name_template: tunnelmesh__checksums.txt
changelog:
disable: true
Do not configure GitHub, Docker, Homebrew, Scoop, signing, or publishing in .goreleaser.yaml; the tag workflow publishes verified files explicitly.
- Step 4: Run the Go contract test and GoReleaser schema check
Run:
go test ./internal/build -run TestGoReleaserConfigCoversEveryBinaryAndMetadataField -count=1
goreleaser check
Expected: both commands PASS with GoReleaser v2.18.1.
- Step 5: Write the Web embed verification script
scripts/verify-web-embed.sh must use set -eu, require web/dist/index.html, require internal/server/web_dist/index.html, and compare file lists and bytes without copying:
#!/bin/sh
set -eu
test -f web/dist/index.html
test -f internal/server/web_dist/index.html
web_list=$(mktemp)
embed_list=$(mktemp)
trap 'rm -f "$web_list" "$embed_list"' EXIT HUP INT TERM
(cd web/dist && find . -type f -print | LC_ALL=C sort) >"$web_list"
(cd internal/server/web_dist && find . -type f -print | LC_ALL=C sort) >"$embed_list"
diff -u "$web_list" "$embed_list"
while IFS= read -r file; do
cmp "web/dist/$file" "internal/server/web_dist/$file"
done <"$web_list"
- Step 6: Prove stale Web assets fail
Run in a temporary worktree or disposable copy:
cd web
npm ci
npm run build
cd ..
./scripts/verify-web-embed.sh
Expected before synchronizing a changed frontend: FAIL with a diff or cmp mismatch. After the normal frontend task updates the tracked embed directory: PASS. The release task itself must never copy into internal/server/web_dist.
- Step 7: Implement the release archive verifier
scripts/verify-release.sh DIST VERSION must:
- Reject a version that does not match
^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$. - Require exactly 18 archives: 3 binaries × 6 platform tuples.
- Require
.zipfor Windows and.tar.gzfor Linux/Darwin. - Run
sha256sum -cwhen available, otherwiseshasum -a 256 -c. - List every archive and reject names matching
(^|/)(\.env|.*\.(db|sqlite|sqlite3|pem|key|crt|p12|pfx|yaml|yml|toml|json|log))$. - Reject archive paths containing
token,password,secret,private-key, ormysql-dump, case-insensitively. - Extract the current host archive to
mktemp -d, run its binary with--version, and require VERSION,commit=, andbuilt=. - On a host whose OS/architecture has no matching archive, print the structural verification result and exit zero after checksum/content checks.
Use unzip -Z1 and tar -tzf for content listing. Cleanup all temporary directories with a trap.
- Step 8: Add Make release targets
Add .PHONY entries for release-check, release, and release-verify. Implement these exact semantics:
GORELEASER ?= goreleaser
release-check:
@test -n "$(VERSION)" || (echo "VERSION is required, for example VERSION=v0.1.0" >&2; exit 1)
@printf '%s\n' "$(VERSION)" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$$'
@test -z "$$(git status --porcelain --untracked-files=all)" || (echo "release requires a clean worktree" >&2; exit 1)
cd web && npm ci && npm run build
./scripts/verify-web-embed.sh
$(GORELEASER) check
release: release-check
RELEASE_VERSION="$(VERSION)" \
RELEASE_COMMIT="$$(git rev-parse HEAD)" \
RELEASE_BUILD_TIME="$$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
GORELEASER_CURRENT_TAG="$(VERSION)" \
$(GORELEASER) release --clean --skip=publish
./scripts/verify-release.sh dist "$(VERSION)"
release-verify:
@test -n "$(VERSION)"
./scripts/verify-release.sh dist "$(VERSION)"
- Step 9: Run local release RED/GREEN checks
Run first without VERSION:
make release
Expected: FAIL before building with VERSION is required.
Run from a clean worktree with GoReleaser v2.18.1:
make release VERSION=v0.1.0
Expected: PASS, 18 archives plus one checksum file in dist/, no tag, no Git push, and no GitHub release.
Task 4: Nginx HTTPS/WSS Reference Configuration
Files:
- Create:
deploy/nginx/tunnelmesh.conf - Create:
deploy/nginx/proxy-headers.conf - Create:
scripts/verify-nginx.sh - Create:
docs/deployment/nginx.md
Interfaces:
- Consumes: Server HTTP listener
127.0.0.1:8080; subproject A Bearer authentication and Host/Origin validation; subproject B/health/live,/health/ready,/ws/agent, and/ws/clientroutes. -
Produces: a complete Nginx configuration and
scripts/verify-nginx.sh CONFIG_PATHsyntax/semantic validator. - Step 1: Write the failing semantic validator
Create a POSIX script that fails unless the config contains all required controls:
#!/bin/sh
set -eu
config=${1:-deploy/nginx/tunnelmesh.conf}
test -f "$config"
for required in \
'listen 80' \
'return 301 https://$host$request_uri' \
'listen 443 ssl' \
'ssl_protocols TLSv1.2 TLSv1.3' \
'location /api/' \
'location = /ws/agent' \
'location = /ws/client' \
'proxy_http_version 1.1' \
'proxy_set_header Upgrade $http_upgrade' \
'proxy_set_header Authorization $http_authorization' \
'proxy_set_header Host $host' \
'proxy_buffering off' \
'proxy_read_timeout 120s' \
'proxy_send_timeout 120s' \
'client_max_body_size 2m' \
'limit_req_zone $binary_remote_addr zone=tunnelmesh_api:10m rate=20r/s' \
'limit_req_zone $binary_remote_addr zone=tunnelmesh_ws:10m rate=5r/s'
do
grep -F "$required" "$config" >/dev/null
done
- Step 2: Run the validator and record RED
Run:
chmod +x scripts/verify-nginx.sh
./scripts/verify-nginx.sh deploy/nginx/tunnelmesh.conf
Expected: FAIL because the Nginx configuration does not exist.
- Step 3: Create the Nginx configuration
The file is a standalone Nginx configuration. It must define events, place map $http_upgrade $connection_upgrade in http scope, redirect port 80, and terminate TLS on 443. Use:
events {}
http {
limit_req_zone $binary_remote_addr zone=tunnelmesh_api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=tunnelmesh_ws:10m rate=5r/s;
upstream tunnelmesh_server {
server 127.0.0.1:8080;
keepalive 64;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name tunnel.example.com *.tunnel.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name tunnel.example.com *.tunnel.example.com;
ssl_certificate /etc/nginx/tunnelmesh/fullchain.pem;
ssl_certificate_key /etc/nginx/tunnelmesh/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 2m;
location /api/ {
limit_req zone=tunnelmesh_api burst=40 nodelay;
proxy_pass http://tunnelmesh_server;
include /etc/nginx/tunnelmesh/proxy-headers.conf;
}
location = /ws/agent {
limit_req zone=tunnelmesh_ws burst=10 nodelay;
proxy_pass http://tunnelmesh_server;
include /etc/nginx/tunnelmesh/proxy-headers.conf;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Authorization $http_authorization;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location = /ws/client {
limit_req zone=tunnelmesh_ws burst=10 nodelay;
proxy_pass http://tunnelmesh_server;
include /etc/nginx/tunnelmesh/proxy-headers.conf;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Authorization $http_authorization;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location / {
proxy_pass http://tunnelmesh_server;
include /etc/nginx/tunnelmesh/proxy-headers.conf;
}
}
}
Define the included header file as a second http-context file:
- Create:
deploy/nginx/proxy-headers.conf
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Authorization $http_authorization;
The Server must trust forwarded headers only from configured reverse-proxy addresses; this trust enforcement is supplied by subproject A/B, not by this file.
- Step 4: Run semantic and Nginx syntax checks
Run:
./scripts/verify-nginx.sh deploy/nginx/tunnelmesh.conf
nginx_test_dir="$(mktemp -d)"
trap 'rm -rf "$nginx_test_dir"' EXIT
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=tunnel.example.com' \
-keyout "$nginx_test_dir/privkey.pem" \
-out "$nginx_test_dir/fullchain.pem"
docker run --rm \
-v "$PWD/deploy/nginx:/etc/nginx/tunnelmesh:ro" \
-v "$nginx_test_dir/fullchain.pem:/etc/nginx/tunnelmesh/fullchain.pem:ro" \
-v "$nginx_test_dir/privkey.pem:/etc/nginx/tunnelmesh/privkey.pem:ro" \
nginx:1.27.5-alpine nginx -t -c /etc/nginx/tunnelmesh/tunnelmesh.conf
Expected: semantic script PASS and nginx: configuration file /etc/nginx/tunnelmesh/tunnelmesh.conf test is successful. The disposable certificate directory is outside the repository and is removed by the trap.
- Step 5: Document and execute WSS smoke checks
Document these commands with actual Agent and Client scoped Tokens created by subproject A:
curl -fsS https://tunnel.example.com/health/live
curl -fsS https://tunnel.example.com/health/ready
websocat --binary -H='Authorization: Bearer <agent-token>' wss://tunnel.example.com/ws/agent
websocat --binary -H='Authorization: Bearer <client-token>' wss://tunnel.example.com/ws/client
Expected: health endpoints return 200; each WebSocket reaches the correct backend handler. A missing Bearer header returns 401/403 and never upgrades. The documentation explicitly states that angle-bracket values are shell substitutions supplied by the operator and must not be committed or stored in shell history.
Task 5: Linux systemd Units
Files:
- Create:
deploy/systemd/tunnelmesh-server.service - Create:
deploy/systemd/tunnelmesh-agent.service - Create:
scripts/verify-systemd.sh - Create:
docs/deployment/linux-systemd.md
Interfaces:
- Consumes:
tunnelmesh-{server,agent} --config PATH check-config; subproject B systemdREADY=1,WATCHDOG=1, andSTOPPING=1; Server state at/var/lib/tunnelmesh; external configs under/etc/tunnelmesh. -
Produces: notify/watchdog service units validated by
systemd-analyze verify. - Step 1: Write the failing unit validator
Create scripts/verify-systemd.sh to require both files and these directives:
#!/bin/sh
set -eu
for unit in deploy/systemd/tunnelmesh-server.service deploy/systemd/tunnelmesh-agent.service; do
test -f "$unit"
grep -F 'Type=notify' "$unit" >/dev/null
grep -F 'Restart=on-failure' "$unit" >/dev/null
grep -F 'RestartSec=5s' "$unit" >/dev/null
grep -F 'TimeoutStopSec=30s' "$unit" >/dev/null
grep -F 'NoNewPrivileges=true' "$unit" >/dev/null
grep -F 'PrivateTmp=true' "$unit" >/dev/null
grep -F 'ProtectSystem=strict' "$unit" >/dev/null
done
grep -F 'WatchdogSec=45s' deploy/systemd/tunnelmesh-server.service >/dev/null
grep -F 'WatchdogSec=45s' deploy/systemd/tunnelmesh-agent.service >/dev/null
systemd-analyze verify deploy/systemd/tunnelmesh-server.service deploy/systemd/tunnelmesh-agent.service
- Step 2: Run the validator on Linux and record RED
Run:
./scripts/verify-systemd.sh
Expected: FAIL because the units do not exist.
- Step 3: Create the Server unit
Use:
[Unit]
Description=TunnelMesh Server
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
NotifyAccess=main
User=tunnelmesh
Group=tunnelmesh
StateDirectory=tunnelmesh
RuntimeDirectory=tunnelmesh
ExecStartPre=/usr/local/bin/tunnelmesh-server --config /etc/tunnelmesh/server.yaml check-config
ExecStart=/usr/local/bin/tunnelmesh-server --config /etc/tunnelmesh/server.yaml run
Restart=on-failure
RestartSec=5s
TimeoutStartSec=30s
TimeoutStopSec=30s
WatchdogSec=45s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/tunnelmesh /run/tunnelmesh
UMask=0077
[Install]
WantedBy=multi-user.target
- Step 4: Create the Agent unit
Use the same lifecycle controls, with:
ExecStartPre=/usr/local/bin/tunnelmesh-agent --config /etc/tunnelmesh/agent.yaml check-config
ExecStart=/usr/local/bin/tunnelmesh-agent --config /etc/tunnelmesh/agent.yaml run
Give the Agent ReadOnlyPaths=/etc/tunnelmesh and only the exact metadata file paths configured by the operator through additional systemd drop-ins. Do not grant blanket home-directory access.
- Step 5: Run static and live unit checks
Run on Linux with systemd:
./scripts/verify-systemd.sh
sudo install -m 0644 deploy/systemd/tunnelmesh-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl start tunnelmesh-server
systemctl show tunnelmesh-server -p ActiveState -p SubState -p WatchdogUSec -p MainPID
curl -fsS http://127.0.0.1:8080/health/ready
sudo systemctl stop tunnelmesh-server
Expected: ActiveState=active, SubState=running, non-zero WatchdogUSec, readiness 200, and a clean stop within 30 seconds after GOAWAY/drain. If subproject B has not implemented watchdog notifications, the task is not eligible for GREEN and the unit must not be installed with WatchdogSec.
Task 6: macOS launchd Definitions
Files:
- Create:
deploy/launchd/io.tunnelmesh.server.plist - Create:
deploy/launchd/io.tunnelmesh.agent.plist - Create:
scripts/verify-launchd.sh - Create:
docs/deployment/macos-launchd.md
Interfaces:
- Consumes: foreground
runcommands, SIGTERM-aware shutdown from subproject B, configs under/usr/local/etc/tunnelmesh, and log directory/usr/local/var/log/tunnelmesh. -
Produces: two system LaunchDaemon plists validated by
plutil -lint. - Step 1: Write the failing plist validator
#!/bin/sh
set -eu
for plist in deploy/launchd/io.tunnelmesh.server.plist deploy/launchd/io.tunnelmesh.agent.plist; do
test -f "$plist"
plutil -lint "$plist"
grep -F '<key>RunAtLoad</key>' "$plist" >/dev/null
grep -F '<key>KeepAlive</key>' "$plist" >/dev/null
grep -F '<key>ThrottleInterval</key>' "$plist" >/dev/null
grep -F '<key>StandardOutPath</key>' "$plist" >/dev/null
grep -F '<key>StandardErrorPath</key>' "$plist" >/dev/null
done
- Step 2: Run the validator and record RED
Run:
./scripts/verify-launchd.sh
Expected: FAIL because the plists do not exist.
- Step 3: Create both plists
Use system paths independent of Homebrew architecture:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>io.tunnelmesh.server</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/tunnelmesh-server</string>
<string>--config</string><string>/usr/local/etc/tunnelmesh/server.yaml</string>
<string>run</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
<key>ThrottleInterval</key><integer>5</integer>
<key>ProcessType</key><string>Background</string>
<key>StandardOutPath</key><string>/usr/local/var/log/tunnelmesh/server.log</string>
<key>StandardErrorPath</key><string>/usr/local/var/log/tunnelmesh/server-error.log</string>
</dict>
</plist>
Create the Agent plist with label io.tunnelmesh.agent, tunnelmesh-agent, agent.yaml, agent.log, and agent-error.log.
- Step 4: Run static and live launchd verification
Run on macOS:
./scripts/verify-launchd.sh
sudo install -d -m 0750 /usr/local/etc/tunnelmesh /usr/local/var/log/tunnelmesh
sudo cp deploy/launchd/io.tunnelmesh.server.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/io.tunnelmesh.server.plist
sudo chmod 0644 /Library/LaunchDaemons/io.tunnelmesh.server.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/io.tunnelmesh.server.plist
sudo launchctl print system/io.tunnelmesh.server
curl -fsS http://127.0.0.1:8080/health/ready
sudo launchctl bootout system/io.tunnelmesh.server
Expected: plist lint PASS, launchd reports a running PID, readiness returns 200, and bootout stops the process cleanly.
Task 7: Windows Services Through Pinned WinSW
Files:
- Create:
deploy/windows/tunnelmesh-server.xml - Create:
deploy/windows/tunnelmesh-agent.xml - Create:
deploy/windows/install-service.ps1 - Create:
deploy/windows/uninstall-service.ps1 - Create:
scripts/verify-windows-service.ps1 - Create:
docs/deployment/windows-service.md
Interfaces:
- Consumes: Windows console binaries that remain foreground processes and handle service-wrapper termination; configuration under
%ProgramData%\TunnelMesh; WinSW v2.12.0 x64 wrapper. -
Produces:
Install-TunnelMeshService -Component server|agentandUninstall-TunnelMeshService -Component server|agent. - Step 1: Write the failing PowerShell validator
$ErrorActionPreference = 'Stop'
$files = @(
'deploy/windows/tunnelmesh-server.xml',
'deploy/windows/tunnelmesh-agent.xml',
'deploy/windows/install-service.ps1',
'deploy/windows/uninstall-service.ps1'
)
foreach ($file in $files) {
if (-not (Test-Path $file -PathType Leaf)) { throw "missing $file" }
}
[xml](Get-Content 'deploy/windows/tunnelmesh-server.xml' -Raw) | Out-Null
[xml](Get-Content 'deploy/windows/tunnelmesh-agent.xml' -Raw) | Out-Null
$installer = Get-Content 'deploy/windows/install-service.ps1' -Raw
foreach ($required in @(
'v2.12.0',
'WinSW-x64.exe',
'05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0da',
'Get-FileHash',
'sc.exe failure'
)) {
if (-not $installer.Contains($required)) { throw "installer missing $required" }
}
if ($installer -match 'sc\.exe\s+create') { throw 'installer must not use sc.exe create' }
- Step 2: Run the validator and record RED
Run on Windows:
pwsh -NoProfile -File scripts/verify-windows-service.ps1
Expected: FAIL because the service assets do not exist.
- Step 3: Create the WinSW XML files
Server XML:
<service>
<id>TunnelMeshServer</id>
<name>TunnelMesh Server</name>
<description>TunnelMesh control plane and proxy server</description>
<executable>%BASE%\tunnelmesh-server.exe</executable>
<arguments>--config "%ProgramData%\TunnelMesh\server.yaml" run</arguments>
<logpath>%ProgramData%\TunnelMesh\logs</logpath>
<log mode="roll-by-size-time">
<sizeThreshold>10485760</sizeThreshold>
<pattern>yyyyMMdd</pattern>
<autoRollAtTime>00:00:00</autoRollAtTime>
<zipOlderThanNumDays>7</zipOlderThanNumDays>
<zipDateFormat>yyyyMM</zipDateFormat>
</log>
<stoptimeout>30sec</stoptimeout>
<onfailure action="restart" delay="5 sec" />
<onfailure action="restart" delay="30 sec" />
<onfailure action="restart" delay="60 sec" />
</service>
Agent XML uses IDs/names/descriptions for Agent, tunnelmesh-agent.exe, and agent.yaml.
- Step 4: Implement pinned download and installation
install-service.ps1 accepts -Component server|agent, -InstallDir defaulting to $env:ProgramFiles\TunnelMesh, and -DataDir defaulting to $env:ProgramData\TunnelMesh. It must:
- Require an elevated session.
- Require the matching TunnelMesh
.exeinInstallDir. - Create
DataDir\logswithout overwriting existing YAML. - Download
https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exeto a temporary file. - Compare
(Get-FileHash -Algorithm SHA256).Hash.ToLowerInvariant()with05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0dausing-ceq. - Copy the verified wrapper to
tunnelmesh-$Component-service.exeand the matching XML beside it with the same basename. - Execute
& $wrapper install; never callsc.exe create. - Configure SCM recovery:
sc.exe failure $serviceId reset= 86400 actions= restart/5000/restart/30000/restart/60000
if ($LASTEXITCODE -ne 0) { throw "sc.exe failure exited $LASTEXITCODE" }
- Start via
& $wrapper startand query viasc.exe query $serviceId. - Delete the temporary WinSW download in
finally.
On Windows ARM64, print that the Go executable is native ARM64 while WinSW v2.12.0 is x64 and requires Windows x64 emulation. Abort with a clear error if the wrapper fails to execute; do not silently replace it with an unpinned build.
- Step 5: Implement safe uninstall
uninstall-service.ps1 resolves the existing wrapper, runs stop then uninstall, and removes only the wrapper and adjacent generated XML. It explicitly preserves:
%ProgramData%\TunnelMesh\server.yaml
%ProgramData%\TunnelMesh\agent.yaml
%ProgramData%\TunnelMesh\logs
%ProgramData%\TunnelMesh\tunnelmesh.db
- Step 6: Run static and live Windows checks
Run:
pwsh -NoProfile -File scripts/verify-windows-service.ps1
pwsh -NoProfile -File deploy/windows/install-service.ps1 -Component server
sc.exe query TunnelMeshServer
sc.exe qfailure TunnelMeshServer
Invoke-WebRequest http://127.0.0.1:8080/health/ready -UseBasicParsing
pwsh -NoProfile -File deploy/windows/uninstall-service.ps1 -Component server
Expected: XML parsing PASS, hash verification PASS, SCM reports RUNNING, recovery lists 5/30/60-second restarts, readiness returns 200, uninstall removes the service but preserves config, logs, and data.
Task 8: Dockerfile and Compose Runtime Safety
Files:
- Modify:
Dockerfile - Modify:
docker-compose.local.yml - Modify:
docker-compose.cluster.yml - Modify:
.dockerignore - Create:
scripts/verify-containers.sh - Modify:
docs/deployment/docker.md
Interfaces:
- Consumes: Task 1 build metadata, Task 2
tunnelmesh-server healthcheck, subproject A Agent Token environment variable, and subproject B health endpoints. -
Produces: three non-root image targets; Server image health status; health-gated local and cluster Compose deployments.
- Step 1: Write the failing container semantic validator
Create a POSIX script requiring:
#!/bin/sh
set -eu
grep -F 'USER nonroot:nonroot' Dockerfile >/dev/null
grep -F 'TUNNELMESH_SERVER_HTTP_ADDR=:8080' Dockerfile >/dev/null
grep -F 'HEALTHCHECK' Dockerfile >/dev/null
grep -F 'healthcheck --url http://127.0.0.1:8080/health/live' Dockerfile >/dev/null
grep -F 'org.opencontainers.image.version' Dockerfile >/dev/null
grep -F 'org.opencontainers.image.revision' Dockerfile >/dev/null
grep -F 'target: runtime-server' docker-compose.local.yml >/dev/null
grep -F 'TUNNELMESH_AGENT_TOKEN' docker-compose.local.yml >/dev/null
grep -F 'ws://server:8080/ws/agent' docker-compose.local.yml >/dev/null
grep -F 'condition: service_healthy' docker-compose.local.yml >/dev/null
grep -F '127.0.0.1:8080:8080' docker-compose.local.yml >/dev/null
grep -F 'condition: service_healthy' docker-compose.cluster.yml >/dev/null
docker compose -f docker-compose.local.yml config >/dev/null
- Step 2: Run the validator and record RED
Run with a disposable Agent Token value:
TUNNELMESH_AGENT_TOKEN=release-smoke-token ./scripts/verify-containers.sh
Expected: FAIL because the Dockerfile has no healthcheck/runtime targets and Compose still uses the incorrect URL and ports.
- Step 3: Refactor the Docker runtime stages
Pass metadata as build arguments:
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
Use the same three linker variables as GoReleaser. Create a shared non-root base and three named runtime stages:
FROM gcr.io/distroless/static-debian12:nonroot AS runtime-base
ARG VERSION
ARG COMMIT
ARG BUILD_TIME
LABEL org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$COMMIT \
org.opencontainers.image.created=$BUILD_TIME
WORKDIR /var/lib/tunnelmesh
USER nonroot:nonroot
FROM runtime-base AS runtime-server
COPY --from=go-build /out/tunnelmesh /usr/local/bin/tunnelmesh
ENV TUNNELMESH_SERVER_HTTP_ADDR=:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/tunnelmesh", "healthcheck", "--url", "http://127.0.0.1:8080/health/live", "--timeout", "3s"]
ENTRYPOINT ["/usr/local/bin/tunnelmesh"]
CMD ["run"]
Agent and Client stages copy their binaries and have no fake HTTP healthcheck. Container process exit remains their liveness signal. Select the final stage with a validated APP argument or use explicit Compose target values.
Keep ARG APP=server in global scope and finish the Dockerfile with:
FROM runtime-${APP} AS runtime
This preserves the existing docker build --build-arg APP=server|agent|client interface while Compose may address runtime-server, runtime-agent, and runtime-client directly.
- Step 4: Correct local Compose
Required behavior:
- Server build target
runtime-server, internal port 8080, host mapping127.0.0.1:8080:8080for direct local access. Production public 80/443 belongs to Nginx. - Server healthcheck invokes the binary command.
- Agent build target
runtime-agent, URLws://server:8080/ws/agentonly for the isolated local Compose network, Agent ID, andTUNNELMESH_AGENT_TOKEN: ${TUNNELMESH_AGENT_TOKEN:?set TUNNELMESH_AGENT_TOKEN}. - Agent
depends_on.server.condition: service_healthy. - Client build target
runtime-client; no fabricated service healthcheck. -
All long-running services retain
restart: unless-stopped. - Step 5: Correct cluster Compose
Add MySQL health:
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
timeout: 5s
retries: 12
Both Servers use runtime-server, bind 8080 internally, expose only loopback test ports, wait for mysql: condition: service_healthy, and expose Server readiness. Keep MySQL DSN and CA external. Do not add real passwords, DSNs, certificates, or Tokens to Compose defaults.
- Step 6: Run semantic, build, and runtime verification
Run:
TUNNELMESH_AGENT_TOKEN=release-smoke-token ./scripts/verify-containers.sh
docker buildx build --load --platform linux/amd64 --target runtime-server \
--build-arg APP=server --build-arg VERSION=v0.1.0 \
--build-arg COMMIT="$(git rev-parse HEAD)" \
--build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t tunnelmesh:server-smoke .
docker inspect --format '' tunnelmesh:server-smoke
TUNNELMESH_AGENT_TOKEN=release-smoke-token docker compose -f docker-compose.local.yml up -d --build server
docker compose -f docker-compose.local.yml ps
curl -fsS http://127.0.0.1:8080/health/ready
docker compose -f docker-compose.local.yml down
Expected: image user is nonroot:nonroot; Server binds 8080 without permission errors; Compose reports Server healthy; readiness returns 200.
- Step 7: Verify both Linux architectures and archive no secrets
Run:
docker buildx build --platform linux/amd64,linux/arm64 --target runtime-server --build-arg APP=server .
if docker image inspect tunnelmesh:server-smoke | rg -i 'bearer |password=|private[_-]?key=|mysql.*dsn=|token='; then
echo 'secret-bearing image metadata detected' >&2
exit 1
fi
Expected: both platform builds complete and image inspection finds no secret-bearing Env or labels. Review docker history --no-trunc tunnelmesh:server-smoke separately to ensure no secret value was passed through a build instruction.
Task 9: Installation, Upgrade, Rollback, and Uninstall Documentation
Files:
- Create:
docs/deployment/binary-release.md - Modify:
docs/deployment/nginx.md - Modify:
docs/deployment/linux-systemd.md - Modify:
docs/deployment/macos-launchd.md - Modify:
docs/deployment/windows-service.md - Create:
docs/operations/upgrade-rollback.md - Modify:
docs/deployment/docker.md - Modify:
docs/operations/configuration.md - Modify:
docs/operations/troubleshooting.md - Modify:
docs/README.md - Modify:
README.md - Create:
scripts/verify-docs.sh
Interfaces:
- Consumes: release filenames from Task 3, platform assets from Tasks 4-8, subproject A Token rotation commands/API, and subproject B health/readiness endpoints.
-
Produces: complete operator paths from download through uninstall, with a rollback decision point that fits the five-minute recovery objective.
- Step 1: Write the failing documentation validator
The script checks every file exists and requires concrete command terms:
#!/bin/sh
set -eu
files='docs/deployment/binary-release.md
docs/deployment/nginx.md
docs/deployment/linux-systemd.md
docs/deployment/macos-launchd.md
docs/deployment/windows-service.md
docs/operations/upgrade-rollback.md'
for file in $files; do test -s "$file"; done
grep -F 'checksums.txt' docs/deployment/binary-release.md >/dev/null
grep -F 'shasum -a 256 -c' docs/deployment/binary-release.md >/dev/null
grep -F 'systemctl' docs/deployment/linux-systemd.md >/dev/null
grep -F 'launchctl bootstrap' docs/deployment/macos-launchd.md >/dev/null
grep -F 'WinSW v2.12.0' docs/deployment/windows-service.md >/dev/null
grep -F '05b82d46ad331cc16bdc00de5c6332c1ef818df8ceefcd49c726553209b3a0da' docs/deployment/windows-service.md >/dev/null
grep -F '/health/ready' docs/operations/upgrade-rollback.md >/dev/null
grep -F 'migrations/ddl.sql' docs/operations/upgrade-rollback.md >/dev/null
grep -F 'Token rotation' docs/operations/upgrade-rollback.md >/dev/null
grep -F 'five minutes' docs/operations/upgrade-rollback.md >/dev/null
- Step 2: Run the validator and record RED
Run:
./scripts/verify-docs.sh
Expected: FAIL because the release and platform guides do not exist.
- Step 3: Write binary installation and uninstall procedures
binary-release.md must include:
- Mapping from
uname -s,uname -m, and Windows architecture to exact archive names. - Download of the selected archive and
tunnelmesh_vX.Y.Z_checksums.txt. - Linux
sha256sum -c; macOSshasum -a 256 -c; WindowsGet-FileHashcomparison. - Extraction into a temporary directory and atomic installation using a new filename followed by
mv/Move-Itemon the same filesystem. --versionverification before service restart.- Config files under
/etc/tunnelmesh,/usr/local/etc/tunnelmesh, or%ProgramData%\TunnelMesh, never beside release binaries. - Logs through journald, configured launchd paths, WinSW logs, or container logs.
-
Uninstall that removes binaries/service definitions but preserves DB/config by default and lists a separate, explicit data deletion command.
- Step 4: Write the upgrade/rollback runbook
The runbook must prescribe this sequence:
- Record current binary/image digest and
--versionoutput. - Back up SQLite with the Server stopped or MySQL with a transaction-consistent backup.
- Inspect
migrations/ddl.sql; schema changes must use expand/contract order and remain readable by the immediately previous release. - Run new binary
check-configbefore replacing the active executable. - Canary one Server/Agent; require
/health/liveand/health/ready200, successful authenticated Agent and Client WSS connections, and stable error/reconnect metrics. - Promote remaining nodes one at a time.
- Roll back within five minutes by stopping the failed version, restoring the previous immutable binary/image, restoring DB only when the schema is not backward compatible, starting the service, and rechecking readiness.
- Perform Token rotation through subproject A after binary stability is confirmed; never rotate all Agent/Client Tokens at the same instant as the binary rollout.
- Step 5: Update existing docs and navigation
Update examples from Server :80 to :8080 where they refer to the direct process listener. Keep public URLs on HTTPS 443. Add all new guides to docs/README.md and the root README. Add troubleshooting commands for:
tunnelmesh-server --version
tunnelmesh-server --config /etc/tunnelmesh/server.yaml check-config
tunnelmesh-server healthcheck --url http://127.0.0.1:8080/health/live
curl -fsS http://127.0.0.1:8080/health/ready
systemctl status tunnelmesh-server
launchctl print system/io.tunnelmesh.server
sc.exe query TunnelMeshServer
docker inspect --format '' tunnelmesh-server
- Step 6: Run documentation verification
Run:
./scripts/verify-docs.sh
rg -n 'server.*:80|127\.0\.0\.1:80([^0-9]|$)' README.md docs deploy docker-compose*.yml
Expected: docs validator PASS. Any remaining direct-process :80 match is corrected; matches describing Nginx public port 80 redirects remain documented and are reviewed manually.
Task 10: Tag-Gated GitHub Release With Pre-Publication Native Smoke Tests
Files:
- Create:
.github/workflows/release.yml - Create:
scripts/smoke-release-artifact.sh - Create:
scripts/smoke-release-artifact.ps1 - Modify:
scripts/verify-release.sh - Modify:
README.md
Interfaces:
- Consumes:
make release VERSION=TAG, verifieddist/artifacts, GitHub Actions artifacts, and the protectedGITHUB_TOKEN. -
Produces: a GitHub release for an existing pushed
vX.Y.Ztag only after Linux amd64, macOS arm64, and Windows amd64 artifacts pass native smoke tests. Linux arm64 and Windows arm64 remain structural cross-build checks until trusted native runners are configured. - Step 1: Write a failing workflow policy test
Extend scripts/verify-release.sh with a workflow check that requires packaging, pre-publication smoke, and a final publish job:
workflow=.github/workflows/release.yml
test -f "$workflow"
grep -F 'tags:' "$workflow" >/dev/null
grep -F "'v*.*.*'" "$workflow" >/dev/null
grep -F 'actions/upload-artifact@v4' "$workflow" >/dev/null
grep -F 'actions/download-artifact@v4' "$workflow" >/dev/null
grep -F 'native-smoke:' "$workflow" >/dev/null
grep -F 'publish:' "$workflow" >/dev/null
grep -F 'needs: [package, native-smoke]' "$workflow" >/dev/null
grep -F 'contents: write' "$workflow" >/dev/null
grep -F 'gh release create' "$workflow" >/dev/null
if grep -F 'goreleaser release --clean' "$workflow" | grep -v -- '--skip=publish' >/dev/null; then
echo 'workflow must not let GoReleaser publish before verification' >&2
exit 1
fi
- Step 2: Run the policy test and record RED
Run after producing a structurally valid Task 3 dist/ fixture:
./scripts/verify-release.sh dist v0.1.0
Expected: FAIL because .github/workflows/release.yml does not exist.
- Step 3: Create the tag-only quality and package jobs
Start the workflow with read-only default permissions. Run tests on all three native runner families, package once on Linux, and upload the complete verified dist/ directory without publishing:
name: release
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: read
jobs:
quality:
strategy:
matrix:
os: [ubuntu-24.04, macos-14, windows-2022]
runs-on: $
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: {go-version-file: go.mod, cache: true}
- uses: actions/setup-node@v4
with: {node-version: '22', cache: npm, cache-dependency-path: web/package-lock.json}
- run: go test ./... -count=1
- run: go test -race ./...
if: runner.os == 'Linux'
- run: go vet ./...
- run: npm ci
working-directory: web
- run: npm test -- --run
working-directory: web
- run: npm run build
working-directory: web
package:
needs: quality
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- uses: actions/setup-go@v5
with: {go-version-file: go.mod, cache: true}
- uses: actions/setup-node@v4
with: {node-version: '22', cache: npm, cache-dependency-path: web/package-lock.json}
- uses: goreleaser/goreleaser-action@v6
with: {install-only: true, version: v2.18.1}
- name: Validate semantic version tag
shell: bash
run: |
[[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]
- run: make release VERSION=$
- uses: actions/upload-artifact@v4
with:
name: release-dist
path: dist/
if-no-files-found: error
retention-days: 7
- Step 4: Add the POSIX native artifact smoke script
scripts/smoke-release-artifact.sh DIST VERSION BINARY OS ARCH must locate the exact archive under DIST, verify its checksum entry, extract it to mktemp -d, execute ./BINARY --version, and require VERSION, commit=, and built=. For tunnelmesh-server, run:
if "$binary_path" healthcheck --url http://127.0.0.1:1/health/live --timeout 100ms; then
echo 'healthcheck unexpectedly accepted an unreachable endpoint' >&2
exit 1
fi
The script must clean temporary files with a trap and never download from a public release.
- Step 5: Add the Windows native artifact smoke script
scripts/smoke-release-artifact.ps1 -Dist DIR -Version VERSION -Binary BINARY -OS windows -Arch amd64 must select the exact zip, extract the matching line from the checksum file, compare it with Get-FileHash -Algorithm SHA256, expand into a temporary directory, run & $binaryPath --version, and require VERSION, commit=, and built=. Delete the temporary directory in finally; do not use gh release download because publication has not occurred yet.
- Step 6: Add native smoke jobs before publication
Add a matrix job that downloads release-dist and runs the correct script:
native-smoke:
needs: package
strategy:
matrix:
include:
- runner: ubuntu-24.04
binary: tunnelmesh-server
os: linux
arch: amd64
shell: bash
- runner: macos-14
binary: tunnelmesh-agent
os: darwin
arch: arm64
shell: bash
- runner: windows-2022
binary: tunnelmesh-client
os: windows
arch: amd64
shell: pwsh
runs-on: $
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with: {name: release-dist, path: dist}
- if: runner.os != 'Windows'
run: ./scripts/smoke-release-artifact.sh dist "${GITHUB_REF_NAME}" "$" "$" "$"
- if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/smoke-release-artifact.ps1 -Dist dist -Version "$env:GITHUB_REF_NAME" -Binary "$" -OS "$" -Arch "$"
Task 3’s archive verifier proves the other target archives exist and have safe contents. Add Linux arm64 and Windows arm64 native jobs when trusted runner capacity is available.
- Step 7: Publish only after every smoke job passes
Add the sole write-capable job:
publish:
needs: [package, native-smoke]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with: {name: release-dist, path: dist}
- run: gh release create "${GITHUB_REF_NAME}" dist/*.tar.gz dist/*.zip dist/*_checksums.txt --verify-tag --generate-notes
env:
GH_TOKEN: $
gh release create is the only publishing action. A failed quality, package, checksum, or native smoke job leaves no GitHub release.
- Step 8: Validate workflow syntax and release policy
Run locally:
git diff --check
rg -n 'pull_request|branches:' .github/workflows/release.yml
rg -n 'gh release create|contents: write|needs: \[package, native-smoke\]' .github/workflows/release.yml
rg -n 'goreleaser.*publish|sc\.exe create|gh release download' .github deploy scripts Makefile
Expected: no whitespace errors; no branch or pull-request publishing trigger; exactly one publication command under the final job; no direct GoReleaser publishing, direct SCM creation, or smoke download from an already-public release.
- Step 9: Perform a controlled release-candidate smoke after explicit Git authorization
Only after a human authorizes tag creation and push, use an agreed release-candidate version:
git tag -s v0.1.0-rc.1 -m 'TunnelMesh v0.1.0-rc.1'
git push origin v0.1.0-rc.1
gh run watch --exit-status
gh release view v0.1.0-rc.1
Expected: quality passes on Linux/macOS/Windows, packaging creates exactly 18 archives and one checksum file, native smoke jobs pass before the publish job starts, and only then does the GitHub release appear. These commands are operator documentation and are not executed while writing or implementing this plan without separate Git authorization.
Final Verification Gate
- Run the mandatory repository checks:
go test ./... -count=1
go test -race ./...
go vet ./...
git diff --check
cd web && npm test -- --run && npm run build
Expected: all PASS.
- Run release and deployment static checks from a clean worktree:
make release VERSION=v0.1.0
./scripts/verify-release.sh dist v0.1.0
./scripts/verify-web-embed.sh
./scripts/verify-nginx.sh deploy/nginx/tunnelmesh.conf
./scripts/verify-docs.sh
Expected: all PASS; git status --short remains empty after make release.
- Run platform-native checks on their actual operating systems:
Linux: systemd-analyze verify, systemctl watchdog/start/stop, Docker Buildx amd64/arm64
macOS: plutil -lint, launchctl bootstrap/print/bootout, darwin arm64 --version
Windows: PowerShell XML/hash checks, WinSW install/query/recovery/uninstall, windows amd64 --version
Expected: all service managers report the process running, readiness returns 200, and controlled stop completes within the configured timeout.
- Inspect release content and secret exposure:
git status --short --untracked-files=all
git diff --cached
find dist -maxdepth 1 -type f -print | LC_ALL=C sort
rg -n -i 'bearer |password|private[_-]?key|mysql.*dsn|token=' dist deploy .github docs Makefile scripts
Expected: no staged secret or runtime credential; matches in documentation are explanatory field names or redacted examples only; archives contain no runtime configuration or data.
- Record platform limitations in the release evidence:
- Race tests are native tests and are not replaced by cross-compilation.
- systemd watchdog verification requires a Linux system running systemd as PID 1.
- launchd verification requires macOS and root for LaunchDaemon installation.
- WinSW v2.12.0 provides x64 only; Windows ARM64 service use depends on OS x64 emulation, while the TunnelMesh executable itself is native ARM64.
- macOS notarization, Apple code signing, Windows Authenticode signing, SBOM, and artifact signatures are not part of this design; SHA256 verifies integrity but not publisher identity.
Expected: the release report names which native checks ran and which target artifacts received structural cross-build verification only.