Merge pull request 'ci: Gitea checks — lint + CPython 3.8–3.14 matrix + artifact smoke tests' (#13) from ci/gitea into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/app_skellington/pulls/13
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, edited] # edited: un-WIP starts CI
|
||||||
|
push:
|
||||||
|
branches: [develop, main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
# Cancel superseded PR runs. Never cancel a push run.
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
if: >-
|
||||||
|
github.event_name != 'pull_request' || (
|
||||||
|
!startsWith(github.event.pull_request.title, 'WIP:') &&
|
||||||
|
!startsWith(github.event.pull_request.title, '[WIP]')
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: python:3.14
|
||||||
|
env:
|
||||||
|
UV_PYTHON_DOWNLOADS: never
|
||||||
|
steps:
|
||||||
|
- name: Install node (Gitea actions are node programs)
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends nodejs
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- run: git config --global --add safe.directory "$PWD"
|
||||||
|
- run: pip install uv
|
||||||
|
- run: uv sync --locked
|
||||||
|
- run: uv run ruff check
|
||||||
|
- run: uv run ruff format --check
|
||||||
|
|
||||||
|
test:
|
||||||
|
if: >-
|
||||||
|
github.event_name != 'pull_request' || (
|
||||||
|
!startsWith(github.event.pull_request.title, 'WIP:') &&
|
||||||
|
!startsWith(github.event.pull_request.title, '[WIP]')
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: python:${{ matrix.python }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
|
env:
|
||||||
|
UV_PYTHON: ${{ matrix.python }}
|
||||||
|
UV_PYTHON_DOWNLOADS: never
|
||||||
|
steps:
|
||||||
|
- name: Install node (Gitea actions are node programs)
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends nodejs
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # setuptools_scm needs tags
|
||||||
|
- run: git config --global --add safe.directory "$PWD"
|
||||||
|
- run: pip install uv
|
||||||
|
- run: uv sync --locked
|
||||||
|
- run: uv run pytest
|
||||||
|
- name: Build, install and smoke-test the wheel and the sdist
|
||||||
|
run: |
|
||||||
|
rm -rf dist && uv build
|
||||||
|
for dist in dist/*.whl dist/*.tar.gz; do
|
||||||
|
rm -rf /tmp/smoke && uv venv /tmp/smoke
|
||||||
|
uv pip install --python /tmp/smoke "$dist"
|
||||||
|
(cd / && /tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/smoke_import.py")
|
||||||
|
done
|
||||||
|
|
||||||
|
ci-ok:
|
||||||
|
needs: [lint, test]
|
||||||
|
# always(): a plain needs: aggregate would be *skipped* when a gate fails,
|
||||||
|
# and Gitea reports skipped as success. The WIP gate stays.
|
||||||
|
if: >-
|
||||||
|
always() && (
|
||||||
|
github.event_name != 'pull_request' || (
|
||||||
|
!startsWith(github.event.pull_request.title, 'WIP:') &&
|
||||||
|
!startsWith(github.event.pull_request.title, '[WIP]')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Require every gate to have succeeded
|
||||||
|
run: |
|
||||||
|
echo "lint=${{ needs.lint.result }} test=${{ needs.test.result }}"
|
||||||
|
test "${{ needs.lint.result }}" = success
|
||||||
|
test "${{ needs.test.result }}" = success
|
||||||
@@ -30,6 +30,15 @@ uv lock # after any dependency change; commit uv.lock
|
|||||||
|
|
||||||
`requires-python` is `>=3.8`, and the claim should match what CI actually tests. When an end-of-life Python version starts failing, drop it: raise `requires-python` and remove its CI lane. Don't add compat shims, version branches or contorted code just to keep it working. Clean code matters more than supporting EOL runtimes. Users on old Pythons keep the older releases on PyPI.
|
`requires-python` is `>=3.8`, and the claim should match what CI actually tests. When an end-of-life Python version starts failing, drop it: raise `requires-python` and remove its CI lane. Don't add compat shims, version branches or contorted code just to keep it working. Clean code matters more than supporting EOL runtimes. Users on old Pythons keep the older releases on PyPI.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yaml` runs on PRs and on pushes to `develop`/`main`. PRs titled `WIP:` are skipped; removing the prefix starts the run.
|
||||||
|
- `lint`: ruff.
|
||||||
|
- `test`: a matrix over CPython 3.8–3.14 in `python:<v>` containers. Each lane runs the locked suite, then builds the wheel and sdist, installs each into a clean venv, and runs `scripts/smoke_import.py` from outside the source tree.
|
||||||
|
- `ci-ok`: the only required check (`CI / ci-ok (pull_request)`). It uses `if: always()` and fails unless every gate succeeded, because Gitea reports a skipped job as success.
|
||||||
|
|
||||||
|
`tests/test_ci_workflow.py` pins these invariants. To add or drop a Python version, change all four together: the matrix, the classifiers, `scripts/verify_index_release.sh`'s list, and `NEWEST_MINOR` (or `requires-python`).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Package layout: `_bootstrap.py`, `_util.py`, `cfg.py`, `log.py`, `cli.py` and `app_container.py`. `__init__.py` re-exports the public names explicitly.
|
Package layout: `_bootstrap.py`, `_util.py`, `cfg.py`, `log.py`, `cli.py` and `app_container.py`. `__init__.py` re-exports the public names explicitly.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Application framework for Python, features include:
|
|||||||
- Colored logging (provided through colorlog)
|
- Colored logging (provided through colorlog)
|
||||||
- Linux supported. Windows and macOS are goals, but untested.
|
- Linux supported. Windows and macOS are goals, but untested.
|
||||||
|
|
||||||
|
**Tested:** Linux, CPython 3.8–3.14 (Gitea CI on every PR). Windows and macOS: goals, untested.
|
||||||
|
|
||||||
Principles:
|
Principles:
|
||||||
- Lend to creating beautiful, easy to read and understand code in the application.
|
- Lend to creating beautiful, easy to read and understand code in the application.
|
||||||
- Minimize coupling of applications to this framework.
|
- Minimize coupling of applications to this framework.
|
||||||
|
|||||||
+9
-2
@@ -20,8 +20,15 @@ authors = [
|
|||||||
keywords = ["cli", "logging", "application"]
|
keywords = ["cli", "logging", "application"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"License :: OSI Approved :: MIT No Attribution License (MIT-0)",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Operating System :: OS Independent"
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"License :: OSI Approved :: MIT No Attribution License (MIT-0)"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Smoke-test an *installed* app_skellington (wheel, sdist or index release).
|
||||||
|
|
||||||
|
Run it from outside the source tree:
|
||||||
|
|
||||||
|
cd / && python /path/to/scripts/smoke_import.py [EXPECTED_VERSION]
|
||||||
|
|
||||||
|
The script's own directory (scripts/) heads sys.path, so the main import
|
||||||
|
can't see the source tree. But the APPSKELLINGTON_DEBUG child process puts
|
||||||
|
its cwd first, which is why the cwd must be outside the repo. The child's
|
||||||
|
import location is checked too, so a wrong cwd fails instead of passing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.metadata
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Any real release is >= 0.2. With no tags reachable (a shallow clone),
|
||||||
|
# setuptools_scm's release-branch-semver scheme yields 0.1.0.devN, and other
|
||||||
|
# schemes yield 0.0.x.
|
||||||
|
MIN_RELEASE = (0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg):
|
||||||
|
sys.exit("smoke FAILED: " + msg)
|
||||||
|
|
||||||
|
|
||||||
|
def check_version(version, expected):
|
||||||
|
major_minor = tuple(int(n) for n in re.match(r"(\d+)\.(\d+)", version).groups())
|
||||||
|
if major_minor < MIN_RELEASE:
|
||||||
|
fail("setuptools_scm found no tags (shallow clone?): %s" % version)
|
||||||
|
if expected is not None and version != expected:
|
||||||
|
fail("expected version %s, got %s" % (expected, version))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
expected = argv[1] if len(argv) > 1 else None
|
||||||
|
|
||||||
|
import app_skellington
|
||||||
|
|
||||||
|
location = pathlib.Path(app_skellington.__file__).resolve()
|
||||||
|
if "site-packages" not in location.parts:
|
||||||
|
fail("imported the source tree, not an installed copy: %s" % location)
|
||||||
|
|
||||||
|
from app_skellington import ( # noqa: F401 (the public API must import)
|
||||||
|
ApplicationContainer,
|
||||||
|
CommandTree,
|
||||||
|
Config,
|
||||||
|
LoggingLayer,
|
||||||
|
NoCommandSpecified,
|
||||||
|
ServiceNotFound,
|
||||||
|
SubMenu,
|
||||||
|
)
|
||||||
|
|
||||||
|
check_version(importlib.metadata.version("app_skellington"), expected)
|
||||||
|
|
||||||
|
# _bootstrap.py takes a different code path when APPSKELLINGTON_DEBUG is set.
|
||||||
|
env = dict(os.environ, APPSKELLINGTON_DEBUG="1")
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-c",
|
||||||
|
"import app_skellington, pathlib;"
|
||||||
|
"print(pathlib.Path(app_skellington.__file__).resolve())",
|
||||||
|
],
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
fail("import failed with APPSKELLINGTON_DEBUG=1")
|
||||||
|
child_location = proc.stdout.strip().splitlines()[-1]
|
||||||
|
if child_location != str(location):
|
||||||
|
fail(
|
||||||
|
"APPSKELLINGTON_DEBUG import resolved %s, not the installed %s"
|
||||||
|
" (run from outside the source tree)" % (child_location, location)
|
||||||
|
)
|
||||||
|
|
||||||
|
print("smoke ok:", importlib.metadata.version("app_skellington"), location)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Pins the CI workflow's invariants (spec: docs/superpowers/specs/
|
||||||
|
2026-10-04-modernization-design.md). CI drift shows up as a red test here."""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
WORKFLOW = ROOT / ".gitea" / "workflows" / "ci.yaml"
|
||||||
|
VERIFY_SCRIPT = ROOT / "scripts" / "verify_index_release.sh"
|
||||||
|
|
||||||
|
# Newest CPython the project claims. To add a version, bump this, the
|
||||||
|
# matrix, the classifiers and the verify script's list together.
|
||||||
|
NEWEST_MINOR = 14
|
||||||
|
|
||||||
|
WIP_CLAUSES = (
|
||||||
|
"!startsWith(github.event.pull_request.title, 'WIP:')",
|
||||||
|
"!startsWith(github.event.pull_request.title, '[WIP]')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load():
|
||||||
|
return yaml.safe_load(WORKFLOW.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def triggers(wf):
|
||||||
|
# PyYAML follows YAML 1.1, where the bare key `on` parses as boolean True.
|
||||||
|
return wf["on"] if "on" in wf else wf[True]
|
||||||
|
|
||||||
|
|
||||||
|
def expected_versions():
|
||||||
|
text = (ROOT / "pyproject.toml").read_text()
|
||||||
|
floor = int(re.search(r'requires-python\s*=\s*">=3\.(\d+)"', text).group(1))
|
||||||
|
return ["3.%d" % minor for minor in range(floor, NEWEST_MINOR + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def flat(text):
|
||||||
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
def test_triggers():
|
||||||
|
on = triggers(load())
|
||||||
|
assert set(on["pull_request"]["types"]) == {
|
||||||
|
"opened",
|
||||||
|
"synchronize",
|
||||||
|
"reopened",
|
||||||
|
"edited",
|
||||||
|
}
|
||||||
|
assert on["push"]["branches"] == ["develop", "main"]
|
||||||
|
assert "workflow_dispatch" in on
|
||||||
|
|
||||||
|
|
||||||
|
def test_superseded_pr_runs_cancel_but_push_runs_never_do():
|
||||||
|
assert (
|
||||||
|
load()["concurrency"]["cancel-in-progress"]
|
||||||
|
== "${{ github.event_name == 'pull_request' }}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_job_is_wip_gated():
|
||||||
|
# Compared whole, not by substring: `&&` turned into `||` must fail.
|
||||||
|
gate = "github.event_name != 'pull_request' || ( %s && %s )" % WIP_CLAUSES
|
||||||
|
for name, job in load()["jobs"].items():
|
||||||
|
cond = flat(job.get("if", ""))
|
||||||
|
assert cond in (gate, "always() && ( %s )" % gate), name
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_gate_can_be_softened_by_continue_on_error():
|
||||||
|
# continue-on-error makes needs.<job>.result read success, so ci-ok
|
||||||
|
# would go green over a failure.
|
||||||
|
for name, job in load()["jobs"].items():
|
||||||
|
assert "continue-on-error" not in job, name
|
||||||
|
for step in job.get("steps", []):
|
||||||
|
assert "continue-on-error" not in step, name
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_lane_really_runs_its_matrix_python():
|
||||||
|
test = load()["jobs"]["test"]
|
||||||
|
assert test["container"] == "python:${{ matrix.python }}"
|
||||||
|
assert test["env"]["UV_PYTHON"] == "${{ matrix.python }}"
|
||||||
|
assert test["env"]["UV_PYTHON_DOWNLOADS"] == "never"
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_matches_requires_python():
|
||||||
|
test = load()["jobs"]["test"]
|
||||||
|
assert test["strategy"]["matrix"]["python"] == expected_versions()
|
||||||
|
assert test["strategy"]["fail-fast"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifiers_match_matrix():
|
||||||
|
text = (ROOT / "pyproject.toml").read_text()
|
||||||
|
claimed = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text)
|
||||||
|
assert claimed == expected_versions()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not VERIFY_SCRIPT.exists(), reason="added with the release task")
|
||||||
|
def test_verify_script_covers_matrix():
|
||||||
|
match = re.search(r"^pythons=\(([^)]*)\)", VERIFY_SCRIPT.read_text(), re.M)
|
||||||
|
assert match.group(1).split() == expected_versions()
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_fetches_full_history_for_setuptools_scm():
|
||||||
|
for name, job in load()["jobs"].items():
|
||||||
|
for step in job.get("steps", []):
|
||||||
|
if step.get("uses", "").startswith("actions/checkout"):
|
||||||
|
assert step.get("with", {}).get("fetch-depth") == 0, name
|
||||||
|
|
||||||
|
|
||||||
|
def test_lint_job_runs_ruff():
|
||||||
|
runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["lint"]["steps"]))
|
||||||
|
assert "uv run ruff check" in runs
|
||||||
|
assert "uv run ruff format --check" in runs
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_lanes_run_locked_suite_and_smoke_test_both_artifacts():
|
||||||
|
runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["test"]["steps"]))
|
||||||
|
assert "uv sync --locked" in runs
|
||||||
|
assert "uv run pytest" in runs
|
||||||
|
assert "uv build" in runs
|
||||||
|
assert "dist/*.whl dist/*.tar.gz" in runs
|
||||||
|
assert "scripts/smoke_import.py" in runs
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_ok_fails_unless_every_gate_succeeded():
|
||||||
|
jobs = load()["jobs"]
|
||||||
|
ci_ok = jobs["ci-ok"]
|
||||||
|
# Gitea reports a skipped job as success, so ci-ok must run always()
|
||||||
|
# and check each result explicitly.
|
||||||
|
assert set(ci_ok["needs"]) == set(jobs) - {"ci-ok"}
|
||||||
|
assert flat(ci_ok["if"]).startswith("always() &&")
|
||||||
|
runs = " ".join(s.get("run", "") for s in ci_ok["steps"])
|
||||||
|
for needed in ci_ok["needs"]:
|
||||||
|
assert 'test "${{ needs.%s.result }}" = success' % needed in runs
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Unit tests for scripts/smoke_import.py's version checks."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "smoke_import.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("smoke_import", SCRIPT)
|
||||||
|
smoke_import = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(smoke_import)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"version",
|
||||||
|
[
|
||||||
|
"0.0.0",
|
||||||
|
"0.0.1.dev3+gabc1234",
|
||||||
|
# release-branch-semver with no tags reachable (shallow clone):
|
||||||
|
"0.1.0.dev1+g6bd717f23",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_tagless_versions_are_rejected(version):
|
||||||
|
with pytest.raises(SystemExit, match="no tags"):
|
||||||
|
smoke_import.check_version(version, expected=None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version", ["0.2.3", "0.3.0.dev23+gc361b286c", "1.0.0"])
|
||||||
|
def test_tag_derived_versions_pass(version):
|
||||||
|
smoke_import.check_version(version, expected=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_version_must_match_exactly():
|
||||||
|
with pytest.raises(SystemExit, match="expected version 0.2.3"):
|
||||||
|
smoke_import.check_version("0.2.3.dev1", expected="0.2.3")
|
||||||
Reference in New Issue
Block a user