From 29ba8594b8f494fc7e3dee8a499ea6469907dc4c Mon Sep 17 00:00:00 2001 From: Timmy Date: Wed, 19 Aug 2026 08:09:38 +0000 Subject: [PATCH] ci: enforce reproducible quality gates --- .gitea/workflows/quality.yml | 54 ++++++++++++++++++++++++++++++++ package.json | 4 ++- scripts/check_diff.sh | 14 +++++++++ scripts/record_release_demo.mjs | 1 + tests/ci-workflow.test.js | 31 ++++++++++++++++++ tests/photo-first.acceptance.mjs | 1 + tests/release-demo.test.js | 13 ++++++++ 7 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/quality.yml create mode 100644 scripts/check_diff.sh create mode 100644 tests/ci-workflow.test.js create mode 100644 tests/release-demo.test.js diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml new file mode 100644 index 0000000..a1230e5 --- /dev/null +++ b/.gitea/workflows/quality.yml @@ -0,0 +1,54 @@ +name: Quality gates + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install reproducibly + run: npm ci + - name: Install browser + run: npx playwright install --with-deps chromium + - name: Unit and security tests + run: npm test + - name: Mobile browser acceptance + run: | + npm start > /tmp/timmy-server.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid"' EXIT + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:4173/ > /dev/null; then + break + fi + if [ "$attempt" -eq 30 ]; then + cat /tmp/timmy-server.log + exit 1 + fi + sleep 1 + done + npm run test:ui + npm run test:photo + - name: Dependency audit + run: npm audit --audit-level=high + - name: Syntax checks + run: npm run check:syntax + - name: Diff hygiene + run: npm run check:diff diff --git a/package.json b/package.json index d8c5b32..cae5094 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/training-ingest.test.js", + "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/release-demo.test.js", "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", + "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py", + "check:diff": "bash scripts/check_diff.sh", "start": "node server.mjs" }, "devDependencies": { diff --git a/scripts/check_diff.sh b/scripts/check_diff.sh new file mode 100644 index 0000000..f4f5fac --- /dev/null +++ b/scripts/check_diff.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "${GITHUB_BASE_REF:-}" ]] && git rev-parse --verify --quiet "origin/$GITHUB_BASE_REF" >/dev/null; then + git diff --check "origin/$GITHUB_BASE_REF...HEAD" +elif [[ -n "${GITHUB_EVENT_BEFORE:-}" ]] && [[ "$GITHUB_EVENT_BEFORE" != "0000000000000000000000000000000000000000" ]] && git cat-file -e "$GITHUB_EVENT_BEFORE^{commit}" 2>/dev/null; then + git diff --check "$GITHUB_EVENT_BEFORE..HEAD" +elif git rev-parse --verify --quiet HEAD^ >/dev/null; then + git diff --check HEAD^..HEAD +else + git diff --check HEAD +fi + +git diff --check diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index 774629b..5c9c4e7 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -87,6 +87,7 @@ async function tap(selector, after = 650) { } await caption(`TIMMY ${version} • FEATURE DEMO`, 1300); +await caption('Automated checks replay this synthetic path before review', 1300); await caption('New path: private, photo-first stool logging', 1100); await tap('[data-scan]', 500); await page.getByText(/Self-hosted model ready/i).waitFor(); diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js new file mode 100644 index 0000000..e8f8cc0 --- /dev/null +++ b/tests/ci-workflow.test.js @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const workflowPath = new URL('../.gitea/workflows/quality.yml', import.meta.url); +const diffCheckPath = new URL('../scripts/check_diff.sh', import.meta.url); + +test('Gitea CI gates pull requests and main with the reproducible quality suite', async () => { + const workflow = await readFile(workflowPath, 'utf8'); + + assert.match(workflow, /^name: Quality gates$/m); + assert.match(workflow, /^on:\n push:\n branches: \[main\]\n pull_request:\n branches: \[main\]$/m); + assert.match(workflow, /^permissions:\n contents: read$/m); + assert.match(workflow, /^ timeout-minutes: 15$/m); + assert.match(workflow, /fetch-depth: 0/); + assert.match(workflow, /npm ci/); + assert.match(workflow, /npm test/); + assert.match(workflow, /npm run test:ui/); + assert.match(workflow, /npm run test:photo/); + assert.match(workflow, /npm audit --audit-level=high/); + assert.match(workflow, /npm run check:syntax/); + assert.match(workflow, /npm run check:diff/); +}); + +test('diff hygiene checks only the pull request or latest commit range', async () => { + const script = await readFile(diffCheckPath, 'utf8'); + + assert.match(script, /origin\/\$GITHUB_BASE_REF\.\.\.HEAD/); + assert.match(script, /HEAD\^\.\.HEAD/); + assert.doesNotMatch(script, /hash-object -t tree/); +}); diff --git a/tests/photo-first.acceptance.mjs b/tests/photo-first.acceptance.mjs index d5c34f7..0850e36 100644 --- a/tests/photo-first.acceptance.mjs +++ b/tests/photo-first.acceptance.mjs @@ -41,6 +41,7 @@ await page.locator('#analyze-photo').click(); await page.getByText(/83% confidence/i).waitFor(); assert.equal(await page.getByText('Type 4', { exact: true }).isVisible(), true); assert.equal(await page.getByText('brown', { exact: true }).isVisible(), true); +assert.match(await page.locator('.scan-result .fine').innerText(), /not a diagnosis/i); await page.screenshot({ path: 'artifacts/photo-first-result-mobile.png', fullPage: false }); await page.locator('#use-suggestion').click(); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js new file mode 100644 index 0000000..a2bd688 --- /dev/null +++ b/tests/release-demo.test.js @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const demoPath = new URL('../scripts/record_release_demo.mjs', import.meta.url); + +test('release demo visibly explains the CI-protected browser path without overstating safety', async () => { + const demo = await readFile(demoPath, 'utf8'); + + assert.match(demo, /Automated checks replay this synthetic path before review/); + assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/); + assert.match(demo, /Visual assistance — never a diagnosis\./); +});