Skip to content

feat(mcp): expand tool surface, add feature flags, FastMCP transforms #263

feat(mcp): expand tool surface, add feature flags, FastMCP transforms

feat(mcp): expand tool surface, add feature flags, FastMCP transforms #263

Workflow file for this run

# =============================================================================
# unit-tests.yml — Unit tests & fast smoke tests
# =============================================================================
#
# Maps to the "Unit Tests" row of the README badge table.
#
# Triggers: push: main, pull_request, workflow_dispatch.
#
# Jobs (alphabetical by display name):
# - unit:bats:shell — BATS shell tests
# - unit:cdk:config-matrix — tests/test_cdk_synthesis_matrix.py (in-process, -n auto)
# - unit:cdk:nag-compliance — cdk-nag compliance across full config matrix
# - unit:cdk:synth — cdk synth of the default config
# - unit:cli:smoke — gco --help and all subcommand help pages
# - unit:fresh-install — pip install from scratch, verify imports
# - unit:lockfile:freshness — requirements-lock.txt matches pyproject.toml
# - unit:pages:deploy — publish htmlcov/ (coverage report + badge) to Pages
# - unit:pytest:core — pytest (coverage target 85%) + upload Pages artifact
# - unit:workload:imports — K8s service import + circular-import detection
#
# =============================================================================
name: Unit Tests
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# --------------------------------------------------------------------------
# Path filter used by the lockfile / fresh-install jobs below. Both of those
# only exercise pyproject.toml + requirements-lock.txt (plus the workflow
# file itself), so running them on every docs- or code-only PR is waste —
# especially unit:fresh-install, which intentionally disables the pip cache.
#
# On push to main and workflow_dispatch the filter is skipped and the
# dependent jobs always run: we want at least one guaranteed green run per
# merge. On PRs, dorny/paths-filter queries the GitHub API (no checkout
# needed) for the changed files and gates the jobs via their `if:`.
# --------------------------------------------------------------------------
changes:
name: "changes"
runs-on: ubuntu-latest
timeout-minutes: 2
if: github.event_name == 'pull_request'
outputs:
deps: ${{ steps.filter.outputs.deps }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
deps:
- 'pyproject.toml'
- 'requirements-lock.txt'
- '.github/workflows/unit-tests.yml'
unit-pytest-core:
name: "unit:pytest:core"
runs-on: ubuntu-latest
timeout-minutes: 20
# Several tests import Lambda handler modules that construct boto3
# clients at module scope (lambda/api-gateway-proxy/proxy_utils.py,
# lambda/cross-region-aggregator/handler.py). boto3 requires a region
# even for imports, so set a default for the whole job. Matches the
# AWS_DEFAULT_REGION=us-east-1 env from the retired GitLab pipeline.
env:
AWS_DEFAULT_REGION: us-east-1
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: actions/setup-python@v6
with:
python-version: "3.14"
cache: "pip"
cache-dependency-path: "requirements-lock.txt"
- name: Install dependencies
run: |
pip install -e ".[dev,mcp]"
- uses: ./.github/actions/build-lambda-package
- name: Run pytest with coverage
# -n auto distributes tests across all available CPU cores via
# pytest-xdist. Every test is xdist-safe because
# tests/conftest.py::_neutralize_lambda_build patches
# StackManager._ensure_lambda_build and _rebuild_lambda_packages
# so tests can't rebuild the real lambda/kubectl-applier-simple-build
# tree mid-run — that rebuild is what CDK's Code.from_asset() races
# against when two workers synthesize stacks concurrently. The
# session-wide patch guards on `project_root` so the handful of
# tests that legitimately exercise these methods against a
# `tmp_path` keep working.
#
# --dist=load (xdist's default) round-robins individual test items
# across workers. --maxfail=1 matches the previous -x "stop at
# first failure" behavior — `-x` itself isn't compatible with xdist.
run: |
pytest tests/ -v \
--ignore=tests/test_integration.py \
--ignore=tests/test_nag_compliance.py \
--ignore=tests/test_cdk_synthesis_matrix.py \
--cov=gco --cov=cli --cov=mcp \
--cov-report=xml --cov-report=html --cov-report=json \
--cov-report=term-missing \
--cov-fail-under=90 \
--junitxml=report.xml \
-n auto --maxfail=1
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: pytest-coverage
path: |
htmlcov/
coverage.xml
coverage.json
report.xml
retention-days: 7
- name: Generate shields.io badge endpoint JSON
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
set -euo pipefail
# coverage.json is written by coverage.py. Pull totals.percent_covered,
# round to one decimal, and emit the shields.io endpoint badge
# schema so the README badge can link to a static JSON file on
# GitHub Pages without calling a third-party service.
#
# Color threshold matches the --cov-fail-under value (90%) from
# the pytest step above: anything below that would have already
# failed CI, so the badge only has two meaningful states.
#
# The JSON write is done entirely in Python (heredoc + json.dump)
# rather than piped back through shell — earlier attempts to
# interpolate a Python f-string result into shell printf ran into
# PEP 498's ban on backslash escapes inside f-string `{ }`
# expressions, producing a `SyntaxError: unexpected character
# after line continuation character` at CI time.
python3 <<'PY'
import json
with open("coverage.json") as f:
pct = json.load(f)["totals"]["percent_covered"]
color = "brightgreen" if pct >= 90 else "red"
badge = {
"schemaVersion": 1,
"label": "coverage",
"message": f"{pct:.1f}%",
"color": color,
}
with open("htmlcov/coverage-badge.json", "w") as f:
json.dump(badge, f)
print(f"Coverage: {pct:.1f}% ({color})")
PY
- name: Upload coverage report as Pages artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v5
with:
# htmlcov/ now contains index.html (coverage.py's report root)
# plus coverage-badge.json. GitHub Pages serves this at
# https://awslabs.github.io/<repo>/ once the deploy job below
# finalizes it. The README badge and report link both point
# under that base URL.
path: htmlcov
unit-pages-deploy:
name: "unit:pages:deploy"
# Deploys the htmlcov Pages artifact uploaded by unit-pytest-core to
# GitHub Pages. Runs only on push to main — PRs, manual dispatch,
# and forks all skip this job. Uses the official actions/deploy-pages
# flow rather than pushing to a gh-pages branch, so the Pages site
# is managed entirely through GitHub's built-in deployment system.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: unit-pytest-core
runs-on: ubuntu-latest
timeout-minutes: 5
# `pages: write` lets actions/deploy-pages finalize the deployment.
# `id-token: write` gives the action an OIDC token to authenticate
# with the Pages service — this is the documented requirement, not
# optional. `contents: read` is the default and sufficient since
# this job doesn't check out the repo.
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
unit-bats-shell:
name: "unit:bats:shell"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Install bats + deps
run: |
sudo apt-get update
sudo apt-get install -y bats jq python3 python3-yaml bash
- name: Run BATS suite
run: bats tests/BATS/
unit-cli-smoke:
name: "unit:cli:smoke"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.14"
cache: "pip"
cache-dependency-path: "requirements-lock.txt"
- name: Install CLI
run: |
pip install -e .
- name: Exercise CLI surface
run: |
python -c "from cli import GCOConfig, JobManager, CapacityChecker; print('CLI imports OK')"
gco --help
gco jobs --help
gco capacity --help
gco stacks --help
gco costs --help
gco dag --help
gco inference --help
unit-cdk-synth:
name: "unit:cdk:synth"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: actions/setup-python@v6
with:
python-version: "3.14"
cache: "pip"
cache-dependency-path: "requirements-lock.txt"
- name: Install CDK + dependencies
run: |
pip install -e ".[cdk]"
npm install -g aws-cdk
- uses: ./.github/actions/build-lambda-package
- name: cdk synth
run: cdk synth --quiet
- name: Upload cdk.out
if: always()
uses: actions/upload-artifact@v7
with:
name: cdk-synth-output
path: cdk.out/
retention-days: 7
unit-cdk-config-matrix:
name: "unit:cdk:config-matrix"
runs-on: ubuntu-latest
timeout-minutes: 20
env:
AWS_ACCESS_KEY_ID: "fake"
AWS_SECRET_ACCESS_KEY: "fake"
AWS_DEFAULT_REGION: "us-east-1"
CDK_DEFAULT_REGION: "us-east-1"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: actions/setup-python@v6
with:
python-version: "3.14"
cache: "pip"
cache-dependency-path: "requirements-lock.txt"
- name: Install CDK + test dependencies
run: pip install -e ".[cdk,test]"
- uses: ./.github/actions/build-lambda-package
- name: Run config matrix
# tests/test_cdk_synthesis_matrix.py parametrizes across every
# entry in tests/_cdk_config_matrix.CONFIGS and runs ``app.synth()``
# in-process per config — no ``cdk synth`` subprocess, no cdk.json
# mutation. The Node.js/CDK CLI is no longer required, so the
# ``npm install -g aws-cdk`` step was dropped alongside the
# subprocess loop.
#
# Runs serially (no ``-n auto``) for the same reason as
# unit:pytest:core: every parametrized case stages the same
# ``lambda/kubectl-applier-simple-build`` tree into
# ``cdk.out/asset.<hash>/`` and CDK's in-process asset-staging
# cache races under xdist. The bulk of CDK-config parallelism
# lives in the sibling unit:cdk:nag-compliance job, which fans
# out one GitHub runner per config.
run: pytest tests/test_cdk_synthesis_matrix.py -v
unit-cdk-nag-compliance-matrix:
name: "unit:cdk:nag-compliance:list"
# Emits the NAG_CONFIGS list as a JSON array for the fan-out matrix
# below. Reading the Python list here (rather than hard-coding config
# names in YAML) keeps the workflow in lockstep with
# ``tests/_cdk_config_matrix.NAG_CONFIGS`` — add an entry there and
# the matrix automatically picks it up on the next run.
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
configs: ${{ steps.list.outputs.configs }}
steps:
- uses: actions/checkout@v6
- id: list
run: |
python3 -c "
import json, sys
sys.path.insert(0, '.')
from tests._cdk_config_matrix import NAG_CONFIGS
names = [name for name, _ in NAG_CONFIGS]
print(f'configs={json.dumps(names)}')
" >> "$GITHUB_OUTPUT"
unit-cdk-nag-compliance:
name: "unit:cdk:nag-compliance (${{ matrix.config }})"
# Fan out across ``NAG_CONFIGS`` so each configuration synthesizes
# and runs cdk-nag in its own runner — the per-config work (build
# global + API gateway + regional(s) + monitoring + optionally
# analytics, then walk five rule packs across the resource tree)
# is heavy enough that GitHub's free 4-vCPU arm64 runner beats
# cramming every config onto a 2-vCPU amd64 runner under xdist.
#
# Each matrix cell invokes pytest with ``-k <config_name>`` so it
# runs exactly the parametrized case matching that config. This is
# the same hard gate as before against shipping an IAM-wildcard or
# similar cdk-nag error to a user's ``cdk deploy``: if every matrix
# cell is green, every configuration the user can pick from cdk.json
# has been validated against AwsSolutions, HIPAA Security, NIST
# 800-53 R5, PCI DSS 3.2.1, and Serverless rule packs in the same
# process that ``app.py`` uses for real deploys.
needs: unit-cdk-nag-compliance-matrix
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
config: ${{ fromJson(needs.unit-cdk-nag-compliance-matrix.outputs.configs) }}
env:
AWS_DEFAULT_REGION: us-east-1
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: actions/setup-python@v6
with:
python-version: "3.14"
cache: "pip"
cache-dependency-path: "requirements-lock.txt"
- name: Install dependencies
run: pip install -e ".[dev,mcp]"
- uses: ./.github/actions/build-lambda-package
- name: Run cdk-nag compliance for ${{ matrix.config }}
# Select the exact parametrized test case by test ID. The
# parametrize call in tests/test_nag_compliance.py uses
# ``ids=[c[0] for c in CONFIGS]``, so the config name *is* the
# test ID — ``::`` rather than ``-k`` avoids substring
# collisions (e.g. ``analytics-enabled`` matching both
# ``analytics-enabled`` and ``analytics-enabled-hyperpod-canvas``).
run: |
pytest -v \
"tests/test_nag_compliance.py::TestCdkNagCompliance::test_no_unsuppressed_findings[${{ matrix.config }}]" \
--junitxml=report-nag-compliance-${{ matrix.config }}.xml
- name: Upload nag compliance report
if: always()
uses: actions/upload-artifact@v7
with:
name: nag-compliance-report-${{ matrix.config }}
path: report-nag-compliance-${{ matrix.config }}.xml
retention-days: 7
unit-lockfile-freshness:
name: "unit:lockfile:freshness"
runs-on: ubuntu-latest
timeout-minutes: 10
# Skip on PRs that don't touch pyproject.toml or the lockfile. The
# `needs: changes` only applies on pull_request (where the `changes`
# job itself runs) — on push/dispatch `changes` is skipped and this
# condition short-circuits to true so the job always runs.
needs: changes
if: |
always() && (
github.event_name != 'pull_request' ||
needs.changes.outputs.deps == 'true'
)
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.14"
cache: "pip"
cache-dependency-path: "requirements-lock.txt"
- name: Install pip-tools
run: |
pip install pip-tools
- name: Verify lockfile is up to date
# Pre-seed the candidate lockfile with the committed one so pip-compile
# treats the existing pins as the current resolution state. Without
# this, an unrelated transitive release between a local pip-compile
# and the CI run would falsely flag drift.
#
# ``--all-extras`` pins every optional-dependency group (dev, test,
# cdk, mcp, typecheck, lint, security, inference-monitor). Without
# it, only the runtime ``dependencies`` list gets resolved and CI's
# own toolchain (pytest, pytest-xdist, moto, ruff, mypy, aws-cdk-lib,
# etc.) installs against live PyPI on every run. That means a
# transitive bump to pluggy or coverage between PR runs could
# silently change test behavior. Locking everything keeps every job
# reproducible against a known pinned tree.
env:
# pip-compile invokes pip, which can hit interactive keyring prompts
# on CI ("Unhandled exception: EOF when reading a line"). Disabling
# the keyring backend lets pip resolve without touching it.
PYTHON_KEYRING_BACKEND: keyring.backends.null.Keyring
run: |
set -euo pipefail
cp requirements-lock.txt /tmp/requirements-lock-fresh.txt
pip-compile --all-extras --no-emit-index-url --strip-extras -o /tmp/requirements-lock-fresh.txt pyproject.toml -q
grep -vE '^\s*#' requirements-lock.txt | grep -v '^gco-cli @ file' > /tmp/lock-no-comments.txt
grep -vE '^\s*#' /tmp/requirements-lock-fresh.txt | grep -v '^gco-cli @ file' > /tmp/fresh-no-comments.txt
if ! diff /tmp/lock-no-comments.txt /tmp/fresh-no-comments.txt; then
echo ""
echo "ERROR: requirements-lock.txt is stale."
echo "Regenerate using the Docker workflow documented in CONTRIBUTING.md"
echo "(Dependency Management → Regenerating the Lockfile)."
echo "Do not run pip-compile directly on the host — the result depends"
echo "on your OS and will diverge from the Linux-targeted lockfile CI expects."
echo "Then commit the updated lockfile."
exit 1
fi
echo "Lockfile is up to date"
unit-fresh-install:
name: "unit:fresh-install"
runs-on: ubuntu-latest
timeout-minutes: 10
# Same path-filter gate as unit:lockfile:freshness — the fresh install
# verifies a cold `pip install -e ".[cdk]"` succeeds and imports the
# expected modules. Only dependency/packaging changes can break it,
# and it's the most expensive small job in the workflow (no pip cache
# by design). Always runs on push to main / workflow_dispatch.
needs: changes
if: |
always() && (
github.event_name != 'pull_request' ||
needs.changes.outputs.deps == 'true'
)
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: actions/setup-python@v6
with:
python-version: "3.14"
# Intentionally no pip cache — this job verifies a cold install.
- name: Fresh install
run: |
pip install -e ".[cdk]"
- name: Verify imports
run: |
python3 -c "import aws_cdk; import cdk_nag; import aws_cdk.aws_eks_v2; print('All CDK imports OK')"
python3 -c "import click; import boto3; import requests; import yaml; print('All runtime imports OK')"
python3 -c "from cli.main import cli; print('CLI entry point OK')"
python3 -c "from gco.stacks.regional_stack import GCORegionalStack; print('Regional stack import OK')"
unit-workload-imports:
name: "unit:workload:imports"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.14"
cache: "pip"
cache-dependency-path: "requirements-lock.txt"
- name: Install core + inference-monitor extras
run: |
pip install -e .
pip install -e ".[inference-monitor]"
- name: Import health-monitor + manifest-processor
run: |
python -c "from gco.services.health_api import app; print('health-monitor OK')"
python -c "from gco.services.manifest_api import app; print('manifest-processor OK')"
- name: Import inference-monitor
run: |
python -c "from gco.services.inference_monitor import InferenceMonitor; print('inference-monitor OK')"
- name: Detect circular imports via importlib
# importlib.import_module executes all module-level code including
# deferred router imports; plain `import` can paper over cycles.
run: |
python -c "import importlib; importlib.import_module('gco.services.manifest_api'); print('manifest-api full import OK')"
python -c "import importlib; importlib.import_module('gco.services.health_api'); print('health-api full import OK')"
python -c "import importlib; importlib.import_module('gco.services.inference_monitor'); print('inference-monitor full import OK')"
python -c "from gco.services.api_routes import jobs, manifests, queue, templates, webhooks; print('all api_routes OK')"