ci: forward-port current pipeline (harness + timeout) to gramps61 (#40) #80
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # Branch-neutral workflow: the image tag, make.py argument, and Gramps | |
| # series pin are all derived from the branch ref at runtime, so the same | |
| # file runs unchanged on maintenance/gramps60, maintenance/gramps61, and | |
| # any future maintenance/grampsNN branch. | |
| # | |
| # NOTE for maintainers — first push to a new maintenance branch: the | |
| # corresponding gramps-ci:<suffix> image does not exist in GHCR yet, so | |
| # the container jobs in this workflow will fail at "Initialize containers" | |
| # on the very first run. The companion docker-build.yml workflow fires | |
| # on the same push and builds/pushes the image (~5 min cold). After it | |
| # finishes, re-run the failed CI jobs (Actions tab → this run → "Re-run | |
| # failed jobs") and they will pull the now-existing image. This race | |
| # happens only on initial branch creation — every subsequent push to | |
| # that branch finds the image already in GHCR. | |
| # | |
| # Full operational runbook (GHCR visibility, PyPI-release transitions, | |
| # diagnostic log markers, etc.): .github/CI-MAINTAINER.md | |
| on: | |
| push: | |
| branches: [maintenance/gramps**] | |
| pull_request: | |
| branches: [maintenance/gramps**] | |
| jobs: | |
| # ----------------------------------------------------------------- | |
| # Setup — derive the branch suffix (gramps60 / gramps61 / …) from | |
| # the ref. On push events github.ref_name is the branch being | |
| # pushed; on pull_request events github.base_ref is the target | |
| # branch. Either way the suffix is what follows "maintenance/". | |
| # ----------------------------------------------------------------- | |
| setup: | |
| name: Setup | |
| runs-on: ubuntu-latest | |
| outputs: | |
| branch_suffix: ${{ steps.compute.outputs.branch_suffix }} | |
| ci_image: ${{ steps.compute.outputs.ci_image }} | |
| steps: | |
| - id: compute | |
| shell: bash | |
| run: | | |
| ref="${{ github.base_ref || github.ref_name }}" | |
| suffix="${ref#maintenance/}" | |
| case "$suffix" in | |
| gramps[0-9][0-9]) ;; | |
| *) echo "::error::unexpected ref '$ref' (suffix '$suffix')"; exit 1 ;; | |
| esac | |
| echo "branch_suffix=$suffix" >> "$GITHUB_OUTPUT" | |
| echo "ci_image=ghcr.io/${{ github.repository }}/gramps-ci:$suffix" >> "$GITHUB_OUTPUT" | |
| # ----------------------------------------------------------------- | |
| # Lint (ci container) | |
| # ----------------------------------------------------------------- | |
| lint: | |
| name: Lint | |
| needs: setup | |
| runs-on: ubuntu-latest | |
| container: | |
| image: ${{ needs.setup.outputs.ci_image }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Run ruff (syntax and import errors only) | |
| # Skip addon directories whose every register() in .gpr.py sets | |
| # include_in_listing=False — those addons are not built or released | |
| # by make.py, so CI does not gate on their lint state (per Gary | |
| # Griffin's request on PR #820). To re-enable lint gating for an | |
| # addon, set include_in_listing=True on at least one register() | |
| # call in its descriptor (or remove the field — Gramps' default | |
| # is True). Repeated inline rather than centralised so each job | |
| # step stays self-contained. | |
| shell: bash | |
| run: | | |
| is_active() { | |
| local addon="$1" g | |
| for g in "$addon"/*.gpr.py; do | |
| [ -f "$g" ] || continue | |
| grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 | |
| grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 | |
| done | |
| return 1 | |
| } | |
| excludes="" | |
| for d in */; do | |
| d="${d%/}" | |
| ls "$d"/*.gpr.py >/dev/null 2>&1 || continue | |
| is_active "$d" || excludes="$excludes --exclude=$d" | |
| done | |
| ruff check --select=E9,F63,F7,F82 --no-fix --exclude='*.gpr.py' $excludes . | |
| - name: Check trailing whitespace in Python files | |
| run: | | |
| # Use PCRE (-P): in BRE/ERE the bracket expression [ \t] is the | |
| # set { space, backslash, 't' } — git grep matches anything ending | |
| # in 't', not just whitespace. -P makes \t a tab. | |
| if git --no-pager grep --color -n --full-name -P '[ \t]+$' -- '*.py'; then | |
| echo "::error::Trailing whitespace found in Python files" | |
| exit 1 | |
| fi | |
| # ----------------------------------------------------------------- | |
| # Addon structure (bare runner — just bash, no deps needed) | |
| # ----------------------------------------------------------------- | |
| addon-structure: | |
| name: Addon Structure | |
| runs-on: ubuntu-latest | |
| # Non-blocking until the four addons missing po/template.pot are fixed | |
| # in a follow-up PR. Flip this off in that PR. | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Check all listed addons have po/template.pot | |
| # Skip include_in_listing=False addons (see lint job for rationale). | |
| shell: bash | |
| run: | | |
| is_active() { | |
| local addon="$1" g | |
| for g in "$addon"/*.gpr.py; do | |
| [ -f "$g" ] || continue | |
| grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 | |
| grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 | |
| done | |
| return 1 | |
| } | |
| failed=0 | |
| for gpr in */*.gpr.py; do | |
| addon_dir="$(dirname "$gpr")" | |
| is_active "$addon_dir" || continue | |
| if [ ! -d "$addon_dir/po" ]; then | |
| echo "::error::$addon_dir is missing po/ directory" | |
| failed=1 | |
| elif [ ! -f "$addon_dir/po/template.pot" ]; then | |
| echo "::error::$addon_dir is missing po/template.pot" | |
| failed=1 | |
| fi | |
| done | |
| if [ "$failed" -eq 0 ]; then | |
| echo "All listed addons have po/template.pot" | |
| fi | |
| exit $failed | |
| # ----------------------------------------------------------------- | |
| # Compile check (ci container) | |
| # ----------------------------------------------------------------- | |
| compile-check: | |
| name: Compile Check | |
| needs: setup | |
| runs-on: ubuntu-latest | |
| container: | |
| image: ${{ needs.setup.outputs.ci_image }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Compile all Python files in listed addons (excluding .gpr.py) | |
| # Skip include_in_listing=False addons (see lint job for rationale). | |
| shell: bash | |
| run: | | |
| is_active() { | |
| local addon="$1" g | |
| for g in "$addon"/*.gpr.py; do | |
| [ -f "$g" ] || continue | |
| grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 | |
| grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 | |
| done | |
| return 1 | |
| } | |
| skipped="" | |
| for d in */; do | |
| d="${d%/}" | |
| ls "$d"/*.gpr.py >/dev/null 2>&1 || continue | |
| is_active "$d" || skipped="$skipped $d" | |
| done | |
| failed=0 | |
| while IFS= read -r f; do | |
| skip=0 | |
| for s in $skipped; do | |
| case "$f" in ./$s/*) skip=1; break;; esac | |
| done | |
| [ "$skip" = 1 ] && continue | |
| if ! python3 -m py_compile "$f" 2>&1; then | |
| failed=1 | |
| fi | |
| done < <(find . -name '*.py' ! -name '*.gpr.py' ! -path './.git/*' ! -path '*/__pycache__/*') | |
| exit $failed | |
| # ----------------------------------------------------------------- | |
| # Unit tests — Linux (ci container) | |
| # ----------------------------------------------------------------- | |
| unit-test-linux: | |
| name: Unit Tests (Linux) | |
| needs: setup | |
| runs-on: ubuntu-latest | |
| container: | |
| image: ${{ needs.setup.outputs.ci_image }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install addon system deps (derived from requires_gi / requires_exe) | |
| # System deps (GI typelibs, executables) are not pip-installable and | |
| # gramps only *checks* them, so the image cannot bake them generically | |
| # (its build context excludes addons-source). Derive the apt set from | |
| # every .gpr.py via the single-source map and install it — the container | |
| # runs as root. Mirrors the requires_mod derivation below. | |
| shell: bash | |
| run: | | |
| pkgs=$(python3 .github/scripts/addon_system_deps.py --platform apt) | |
| if [ -n "$pkgs" ]; then | |
| echo "→ addon system deps (apt): $pkgs" | |
| apt-get update | |
| apt-get install -y --no-install-recommends $pkgs | |
| else | |
| echo "no requires_gi / requires_exe declarations found" | |
| fi | |
| - name: Validate addon system deps are mapped | |
| # Every requires_gi / requires_exe an addon declares must have an entry | |
| # in addon_system_deps.py, so the install list never silently drifts | |
| # from what addons declare (the system-dep analogue of the requires_mod | |
| # find_spec gate below). | |
| shell: bash | |
| run: | | |
| python3 .github/scripts/addon_system_deps.py --unmapped . || { | |
| echo "::error::Addon(s) declare requires_gi/requires_exe with no entry in .github/scripts/addon_system_deps.py — add a mapping row." | |
| exit 1 | |
| } | |
| - name: Install addon runtime deps (derived from requires_mod) | |
| # Auto-derive the union of requires_mod across every .gpr.py in | |
| # the repo. Mirrors Gramps' Addon Manager install path | |
| # (gramps/gui/plug/_windows.py __on_install_clicked → req.install → | |
| # gen/utils/requirements.py). Keeps .gpr.py files as the single | |
| # source of truth for addon deps — no parallel list to maintain | |
| # in the image or workflow. Best-effort: a package needing exotic | |
| # system deps (pygraphviz → graphviz-dev, psycopg2 → libpq-dev) | |
| # may fail here; the affected addon's tests will skip or fail in | |
| # isolation without blocking the rest. | |
| shell: bash | |
| run: | | |
| addon_mods=$(python3 - <<'PY' | |
| import ast, glob, re | |
| pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") | |
| mods = set() | |
| for f in glob.glob("*/*.gpr.py"): | |
| try: | |
| text = open(f, encoding="utf-8").read() | |
| except OSError: | |
| continue | |
| for m in pat.finditer(text): | |
| try: | |
| mods.update(ast.literal_eval(m.group(1))) | |
| except (ValueError, SyntaxError): | |
| pass | |
| print(" ".join(sorted(mods))) | |
| PY | |
| ) | |
| if [ -n "$addon_mods" ]; then | |
| echo "→ addon deps: $addon_mods" | |
| for mod in $addon_mods; do | |
| pip install "$mod" || echo "× $mod failed to install (continuing)" | |
| done | |
| else | |
| echo "no requires_mod declarations found" | |
| fi | |
| - name: Validate requires_mod names against Gramps' dep gate | |
| # Cross-check: every requires_mod entry that pip successfully | |
| # installed in the previous step must also resolve via | |
| # find_spec(), since that is what Gramps' Addon Manager calls | |
| # (gramps/gen/utils/requirements.py:check_mod). A name that | |
| # pip-installs but does not import is a declaration bug — e.g. | |
| # requires_mod=["Pillow"] when the importable name is "PIL". | |
| # Pip-install failures upstream are skipped: those are | |
| # system-dep / image gaps, not PR-caused. | |
| shell: bash | |
| run: | | |
| python3 - <<'PY' | |
| import ast, glob, re, subprocess, sys | |
| from importlib.util import find_spec | |
| pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") | |
| names = set() | |
| for f in glob.glob("*/*.gpr.py"): | |
| try: | |
| text = open(f, encoding="utf-8").read() | |
| except OSError: | |
| continue | |
| for m in pat.finditer(text): | |
| try: | |
| names.update(ast.literal_eval(m.group(1))) | |
| except (ValueError, SyntaxError): | |
| pass | |
| bad = [] | |
| for name in sorted(names): | |
| installed = subprocess.run( | |
| [sys.executable, "-m", "pip", "show", name], | |
| capture_output=True, | |
| ).returncode == 0 | |
| if not installed: | |
| print(f"~ {name} (pip-install failed earlier, skipping)") | |
| continue | |
| if find_spec(name) is None: | |
| bad.append(name) | |
| print(f"x {name} (pip-installed but find_spec returned None)") | |
| else: | |
| print(f"ok {name}") | |
| if bad: | |
| print() | |
| print(f"::error::Wrong requires_mod names: {bad}") | |
| print("These pip-install but are not importable. requires_mod is") | |
| print("consumed by gramps' check_mod() via find_spec(), so the") | |
| print("importable module name is required (e.g. 'PIL', not 'Pillow').") | |
| sys.exit(1) | |
| PY | |
| - name: Run per-addon unit tests | |
| # Filename convention (all OSes): | |
| # test_*.py — general (any OS) | |
| # test_linux_*.py — Linux-only | |
| # test_windows_*.py — Windows-only | |
| # test_integration_*.py — Linux-only, full-pipeline/DB-backed | |
| # The Linux job runs test_*.py except the Windows-only and | |
| # integration buckets. Integration tests run in their own job. | |
| # | |
| # shell: bash — the container's default shell is /bin/sh | |
| # (dash on python:3.12-slim), which does not support the | |
| # ${var//pattern/repl} and ${var%.py} parameter expansions | |
| # used below. | |
| shell: bash | |
| env: | |
| PYTHONPATH: . | |
| run: | | |
| is_active() { | |
| local addon="$1" g | |
| for g in "$addon"/*.gpr.py; do | |
| [ -f "$g" ] || continue | |
| grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 | |
| grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 | |
| done | |
| return 1 | |
| } | |
| modules="" | |
| for f in */tests/test_*.py; do | |
| [ -f "$f" ] || continue | |
| addon="${f%%/*}" | |
| is_active "$addon" || continue | |
| case "$(basename "$f")" in | |
| test_integration*) continue ;; | |
| test_windows_*) continue ;; | |
| esac | |
| case "$f" in | |
| Sqlite/tests/test_sqlite.py) continue ;; | |
| esac | |
| mod="${f%.py}" | |
| mod="${mod//\//.}" | |
| modules="$modules $mod" | |
| done | |
| if [ -n "$modules" ]; then | |
| echo "Running unit tests:$modules" | |
| # xvfb-run: some addons create a Gtk style context at import and | |
| # need a display (else a hard Gtk-ERROR abort, not a clean skip). | |
| # run_addon_tests.py: pins the GI versions like gramps' launcher | |
| # (so gramps.gui imports load GTK 3, no PyGIWarning) and fails a | |
| # wholly-skipped module unless the addon's deps are unavailable on | |
| # this platform. | |
| xvfb-run -a --server-args="-screen 0 1920x1080x24" \ | |
| python3 .github/scripts/run_addon_tests.py --platform apt --root . $modules | |
| else | |
| echo "No per-addon unit test modules found" | |
| fi | |
| # ----------------------------------------------------------------- | |
| # Unit tests — Windows (conda-forge: bundles PyGObject + GTK + Gramps) | |
| # ----------------------------------------------------------------- | |
| unit-test-windows: | |
| name: Unit Tests (Windows) | |
| needs: setup | |
| runs-on: windows-latest | |
| defaults: | |
| run: | |
| shell: bash -el {0} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Miniforge | |
| uses: conda-incubator/setup-miniconda@v3 | |
| with: | |
| miniforge-version: latest | |
| activate-environment: addons-ci | |
| environment-file: .github/environment.yml | |
| use-mamba: true | |
| - name: Verify environment | |
| run: | | |
| mamba info | |
| mamba list | head -30 | |
| python -c "import gramps, gi; print('deps OK')" | |
| - name: Report gramps-vs-branch series (Windows lane caveat) | |
| # The Linux lane git-builds the branch's exact gramps in its CI image | |
| # (.github/docker/gramps-ci/Dockerfile, PyPI-first/git-fallback). The | |
| # conda-forge Windows lane CANNOT match that: conda-forge has no gramps | |
| # 6.1 yet, and gramps' own Windows build targets MSYS2 UCRT64, not conda | |
| # — building 6.1 from git here fails in gramps' build hook (build_intl's | |
| # `msgfmt --xml` cannot locate the shared-mime-info/appstream ITS rules, | |
| # which are absent in the conda env). So on a maintenance/gramps61 (or | |
| # later) branch this lane validates addons against conda-forge's newest | |
| # in-range gramps (6.0.x today) rather than the branch's series. This | |
| # step surfaces that honestly; it does NOT fail. Addon tests that depend | |
| # on series-exact gramps behaviour skip themselves on Windows (e.g. | |
| # TMGimporter's real-DB import tests) and run on the Linux lane instead. | |
| # When gramps 6.1 reaches conda-forge, environment.yml's pin picks it up | |
| # and this caveat disappears on its own. | |
| run: | | |
| suffix="${{ needs.setup.outputs.branch_suffix }}" # e.g. gramps61 | |
| digits="${suffix#gramps}" # e.g. 61 | |
| want="${digits:0:1}.${digits:1}" # e.g. 6.1 | |
| have="$(python -c 'from gramps.version import major_version; print(major_version)')" | |
| if [ "$want" = "$have" ]; then | |
| echo "conda-forge gramps $have matches branch series $want — addons tested against the branch's gramps" | |
| else | |
| echo "::warning::Windows lane: branch targets gramps $want but conda-forge ships $have; addons here are validated against $have. Full $want coverage is on the Linux lane (its CI image git-builds $want). See step comment for why conda-Windows cannot build $want." | |
| fi | |
| - name: Install addon system deps (derived, conda-available subset) | |
| # Only the conda-forge-available subset (e.g. graphviz). The GTK 3 addon | |
| # GI libs (goocanvas/osm-gps-map/gexiv2) are NOT on conda-forge, so the | |
| # map returns None for them on conda and they are not installed; addons | |
| # needing them skip on Windows by necessity, which run_addon_tests | |
| # tolerates (--platform conda). | |
| run: | | |
| pkgs=$(python .github/scripts/addon_system_deps.py --platform conda) | |
| if [ -n "$pkgs" ]; then | |
| echo "→ addon system deps (conda): $pkgs" | |
| mamba install -y -c conda-forge $pkgs | |
| else | |
| echo "no conda-available addon system deps to install" | |
| fi | |
| - name: Install addon runtime deps (derived from requires_mod) | |
| # See unit-test-linux for rationale. Uses `python` (conda-forge | |
| # env) to match the surrounding Windows job style. | |
| run: | | |
| addon_mods=$(python - <<'PY' | |
| import ast, glob, re | |
| pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") | |
| mods = set() | |
| for f in glob.glob("*/*.gpr.py"): | |
| try: | |
| text = open(f, encoding="utf-8").read() | |
| except OSError: | |
| continue | |
| for m in pat.finditer(text): | |
| try: | |
| mods.update(ast.literal_eval(m.group(1))) | |
| except (ValueError, SyntaxError): | |
| pass | |
| print(" ".join(sorted(mods))) | |
| PY | |
| ) | |
| if [ -n "$addon_mods" ]; then | |
| echo "→ addon deps: $addon_mods" | |
| for mod in $addon_mods; do | |
| pip install "$mod" || echo "× $mod failed to install (continuing)" | |
| done | |
| else | |
| echo "no requires_mod declarations found" | |
| fi | |
| - name: Validate requires_mod names against Gramps' dep gate | |
| # See unit-test-linux for rationale. Uses `python` (conda-forge | |
| # env) to match the surrounding Windows job style. | |
| run: | | |
| python - <<'PY' | |
| import ast, glob, re, subprocess, sys | |
| from importlib.util import find_spec | |
| pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") | |
| names = set() | |
| for f in glob.glob("*/*.gpr.py"): | |
| try: | |
| text = open(f, encoding="utf-8").read() | |
| except OSError: | |
| continue | |
| for m in pat.finditer(text): | |
| try: | |
| names.update(ast.literal_eval(m.group(1))) | |
| except (ValueError, SyntaxError): | |
| pass | |
| bad = [] | |
| for name in sorted(names): | |
| installed = subprocess.run( | |
| [sys.executable, "-m", "pip", "show", name], | |
| capture_output=True, | |
| ).returncode == 0 | |
| if not installed: | |
| print(f"~ {name} (pip-install failed earlier, skipping)") | |
| continue | |
| if find_spec(name) is None: | |
| bad.append(name) | |
| print(f"x {name} (pip-installed but find_spec returned None)") | |
| else: | |
| print(f"ok {name}") | |
| if bad: | |
| print() | |
| print(f"::error::Wrong requires_mod names: {bad}") | |
| print("These pip-install but are not importable. requires_mod is") | |
| print("consumed by gramps' check_mod() via find_spec(), so the") | |
| print("importable module name is required (e.g. 'PIL', not 'Pillow').") | |
| sys.exit(1) | |
| PY | |
| - name: Run per-addon unit tests | |
| # See filename-convention note in unit-test-linux. The Windows | |
| # job runs test_*.py except test_linux_* and test_integration_*. | |
| env: | |
| PYTHONPATH: . | |
| run: | | |
| is_active() { | |
| local addon="$1" g | |
| for g in "$addon"/*.gpr.py; do | |
| [ -f "$g" ] || continue | |
| grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 | |
| grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 | |
| done | |
| return 1 | |
| } | |
| modules="" | |
| for f in */tests/test_*.py; do | |
| [ -f "$f" ] || continue | |
| addon="${f%%/*}" | |
| is_active "$addon" || continue | |
| case "$(basename "$f")" in | |
| test_integration*) continue ;; | |
| test_linux_*) continue ;; | |
| esac | |
| case "$f" in | |
| Sqlite/tests/test_sqlite.py) continue ;; | |
| esac | |
| mod="${f%.py}" | |
| mod="${mod//\//.}" | |
| modules="$modules $mod" | |
| done | |
| if [ -n "$modules" ]; then | |
| echo "Running unit tests:$modules" | |
| # No xvfb on Windows (GTK renders natively). run_addon_tests pins | |
| # the GI versions and tolerates addons whose GI deps are not on | |
| # conda-forge (they skip here by platform necessity). | |
| python .github/scripts/run_addon_tests.py --platform conda --root . $modules | |
| else | |
| echo "No per-addon unit test modules found" | |
| fi | |
| # ----------------------------------------------------------------- | |
| # Integration tests — Gramps (ci container, xvfb available) | |
| # ----------------------------------------------------------------- | |
| integration-test: | |
| name: Integration Tests (Gramps) | |
| runs-on: ubuntu-latest | |
| needs: [setup, unit-test-linux] | |
| container: | |
| image: ${{ needs.setup.outputs.ci_image }} | |
| options: --init | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install addon system deps (derived from requires_gi / requires_exe) | |
| # Same as unit-test-linux: derive the apt set from the single-source | |
| # map and install it (container runs as root). The plugin registration | |
| # test subprocess-loads each addon module, so the GI typelibs those | |
| # modules import must be present here too. (Mapping is drift-guarded in | |
| # unit-test-linux, which this job needs:, so no duplicate gate here.) | |
| shell: bash | |
| run: | | |
| pkgs=$(python3 .github/scripts/addon_system_deps.py --platform apt) | |
| if [ -n "$pkgs" ]; then | |
| echo "→ addon system deps (apt): $pkgs" | |
| apt-get update | |
| apt-get install -y --no-install-recommends $pkgs | |
| fi | |
| - name: Install addon runtime deps (derived from requires_mod) | |
| # See unit-test-linux for rationale. The plugin registration test | |
| # subprocess-loads each addon's module, which imports its | |
| # requires_mod packages. | |
| shell: bash | |
| run: | | |
| addon_mods=$(python3 - <<'PY' | |
| import ast, glob, re | |
| pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") | |
| mods = set() | |
| for f in glob.glob("*/*.gpr.py"): | |
| try: | |
| text = open(f, encoding="utf-8").read() | |
| except OSError: | |
| continue | |
| for m in pat.finditer(text): | |
| try: | |
| mods.update(ast.literal_eval(m.group(1))) | |
| except (ValueError, SyntaxError): | |
| pass | |
| print(" ".join(sorted(mods))) | |
| PY | |
| ) | |
| if [ -n "$addon_mods" ]; then | |
| echo "→ addon deps: $addon_mods" | |
| for mod in $addon_mods; do | |
| pip install "$mod" || echo "× $mod failed to install (continuing)" | |
| done | |
| else | |
| echo "no requires_mod declarations found" | |
| fi | |
| - name: Validate requires_mod names against Gramps' dep gate | |
| # See unit-test-linux for rationale. | |
| shell: bash | |
| run: | | |
| python3 - <<'PY' | |
| import ast, glob, re, subprocess, sys | |
| from importlib.util import find_spec | |
| pat = re.compile(r"requires_mod\s*=\s*(\[[^\]]*\])") | |
| names = set() | |
| for f in glob.glob("*/*.gpr.py"): | |
| try: | |
| text = open(f, encoding="utf-8").read() | |
| except OSError: | |
| continue | |
| for m in pat.finditer(text): | |
| try: | |
| names.update(ast.literal_eval(m.group(1))) | |
| except (ValueError, SyntaxError): | |
| pass | |
| bad = [] | |
| for name in sorted(names): | |
| installed = subprocess.run( | |
| [sys.executable, "-m", "pip", "show", name], | |
| capture_output=True, | |
| ).returncode == 0 | |
| if not installed: | |
| print(f"~ {name} (pip-install failed earlier, skipping)") | |
| continue | |
| if find_spec(name) is None: | |
| bad.append(name) | |
| print(f"x {name} (pip-installed but find_spec returned None)") | |
| else: | |
| print(f"ok {name}") | |
| if bad: | |
| print() | |
| print(f"::error::Wrong requires_mod names: {bad}") | |
| print("These pip-install but are not importable. requires_mod is") | |
| print("consumed by gramps' check_mod() via find_spec(), so the") | |
| print("importable module name is required (e.g. 'PIL', not 'Pillow').") | |
| sys.exit(1) | |
| PY | |
| - name: Run plugin registration tests | |
| # shell: bash for consistency with the surrounding steps; the | |
| # current command uses no bashisms, but keeps this block safe | |
| # against future edits. Container default is /bin/sh → dash. | |
| # | |
| # gi_bootstrap on PYTHONPATH pins the GI versions (like the gramps GUI | |
| # launcher) for this process AND the addon-module subprocesses this test | |
| # spawns, so gramps.gui imports load GTK 3 without a PyGIWarning. | |
| # | |
| # NOT run under xvfb: this test only *loads* (imports) addon modules in | |
| # subprocesses and tolerates load failures; it does not render. Giving it | |
| # a display made an addon load hang on the (absent) AT-SPI accessibility | |
| # bus until the per-load timeout. Imports that build a Gtk style context | |
| # are exercised under xvfb in the unit/integration test runs instead. | |
| shell: bash | |
| env: | |
| PYTHONPATH: .github/scripts/gi_bootstrap:. | |
| run: | | |
| python3 -m unittest discover -s tests -p "test_*.py" -t . -v | |
| - name: Run per-addon integration tests | |
| # shell: bash — see unit-test-linux for rationale; the | |
| # ${var//pattern/repl} and ${var%.py} expansions below are | |
| # bash-only. | |
| shell: bash | |
| env: | |
| PYTHONPATH: . | |
| run: | | |
| is_active() { | |
| local addon="$1" g | |
| for g in "$addon"/*.gpr.py; do | |
| [ -f "$g" ] || continue | |
| grep -qE 'include_in_listing[[:space:]]*=[[:space:]]*True' "$g" && return 0 | |
| grep -qE 'include_in_listing[[:space:]]*=' "$g" || return 0 | |
| done | |
| return 1 | |
| } | |
| modules="" | |
| for f in */tests/test_integration*.py; do | |
| [ -f "$f" ] || continue | |
| addon="${f%%/*}" | |
| is_active "$addon" || continue | |
| mod="${f%.py}" | |
| mod="${mod//\//.}" | |
| modules="$modules $mod" | |
| done | |
| if [ -n "$modules" ]; then | |
| echo "Running per-addon integration tests:$modules" | |
| xvfb-run -a --server-args="-screen 0 1920x1080x24" \ | |
| python3 .github/scripts/run_addon_tests.py --platform apt --root . $modules | |
| else | |
| echo "No per-addon integration test modules found" | |
| fi | |
| # ----------------------------------------------------------------- | |
| # Build (ci container) | |
| # ----------------------------------------------------------------- | |
| build: | |
| name: Build | |
| needs: setup | |
| runs-on: ubuntu-latest | |
| container: | |
| image: ${{ needs.setup.outputs.ci_image }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Determine GRAMPSPATH | |
| id: gramps-path | |
| run: | | |
| GPATH=$(python3 -c "import gramps, os; print(os.path.dirname(os.path.dirname(gramps.__file__)))") | |
| echo "path=$GPATH" >> "$GITHUB_OUTPUT" | |
| - name: Build all addons | |
| env: | |
| GRAMPSPATH: ${{ steps.gramps-path.outputs.path }} | |
| run: | | |
| mkdir -p ../download | |
| python3 make.py "${{ needs.setup.outputs.branch_suffix }}" build all |