diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 425fdc5f..8abb2382 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,11 @@ # CI for shopdb-flask on GitHub Actions. # -# Mirrors the internal CI pipeline. Four jobs on push + pull_request: +# 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 # 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 @@ -22,6 +24,12 @@ 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 @@ -40,6 +48,24 @@ jobs: - uses: actions/checkout@v4 - run: bash scripts/check-naming-and-style.sh + 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@v4 + - uses: actions/setup-python@v5 + 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe63d64..cc1b6ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,25 @@ ADR-007 and ADR-002. ### Fixed +- **The published site's API documentation page had no spec to show.** + `shopdb/core/api/docs.py` reads `docs/openapi.json` at runtime to serve + `/api/docs`, and publication excluded the whole of `docs/` because the prose + becomes the wiki. So every site installed from the published repo shipped the + ReDoc page, loaded it, and answered `{"error":"openapi.json not generated"}`. + The spec now publishes: it is a product file that happens to live under + `docs/`, not documentation. The prose still goes to the wiki and nowhere else. + +- **Three gates were reporting green by not running.** + `test_api_inventory_parity`, `test_openapi_spec` and `test_docs_publishable` + all skip themselves when the file they read is absent - correct in a + published checkout, and silently wrong on GitHub, which runs against exactly + that checkout. An outside branch added nine routes documented nowhere and + passed CI. `scripts/check-gates-ran.sh` now asserts each gate actually ran, + splitting the ones that must run everywhere from the ones that need files + publication deliberately leaves behind, and a new `gates` job runs it in CI. + Verified in both directions: it passes here, and it fails when the file a + gate reads is taken away. + - **Secure Send now hands you a message, not a bare URL.** A link on its own invites being pasted onto text already sitting in the chat box, and a chat client turns the selection into the link's caption - so the secret travels as diff --git a/scripts/check-gates-ran.sh b/scripts/check-gates-ran.sh new file mode 100755 index 00000000..501e56ab --- /dev/null +++ b/scripts/check-gates-ran.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# Prove the gates actually RAN. +# +# Several checks skip themselves when the file they read is absent: +# +# tests/test_api_inventory_parity.py skipif docs/api-inventory.json missing +# tests/test_openapi_spec.py skipif docs/openapi.json missing +# tests/test_docs_publishable.py skipif docs/ missing +# +# That is correct in a published checkout, where docs/ becomes the wiki and +# there is genuinely nothing to check. It was silently wrong on GitHub, which +# runs against exactly that checkout: the modules skipped, CI reported green, +# and an outside branch added nine routes documented nowhere and passed. +# +# So the list is split. Some gates must run everywhere; the docs-dependent ones +# must run HERE, where the files exist, and are allowed to be absent there. +# +# A gate that stops running is worse than no gate: nobody looks at it again. +# This asserts each named module reported at least one PASS and no SKIPS. +# +# Usage: +# bash scripts/check-gates-ran.sh # runs the gates itself +# bash scripts/check-gates-ran.sh report.txt # reads an existing -rs report +set -uo pipefail + +cd "$(dirname "$0")/.." + +# Gates that must run in EVERY checkout, published or internal. A skip here is +# always a fault. +GATES_EVERYWHERE=( + "tests/test_openapi_spec.py" + "tests/test_core/test_public_read_surface.py" + "tests/test_core/test_authz.py" + "tests/test_plugin_migrations.py" +) + +# Gates that need files the publication export deliberately leaves behind - +# docs/ becomes the wiki, so a published checkout genuinely has nothing to check +# and skipping is correct there. In THIS repo, skipping means something broke. +GATES_INTERNAL_ONLY=( + "tests/test_api_inventory_parity.py" + "tests/test_docs_publishable.py" +) + +# A published checkout is one with no docs/ prose. openapi.json alone does not +# make it internal: that file ships because the app serves it at runtime. +if compgen -G "docs/*.md" > /dev/null; then + GATES=("${GATES_EVERYWHERE[@]}" "${GATES_INTERNAL_ONLY[@]}") + echo "==> Internal checkout: every gate must run" +else + GATES=("${GATES_EVERYWHERE[@]}") + echo "==> Published checkout: docs-dependent gates are expected to be absent" +fi + +PYTHON="${PYTHON:-python}" +[ -x venv/bin/python ] && PYTHON=venv/bin/python + +report="${1:-}" +if [ -z "$report" ]; then + report="$(mktemp)" + # -rs lists every skip with its reason, which is the thing being checked. + "$PYTHON" -m pytest "${GATES[@]}" -q -rs > "$report" 2>&1 || true +fi + +echo "==> Checking that every gate ran" + +failed=0 +for gate in "${GATES[@]}"; do + if [ ! -f "$gate" ]; then + echo " MISSING $gate - the file is not in this checkout" + failed=1 + continue + fi + if grep -q "SKIPPED.*$(basename "$gate")" "$report"; then + reason=$(grep "SKIPPED.*$(basename "$gate")" "$report" | head -1 | sed 's/.*-- //') + echo " SKIPPED $gate" + echo " reason: ${reason:-not given}" + failed=1 + continue + fi + echo " ran $gate" +done + +if grep -qE "^[0-9]+ failed|error" "$report"; then + echo + echo "Gate tests FAILED (not merely skipped):" + grep -E "^FAILED|^ERROR" "$report" | sed 's/^/ /' + failed=1 +fi + +echo +if [ "$failed" -ne 0 ]; then + echo "FAIL: a gate did not run. A skipped gate reports green and checks nothing." + echo " If a docs-dependent gate skipped in THIS repo, the file it reads" + echo " was not generated: run scripts/gen_openapi.py and" + echo " scripts/gen_project_map.py. If one skipped in the PUBLISHED repo," + echo " the export dropped a file the gate needs - see tools/export-github.sh." + exit 1 +fi +echo "All gates ran." diff --git a/tools/export-github.sh b/tools/export-github.sh index 530a814b..e7eb2506 100755 --- a/tools/export-github.sh +++ b/tools/export-github.sh @@ -41,7 +41,31 @@ done # the publication tree before its exclude existed stays there forever, invisible # to the sync and caught only by the scrub gate. Purge the generated paths first # so adding an exclude is enough on its own. -for stale in .pytest_cache .ruff_cache htmlcov .coverage \ +# +# docs/ does NOT publish - the prose becomes the WIKI instead. One exception, +# and it is not a documentation decision: +# +# docs/openapi.json is read AT RUNTIME by shopdb/core/api/docs.py, which +# serves the /api/docs page every published site ships. Excluding it meant +# that page loaded and then answered {"error":"openapi.json not generated"} +# on every site installed from the published repo. It is a product file that +# happens to live under docs/, not documentation. +# +# It also un-skips tests/test_openapi_spec.py, which had been reporting green +# by not running - see scripts/check-gates-ran.sh. +# +# docs/api-inventory.json stays OUT: nothing reads it at runtime, so it is a +# development gate and belongs with the wiki. That does mean +# test_api_inventory_parity cannot run on GitHub, which is why the gate guard +# separates the checks that must run everywhere from the ones that only run +# where the repo is complete. +# instance/plugins.json is one site's plugin CHOICES. It is excluded, so it was +# never published - but an old copy sat in the publication tree for months, +# protected from --delete by that same exclude, and it made the published repo's +# own test suite fail locally: 13 plugins enabled where the code expects 16. +# A publication tree that cannot pass its own tests is not one anybody can +# verify before shipping. +for stale in .pytest_cache .ruff_cache htmlcov .coverage instance/plugins.json \ deploy/windows/installer/bundle deploy/windows/installer/Output \ deploy/windows/installer/plugins.iss deploy/windows/installer/version.iss; do rm -rf "${PUB:?}/$stale" @@ -50,7 +74,9 @@ done rsync -a --delete \ --exclude '.git' \ --exclude '.gitea' \ - --exclude '/docs' \ + --include '/docs/' \ + --include '/docs/openapi.json' \ + --exclude '/docs/*' \ --exclude '/tools' \ --exclude '/mcp' \ --exclude 'start-api.sh' \