From a854b8076f5b7a808496e0c4a3363814eb2abe25 Mon Sep 17 00:00:00 2001 From: Luper Date: Sun, 9 Aug 2026 09:05:40 +0800 Subject: [PATCH 1/2] ci: align pipeline with local quality gate and add migration checks - Split the single CI job into parallel backend / migrations / frontend jobs - Backend job now runs scripts/check.sh (ruff check + format, mypy, uv lock --check, pytest, alembic heads) so CI matches the local quality gate - Add a Postgres-backed migrations job that runs `alembic upgrade head` then `alembic downgrade base` to validate the migration chain both ways - Add concurrency cancel-in-progress, per-job timeout-minutes, and a workflow_dispatch trigger - Add pytest-cov with an 80% coverage floor enforced via scripts/check.sh (kept out of default pytest addopts so ad-hoc local runs stay clean) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 130 ++++++++++++++++++++++++++++----------- .gitignore | 1 + backend/pyproject.toml | 9 +++ backend/scripts/check.sh | 2 +- backend/uv.lock | 63 +++++++++++++++++++ 5 files changed, 167 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0778564..9205fc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,66 +4,122 @@ on: pull_request: push: branches: [main] + workflow_dispatch: permissions: contents: read +# Cancel superseded runs on the same PR/branch to save CI minutes. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.11.15" + UV_VERSION: "0.11.28" + NODE_VERSION: "20.20.2" + jobs: - ci: - name: Minimal CI + backend: + name: Backend (lint, types, tests) runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: backend steps: - uses: actions/checkout@v4 - - id: detect - shell: bash - run: | - backend=false - frontend=false + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - uses: astral-sh/setup-uv@v6 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + cache-dependency-glob: backend/uv.lock - if [[ -f backend/pyproject.toml && -f backend/uv.lock ]]; then - backend=true - fi - if [[ -f frontend/package-lock.json ]]; then - frontend=true - fi + # Single source of truth: mirrors the local scripts/check.sh gate + # (uv lock --check, ruff check, ruff format --check, mypy, pytest+coverage, alembic heads). + - name: Quality gate + run: bash scripts/check.sh - echo "backend=$backend" >> "$GITHUB_OUTPUT" - echo "frontend=$frontend" >> "$GITHUB_OUTPUT" + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: backend/coverage.xml + if-no-files-found: ignore + + migrations: + name: Backend (migrations) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: backend + services: + postgres: + image: postgres:16.4-alpine + env: + POSTGRES_DB: timeapp + POSTGRES_USER: timeapp + POSTGRES_PASSWORD: timeapp + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U timeapp -d timeapp" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + TIMEFLOW_DATABASE_URL: postgresql+psycopg://timeapp:timeapp@127.0.0.1:5432/timeapp + steps: + - uses: actions/checkout@v4 - uses: actions/setup-python@v5 - if: steps.detect.outputs.backend == 'true' with: - python-version: 3.11.15 + python-version: ${{ env.PYTHON_VERSION }} - uses: astral-sh/setup-uv@v6 - if: steps.detect.outputs.backend == 'true' with: - version: 0.11.28 + version: ${{ env.UV_VERSION }} enable-cache: true cache-dependency-glob: backend/uv.lock - - name: Backend checks - if: steps.detect.outputs.backend == 'true' - working-directory: backend - shell: bash - run: | - uv sync --locked --all-groups - uv run ruff check . - uv run pytest + - name: Install dependencies + run: uv sync --locked --all-groups + + # Validate the migration chain against a real Postgres, both directions. + - name: Apply migrations (upgrade head) + run: uv run alembic upgrade head + + - name: Verify downgrade path (downgrade base) + run: uv run alembic downgrade base + + frontend: + name: Frontend (lint, types, build) + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - if: steps.detect.outputs.frontend == 'true' with: - node-version: 20.20.2 + node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: frontend/package-lock.json - - name: Frontend checks - if: steps.detect.outputs.frontend == 'true' - working-directory: frontend - shell: bash - run: | - npm ci - npm run check - npx expo export --platform android --output-dir dist + - name: Install dependencies + run: npm ci + + - name: Checks (lint, format, typecheck) + run: npm run check + + - name: Export build + run: npx expo export --platform android --output-dir dist diff --git a/.gitignore b/.gitignore index 297b77f..cf4adc4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ .mypy_cache/ .pytest_cache/ .ruff_cache/ +.coverage coverage.xml dist/ htmlcov/ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 445d7a2..d489859 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,6 +22,7 @@ dev = [ "httpx2>=2,<3", "mypy>=1.19,<2", "pytest>=9,<10", + "pytest-cov>=7,<8", "ruff>=0.15,<1", ] @@ -32,6 +33,14 @@ packages = ["src/timeflow"] testpaths = ["tests"] addopts = ["-q"] +[tool.coverage.run] +source = ["timeflow"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false + [tool.ruff] target-version = "py311" line-length = 100 diff --git a/backend/scripts/check.sh b/backend/scripts/check.sh index 341636d..476bd77 100755 --- a/backend/scripts/check.sh +++ b/backend/scripts/check.sh @@ -11,5 +11,5 @@ uv lock --check uv run ruff check . uv run ruff format --check . uv run mypy -uv run pytest +uv run pytest --cov --cov-report=term-missing --cov-report=xml --cov-fail-under=80 uv run alembic heads diff --git a/backend/uv.lock b/backend/uv.lock index b9fbb29..ca1053a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -68,6 +68,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "fastapi" version = "0.140.7" @@ -380,6 +409,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -465,6 +508,7 @@ dev = [ { name = "httpx2" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -483,9 +527,28 @@ dev = [ { name = "httpx2", specifier = ">=2,<3" }, { name = "mypy", specifier = ">=1.19,<2" }, { name = "pytest", specifier = ">=9,<10" }, + { name = "pytest-cov", specifier = ">=7,<8" }, { name = "ruff", specifier = ">=0.15,<1" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "truststore" version = "0.10.4" From 727ad11b74be9ea507dc2ad1bc2838c00fd9493b Mon Sep 17 00:00:00 2001 From: Luper Date: Mon, 10 Aug 2026 11:49:37 +0800 Subject: [PATCH 2/2] ci: skip CI on draft pull requests Run only once a PR is ready for review, so stacked PRs that wait on an unmerged parent do not consume CI minutes while parked as drafts. Adds ready_for_review to the trigger types, without which converting a draft would never start a run. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9205fc5..f1c85c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,10 @@ name: CI on: + # ready_for_review is not in the default type set, and without it a draft PR + # turned ready would never start a run. pull_request: + types: [opened, synchronize, reopened, ready_for_review] push: branches: [main] workflow_dispatch: @@ -22,6 +25,10 @@ env: jobs: backend: name: Backend (lint, types, tests) + # Skip while the PR is a draft; the ready_for_review event starts the real run. + # Repeated per job because there is no workflow-level `if`. The event_name guard + # comes first so push and workflow_dispatch runs are unaffected. + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 15 defaults: @@ -55,6 +62,7 @@ jobs: migrations: name: Backend (migrations) + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 10 defaults: @@ -101,6 +109,7 @@ jobs: frontend: name: Frontend (lint, types, build) + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 15 defaults: