diff --git a/.github/workflows/check-api-updates.yml b/.github/workflows/check-api-updates.yml index b6ee6e5..d411a19 100644 --- a/.github/workflows/check-api-updates.yml +++ b/.github/workflows/check-api-updates.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Check OpenAPI spec for changes id: api-check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9535607..39ff2e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: node-version: [18.x, 20.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} cache: 'npm' @@ -57,10 +57,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '20.x' cache: 'npm' diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 8817787..d8220d8 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -36,10 +36,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '20.x' cache: 'npm' @@ -62,7 +62,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true @@ -117,31 +117,36 @@ jobs: # restarts compose when the digest changes. CI has no path into the host. - name: Wait for host to pull and redeploy run: | - EXPECTED=$(node -p "require('./package.json').version") - echo "Image pushed. Waiting for the dev host timer to pick it up (expecting version $EXPECTED, up to 6 min)..." + EXPECTED_COMMIT="${{ github.sha }}" + echo "Image pushed. Waiting for the dev host timer to pick it up (expecting commit $EXPECTED_COMMIT, up to 6 min)..." UP=0 + COMMIT="" for i in $(seq 1 18); do sleep 20 BODY=$(curl -s --max-time 10 ${{ secrets.MCP_URL }}/health || echo "") STATUS=$(echo "$BODY" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) VERSION=$(echo "$BODY" | grep -o '"version":"[^"]*"' | cut -d'"' -f4) - echo "attempt $i: status=${STATUS:-none} version=${VERSION:-none}" + COMMIT=$(echo "$BODY" | grep -o '"commit":"[^"]*"' | cut -d'"' -f4) + echo "attempt $i: status=${STATUS:-none} version=${VERSION:-none} commit=${COMMIT:-none}" [ "$STATUS" = "ok" ] && UP=1 - if [ "$VERSION" = "$EXPECTED" ]; then + if [ "$COMMIT" = "$EXPECTED_COMMIT" ]; then echo "SERVED_VERSION=$VERSION" >> $GITHUB_ENV - echo "Host is serving $VERSION" + echo "Host is serving commit $COMMIT (version $VERSION)" exit 0 fi done - # The health endpoint only reports package version, so a commit that does - # not bump the version is indistinguishable from a stale deploy. Treat a - # healthy-but-unchanged version as a warning, a dead endpoint as failure. - if [ "$UP" = "1" ]; then - echo "::warning::Host healthy but still serving ${VERSION:-unknown}, expected $EXPECTED. Check the deploy timer on the dev host." + if [ "$UP" != "1" ]; then + echo "Health endpoint never returned status=ok within 6 minutes" + curl -v ${{ secrets.MCP_URL }}/health || true + exit 1 + fi + if [ -z "$COMMIT" ]; then + # Transitional: the running image predates the commit field in /health, + # so staleness cannot be proven. Should only happen once. + echo "::warning::Host healthy but /health has no commit field (pre-commit-field build still running?). Cannot verify the deploy; check the dev host timer." exit 0 fi - echo "Health endpoint never returned status=ok within 6 minutes" - curl -v ${{ secrets.MCP_URL }}/health || true + echo "Host healthy but serving commit $COMMIT, expected $EXPECTED_COMMIT - the deploy did not land within 6 minutes" exit 1 - name: Deployment summary diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 9ece9c2..75a39c2 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -36,10 +36,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '20.x' cache: 'npm' @@ -61,7 +61,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -113,17 +113,25 @@ jobs: - name: Wait for host to pull and redeploy run: | EXPECTED="${{ steps.version.outputs.version }}" - echo "Image pushed. Waiting for the prod host timer to pick it up (expecting version $EXPECTED, up to 10 min)..." + EXPECTED_COMMIT="${{ github.sha }}" + echo "Image pushed. Waiting for the prod host timer to pick it up (expecting commit $EXPECTED_COMMIT / version $EXPECTED, up to 10 min)..." UP=0 for i in $(seq 1 30); do sleep 20 BODY=$(curl -s --max-time 10 ${{ secrets.MCP_URL }}/health || echo "") STATUS=$(echo "$BODY" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) VERSION=$(echo "$BODY" | grep -o '"version":"[^"]*"' | cut -d'"' -f4) - echo "attempt $i: status=${STATUS:-none} version=${VERSION:-none}" + COMMIT=$(echo "$BODY" | grep -o '"commit":"[^"]*"' | cut -d'"' -f4) + echo "attempt $i: status=${STATUS:-none} version=${VERSION:-none} commit=${COMMIT:-none}" [ "$STATUS" = "ok" ] && UP=1 - if [ "$VERSION" = "$EXPECTED" ]; then - echo "Production is serving $VERSION" + if [ "$COMMIT" = "$EXPECTED_COMMIT" ]; then + echo "Production is serving commit $COMMIT (version $VERSION)" + exit 0 + fi + # Fallback while the running image predates the commit field in /health: + # release versions are unique per tag, so a version match is trustworthy + if [ -z "$COMMIT" ] && [ "$VERSION" = "$EXPECTED" ]; then + echo "Production is serving $VERSION (no commit field yet)" exit 0 fi done diff --git a/CLAUDE.md b/CLAUDE.md index a540684..082c791 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,11 +108,20 @@ Implementation notes: ## CI/CD & Deployment -- `ci.yml` - build + unit tests (Node 18/20 matrix), lint + format check (both enforced) -- `deploy-dev.yml` - push to `develop` → Docker build → deploy to mcp-dev.digitalsamba.com -- `deploy-prod.yml` - tag `v*` → deploy to mcp.digitalsamba.com, then syncs develop/main +Deployment is **pull-based** (since 2026-07): CI builds and pushes images to the +private Monza registry but never touches the hosts. Each host pulls its tag on a +short timer (dev: `:latest`, ~2 min; prod: `:production`, ~5 min) and restarts +itself when the digest changes. The final workflow step waits for the host's +`/health` to report the expected git commit. + +- `ci.yml` - build + tests (Node 18/20 matrix), lint + format check (all enforced) +- `deploy-dev.yml` - push to `develop` → image pushed → dev host self-deploys (mcp-dev.digitalsamba.com) +- `deploy-prod.yml` - tag `v*` → image pushed → prod host self-deploys (mcp.digitalsamba.com), then syncs develop/main - `check-api-updates.yml` - weekly OpenAPI drift check (Mondays), opens issues labeled `api-update` -- Docker: `deployment/` contains Dockerfile and docker-compose (includes redis:7 sidecar) +- `deployment/docker-compose.yml` is a **reference copy only** - the live compose + and .env files are host-managed by ops; compose/env changes are an ops request, + not a commit +- `:latest` is dev's tag exclusively; prod publishes `:production`, version, and sha tags ## Critical Constraints diff --git a/src/transports/http.ts b/src/transports/http.ts index ebd1b65..4fbf576 100644 --- a/src/transports/http.ts +++ b/src/transports/http.ts @@ -17,6 +17,7 @@ import { createServer, VERSION, VERSION_INFO, + GIT_COMMIT, COMMITS_AHEAD, getDisplayVersion, isDevBuild, @@ -208,6 +209,7 @@ export async function startHttpServer( res.json({ status: "ok", version: VERSION, + commit: GIT_COMMIT, transport: "http", activeSessions: transports.size, oauthSessions: await getActiveSessionCount(),