293 lines
13 KiB
YAML
293 lines
13 KiB
YAML
name: Managed validation
|
|
|
|
# The gate on protected master. Before v1 this keyed off manifests at the repository root, and this
|
|
# repository has none — pyproject.toml, package.json and the lock files all live in backend/,
|
|
# frontend/, node-agent/ and runtime-worker/. The "full" profile therefore completed in nine seconds
|
|
# having run a whitespace check, a merge-marker scan and py_compile, and no test suite at all, while
|
|
# reporting success to a branch protection rule that required it.
|
|
#
|
|
# It now targets the component roots explicitly, and refuses to report success when a component that
|
|
# should have run tests ran none. A gate that passes because it found nothing to do is worse than no
|
|
# gate: it produces the paperwork of validation without the fact of it.
|
|
|
|
on:
|
|
# Public exports require explicit owner dispatch; fork PRs never reach private runners.
|
|
workflow_dispatch:
|
|
inputs:
|
|
profile:
|
|
description: Allowlisted validation profile
|
|
required: true
|
|
default: full
|
|
type: choice
|
|
options: [test, lint, typecheck, build, security, full]
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
full:
|
|
name: full
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 45
|
|
env:
|
|
PROFILE: ${{ inputs.profile || 'full' }}
|
|
# An explicit interpreter path rather than PATH manipulation: how a runner propagates PATH
|
|
# between steps varies, and a validation gate should not depend on that detail.
|
|
VENV: /tmp/modelforge-validation-venv
|
|
steps:
|
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
|
|
- name: Production delivery policy
|
|
shell: bash
|
|
run: python3 -m unittest discover -s .gitea/tests -p 'test_*.py' -v
|
|
|
|
- name: Validate the requested profile
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
case "${PROFILE}" in
|
|
test|lint|typecheck|build|security|full) ;;
|
|
*) echo "Profile is not allowlisted: ${PROFILE}" >&2; exit 2 ;;
|
|
esac
|
|
echo "profile=${PROFILE}"
|
|
echo "commit=$(git rev-parse HEAD)"
|
|
|
|
- name: Repository hygiene
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
git diff --check
|
|
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
|
echo "Unresolved merge markers detected" >&2
|
|
exit 1
|
|
fi
|
|
echo "hygiene: clean"
|
|
|
|
- name: Prepare the report directory
|
|
shell: bash
|
|
run: mkdir -p reports
|
|
|
|
# Deliberately not using setup-python/setup-node: this runs on a self-hosted runner whose
|
|
# image already carries both, and the previous workflow depended on that too. What changes is
|
|
# that a missing toolchain now stops the run instead of quietly reducing what gets validated.
|
|
- name: Toolchain
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
command -v python3 >/dev/null || { echo "python3 is not on PATH" >&2; exit 1; }
|
|
command -v node >/dev/null || { echo "node is not on PATH" >&2; exit 1; }
|
|
command -v npm >/dev/null || { echo "npm is not on PATH" >&2; exit 1; }
|
|
echo " python $(python3 --version)"
|
|
echo " node $(node --version)"
|
|
echo " npm $(npm --version)"
|
|
python3 -m venv "${VENV}"
|
|
"${VENV}/bin/python" -m pip install --disable-pip-version-check --quiet --upgrade pip
|
|
if [[ "${PROFILE}" == security || "${PROFILE}" == full ]]; then
|
|
command -v gitleaks >/dev/null || {
|
|
echo "gitleaks is required for the security profile" >&2
|
|
exit 1
|
|
}
|
|
"${VENV}/bin/python" -m pip install \
|
|
--disable-pip-version-check --quiet 'pip-audit==2.10.0'
|
|
fi
|
|
echo " venv $("${VENV}/bin/python" --version) at ${VENV}"
|
|
|
|
- name: Backend — install, lint, typecheck, test
|
|
id: backend
|
|
shell: bash
|
|
working-directory: backend
|
|
run: |
|
|
set -euo pipefail
|
|
"${VENV}/bin/python" -m pip install --disable-pip-version-check --quiet -e '.[dev]'
|
|
if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m ruff check src tests
|
|
fi
|
|
if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m mypy src
|
|
fi
|
|
if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m pytest -q --no-header --junitxml=../reports/backend.xml
|
|
fi
|
|
|
|
- name: Node Agent — install, lint, typecheck, test
|
|
id: node_agent
|
|
shell: bash
|
|
working-directory: node-agent
|
|
run: |
|
|
set -euo pipefail
|
|
"${VENV}/bin/python" -m pip install --disable-pip-version-check --quiet -e '.[dev]'
|
|
if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m ruff check src
|
|
fi
|
|
if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m mypy src
|
|
fi
|
|
if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m pytest -q --no-header --junitxml=../reports/node-agent.xml
|
|
fi
|
|
|
|
- name: Runtime Worker — install, lint, typecheck, test
|
|
id: runtime_worker
|
|
shell: bash
|
|
working-directory: runtime-worker
|
|
run: |
|
|
set -euo pipefail
|
|
"${VENV}/bin/python" -m pip install --disable-pip-version-check --quiet -e '.[dev]'
|
|
if [[ "${PROFILE}" == lint || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m ruff check src
|
|
fi
|
|
if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m mypy src
|
|
fi
|
|
if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then
|
|
"${VENV}/bin/python" -m pytest -q --no-header --junitxml=../reports/runtime-worker.xml
|
|
fi
|
|
|
|
- name: Console — install frozen, typecheck, test, build
|
|
id: console
|
|
shell: bash
|
|
working-directory: frontend
|
|
run: |
|
|
set -euo pipefail
|
|
npm ci
|
|
if [[ "${PROFILE}" == typecheck || "${PROFILE}" == full ]]; then npx tsc --noEmit; fi
|
|
if [[ "${PROFILE}" == test || "${PROFILE}" == full ]]; then
|
|
npx vitest run --reporter=junit --outputFile=../reports/frontend.xml
|
|
fi
|
|
if [[ "${PROFILE}" == build || "${PROFILE}" == full ]]; then npm run build; fi
|
|
|
|
- name: Security — secrets and vulnerable dependencies
|
|
if: ${{ env.PROFILE == 'full' || env.PROFILE == 'security' }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
gitleaks git . --no-banner --redact --report-format json \
|
|
--report-path reports/gitleaks.json
|
|
"${VENV}/bin/python" -m pip_audit --strict --skip-editable --desc=on \
|
|
--format=json --output=reports/python-audit.json
|
|
cd frontend
|
|
npm audit --audit-level=high --omit=dev --json > ../reports/npm-audit.json
|
|
|
|
- name: Compose projections
|
|
if: ${{ env.PROFILE == 'full' || env.PROFILE == 'build' }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
if ! command -v docker >/dev/null; then
|
|
echo "SKIPPED: no docker CLI on this runner; compose projections are validated by the"
|
|
echo "local release gate instead. This step never reports a pass it did not earn."
|
|
exit 0
|
|
fi
|
|
# Compose interpolation needs values, not live credentials. These fixed validation-only
|
|
# strings never reach a service and ensure the fail-closed production projection is
|
|
# actually parsed on every full/build run.
|
|
export MODELFORGE_POSTGRES_DB=modelforge_validation
|
|
export MODELFORGE_POSTGRES_ADMIN_PASSWORD=validation-admin-only
|
|
export MODELFORGE_MIGRATION_DB_PASSWORD=validation-owner-only
|
|
export MODELFORGE_RUNTIME_DB_PASSWORD=validation-runtime-only
|
|
export MODELFORGE_MIGRATION_DATABASE_URL=postgresql+psycopg://modelforge:validation-owner-only@postgres:5432/modelforge_validation
|
|
export MODELFORGE_RUNTIME_DATABASE_URL=postgresql+psycopg://modelforge_runtime:validation-runtime-only@postgres:5432/modelforge_validation
|
|
export MODELFORGE_OPERATOR_API_KEY=validation-operator-key-32-characters # gitleaks:allow — synthetic Compose interpolation only
|
|
export MODELFORGE_BACKUP_ENCRYPTION_KEY=dmFsaWRhdGlvbi1vbmx5LWtleS0zMi1ieXRlcw== # gitleaks:allow — base64 of a public validation-only string
|
|
export MODELFORGE_CORS_ORIGINS=https://modelforge.example.test
|
|
export VITE_API_BASE_URL=https://modelforge.example.test
|
|
export MODELFORGE_VERSION=1.2.1
|
|
export MODELFORGE_AGENT_CONTROL_PLANE_HOST_ADDRESS=127.0.0.1
|
|
export MODELFORGE_AGENT_CA_CERT_PATH=./config/ca.crt
|
|
failures=0
|
|
check() {
|
|
if docker compose "$@" config -q >/dev/null 2>&1; then
|
|
echo " OK $*"
|
|
else
|
|
echo " FAIL $*"; failures=$((failures + 1))
|
|
fi
|
|
}
|
|
check -f docker-compose.yml
|
|
for overlay in backup dr gpu node-agent node-recovery production runtime-worker; do
|
|
check -f docker-compose.yml -f "docker-compose.${overlay}.yml"
|
|
done
|
|
check -f docker-compose.yml -f docker-compose.node-agent.yml \
|
|
-f docker-compose.node-agent.private-ca.yml
|
|
check -f docker-compose.yml -f docker-compose.runtime-worker.yml \
|
|
-f docker-compose.runtime-worker.private-ca.yml
|
|
[[ "${failures}" -eq 0 ]] || { echo "${failures} projection(s) invalid" >&2; exit 1; }
|
|
|
|
- name: Configuration documentation is current
|
|
if: ${{ env.PROFILE == 'full' }}
|
|
shell: bash
|
|
run: |
|
|
"${VENV}/bin/python" scripts/generate_configuration_docs.py --check
|
|
|
|
# The rule that makes the rest of this meaningful. Every component above declares a manifest,
|
|
# so every component must have reported a test count. Zero tests where tests were expected is
|
|
# a failure, not a pass — that is exactly how the previous workflow reported success.
|
|
- name: Refuse a validation that silently ran no tests
|
|
if: ${{ env.PROFILE == 'full' || env.PROFILE == 'test' }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
"${VENV}/bin/python" - <<'PY'
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
expected = {
|
|
"backend.xml": "backend",
|
|
"node-agent.xml": "node-agent",
|
|
"runtime-worker.xml": "runtime-worker",
|
|
"frontend.xml": "frontend",
|
|
}
|
|
reports = Path("reports")
|
|
failures = []
|
|
total = 0
|
|
for filename, component in expected.items():
|
|
path = reports / filename
|
|
if not path.is_file():
|
|
failures.append(f"{component}: no test report was produced")
|
|
continue
|
|
root = ET.parse(path).getroot()
|
|
suites = [root] if root.tag == "testsuite" else list(root.iter("testsuite"))
|
|
tests = sum(int(suite.get("tests", 0)) for suite in suites)
|
|
errors = sum(int(suite.get("errors", 0)) for suite in suites)
|
|
failed = sum(int(suite.get("failures", 0)) for suite in suites)
|
|
skipped = sum(int(suite.get("skipped", 0)) for suite in suites)
|
|
executed = tests - skipped
|
|
total += tests
|
|
print(f" {component:16} {tests:5} tests, {skipped} skipped, "
|
|
f"{failed} failed, {errors} errors")
|
|
if executed <= 0:
|
|
failures.append(f"{component}: {tests} tests collected, {executed} executed")
|
|
if failed or errors:
|
|
failures.append(f"{component}: {failed} failed, {errors} errors")
|
|
print(f" {'TOTAL':16} {total:5} tests")
|
|
if failures:
|
|
print("\nManaged validation refused:", file=sys.stderr)
|
|
for failure in failures:
|
|
print(f" - {failure}", file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
|
|
- name: Validation summary
|
|
if: always()
|
|
shell: bash
|
|
run: |
|
|
echo "commit: $(git rev-parse HEAD)"
|
|
echo "profile: ${PROFILE}"
|
|
ls -l reports/ 2>/dev/null || echo "no reports directory"
|
|
|
|
# Gitea only lists dispatchable workflow files from the default branch. Reusing the dedicated
|
|
# workflow from the existing managed entry point lets an unmerged branch prove its exact public
|
|
# export on the server. Ordinary PR validation and every profile except an explicit `build`
|
|
# dispatch remain unchanged.
|
|
public_candidate_acceptance:
|
|
name: Public candidate server acceptance
|
|
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.profile == 'build' }}
|
|
uses: ./.gitea/workflows/public-candidate-acceptance.yml
|
|
with:
|
|
source_commit: ${{ gitea.sha }}
|
|
public_api_origin: https://modelforge.example.test
|