diff --git a/.forgejo/workflows/test-and-deploy.yml b/.forgejo/workflows/test-and-deploy.yml new file mode 100644 index 0000000..b7f6607 --- /dev/null +++ b/.forgejo/workflows/test-and-deploy.yml @@ -0,0 +1,24 @@ +name: test-and-deploy + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: 138 + steps: + - uses: actions/checkout@v4 + - name: Unit tests + run: bash scripts/test-138.sh + + deploy-138: + needs: test + if: success() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: 138 + steps: + - uses: actions/checkout@v4 + - name: Deploy with automatic health rollback + run: bash scripts/deploy-138.sh diff --git a/README.md b/README.md index 7abd0cc..cf46585 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ cd verae-mcp-timestamping-server-go go build -o bin/verae-mcp-timestamping-server ./cmd/verae-mcp-timestamping-server ``` -Requires Go 1.26+. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`. Shadow systemd unit (not installed by this build): `deploy/verae-mcp-timestamping-go.service`. +Requires Go 1.26+. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`. The ns1 unit `verae-mcp-timestamping.service` runs this binary from `/opt/verae/verae-mcp-timestamping-server-go/bin`. `.forgejo/workflows/test-and-deploy.yml` tests on the `138` runner and `scripts/deploy-138.sh` swaps that binary, with a probe of `http://127.0.0.1:8000/mcp` and rollback to the previous file. ## Usage diff --git a/internal/embednats/server.go b/internal/embednats/server.go index 87b80d2..a98fd84 100644 --- a/internal/embednats/server.go +++ b/internal/embednats/server.go @@ -119,5 +119,15 @@ func splitHostPort(listen string) (string, int, error) { if host == "" { host = "127.0.0.1" } + // nats-server treats port 0 as its default (4222), which is already taken + // on ns1. Bind an ephemeral port and hand that number to the server. + if port == 0 { + ln, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + if err != nil { + return "", 0, fmt.Errorf("embednats: ephemeral listen: %w", err) + } + port = ln.Addr().(*net.TCPAddr).Port + ln.Close() + } return host, port, nil } diff --git a/scripts/deploy-138.sh b/scripts/deploy-138.sh new file mode 100755 index 0000000..04d2bf4 --- /dev/null +++ b/scripts/deploy-138.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Test, build, and swap the Go MCP binary on ns1. +# Does not restart verae-timestamping-svc or forgejo-runner, and does not read secret files. +set -euo pipefail + +LOCK="${LOCK:-/tmp/verae-mcp-go-deploy.lock}" +DEST="${DEST:-/opt/verae/verae-mcp-timestamping-server-go/bin/verae-mcp-timestamping-server}" +SERVICE="${SERVICE:-verae-mcp-timestamping.service}" +PROBE="${PROBE:-http://127.0.0.1:8000/mcp}" +GO_ROOT="${GO_ROOT:-/usr/local/lib/go1.26.9}" +GO_TARBALL="${GO_TARBALL:-go1.26.9.linux-amd64.tar.gz}" +GO_SHA256="${GO_SHA256:-42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +mkdir -p "$(dirname "$LOCK")" +exec 9>"$LOCK" +if ! flock -n 9; then + echo "another mcp deploy holds the lock" >&2 + exit 1 +fi + +ensure_go() { + if [[ -x "$GO_ROOT/bin/go" ]] && "$GO_ROOT/bin/go" version | grep -q 'go1.26.9 '; then + export PATH="$GO_ROOT/bin:$PATH" + return + fi + local tmp + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/go.tgz" "https://go.dev/dl/${GO_TARBALL}" + echo "${GO_SHA256} ${tmp}/go.tgz" | sha256sum -c - + tar -C "$tmp" -xzf "$tmp/go.tgz" + sudo rm -rf "$GO_ROOT" + sudo mv "$tmp/go" "$GO_ROOT" + rm -rf "$tmp" + export PATH="$GO_ROOT/bin:$PATH" +} + +probe_ok() { + local code + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$PROBE" || true)" + [[ -n "$code" && "$code" != "000" ]] +} + +wait_probe() { + local i + for i in 1 2 3 4 5 6 7 8; do + if probe_ok; then + return 0 + fi + sleep 1 + done + return 1 +} + +swap_binary() { + local src="$1" + sudo systemctl stop "$SERVICE" || true + local n=0 + while (( n < 5 )); do + if sudo cp "$src" "$DEST"; then + sudo chown verae-mcp:verae-mcp "$DEST" + sudo chmod 755 "$DEST" + sudo systemctl start "$SERVICE" + return 0 + fi + n=$((n + 1)) + sleep 1 + sudo systemctl stop "$SERVICE" || true + done + echo "could not replace $DEST" >&2 + sudo systemctl start "$SERVICE" || true + return 1 +} + +rollback() { + local latest + latest="$(ls -1t "${DEST}.bak-"* 2>/dev/null | head -1 || true)" + if [[ -z "$latest" ]]; then + echo "no backup to roll back to" >&2 + sudo systemctl start "$SERVICE" || true + exit 1 + fi + swap_binary "$latest" + if wait_probe; then + echo "rolled back to $latest (probe ok)" + return 0 + fi + echo "rollback probe still failing" >&2 + return 1 +} + +if [[ "${1:-}" == "--rollback" ]]; then + rollback + exit $? +fi + +ensure_go +cd "$ROOT" +go test ./... -count=1 +go build -o /tmp/verae-mcp-timestamping-server-new ./cmd/verae-mcp-timestamping-server +if ! grep -a -q 'verae-meta-git/1' /tmp/verae-mcp-timestamping-server-new; then + echo "built binary is missing verae-meta-git/1" >&2 + exit 1 +fi + +if [[ "${1:-}" == "--test-only" ]]; then + echo "tests ok" + exit 0 +fi + +ts="$(date -u +%Y%m%dT%H%M%SZ)" +sudo cp -a "$DEST" "${DEST}.bak-${ts}" +if ! swap_binary /tmp/verae-mcp-timestamping-server-new; then + rollback || true + exit 1 +fi +if ! wait_probe; then + echo "new binary failed probe; rolling back" >&2 + rollback || true + exit 1 +fi +echo "deployed ${ts} probe ok" diff --git a/scripts/test-138.sh b/scripts/test-138.sh new file mode 100755 index 0000000..7b21381 --- /dev/null +++ b/scripts/test-138.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Unit-test the Go MCP server with Go 1.26. Does not restart a service. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec bash "$ROOT/scripts/deploy-138.sh" --test-only