Skip to content

feat(ci): tag-blocking release QA gate — server-type matrix + invariants (Spec 081 T1)#819

Open
Dumbris wants to merge 11 commits into
mainfrom
081-release-qa-gate-t1
Open

feat(ci): tag-blocking release QA gate — server-type matrix + invariants (Spec 081 T1)#819
Dumbris wants to merge 11 commits into
mainfrom
081-release-qa-gate-t1

Conversation

@Dumbris

@Dumbris Dumbris commented Jul 7, 2026

Copy link
Copy Markdown
Member

What

Spec 081 T1 — a single tag-blocking release qualification gate. This PR is stage 3 (workflow + wiring); it lands on top of the stage-1/2 fixtures + driver already on this branch. It assembles the existing suites with a new server-type matrix and end-to-end invariants, produces one machine-readable verdict, and makes artifact publication depend on it: a red gate publishes nothing, mechanically.

Architecture

Reusable workflow .github/workflows/release-qa-gate.yml. Every job writes a JSON report fragment into a shared dir; a final verdict job merges them against the hardcoded internal/gatereport manifest and exits per verdict.

Job Blocking manifest entries Timeout
build-candidate — (builds candidate core + fixtures + driver, one shared artifact) 15m
suite-api-e2e suite/api-e2e (scripts/test-api-e2e.sh unmodified) 15m
suite-race suite/unit-race, suite/server-race 25m
suite-scan-eval suite/scan-eval (unconditional, FR-015) 10m
matrix-invariants matrix/{stdio,http,sse,docker,oauth} + invariant/{activity-request-id,counters,quarantine-flow,upgrade-in-place} 20m
verdict (if: always()) merges → gate-report.json$GITHUB_STEP_SUMMARY; exit code is the verdict 10m

matrix and invariants run in one shell step so the state-file'd core survives between them. Missing/failed blocking fragments fail the gate (fail-closed, FR-004). Every job has an explicit timeout (FR-005).

FR coverage map

  • FR-001 / 001a — one verdict per tag; workflow_dispatch dry-run against any ref (publishes nothing).
  • FR-002release.yml release job (and everything cascading from it) + prerelease.yml release job now needs: a qa-gate job that uses: the reusable workflow.
  • FR-003 — invokes test-api-e2e.sh, go test -race ./internal/..., server-edition race, and scan-eval --gate (not reimplementations).
  • FR-004 / 010 — fragment statuses pass|fail|flaky|skipped|not-run|advisory-fail; no silent skips.
  • FR-005 — explicit timeout-minutes per job.
  • FR-006..010 — five-type matrix (driver, stage 1/2) wired with a Docker preflight; docker cell fails-not-skips.
  • FR-011..014 — invariants wired against the live matrix instance + upgrade-in-place from the latest stable release.
  • FR-015 — scan-eval runs on every tag regardless of changed paths.
  • FR-022 / SC-004cmd/release-gate/workflow_audit_test.go parses both publishers with yaml.v3 and asserts every artifact-publishing job's transitive needs includes the qa-gate job (statically disabled if: false jobs excluded). Verified it fails on an ungated release.

Gating safety

Both qa-gate jobs share the exact trigger condition of their release job (release.yml: stable-only; prerelease.yml: prerelease tags only, skips the next branch). So a skipped gate skips the publish job too — it can never silently un-gate. workflow_dispatch of release.yml is unchanged (all jobs still guard on the tag ref). RC tags don't double-run the gate in release.yml (prerelease.yml owns the RC gate).

Notable decisions

  • The bare FR-003 scan-eval command omits the required --corpus flag; I use the full eval.yml invocation (--corpus …/detect_corpus_v1.json --gate --min-recall 0.90 --max-fp 0.05) so the gate actually runs.
  • FR-003 -short fallback: suite-race runs the full non-short suites; if that empirically blows the 25m budget, fall back to -short in that job only with a loud comment — the heavy property/timing tests still run unguarded in e2e-tests.yml's stress-tests job.
  • FR-011 correlation finding: the activity-log invariant found that a caller-supplied X-Request-Id is not persisted on tool_call activity records today (internal/server/mcp.go synthesizes its own per-call ids). The check falls back to locating each call by a unique argument nonce and proving the core-recorded request id round-trips via ?request_id=, and records the limitation in the fragment rather than hiding it. Persisting the caller's header end-to-end is a middleware change left out of this stage.

T2–T4 extension points

Manifest reserves three non-blocking slots (recorded not-run/not-implemented-yet): reserved/web-ui-sweep (T2 Playwright sweep vs embedded frontend), reserved/macos-smoke (T3 advisory tray smoke; promotion criterion — 3 consecutive clean tags — documented), reserved/surface-consistency (T4 REST/CLI/Web-UI/tray agreement). See docs/development/release-gate.md.

Dry-run instructions

gh workflow run release-qa-gate.yml -f ref=<branch-or-sha> (or Actions UI → Release QA Gate → Run workflow). A dry run publishes nothing and never qualifies a later tag.

Note: release-qa-gate.yml carries a TEMP push: trigger on 081-release-qa-gate-t1 (commented # TEMP: remove before merge) so the branch gets real CI; remove before merge.

Testing

  • go build ./..., go vet, gofmt clean.
  • golangci-lint run --config .github/.golangci.yml ./cmd/release-gate/... → 0 issues.
  • go test -race ./cmd/release-gate/ passes (incl. the new audit test).
  • actionlint clean on the new workflow; no new findings on the edited publishers (pre-existing shellcheck infos untouched).
  • Python yaml.safe_load parses all three workflows.

Dumbris added 5 commits July 7, 2026 14:40
…ipt (Spec 081 T1)

Adds cmd/mcpfixture, a single fixture binary for the release QA gate's
server-type matrix (FR-006/FR-007): serves the deterministic echo and
ping tools over stdio, streamable-http, and legacy SSE, wired through
mark3labs/mcp-go's server package so each transport matches the exact
client contract mcpproxy pins (internal/upstream/core). ping returns a
per-process monotonic counter plus a random per-process instance_id so
kill/restart cells (FR-007d) can prove reconnection hit a fresh
instance. SIGTERM/SIGINT exit cleanly on every transport; SIGPIPE is
ignored so a parent closing our pipes cannot turn shutdown into a
broken-pipe death.

Adds scripts/gate/build-fixture-image.sh + cmd/mcpfixture/Dockerfile:
CGO_ENABLED=0 static linux build, FROM scratch image tagged
mcpfixture:gate — zero third-party pulls (FR-009 infra preflight fails
loudly when Docker is absent). The image intentionally has no
ENTRYPOINT: mcpproxy's isolation layer appends the container command
explicitly (docker run ... docker.io/library/mcpfixture:gate
/mcpfixture --transport stdio), verified against
internal/upstream/core/isolation.go BuildDockerArgs /
TransformCommandForContainer and documented in the script.

Tests (FR-022, -short friendly, exec the real binary): per-transport
handshake + tools/list + echo/ping round-trip + SIGTERM-then-restart
with counter reset and instance_id change; stdio SIGTERM clean-exit.

Decisions made under the zero-interruption policy: preferred mcp-go's
server package over hand-rolled framing; SSE endpoint event stays
relative (mcp-go client resolves it against its base URL); oauth cell
reuses tests/oauthserver as-is (not rebuilt here).
…e tools

The MCP spec default for an un-annotated tool is destructiveHint=true, which
makes mcpproxy's Spec-018 intent validation reject the tool through the
call_tool_read variant. The Spec 081 release-gate matrix and its OAuth cell
both drive fixtures via call_tool_read, so annotate the deterministic fixture
tools (mcpfixture echo/ping and tests/oauthserver echo/get_time) as read-only,
non-destructive, closed-world.

Related to Spec 081 (release-qa-gate) T1.
…reate

AddServerRequest has always declared (and documented) an Isolation field, but
only the PATCH/update path mapped it into the ServerConfig — handleAddServer
silently dropped it. A caller therefore could not opt a host-run stdio server
OUT of isolation on create when global docker_isolation.enabled=true: the
server was force-wrapped in a container with its host command path and failed
to start ("stat <host-path>: no such file or directory"). Surfaced by the
Spec 081 release-gate quarantine invariant, which adds a fresh stdio fixture
via POST while the docker matrix cell has global isolation enabled.

Map req.Isolation on create exactly like the update path (toConfig), so the
field means the same thing on both verbs. Add regression coverage for opt-out,
image override, and the omitted (do-not-touch) case.

Related to Spec 081 (release-qa-gate) T1.
Add the release-qualification gate driver (cmd/release-gate) and the report
schema/merger (internal/gatereport) that assemble the Spec 081 US1/US2 checks
into one machine-readable verdict.

Driver subcommands:
  - matrix:     boots the candidate core against five local fixtures
                (stdio/http/sse/docker/oauth) and runs FR-007 (a)ready
                (b)tools listed + discoverable via retrieve_tools (c)tool
                call round-trips through /mcp AND POST /api/v1/tools/call with
                a caller X-Request-Id (d)kill+restart+re-call. Docker cell
                fails infrastructure-classified when 'docker info' fails
                (FR-009, never skip/fall back); oauth cell exercises an
                initial authorization and a forced token refresh against the
                short-TTL mock IdP (FR-008); each cell retries <=2 and reports
                flaky on pass-on-retry (FR-010). Leaves the core running behind
                a --state-file for the invariants stage.
  - invariants: attaches to that live core and asserts FR-011 (100% of issued
                calls resolve in the activity log by request id), FR-012
                (token/usage/telemetry counters strictly increase under
                traffic), FR-013 (add mid-run -> quarantined -> call blocked ->
                approve -> round-trips), then FR-014 upgrade-in-place from the
                latest STABLE release (downloads the host tarball, runs the OLD
                binary on a scratch dir, SIGTERM+WAIT on the BBolt lock, starts
                the candidate on the same dir, asserts servers/quarantine/
                index survive).
  - run-suite:  wraps an existing suite entry point (FR-003) as a fragment.
  - report:     merges fragments against a hardcoded manifest into
                gate-report.json + a markdown summary; a missing blocking
                fragment is a FAIL (FR-004, no silent skips), reserved T2-T4
                slots record not-run, flaky counts green, unexpected fragments
                fail-closed.

Empirically confirmed against a live core that a caller-supplied X-Request-Id
is NOT persisted on tool_call activity records, so the activity invariant
issues the correlated call via POST /api/v1/tools/call in addition to the
MCP-native call and correlates by argument nonce + core-recorded request id,
recording the limitation in the report rather than hiding it.

Gate cores run with DO_NOT_TRACK=1 so the real downloaded release binary in
the upgrade check never emits production telemetry on local/dry-run
invocations (CI=true only covers GitHub runners); the in-process
builtin_tool_calls counters FR-012 reads stay live.

Negative-case coverage (FR-022): activity invariant fails on a missing/
unresolvable request id; flat counters fail; a pre-approved server fails the
quarantine invariant; the upgrade preservation check fails on an empty/
wrong-server index; the merger handles flaky/skipped/advisory-fail/not-run and
missing-fragment=fail.

Related to Spec 081 (release-qa-gate) T1.
…nts (Spec 081 T1)

Wire the Spec 081 stage-1/2 gate driver + fixtures into CI as a single
reusable workflow that qualifies a release tag before any artifact is
published.

## Workflow (.github/workflows/release-qa-gate.yml)
- workflow_call (publishers) + workflow_dispatch (dry run, publishes
  nothing — FR-001a); TEMP push trigger on the T1 branch for real CI.
- build-candidate: frontend build + embed + go build of the candidate
  core, fixtures and release-gate driver, shared as one artifact.
- suite-api-e2e: scripts/test-api-e2e.sh UNMODIFIED (FR-003).
- suite-race: go test -race ./internal/... + server edition.
- suite-scan-eval: scan-eval --gate --min-recall 0.90 --max-fp 0.05 on
  every tag regardless of changed paths (FR-015). The bare FR-003
  command omits the required --corpus flag; the full eval.yml invocation
  is used so the gate actually runs.
- matrix-invariants: five fixture upstreams (stdio/http/sse/docker/oauth,
  connect→list→call→kill/reconnect) + activity-request-id / counters /
  quarantine-flow / upgrade-in-place invariants; matrix + invariants run
  in one shell so the state-file'd core survives between them.
- verdict (if: always()): merges fragments against the hardcoded
  gatereport manifest, uploads gate-report.json, exits per verdict. A
  missing blocking fragment is a FAIL (fail-closed, FR-004). Every job
  has an explicit timeout (FR-005).

## Publishers wired (FR-002)
- release.yml: qa-gate job (reusable workflow) added; release.needs gains
  qa-gate so every public job cascading from release is gated. Guarded to
  the same stable-only condition so a skipped gate skips release too
  (never un-gates) and RC tags don't double-run the gate here.
- prerelease.yml: same, guarded to prerelease TAG refs only (skips on the
  next branch).

## Audit (FR-022 / SC-004)
- cmd/release-gate/workflow_audit_test.go parses both publisher workflows
  with yaml.v3 and asserts every artifact-publishing job's transitive
  needs closure includes the qa-gate job; statically disabled (if: false)
  jobs are excluded.

## Docs
- docs/development/release-gate.md: what runs, how to dry-run, the
  T2/T3/T4 reserved extension slots + macOS-smoke promotion criterion,
  the FR-003 -short fallback note, and the FR-011 request-id correlation
  finding (X-Request-Id is not persisted on tool_call records today;
  correlation falls back to argument nonces + core-recorded ids).
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3fc2729
Status: ✅  Deploy successful!
Preview URL: https://310cbbf5.mcpproxy-docs.pages.dev
Branch Preview URL: https://081-release-qa-gate-t1.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Jul 7, 2026

Copy link
Copy Markdown

Dumbris added 3 commits July 7, 2026 17:03
…ce tests, presence-check gate driver

Two gate-driver defects surfaced by the TEMP push-trigger dry run:

- suite-race ran the full non-short entry point (go test -race ./internal/...),
  which pulls in TestBinary*/TestMCP*/TestE2E_* — these hard-Fatal without a
  staged mcpproxy binary and exceed the 25m job budget. Fall back to the fast
  unit-race command proven green in e2e-tests.yml (-short -race + -skip for the
  binary/protocol/E2E entry points). The candidate binary is still exercised
  end-to-end by the server-type matrix job, and heavy variants run in
  e2e-tests.yml's stress job. FR-003 deviation documented in-workflow.

- verdict guarded on [ ! -x dist-bin/release-gate ], but actions/upload-artifact
  strips the executable bit, so a successfully-built driver reads as missing and
  the gate self-aborts with 'candidate build failed'. Switch to a presence check
  ([ ! -f ]) and rely on the existing chmod +x.
The two registry-search E2E assertions proxy to the LIVE public MCP registry
(/registries/official/servers). When that third-party service is slow or down
the curl hits its 10s timeout and the tests hard-fail — which, under the Spec 081
tag-blocking release gate, would block every release on a third party's uptime.

Add a log_skip outcome: on a confirmed external outage (curl non-zero, empty body,
or upstream success:false) the test is skipped, not failed. A reachable registry
returning a malformed body still hard-fails, so proxy-regression coverage is
preserved. Surfaces a skipped count in the summary.
The new Spec 081 gate helpers are Linux-only: cmd/mcpfixture is built and run
exclusively on the ubuntu-latest gate runner and inside the linux/amd64 Docker
image, and cmd/release-gate runs only on the gate runner. Their tests exercise
Unix-only semantics — exec of the built fixture + SIGTERM restart, and a
copyFile owner-execute (0o100) bit — which the cross-compilation 'PR Build'
Windows job cannot satisfy (exec-not-found without .exe, no Unix perm bits),
turning them red. Guard them with runtime.GOOS=="windows" skips; full coverage
is retained on Linux where these helpers actually run.
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: 081-release-qa-gate-t1

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (25 MB)
  • archive-linux-amd64 (16 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (19 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 28880166035 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris added 3 commits July 7, 2026 17:41
The new release-gate docs page linked to in-repo source (workflow, cmd/release-gate,
internal/gatereport, cmd/mcpfixture, scripts/gate, tests/oauthserver, spec.md, audit
test) via ../../ relative paths. Docusaurus resolves those as internal site links,
fails its broken-link check, and reds the Documentation build + Cloudflare Pages
deploy. Point them at absolute github.com blob/tree URLs so they are treated as
external links and rendered correctly.
… errors

Codex review round 1 (PR #819):
- checkCountersMoved skipped the telemetry sub-check whenever the AFTER
  snapshot was 503, masking an endpoint that regressed under matrix traffic
  after a healthy baseline. Skip now only when telemetry was unavailable at
  the boot baseline; a healthy-then-503 transition FAILS the invariant.
- test-api-e2e.sh treated any registry success:false as an external outage
  skip. The handler returns the same writeError envelope for its own bugs, so
  a proxy-side regression was silently skipped. Skip now requires transport
  evidence (curl rc, empty body, or upstream/transport error text).
…arts them

Codex review round 2 (PR #819): fixtureProc.alive() consulted
Cmd.ProcessState, which is only populated by cmd.Wait. Nothing called
cmd.Wait on the fixture (kill used os.Process.Wait, which does not set it),
so alive() always returned true — even for a crashed/self-exited fixture,
which lingered as an unreaped zombie. prepareRetry then never restarted a
dead driver-owned fixture (http/sse/oauth), and retries kept hitting the
dead port, defeating FR-010.

Own the process with a reaper goroutine (single cmd.Wait) that closes an
exited channel; alive() consults that channel and kill() waits on it instead
of calling Wait a second time.
@Dumbris

Dumbris commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

ReleaseEngineer review (MCP-4092 fallback — GeminiCritic adapter failed, reviewed directly)

VERDICT: ACCEPT

Verification

  • go build ./cmd/release-gate/ ./cmd/mcpfixture/
  • go test ./cmd/release-gate/ ./internal/gatereport/ ✓ (both pass)
  • go vet ./cmd/release-gate/ ./internal/gatereport/
  • actionlint .github/workflows/release-qa-gate.yml ✓ (zero findings)
  • TestPublishJobsGatedOnQAGate passes — FR-022/SC-004 audit confirmed: every publishing job in both release.yml and prerelease.yml transitively depends on qa-gate

Architecture check

Fail-closed wiring is correct:

  • verdict job runs if: always(); its exit code is the gate verdict the publishers depend on
  • Missing blocking fragments fail the merger explicitly (FR-004, no silent skips)
  • Gate condition in both publishers exactly matches their release job guard — skipped gate = skipped publish, never un-gated
  • continue-on-error: true on fragment downloads in verdict is correct (no-fragments → merger hard-fails)

One pre-merge action

Remove the TEMP push: trigger (lines 39–43 of release-qa-gate.yml) before merging. It is already marked # TEMP: remove before merge. No other changes needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants