Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 132 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,142 @@ jobs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
python-version: "3.12"
node-version: "24"
enable-cache: true
uv-version: 0.12.10
sync-command: uv sync --all-packages --locked
lint-command: bash .github/scripts/python-checks.sh
test-command: bash scripts/ci/python-tests.sh unit
test-results-path: |
reports/python-unit.xml
reports/coverage/python-unit/coverage.db
reports/coverage/
test-results-name: python-unit-arm64-${{ github.run_attempt }}
name: Unit / Python lint and tests (ARM64)
coverage-report:
name: Report / Backend unit coverage PR comment
needs: python-unit
if: >-
always() && !cancelled() && github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
# No checkout: downloaded reports are data, never executable code.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: python-unit-arm64-*
path: report
merge-multiple: false
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
UNIT_RESULT: ${{ needs.python-unit.result }}
with:
script: |
const fs = require('fs');
const marker = '<!-- devfeed-backend-unit-coverage -->';
const pr = context.payload.pull_request;
const current = await github.rest.pulls.get({...context.repo, pull_number: pr.number});
if (current.data.head.sha !== pr.head.sha) return;
const url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const workspaces = [
'apps/admin-api', 'apps/aggregator', 'apps/api', 'apps/article-enrichment-worker',
'apps/cli', 'apps/images-worker', 'apps/mcp', 'apps/notifications',
'apps/search-indexer', 'apps/source-discovery-worker', 'apps/user-api',
'packages/core', 'packages/http'
];
const counters = ['covered_lines', 'num_statements', 'covered_branches', 'num_branches'];
const integer = value => Number.isSafeInteger(value) && value >= 0;
const metrics = value => {
if (!value || !counters.every(key => integer(value[key])) ||
value.covered_lines > value.num_statements || value.covered_branches > value.num_branches) {
throw new Error('Invalid coverage counts');
}
const percent = (covered, total) => total ? 100 * covered / total : 100;
return [percent(value.covered_lines, value.num_statements),
percent(value.covered_branches, value.num_branches),
percent(value.covered_lines + value.covered_branches, value.num_statements + value.num_branches)];
};
let summary, measured;
try {
// A report-only rerun reuses the successful unit job's earlier artifact.
const attempt = Number(process.env.GITHUB_RUN_ATTEMPT);
// download-artifact flattens a single match, even with merge-multiple: false.
const flat = fs.existsSync('report/coverage/summary.json');
const artifacts = flat ? [] : fs.readdirSync('report')
.map(name => /^python-unit-arm64-(\d+)$/.exec(name))
.filter(Boolean).map(match => Number(match[1]))
.filter(value => integer(value) && value > 0 && value <= attempt);
const artifactAttempt = Math.max(...artifacts);
if (!flat && (!integer(artifactAttempt) ||
(process.env.UNIT_RESULT !== 'success' && artifactAttempt !== attempt))) {
throw new Error('No matching unit artifact');
}
const file = flat ? 'report/coverage/summary.json' :
`report/python-unit-arm64-${artifactAttempt}/coverage/summary.json`;
const raw = fs.readFileSync(file, 'utf8');
if (raw.length > 30000) throw new Error('Oversized coverage report');
summary = JSON.parse(raw);
if (!integer(summary.unit_attempt) || summary.unit_attempt < 1 ||
summary.unit_attempt > attempt || (!flat && summary.unit_attempt !== artifactAttempt) ||
(process.env.UNIT_RESULT !== 'success' && summary.unit_attempt !== attempt)) {
throw new Error('Coverage belongs to a different unit attempt');
}
if (summary.schema !== 1 || summary.valid !== true ||
!['passed', 'failed'].includes(summary.status) ||
!Number.isFinite(summary.minimum) || summary.minimum <= 0 || summary.minimum > 100 ||
!integer(summary.unit_exit_code) || !integer(summary.source_files) || !summary.source_files ||
Object.keys(summary.packages).sort().join('|') !== workspaces.join('|')) {
throw new Error('Incomplete coverage report');
}
measured = metrics(summary.totals);
if (!summary.totals.num_statements) throw new Error('Empty coverage report');
const sum = Object.fromEntries(counters.map(key => [key, 0]));
for (const name of workspaces) {
metrics(summary.packages[name]);
for (const key of counters) sum[key] += summary.packages[name][key];
}
if (!counters.every(key => sum[key] === summary.totals[key])) throw new Error('Inconsistent totals');
const tests = summary.tests;
if (!tests || !['total', 'passed', 'failed', 'skipped'].every(key => integer(tests[key])) ||
!tests.total || tests.total !== tests.passed + tests.failed + tests.skipped) {
throw new Error('Invalid unit test counts');
}
} catch {
summary = null;
}
const lines = [marker, '### Backend unit coverage', ''];
if (summary) {
const tests = summary.tests;
const passed = process.env.UNIT_RESULT === 'success' && summary.status === 'passed' &&
summary.unit_exit_code === 0 && tests.failed === 0 && tests.skipped === 0 &&
Number(measured[2].toFixed(2)) >= summary.minimum;
lines.push(`**${passed ? 'PASS' : 'FAIL'}** · minimum combined coverage **${summary.minimum.toFixed(2)}%**.`, '',
`Tests: ${tests.passed} passed, ${tests.failed} failed, ${tests.skipped} skipped. Source files: ${summary.source_files}.`, '',
'| Workspace | Lines | Branches | Combined |', '| --- | ---: | ---: | ---: |');
const row = (name, values) => `| ${name} | ${values.map(value => `${value.toFixed(2)}%`).join(' | ')} |`;
lines.push(row('All backend code', measured));
for (const name of workspaces) lines.push(row(name, metrics(summary.packages[name])));
lines.push('', 'Unit coverage includes every backend workspace. PostgreSQL/Redis integration tests run separately.');
} else {
lines.push('**Coverage unavailable:** no valid unit coverage artifact could be loaded. Inspect the Python unit and coverage-report logs; this does not count as a passing coverage check.');
}
lines.push('', `Commit: ${pr.head.sha.slice(0, 12)} · [HTML, JSON, XML reports and job logs](${url}) · attempt ${process.env.GITHUB_RUN_ATTEMPT}`);
const body = lines.join('\n');
const comments = await github.paginate(github.rest.issues.listComments, {...context.repo, issue_number: pr.number});
const previous = comments.find(comment => comment.user.type === 'Bot' && comment.body?.startsWith(marker));
const latest = await github.rest.pulls.get({...context.repo, pull_number: pr.number});
if (latest.data.head.sha !== pr.head.sha) return;
if (previous) {
await github.rest.issues.updateComment({...context.repo, comment_id: previous.id, body});
} else {
await github.rest.issues.createComment({...context.repo, issue_number: pr.number, body});
}
if (!summary) core.setFailed('Backend coverage artifact is missing or invalid');
admin-web:
uses: abhi1693/actions/.github/workflows/node-ci.yml@afba12afb85bf40be9328c92dc53b779f18d5c6d # v1.0.1
with:
Expand Down Expand Up @@ -280,6 +406,7 @@ jobs:
if: ${{ always() && !cancelled() }}
needs:
- python-unit
- coverage-report
- admin-web
- user-web
- extensions
Expand All @@ -299,14 +426,15 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
REF_TYPE: ${{ github.ref_type }}
COVERAGE_COMMENT_REQUIRED: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' }}
SONAR_EXPECTED: >-
${{ (github.event_name == 'push' && github.ref == 'refs/heads/master') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]') }}
RESULTS: ${{ toJSON(needs) }}
run: |
echo "$RESULTS" | jq -e --arg event "$EVENT_NAME" --arg ref_type "$REF_TYPE" --arg sonar "$SONAR_EXPECTED" '
echo "$RESULTS" | jq -e --arg event "$EVENT_NAME" --arg ref_type "$REF_TYPE" --arg comment "$COVERAGE_COMMENT_REQUIRED" --arg sonar "$SONAR_EXPECTED" '
([.["python-unit"], .["admin-web"], .["user-web"], .extensions,
.["python-integration"], .["migration-upgrade"], .["reader-parity"],
.["admin-browser"], .["live-browser"], .security]
Expand All @@ -316,5 +444,7 @@ jobs:
else .containers.result == "success" and .["release-images"].result == "skipped" end) and
(if $event == "pull_request" then .performance.result == "success"
else .performance.result == "skipped" end) and
(if $comment == "true" then .["coverage-report"].result == "success"
else .["coverage-report"].result == "skipped" end) and
(if $sonar == "true" then .sonarqube.result == "success"
else .sonarqube.result == "skipped" end)'
27 changes: 25 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ Run the checks relevant to your change:
# Backend lint, formatting, types and version consistency
bash .github/scripts/python-checks.sh

# Backend unit tests; no dependency services are started
bash scripts/test.sh -q -m 'not integration'
# Backend unit tests with line and branch coverage; no services are started
bash scripts/ci/python-tests.sh unit

# Reader app
npm run web:lint
Expand All @@ -92,6 +92,29 @@ Pre-commit runs formatting and the affected projects' lint, type and unit checks
it does not replace CI or integration testing. If a formatter changes files,
review and stage those changes before retrying the commit.

Backend unit coverage includes every Python source file in all 13 backend
workspaces, including modules the tests never import. The minimum combined line
and branch coverage is **67.60%**, configured by `tool.coverage.report.fail_under` in
`pyproject.toml`; both CI and backend pre-commit checks enforce it. Keep this
minimum when adding code, and raise it as coverage improves. A missing workspace,
missing report, empty suite, failure, or skipped unit test fails the CI report gate.

CI updates one coverage comment on each same-repository PR, showing test counts
and line, branch, and combined coverage for each workspace. Superseded PR commits
cannot overwrite the current comment. Fork PRs and Dependabot runs retain the job
summary and report artifacts because their tokens cannot write PR comments.
Download the Python unit artifact for HTML, JSON, and Cobertura XML reports; local
reports are in `reports/coverage/` (open `html/index.html`). Integration results do
not inflate the unit coverage score and remain independent CI requirements.

For a focused test run, omit coverage or temporarily disable its whole-suite
minimum for that command:

```sh
uv run --locked pytest -q tests/test_mcp_oauth_unit.py
uv run --locked pytest -q tests/test_mcp_oauth_unit.py --cov --cov-fail-under=0
```

Integration tests require explicit `DEVFEED_TEST_DATABASE_URL` and
`DEVFEED_TEST_REDIS_URL`. The PostgreSQL database name must end in `_test`, and
Redis must use database **15**. These tests truncate tables and flush Redis: never
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/catalog_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,11 @@ def snapshot(session, names, loader):
if body is not None:
entry = json.loads(body)
if entry["revision"] == [list(item) for item in before]:
value = entry["value"]
record_cache_read("catalog", "hit")
if values is not None:
values[slot] = (before, entry["value"])
return entry["value"]
values[slot] = (before, value)
return value
except CacheUnavailable:
outcome, reason = "bypass", "cache_unavailable"
except (ValueError, UnicodeError, KeyError, TypeError):
Expand Down
23 changes: 22 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dev = [
"pre-commit>=4.3,<5",
"jsonschema>=4.23,<5",
"testcontainers>=4.10,<5",
"pytest-cov>=7,<8",
]
loadtest = [
"locust==2.46.6",
Expand Down Expand Up @@ -108,7 +109,27 @@ markers = ["integration: requires a dedicated PostgreSQL database and Redis"]
[tool.coverage.run]
branch = true
relative_files = true
include = ["apps/*/src/*.py", "packages/*/src/*.py", "scripts/**/*.py"]
data_file = "reports/coverage/.coverage-unit"
source = [
"packages/core/src",
"packages/http/src",
"apps/api/src",
"apps/admin-api/src",
"apps/user-api/src",
"apps/mcp/src",
"apps/aggregator/src",
"apps/notifications/src",
"apps/cli/src",
"apps/search-indexer/src",
"apps/article-enrichment-worker/src",
"apps/images-worker/src",
"apps/source-discovery-worker/src",
]

[tool.coverage.report]
fail_under = 67.60
precision = 2
show_missing = true

[tool.mypy]
python_version = "3.12"
Expand Down
Loading