Files
mcp-nextcloud/.github/workflows/pact.yml
T
Chris CoutinhoandClaude Opus 4.8 23789107ab ci(pact): use $GITHUB_SHA env var, add concurrency + timeout
Review round 1 follow-ups:
- Reference the built-in $GITHUB_SHA env var in run scripts instead of
  interpolating ${{ github.sha }}, removing the GitHub Actions script-injection
  surface (SonarCloud security rating on new code).
- Add a concurrency group (cancel-in-progress: false) to
  pact-record-deployment.yml so back-to-back tag pushes don't race the recording.
- Add timeout-minutes: 5 to guard against a hung tailnet join.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:26:09 +02:00

178 lines
7.5 KiB
YAML

name: Pact contract tests
# Consumer-driven contract testing against the homelab-hosted Pact Broker
# (ADR-029). The broker is only reachable over Tailscale, so every job that
# talks to it first joins the tailnet with the shared github-runner OAuth
# client. Jobs no-op when the broker secrets are absent (e.g. on forks).
#
# Required repo/org secrets:
# TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRET - Tailscale github-runner OAuth client
# PACT_BROKER - broker base URL (https://pact-broker.internal.coutinho.io)
# PACT_USERNAME / PACT_PASSWORD - broker basic-auth credentials
on:
pull_request:
branches:
- master
push:
branches:
- master
concurrency:
group: pact-${{ github.ref }}
cancel-in-progress: true
env:
PACT_BROKER: ${{ secrets.PACT_BROKER }}
PACT_USERNAME: ${{ secrets.PACT_USERNAME }}
PACT_PASSWORD: ${{ secrets.PACT_PASSWORD }}
jobs:
consumer:
name: Consumer pacts (mcp -> astrolabe)
runs-on: ubuntu-latest
# Skip on forks / when broker is not configured.
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install the latest version of uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Generate consumer pacts
run: uv run pytest -v -m contract tests/contract/
# Only publish from non-fork builds that have the broker secrets.
- name: Join tailnet
if: ${{ env.PACT_BROKER != '' }}
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github-runner
- name: Install Pact CLI
if: ${{ env.PACT_BROKER != '' }}
run: |
curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-ruby-standalone/f03e620e7552239b6ca59438c9beed9d1038c949/install.sh | bash # v2.6.1
echo "$PWD/pact/bin" >> "$GITHUB_PATH"
- name: Publish pacts to broker
if: ${{ env.PACT_BROKER != '' }}
run: |
pact-broker publish tests/contract/pacts \
--broker-base-url "$PACT_BROKER" \
--broker-username "$PACT_USERNAME" \
--broker-password "$PACT_PASSWORD" \
--consumer-app-version "${{ github.sha }}" \
--branch "${{ github.head_ref || github.ref_name }}"
provider:
name: Provider verification (astrolabe -> mcp)
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install the latest version of uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
# Stand up the MCP server. NOTE: single-user only exposes the *public*
# management endpoints (/api/v1/status, /api/v1/vector-sync/status) — see
# app.py:2248. The authenticated surface astrolabe also consumes
# (/api/v1/search, /webhooks, /apps, /chunk-context, /pdf-preview) needs
# an OAuth-capable profile (login-flow) plus Bearer-token injection into
# the verifier (Verifier.add_custom_header). That is the phase-4 follow-up;
# this job currently verifies the public-endpoint pacts.
- name: Generate ephemeral TOKEN_ENCRYPTION_KEY
run: |
KEY=$(openssl rand -base64 32 | tr '+/' '-_')
echo "TOKEN_ENCRYPTION_KEY=${KEY}" >> "$GITHUB_ENV"
- name: Start MCP server
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
compose-file: "./docker-compose.yml"
compose-flags: "--profile single-user"
up-flags: "--build"
env:
TOKEN_ENCRYPTION_KEY: ${{ env.TOKEN_ENCRYPTION_KEY }}
- name: Wait for MCP server
run: |
for i in $(seq 1 30); do
code=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:8000/api/v1/status || true)
[ "$code" = "200" ] && echo "ready" && exit 0
sleep 5
done
docker compose --profile single-user logs mcp
exit 1
- name: Join tailnet
if: ${{ env.PACT_BROKER != '' }}
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github-runner
- name: Verify provider against broker pacts
if: ${{ env.PACT_BROKER != '' }}
env:
PACT_PROVIDER_URL: http://localhost:8000
PACT_PROVIDER_VERSION: ${{ github.sha }}
PACT_PROVIDER_BRANCH: ${{ github.head_ref || github.ref_name }}
# Publish results only from master so PR runs don't pollute the matrix.
PACT_PUBLISH_RESULTS: ${{ github.ref == 'refs/heads/master' }}
run: uv run pytest -v -m contract tests/contract/test_mcp_provider_verification.py
can-i-deploy:
name: can-i-deploy
runs-on: ubuntu-latest
needs: [consumer, provider]
# Only the `github` context is available in a job-level `if`, so the broker
# guard lives on each step below (the pact-broker CLI errors on an empty
# --broker-base-url, e.g. after a secret rotation or on a fork).
if: ${{ github.ref == 'refs/heads/master' }}
steps:
- name: Join tailnet
if: ${{ env.PACT_BROKER != '' }}
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github-runner
- name: Install Pact CLI
if: ${{ env.PACT_BROKER != '' }}
run: |
curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-ruby-standalone/f03e620e7552239b6ca59438c9beed9d1038c949/install.sh | bash # v2.6.1
echo "$PWD/pact/bin" >> "$GITHUB_PATH"
# SHADOW MODE: run can-i-deploy for signal but never fail the workflow.
# The broker's `production` environment is populated by the
# pact-record-deployment workflow on each repo's next release; until both
# nextcloud-mcp-server AND astrolabe have recorded a prod deployment this
# check cannot pass, so gating now would block every merge on a bootstrap
# gap rather than a real incompatibility. Once it reports ✅ in both repos,
# promote to a hard gate by removing the `set +e`/`exit 0` wrapper and
# relocating it ahead of the deploy step (see card #325 follow-up).
- name: Can I deploy nextcloud-mcp-server? (shadow — non-blocking)
if: ${{ env.PACT_BROKER != '' }}
run: |
set +e
pact-broker can-i-deploy \
--broker-base-url "$PACT_BROKER" \
--broker-username "$PACT_USERNAME" \
--broker-password "$PACT_PASSWORD" \
--pacticipant nextcloud-mcp-server \
--version "$GITHUB_SHA" \
--to-environment production
rc=$?
if [ "$rc" -ne 0 ]; then
echo "::warning title=can-i-deploy (shadow)::can-i-deploy exited $rc — NOT gating (shadow mode). Expected until both repos record a production deployment; see Deck card #325."
else
echo "can-i-deploy passed ✅ (shadow mode — not yet gating)"
fi
exit 0