mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
The suite is 12.1k tests in a single CI job — nearly six minutes of pytest that every push and every PR waits on in one block, on top of a setup step that already installs npm, Playwright, FFmpeg and bubblewrap. Split it into four sections the matrix runs in parallel: 352s becomes a 98s longest pole locally. Shards partition by test *file*, not by the area_* taxonomy markers. Those markers do not partition the suite - a file can carry a hand-applied area_* mark on top of the one conftest derives from its filename, so a marker-based split would run those tests in more than one section. Assignment is a total function of the file path instead, so every file lands in exactly one shard and the four together run every test exactly once. Sharding deselects rather than narrowing collection, so every test module is still imported, in the same order, in every shard. The import-time stubbing in conftest and the session-scoped static server behave identically whether the suite runs whole or in sections - this suite has known collection-order coupling and splitting by path would have walked into it. Balance uses the existing `slow` marker as its weight signal rather than a committed duration table that would go stale unnoticed. Files pack heaviest-first into the lightest shard, which is deterministic for a given file set, so every parallel job computes the same plan from the same commit. Verified: the four shards together reproduce the full run exactly - 12141 tests selected across the four, and the same 59 failures, 78 skips and 2 xfails, by node ID and not merely by count.
227 lines
8.9 KiB
YAML
227 lines
8.9 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main, dev]
|
|
pull_request:
|
|
|
|
# Least privilege: none of the jobs write to the repo.
|
|
permissions:
|
|
contents: read
|
|
|
|
# Cancel superseded runs on the same ref to save Actions minutes.
|
|
concurrency:
|
|
group: ci-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
focused-test-guidance:
|
|
name: Focused test guidance (report-only)
|
|
if: github.event_name == 'pull_request'
|
|
runs-on: ubuntu-latest
|
|
continue-on-error: true
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
- name: Report changed test paths
|
|
env:
|
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
report_file="$RUNNER_TEMP/focused-test-guidance.md"
|
|
publish_report() {
|
|
cat "$report_file"
|
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
cat "$report_file" >> "$GITHUB_STEP_SUMMARY" || true
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
report_unavailable() {
|
|
{
|
|
printf '%s\n\n' '## Focused test guidance unavailable (report-only)'
|
|
printf '%s\n\n' "$1"
|
|
printf '%s\n' 'Existing blocking CI remains the source of truth.'
|
|
} > "$report_file"
|
|
publish_report
|
|
exit 0
|
|
}
|
|
|
|
if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ]; then
|
|
report_unavailable "Pull request base/head metadata is missing."
|
|
fi
|
|
|
|
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
|
report_unavailable "The pull request base commit is unavailable locally."
|
|
fi
|
|
|
|
if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
|
|
report_unavailable "The pull request head commit is unavailable locally."
|
|
fi
|
|
|
|
if ! python3 .github/scripts/focused_test_guidance.py \
|
|
--base-sha "$BASE_SHA" \
|
|
--head-sha "$HEAD_SHA" > "$report_file"; then
|
|
report_unavailable "The focused test guidance helper could not produce a report."
|
|
fi
|
|
|
|
publish_report
|
|
|
|
python-syntax:
|
|
name: Python syntax (compileall)
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
with:
|
|
python-version: "3.11"
|
|
# Byte-compile sources — catches syntax errors without installing deps.
|
|
- run: python -m compileall -q app.py core routes src services scripts tests
|
|
|
|
node-syntax:
|
|
name: JS syntax (node --check)
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version: "20"
|
|
# Syntax-check our own JS (skip vendored libs in static/lib).
|
|
- name: node --check
|
|
run: |
|
|
shopt -s globstar nullglob
|
|
for f in static/app.js static/js/**/*.js; do
|
|
node --check "$f"
|
|
done
|
|
|
|
python-tests:
|
|
name: Python tests (pytest ${{ matrix.shard }})
|
|
# Keep the namespace/AppArmor setup tied to the audited Ubuntu release.
|
|
runs-on: ubuntu-24.04
|
|
# Make Python test validation authoritative for the configured scope.
|
|
strategy:
|
|
# Report every failing section in one run instead of cancelling the rest
|
|
# the moment one shard goes red.
|
|
fail-fast: false
|
|
matrix:
|
|
# Shards partition the suite by test file, so the four together run
|
|
# every test exactly once. tests/_shards.py owns the partition and
|
|
# tests/test_shards.py pins this list to its DEFAULT_SHARD_COUNT.
|
|
shard: ["1/4", "2/4", "3/4", "4/4"]
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
# Detect whether this PR only touches repository prose outside the Pages site.
|
|
# If so, skip the expensive pytest run while still reporting a passing check.
|
|
- name: Check for docs-only changes
|
|
id: docs-check
|
|
run: |
|
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
BASE="${{ github.event.pull_request.base.sha }}"
|
|
HEAD="${{ github.event.pull_request.head.sha }}"
|
|
else
|
|
BASE="${{ github.event.before }}"
|
|
HEAD="${{ github.sha }}"
|
|
fi
|
|
# Keep website/ and assets/branding/ out of this bypass: pytest owns
|
|
# regression guards for their published-file and orphan-asset contracts.
|
|
changed=$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || git diff --name-only HEAD~1 HEAD)
|
|
non_docs=$(echo "$changed" | grep -Ev '^(docs/|[^/]+\.md$|\.github/[^/]+\.md$)' || true)
|
|
if [ -z "$non_docs" ]; then
|
|
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
|
echo "Docs-only change detected — skipping pytest."
|
|
else
|
|
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
with:
|
|
python-version: "3.11"
|
|
cache: pip
|
|
- run: pip install -r requirements.txt
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
with:
|
|
node-version: "20"
|
|
cache: npm
|
|
- run: npm ci
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
- run: npx playwright install --with-deps chromium
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
- name: Install FFmpeg for media integration tests
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y --no-install-recommends ffmpeg
|
|
command -v ffmpeg
|
|
ffmpeg -version | head -n 1
|
|
|
|
- name: Establish functional bubblewrap containment
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
sudo apt-get update
|
|
sudo apt-get install -y --no-install-recommends bubblewrap
|
|
bwrap --version
|
|
sysctl kernel.unprivileged_userns_clone user.max_user_namespaces \
|
|
kernel.apparmor_restrict_unprivileged_userns
|
|
if [ "$(sysctl -n kernel.unprivileged_userns_clone)" != 1 ] || \
|
|
[ "$(sysctl -n user.max_user_namespaces)" -eq 0 ]; then
|
|
echo '::error::The pytest runner must allow unprivileged user namespaces; kernel namespace support is disabled.'
|
|
exit 1
|
|
fi
|
|
|
|
# Match containment._bwrap_available(): PID and mount namespaces,
|
|
# including fresh proc/dev mounts, as the unprivileged runner user.
|
|
bwrap_probe() {
|
|
timeout 3s bwrap --die-with-parent --unshare-pid --ro-bind / / \
|
|
--proc /proc --dev /dev /bin/true
|
|
}
|
|
|
|
if ! bwrap_probe && [ "$(sysctl -n kernel.apparmor_restrict_unprivileged_userns)" = 1 ]; then
|
|
# Ubuntu 24.04 restricts userns for unconfined applications. Allow
|
|
# only the distro bwrap entry point on this ephemeral pytest VM;
|
|
# retain the global restriction and all unrelated AppArmor policy.
|
|
sudo tee /etc/apparmor.d/odysseus-ci-bwrap > /dev/null <<'PROFILE'
|
|
abi <abi/4.0>,
|
|
include <tunables/global>
|
|
profile odysseus-ci-bwrap /usr/bin/bwrap flags=(unconfined) {
|
|
userns,
|
|
}
|
|
PROFILE
|
|
sudo apparmor_parser -r /etc/apparmor.d/odysseus-ci-bwrap
|
|
fi
|
|
|
|
if ! bwrap_probe; then
|
|
echo '::error::Functional bubblewrap PID/mount namespaces are required for pytest; containment setup failed.'
|
|
exit 1
|
|
fi
|
|
# Also gate on the runtime probe so a future requirements change
|
|
# cannot silently leave this job without real containment coverage.
|
|
python - <<'PY'
|
|
from src import containment
|
|
if not containment._bwrap_available():
|
|
raise SystemExit("::error::Runtime bubblewrap functionality probe failed; pytest must not start.")
|
|
print("Runtime bubblewrap PID/mount namespace probe passed.")
|
|
PY
|
|
|
|
- name: pytest (shard ${{ matrix.shard }})
|
|
if: steps.docs-check.outputs.docs_only != 'true'
|
|
env:
|
|
PYTEST_SHARD: ${{ matrix.shard }}
|
|
run: python -m pytest -q -rs --shard "$PYTEST_SHARD"
|