Files
shopdb-flask/.github/workflows/ci.yml
cproudlock 762d1b7210 Make the API usable by an agent, and stop the contract going stale
Someone writing a script against ShopDB had two options: read a 705 KB spec, or
guess. The spec is the reference, not an index, and handing an agent the whole
thing means either truncating it or spending a context window on 479 operations
nobody asked about.

Three additions, each feeding the next.

/api/docs/operations.txt is the index: every operation on one line with its
method, path, tag, auth and summary, at around 70 KB for all 486. A caller can
hold the entire API at once, find the four operations it needs, and only then
fetch their schemas.

/api/docs/openapi.json?tag=... narrows the spec to one of the 41 tag groups.
Components are kept intact deliberately: pruning the schema graph to match would
leave references resolving to nothing, and a generator that fails confusingly is
worse than a file that is larger than strictly necessary.

tools/mcp/ is an MCP server built on both. Five tools rather than 483, because a
tool per operation is unusable - clients degrade past a few dozen and models
start picking the wrong one. It reads everything from the live instance at call
time, so enabling a plugin adds operations it can already reach with no change
here. The token is the security boundary and the README says so plainly: every
operation the credential permits is reachable, so exploratory work wants a
read-scoped token.

The rest of this is about the contract not drifting, which is what makes any of
the above trustworthy.

Two CI jobs:

- api-contract runs the parity gates and then regenerates the spec and fails if
  anything changed. Those gates already existed and already worked - they caught
  both routes added here, unprompted - but nothing ran them except by hand, and
  the committed spec had silently drifted by three operations, including two
  setup endpoints added the day before.
- migrations-mysql is the job the header comment has been claiming for some
  time while not existing. It described "the REAL multi-site deploy path" and
  "what catches an Alembic regression on MySQL before it ships", and there was
  no such job. That is worse than no comment: it is the false confidence that
  let a migration ship which could not build a schema on a fresh MySQL database.
  The suite runs on SQLite, and SQLite accepts a foreign key onto a table that
  does not exist.

CONTRIBUTING.md now says to run gen_openapi.py --refresh after changing a route,
and that a route the sweep cannot classify belongs in PUBLIC_ENDPOINTS with a
reason rather than hand-edited into the inventory, which the next refresh would
overwrite. The new index route is declared there alongside the spec it indexes.
2026-09-20 12:59:49 -04:00

278 lines
12 KiB
YAML

# CI for shopdb-flask on GitHub Actions.
#
# Mirrors the internal CI pipeline. Jobs on push + pull_request:
# backend - pytest (SQLite via TestingConfig, no DB service needed)
# naming - the CONTRIBUTING.md naming/style gate
# gates - proves those checks actually RAN rather than skipping
# frontend - vitest + Vue build
# lean-build - ADR-013: a per-site build carries only its plugins
# api-contract - the published spec matches the code that serves it
# migrations-mysql - the REAL multi-site deploy path: fresh flask db upgrade
# + every plugin's chain on utf8mb4 MySQL 8, idempotent on
# a second run. The pytest suite only exercises SQLite
# create_all(), so this is what catches an Alembic
# regression on MySQL before it ships.
name: CI
# Action versions are the Node 24 native releases (checkout@v5, setup-node@v5,
# setup-python@v6). The v4/v5 generation targets Node 20, which the runners now
# force onto Node 24 with a deprecation warning on every job - working today,
# a hard failure whenever GitHub drops the shim.
#
# Jobs run on the org's self-hosted "arc-runner-set" (enterprise
# ge-aerospace-runner-group, Linux). GitHub-hosted runners are blocked by the
# org IP allow list (hosted Azure runner IPs are not allow-listed -> checkout
# 403), so ubuntu-latest cannot be used here. arc-runner-set checks out from an
# internal allow-listed IP and, being Linux, still supports service containers.
on:
push:
pull_request:
# A second push supersedes the first: the older run answers a question nobody is
# asking any more, and it holds a self-hosted runner while doing it.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- run: pip install -r requirements-dev.txt
- run: python -m pytest -q
naming:
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v5
- run: bash scripts/check-naming-and-style.sh
api-contract:
# The published API contract has to match the code that serves it. These
# gates catch a route that exists and is undocumented, a query parameter the
# handler reads that the spec omits, and a public route nobody declared
# public. They already work; until now nothing ran them except by hand, and
# the spec had silently drifted by three operations.
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- run: pip install -r requirements-dev.txt
- run: |
python -m pytest -q \
tests/test_api_inventory_parity.py \
tests/test_openapi_spec.py \
tests/test_openapi_examples.py \
tests/test_core/test_public_read_surface.py
- name: The spec must be regenerated, not just passing
# --refresh is derived from the code, so if it changes anything the
# committed spec was stale. Fail here rather than let a generated client
# be built from a file that no longer describes the API.
run: |
python scripts/gen_openapi.py --refresh
if ! git diff --quiet docs/openapi.json docs/api-inventory.json; then
echo "docs/openapi.json is stale. Run:"
echo " python scripts/gen_openapi.py --refresh"
git --no-pager diff --stat docs/openapi.json docs/api-inventory.json
exit 1
fi
migrations-mysql:
# THE JOB THE HEADER PROMISED AND NOBODY WROTE. Until now this existed only
# as a comment claiming to cover "the REAL multi-site deploy path", which is
# worse than no comment: it is exactly the false confidence that let a
# migration ship which could not build a schema on a fresh MySQL database.
#
# The pytest suite runs on SQLite, and SQLite accepts a foreign key onto a
# table that does not exist. MySQL does not. That difference is invisible to
# every other job here.
runs-on: arc-runner-set
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: ci
MYSQL_DATABASE: shopdb_flask
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -pci"
--health-interval=10s --health-timeout=5s --health-retries=10
ports:
- 3306:3306
env:
DATABASE_URL: mysql+pymysql://root:ci@127.0.0.1:3306/shopdb_flask?charset=utf8mb4
FLASK_APP: shopdb
SECRET_KEY: ci-not-a-secret
JWT_SECRET_KEY: ci-not-a-secret
CORS_ORIGINS: http://localhost
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- run: pip install -r requirements.txt
- name: Fresh install, exactly as the README tells a site to do it
run: |
flask db upgrade
flask plugin upgrade-all
flask seed permissions
flask seed settings
flask seed reference-data
- name: Enable every bundled plugin and migrate it
# A plugin's chain only runs once the plugin is ENABLED, so a core
# migration that depends on a plugin-owned table passes an empty install
# and fails a real one. Enabling them here is what reproduces that.
run: |
python - <<'PY'
import json, os, pathlib, datetime
root = pathlib.Path('plugins')
names = sorted(d.name for d in root.iterdir()
if (d / 'manifest.json').is_file())
now = datetime.datetime.now().isoformat()
state = {'plugins': {n: {'name': n, 'version': '1.0.0',
'installed_at': now, 'enabled': True,
'migrations_applied': [], 'config': {}}
for n in names}}
out = pathlib.Path('instance'); out.mkdir(exist_ok=True)
(out / 'plugins.json').write_text(json.dumps(state, indent=2))
print('enabled', len(names), 'plugins')
PY
flask plugin upgrade-all
- name: Re-run to prove it is idempotent
run: |
flask db upgrade
flask plugin upgrade-all
flask seed permissions
gates:
# Prove the gates RAN. Several skip themselves when the file they read is
# absent, which is correct in this checkout (docs/ becomes the wiki) and was
# silently wrong here for months: the modules skipped, CI went green, and an
# outside branch added nine routes documented nowhere and passed.
#
# A gate that stops running is worse than no gate, because nobody looks at
# it again.
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip
- run: pip install -r requirements-dev.txt
- run: bash scripts/check-gates-ran.sh
frontend:
runs-on: arc-runner-set
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npx vitest run
- run: npm run build
lean-build:
# ADR-013: prove a per-site build carries only its chosen plugins.
#
# The split below is SYNTHETIC, and deliberately so. Every plugin in this
# repository ships on this line, so there is no "omitted" set to borrow from
# a real site - and a job that iterated an empty omitted list would assert
# nothing while staying green, which is the exact failure the omission
# guarantee exists to catch. So the profile names a genuine SUBSET of the
# bundled plugins and the assertions prove the SUBSETTING MECHANISM: what
# the profile chooses is staged, and what it leaves out is not.
#
# Chosen (7): backups computers endpoints machines network notifications
# printers
# Omitted (5): guides knowledgebase slides tools warranty
#
# The omitted five are picked because nothing depends on them, so the
# closure stays exactly the chosen set. 'applications' is manifest-less core
# and ships in every build (ADR-013). endpoints hard-depends on computers,
# which is already chosen, so the closure adds nothing - if that changes,
# resolve_plugin_closure.py pulls the dependency in and the absence
# assertions below start failing, which is the signal to update this list
# rather than to loosen the check.
#
# Verified by planting a leak, per the standing rule that a green gate may
# be vacuous: adding 'guides' to the profile and rebuilding makes both the
# GuidesList assertion and the plugins/guides directory assertion fail.
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: cd frontend && npm ci
- name: Build lean site (seven-plugin subset)
run: |
printf '%s' '{ "site": "ci-lean", "plugins": ["backups", "computers", "endpoints", "machines", "network", "notifications", "printers"] }' \
> /tmp/lean-profile.json
bash scripts/build-site.sh /tmp/lean-profile.json /tmp/leansite
- name: Assert omitted plugin code is absent, chosen present
run: |
assets=/tmp/leansite/frontend-dist/assets
# One component per OMITTED plugin: guides, knowledgebase, slides,
# tools, warranty. Each name is proven to exist by the chosen-plugin
# loop on a full build, so these are known-good grep targets rather
# than strings that would pass by being misspelled.
for code in GuidesList KnowledgeBaseDetail SlideManager ToolsIndex \
WarrantiesList; do
if grep -rqoh "$code" "$assets"/*.js; then
echo "FAIL: omitted-plugin code '$code' leaked into the lean bundle"
exit 1
fi
done
# One component per CHOSEN plugin, so a plugin silently dropped from
# the bundle fails here instead of on the floor.
for code in BackupHistory PCsList EndpointsList MachineDetail \
NetworkDevicesList NotificationsList PrinterDetail; do
grep -rqoh "$code" "$assets"/*.js || {
echo "FAIL: chosen-plugin code '$code' missing from the lean bundle"
exit 1; }
done
# Core frontends (no manifest, e.g. applications) must ship in EVERY
# build regardless of SITE_PLUGINS, or a lean site loses a core page.
grep -rqoh "ApplicationsList" "$assets"/*.js || {
echo "FAIL: core page 'ApplicationsList' missing from the lean bundle"
exit 1; }
for plugin in backups computers endpoints machines network \
notifications printers; do
test -d "/tmp/leansite/plugins/$plugin" || {
echo "FAIL: chosen plugin '$plugin' missing from the backend tree"
exit 1; }
done
for plugin in guides knowledgebase slides tools warranty; do
test ! -d "/tmp/leansite/plugins/$plugin" || {
echo "FAIL: omitted plugin '$plugin' staged into the backend tree"
exit 1; }
done
echo "lean build verified: only chosen plugins present"
# NOTE: the MySQL-8 migration/seed job (fresh `flask db upgrade` + every
# plugin chain + strict-mode seeders on a real MySQL 8) runs on the internal
# CI server, which supports service containers. The org's arc-runner-set is
# Kubernetes/ARC without docker-in-docker, so GitHub Actions service
# containers ("services: mysql") are unavailable here ("Job Container is
# required"). That coverage stays on the internal CI rather than being
# duplicated on GitHub.