From 873e7535fb2b7e9917e6114edb97442da677ded8 Mon Sep 17 00:00:00 2001 From: Elliot Chen <2340896+cyfyifanchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:47:16 +0800 Subject: [PATCH] ci: harden contributor checks (#254) * ci: harden contributor checks * ci: pin setup-uv action release * ci: split workflow checks * docs: clarify required checks --- .github/BRANCH_PROTECTION.md | 39 ++++++ .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/ci.yml | 64 ++++++++- .github/workflows/commits.yml | 28 ++++ .github/workflows/docs.yml | 111 +-------------- .gitignore | 6 + CONTRIBUTING.md | 49 +++---- Makefile | 26 +++- docs/engineering.md | 79 +++++------ scripts/check_commit_messages.py | 114 ++++++++++++++++ scripts/check_docs.py | 109 +++++++++++++++ .../test_persistence/test_locking.py | 128 ++++++++++++------ 12 files changed, 529 insertions(+), 225 deletions(-) create mode 100644 .github/BRANCH_PROTECTION.md create mode 100644 .github/workflows/commits.yml create mode 100644 scripts/check_commit_messages.py create mode 100644 scripts/check_docs.py diff --git a/.github/BRANCH_PROTECTION.md b/.github/BRANCH_PROTECTION.md new file mode 100644 index 0000000..77b7577 --- /dev/null +++ b/.github/BRANCH_PROTECTION.md @@ -0,0 +1,39 @@ +# Branch Protection Baseline + +Use this as the admin checklist for `main` after the EverOS 1.0 history reset. + +## Required Repository Rule + +- Require pull requests before merging. +- Require two approving reviews for normal work. +- Require conversation resolution before merge. +- Block force pushes. +- Block branch deletion. +- Do not grant routine admin bypasses. + +## Required Status Checks + +Mark these checks as required before merge: + +- `CI / lint` +- `CI / unit tests` +- `CI / integration tests` +- `CI / package build` +- `Docs / links` +- `Commit lint / commit messages` + +## Optional Repository Checks + +Do not require checks that are not emitted for every pull request. Treat these +as advisory unless GitHub shows they run on all normal PRs: + +- `.github/dependabot.yml` + +## Merge Policy + +- Work on feature branches. +- Push branches normally; do not force-push shared branches. +- Merge through PRs after checks are green. +- Delete merged branches. + +Temporary admin bypass should be reserved for repository recovery work only. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d400870..51d4473 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,6 +24,7 @@ ## Checklist - [ ] I kept the change scoped to the relevant area. +- [ ] I am opening this from a separate branch, not pushing directly to `main`. - [ ] I updated docs, examples, or setup notes when behavior changed. - [ ] I added or updated tests when the change affects behavior. - [ ] I did not commit secrets, `.env` files, dependency folders, or generated output. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67125b1..80943a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, dev, master] + branches: [main] pull_request: # Cancel superseded runs on the same ref to save CI minutes. @@ -14,14 +14,14 @@ permissions: contents: read jobs: - ci: - name: lint + test + integration + lint: + name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v8.2.0 with: enable-cache: true cache-dependency-glob: uv.lock @@ -35,8 +35,62 @@ jobs: - name: Lint (ruff + import-linter + datetime + openapi drift) run: make lint + unit: + name: unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies (frozen) + run: make install-deps + - name: Unit tests run: make test + integration: + name: integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies (frozen) + run: make install-deps + - name: Integration tests run: make integration + + package: + name: package build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + run: uv python install 3.12 + + - name: Build and smoke-test package + run: make package diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml new file mode 100644 index 0000000..1286233 --- /dev/null +++ b/.github/workflows/commits.yml @@ -0,0 +1,28 @@ +name: Commit lint + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: commit-lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + messages: + name: commit messages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Validate Conventional Commit subjects + env: + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + GITHUB_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: make check-commits diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0aee1d5..6f6fa31 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,6 +7,7 @@ on: - ".github/ISSUE_TEMPLATE/**" - ".github/PULL_REQUEST_TEMPLATE.md" - ".github/workflows/docs.yml" + - "scripts/check_docs.py" push: branches: [main] paths: @@ -14,6 +15,7 @@ on: - ".github/ISSUE_TEMPLATE/**" - ".github/PULL_REQUEST_TEMPLATE.md" - ".github/workflows/docs.yml" + - "scripts/check_docs.py" permissions: contents: read @@ -28,110 +30,5 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Validate active relative Markdown links - run: | - python3 - <<'PY' - from pathlib import Path - import re - import sys - - # Validate every Markdown file in the documentation surface. Globbing - # (rather than a hand-maintained list) means new docs are covered - # automatically and phantom links cannot slip in. Skip vendored trees. - skip_dirs = {".git", "node_modules", ".venv", ".uv-cache"} - files = sorted( - p - for p in Path(".").rglob("*.md") - if not any(part in skip_dirs for part in p.parts) - ) - - missing = [] - for path in files: - if not path.exists(): - continue - text = path.read_text() - active = re.sub(r"", "", text, flags=re.S) - for raw in re.findall(r"\[[^\]]*\]\(([^)]+)\)", active): - link = raw.split("#", 1)[0] - if not link or link.startswith(("http://", "https://", "mailto:")): - continue - target = (path.parent / link).resolve() - try: - target.relative_to(Path.cwd().resolve()) - except ValueError: - missing.append((path, raw, "outside repository")) - continue - if not target.exists(): - missing.append((path, raw, "missing")) - - if missing: - for path, raw, reason in missing: - print(f"{path}: {raw} -> {reason}") - sys.exit(1) - - print("Active relative Markdown links resolve.") - PY - - - name: Validate use-case banner links - run: | - python3 - <<'PY' - from pathlib import Path - import re - import sys - - files = { - Path("README.md"): "## Use Cases", - Path("use-cases/README.md"): "## Use Cases", - } - - failures = [] - warnings = [] - primary_link_pattern = re.compile( - r"^\[(?:Code|Plugin|Live Demo|Learn more)\]\(([^)]+)\)", - flags=re.M, - ) - - for path, heading in files.items(): - text = path.read_text() - start = text.find(heading) - if start == -1: - failures.append(f"{path}: missing {heading}") - continue - - table_start = text.find("", start) - table_end = text.find("
", table_start) - if table_start == -1 or table_end == -1: - failures.append(f"{path}: missing use-case table") - continue - - table = text[table_start:table_end] - cells = re.findall(r"]*>(.*?)", table, flags=re.S) - for index, cell in enumerate(cells, start=1): - title_match = re.search(r"####\s+(.+)", cell) - title = title_match.group(1).strip() if title_match else f"use case {index}" - banner_match = re.search(r"\[!\[[^\]]*\]\([^)]+\)\]\(([^)]+)\)", cell) - primary_match = primary_link_pattern.search(cell) - - if not banner_match and primary_match: - warnings.append(f"{path}: {title}: primary link has no linked banner") - elif banner_match and not primary_match: - failures.append(f"{path}: {title}: missing primary link") - elif banner_match and primary_match and banner_match.group(1) != primary_match.group(1): - failures.append( - f"{path}: {title}: banner link {banner_match.group(1)} " - f"does not match primary link {primary_match.group(1)}" - ) - - if warnings: - print("\n".join(f"warning: {warning}" for warning in warnings)) - - if failures: - print("\n".join(failures)) - sys.exit(1) - - print("Use-case banner links match primary links.") - PY - - - name: Validate issue template YAML - run: | - ruby -e 'require "yaml"; Dir[".github/ISSUE_TEMPLATE/*.yml"].sort.each { |p| YAML.load_file(p); puts "YAML ok: #{p}" }' + - name: Validate Markdown docs + run: make docs-check diff --git a/.gitignore b/.gitignore index ab1c75b..534399e 100755 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,10 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.ruff_cache/ +.import_linter_cache/ +.uv-cache/ +.package-smoke/ # Translations *.mo @@ -146,6 +150,8 @@ dmypy.json !.claude/settings.json .claude/settings.local.json .claude/worktrees/ +.worktrees/ +worktrees/ # Runtime data (the default memory root + local databases) .everos/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f5ec35..b307de6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,10 +5,9 @@ on this project. ## How EverOS accepts contributions -EverOS follows an **"open source, not open contribution"** model (similar to -SQLite). The codebase is developed and maintained by the EverMind core team, and -we **do not merge external pull requests**. This keeps copyright provenance -clean and the architecture coherent. +EverOS accepts **curated pull request contributions**. The EverMind core team +reviews every change for product fit, architecture consistency, licensing, and +long-term maintainability before it is merged. What we actively welcome from the community: @@ -16,12 +15,11 @@ What we actively welcome from the community: |---|---| | πŸ› Bug reports | [Open a bug issue](https://github.com/EverMind-AI/EverOS/issues/new?template=bug_report.yml) | | πŸ’‘ Feature ideas / use cases | [Open a feature issue](https://github.com/EverMind-AI/EverOS/issues/new?template=feature_request.yml) | -| πŸ”§ Suggested fixes | An issue with a code snippet / patch attached (see below) | +| πŸ”§ Focused fixes | A pull request linked to a bug, design note, or clear reproduction | | ❓ Questions & discussion | [GitHub Discussions](https://github.com/EverMind-AI/EverOS/discussions) / [Discord](https://discord.gg/pfwwskxp) | -> **Pull requests opened against this repository will be closed** with a pointer -> to this policy. Please open an issue instead β€” it is the fastest path to -> getting a change in. +Large architectural changes should start as an issue or discussion before code. +Small documentation, test, CI, and bug-fix PRs can be opened directly. ## Reporting a bug @@ -40,15 +38,16 @@ Use the [feature request template](https://github.com/EverMind-AI/EverOS/issues/ - Proposed API or behavior - Backward-compatibility considerations -## Suggesting a fix (code welcome) +## Sending a pull request -Found the bug *and* the fix? Great β€” paste a minimal patch or code snippet -**in the issue**. Treat it as a proposal: the core team will review it, adapt it -to the project's conventions, and land the actual commit (crediting you in the -commit message / changelog). +1. Create a branch from `main`. +2. Keep the change scoped to one purpose. +3. Run `make ci` locally before requesting review. +4. Use a Conventional Commit title, such as `fix(search): guard empty profile`. +5. Open a pull request to `main` and fill out the PR template. -> By posting a code suggestion in an issue, you agree it may be incorporated into -> EverOS under the project's [Apache-2.0](LICENSE) license. +By submitting a pull request, you agree that your contribution is licensed under +the project's [Apache-2.0](LICENSE) license. ## Reporting security issues @@ -114,24 +113,26 @@ make format # ruff fix + format make lint # ruff check + import-linter ``` -### Branch strategy (GitFlow Lite) +### Branch strategy | Branch | Role | |---|---| -| `master` | Released stable | -| `dev` | Default integration branch | -| `feat/-` | New features (from dev β†’ dev) | -| `fix/-` | Bug fixes (from dev β†’ dev) | -| `hotfix/-` | Emergency fixes (from master β†’ master + dev) | +| `main` | Default and protected branch | +| `feat/-` | Feature work | +| `fix/-` | Bug fixes | +| `docs/-` | Documentation-only changes | +| `ci/-` | CI, build, and developer-experience changes | -Full rationale: [.claude/skills/new-branch/SKILL.md](.claude/skills/new-branch/SKILL.md). +Do not push directly to `main`. Do not force-push shared branches. Merge through +PRs after required checks pass. ### Commit messages **[Conventional Commits](https://www.conventionalcommits.org)**: `[(scope)]: ` (e.g. `feat: add agentic rerank`, `fix(search): guard empty profile`). Enforced -by `gitlint` in the `commit-msg` hook. Use `/commit` for guided generation; full -type list: [.claude/skills/commit/SKILL.md](.claude/skills/commit/SKILL.md). +locally by `gitlint` in the `commit-msg` hook and in GitHub Actions by the +`Commit lint` workflow. Use `/commit` for guided generation; full type list: +[.claude/skills/commit/SKILL.md](.claude/skills/commit/SKILL.md). ### Testing diff --git a/Makefile b/Makefile index 9731cb0..df7fd23 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,12 @@ -.PHONY: help install install-deps lint check-cjk check-datetime openapi check-openapi format test integration cov ci clean +.PHONY: help install install-deps lint docs-check check-commits check-cjk check-datetime openapi check-openapi format test integration package cov ci clean help: @echo "Targets:" @echo " install Install deps + pre-commit hooks (full dev setup)" @echo " install-deps Install deps only (uv sync --frozen, used by CI)" @echo " lint ruff (check + format-check) + import-linter + datetime discipline + openapi drift" + @echo " docs-check Validate Markdown links, use-case banners, and issue template YAML" + @echo " check-commits Validate Conventional Commit subjects for a git range" @echo " check-cjk Scan for CJK outside the language-policy allowlist (advisory)" @echo " check-datetime Scan for code that bypasses component/utils/datetime (HARD gate, run via lint)" @echo " openapi Regenerate docs/openapi.json from the FastAPI app" @@ -12,8 +14,9 @@ help: @echo " format Format src/tests with ruff" @echo " test pytest tests/unit" @echo " integration pytest tests/integration" + @echo " package Build sdist/wheel and smoke-test wheel import" @echo " cov pytest tests/unit + tests/integration with coverage (fail under 80%)" - @echo " ci full CI: lint + test + integration" + @echo " ci full CI: lint + test + integration + package" @echo " clean Remove caches" # Sync deps from uv.lock; CI calls this directly. --frozen means "lock is the @@ -34,6 +37,13 @@ lint: uv run python scripts/check_datetime_discipline.py uv run python scripts/dump_openapi.py --check +docs-check: + python3 scripts/check_docs.py + ruby -e 'require "yaml"; Dir[".github/ISSUE_TEMPLATE/*.yml"].sort.each { |p| YAML.load_file(p); puts "YAML ok: #{p}" }' + +check-commits: + python3 scripts/check_commit_messages.py $(RANGE) + # Advisory CJK scan (see .claude/rules/language-policy.md). Deliberately NOT # wired into `lint` / `ci`: the policy is enforced by review and the rules # doc, not a hard gate. Run on demand when touching potentially-CJK files. @@ -69,6 +79,14 @@ test: integration: uv run pytest tests/integration -v +package: + rm -rf dist .package-smoke + uv build --sdist --wheel + uv venv --python 3.12 --seed .package-smoke + uv pip install --python .package-smoke/bin/python --no-deps dist/*.whl + .package-smoke/bin/python -c "import everos; print(everos.__version__)" + rm -rf .package-smoke + # Coverage runs unit + integration so the number matches what CI's `test` and # `integration` jobs actually exercise. Threshold starts at 80% (unit-only is # currently 87%, unit+integration 91% β€” 80% leaves ~10pp headroom for normal @@ -76,9 +94,9 @@ integration: cov: uv run pytest tests/unit tests/integration --cov=src/everos --cov-report=term-missing --cov-branch --cov-fail-under=80 -ci: lint test integration +ci: lint test integration package clean: - rm -rf .pytest_cache .ruff_cache .uv-cache .mypy_cache .coverage htmlcov + rm -rf .pytest_cache .ruff_cache .uv-cache .mypy_cache .coverage htmlcov .package-smoke find . -type d -name __pycache__ -exec rm -rf {} + find . -type f -name '*.pyc' -delete diff --git a/docs/engineering.md b/docs/engineering.md index 9eda6a4..633e955 100644 --- a/docs/engineering.md +++ b/docs/engineering.md @@ -94,15 +94,15 @@ Reasons this is documented separately: β”‚ β”Œβ”€ CI/CD (GitHub Actions) ───────────────────────────────────┐ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ CI: .github/workflows/ci.yml lint / test / integ β”‚ β”‚ -β”‚ β”‚ Docs: .github/workflows/docs.yml Markdown link check β”‚ β”‚ -β”‚ β”‚ Both invoke Makefile targets; the Makefile is the β”‚ β”‚ +β”‚ β”‚ Docs: .github/workflows/docs.yml Markdown + YAML check β”‚ β”‚ +β”‚ β”‚ Gates invoke Makefile targets; the Makefile is the β”‚ β”‚ β”‚ β”‚ single source of truth for commands. β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”Œβ”€ Collaboration workflow ───────────────────────────────────┐ β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ Branch model: dev / master (GitFlow Lite) β”‚ β”‚ +β”‚ β”‚ Branch model: protected main + short-lived PR branches β”‚ β”‚ β”‚ β”‚ PR template: .github/PULL_REQUEST_TEMPLATE.md β”‚ β”‚ β”‚ β”‚ ISSUE_TEMPLATE: bug / feature / use-case / docs / config β”‚ β”‚ β”‚ β”‚ CONTRIBUTING.md: contributor onboarding β”‚ β”‚ @@ -160,7 +160,7 @@ matching `.py` file. | Command | Purpose | When to use | |---|---|---| | `/commit` | Generate a Conventional Commits message | After a focused change, ready to commit | -| `/new-branch` | Create branch under dev/master strategy | Starting a new feat / fix / hotfix | +| `/new-branch` | Create branch from protected main | Starting a new feat / fix / ci branch | | `/pr` | Open a GitHub PR with the repo template | Ready to merge | Skills and rules use **independent loading mechanisms**: rules auto-load @@ -275,8 +275,9 @@ make format ruff fix + format make lint ruff + import-linter + datetime discipline + openapi drift make test pytest tests/unit make integration pytest tests/integration +make package build sdist/wheel + smoke-test wheel import make cov pytest unit + integration, coverage gate (fail under 80%) -make ci lint + test + integration ← CI invokes these targets +make ci lint + test + integration + package make clean clear caches ``` @@ -313,13 +314,15 @@ Every key has a sensible default except the `API_KEY` fields, which you fill in. β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ GitHub Actions (.github/workflows/) β”‚ -β”‚ ci.yml push (main/dev/master) + PR β”‚ -β”‚ β”œ make install-deps (uv sync --frozen) β”‚ -β”‚ β”œ make lint (ruff + import-linter + β”‚ -β”‚ β”‚ datetime + openapi drift) β”‚ -β”‚ β”œ make test (pytest tests/unit) β”‚ -β”‚ β”” make integration (pytest tests/integration) β”‚ +β”‚ ci.yml push (main) + PR β”‚ +β”‚ β”œ lint make lint β”‚ +β”‚ β”œ unit tests make test β”‚ +β”‚ β”œ integration tests make integration β”‚ +β”‚ β”” package build make package β”‚ β”‚ docs.yml Markdown link check + issue-template YAML β”‚ +β”‚ β”” make docs-check β”‚ +β”‚ commits.yml Conventional Commit subject check β”‚ +β”‚ β”” make check-commits β”‚ β”‚ β”‚ β”‚ Consistency: β”‚ β”‚ β”œ astral-sh/setup-uv (cache keyed by uv.lock) β”‚ @@ -339,55 +342,38 @@ Every key has a sensible default except the `API_KEY` fields, which you fill in. | OpenAPI drift | `make lint` (dump_openapi.py --check) | schema β‰  committed openapi.json | | Unit | `make test` (pytest tests/unit) | any failure | | Integration | `make integration` (pytest tests/integration) | any failure | +| Package build | `make package` (sdist/wheel + import smoke test) | build or import failure | +| Commit message | `Commit lint` workflow | non-Conventional Commit subject | Integration tests run with a `FakeLLMClient` β€” no live credentials are needed in CI. -Commit message format is enforced **locally** via `gitlint` in the `commit-msg` -pre-commit stage; it does not run in CI. +Commit message format is enforced locally via `gitlint` in the `commit-msg` +pre-commit stage and remotely via the `Commit lint` workflow. ### 6.3 Branch protection | Branch | Rule | |---|---| -| **master** | branch protection: PR + 1 review + green CI; no direct push | -| **dev** | same as above | -| feat / fix / hotfix | free push; rebase parent before merge | +| **main** | branch protection: PR + two reviews + green required checks; no direct push | +| feat / fix / docs / ci | contributor branches; merge through PR | --- ## 7. Collaboration workflow -### 7.1 Branch model (GitFlow Lite) +### 7.1 Branch model + +EverOS uses a simple protected-main model after the 1.0 history reset: ``` - v0.1 v0.2 v1.0 - β–² β–² β–² - β”‚ release PR β”‚ release PR β”‚ release PR - β”‚ (devβ†’master+tag) β”‚ (devβ†’master+tag) β”‚ (devβ†’master+tag) -master ●──────────────────────●─────────────●──────────────────●──────────────────────────────────●────► stable / released - β”‚ β–² β”‚ β”‚ - β”‚ β”‚ merge hotfix β”‚ β”‚ - β”‚ β”‚ β”‚ β”‚ - β”‚ β—β”€β”€β—β”€β”€β”˜ β”‚ β”‚ - β”‚ β”‚ hotfix branch β”‚ β”‚ - β”‚ β”‚ (cut from master) β”‚ β”‚ - β”‚ β”‚ β”‚ β”‚ - β”‚ β–Ό sync to dev β”‚ β”‚ - β”‚ β”‚ β”‚ β”‚ -dev ●──●──●──●──●──●──●──●──●─●──●──●─●──●──●──●──●──●──●──●──●─●──●──●──●──●──●──●──●──●──●──●──●─────► integration - β–² ↑ ↑ ↑ - β”‚ release point release point release point - feat/A (dev HEAD β†’ (dev HEAD β†’ (dev HEAD β†’ - ●──●──● master + v0.1) master + v0.2) master + v1.0) - - - feat/* : cut from dev β†’ PR β†’ merge into dev - hotfix/* : cut from master β†’ merge into master + sync into dev (double merge) - release : dev β†’ master + tag on master (no separate release branch) - - Vertical β”‚ in the diagram = "dev HEAD merged into master via release PR + v0.x tag" +main ●────●────●────●────► protected, releasable + β–² β–² β–² + β”‚ β”‚ └─ PR from ci/* + β”‚ └────── PR from fix/* + └─────────── PR from feat/* ``` -Details in [../.claude/skills/new-branch/SKILL.md](../.claude/skills/new-branch/SKILL.md). +All work starts from `main`, lands through a pull request, and requires green +checks. Force-pushing `main` is reserved only for repository recovery work. ### 7.2 PR template @@ -415,8 +401,9 @@ ci: CI configuration revert: revert a previous commit ``` -`gitlint` enforces the format **locally** via its `contrib-title-conventional-commits` -rule in the commit-msg pre-commit stage. See +`gitlint` enforces the format locally via its `contrib-title-conventional-commits` +rule in the commit-msg pre-commit stage. GitHub Actions runs the same policy on +pushes to `main` and pull requests. See [../.claude/skills/commit/SKILL.md](../.claude/skills/commit/SKILL.md). --- diff --git a/scripts/check_commit_messages.py b/scripts/check_commit_messages.py new file mode 100644 index 0000000..6db537a --- /dev/null +++ b/scripts/check_commit_messages.py @@ -0,0 +1,114 @@ +"""Validate commit subjects against the EverOS Conventional Commits policy.""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys + +ZERO_SHA = "0" * 40 +ALLOWED_TYPES = ( + "feat", + "fix", + "refactor", + "test", + "docs", + "style", + "perf", + "chore", + "build", + "ci", + "revert", +) +TITLE_RE = re.compile( + rf"^({'|'.join(ALLOWED_TYPES)})(\([A-Za-z0-9._/-]+\))?(!)?: .+" +) +MAX_TITLE_LENGTH = 72 + + +def _run_git(args: list[str]) -> str: + return subprocess.check_output(["git", *args], text=True).strip() + + +def _default_range() -> str: + event_name = os.getenv("GITHUB_EVENT_NAME", "") + before = os.getenv("GITHUB_EVENT_BEFORE", "") or os.getenv("GITHUB_EVENT_BEFORE_SHA", "") + after = os.getenv("GITHUB_SHA", "HEAD") + pr_base = os.getenv("GITHUB_PR_BASE_SHA", "") + + if event_name.startswith("pull_request") and pr_base: + return f"{pr_base}..HEAD" + + if before and before != ZERO_SHA: + return f"{before}..{after}" + + try: + _run_git(["rev-parse", "--verify", f"{after}^"]) + except subprocess.CalledProcessError: + return after + return f"{after}^..{after}" + + +def _commit_rows(commit_range: str) -> list[tuple[str, str, str]]: + output = _run_git( + [ + "log", + "--format=%H%x00%s%x00%P", + commit_range, + ] + ) + if not output: + return [] + + rows = [] + for line in output.splitlines(): + commit, subject, parents = line.split("\x00", 2) + rows.append((commit, subject, parents)) + return rows + + +def _is_exempt(subject: str, parents: str) -> bool: + if len(parents.split()) > 1: + return True + return subject.startswith(("Revert ", "fixup!", "squash!")) + + +def _validate(commit_range: str) -> list[str]: + failures: list[str] = [] + for commit, subject, parents in _commit_rows(commit_range): + if _is_exempt(subject, parents): + continue + + short = commit[:12] + if len(subject) > MAX_TITLE_LENGTH: + failures.append( + f"{short}: subject is {len(subject)} chars; max is {MAX_TITLE_LENGTH}: {subject}" + ) + continue + + if not TITLE_RE.match(subject): + allowed = ", ".join(ALLOWED_TYPES) + failures.append( + f"{short}: invalid subject: {subject}\n" + f" expected: [(scope)][!]: \n" + f" allowed types: {allowed}" + ) + + return failures + + +def main() -> int: + commit_range = sys.argv[1] if len(sys.argv) > 1 else _default_range() + failures = _validate(commit_range) + if failures: + print(f"Commit message check failed for range {commit_range}:") + print("\n".join(failures)) + return 1 + + print(f"Commit messages follow Conventional Commits for range {commit_range}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_docs.py b/scripts/check_docs.py new file mode 100644 index 0000000..e81aa2a --- /dev/null +++ b/scripts/check_docs.py @@ -0,0 +1,109 @@ +"""Validate contributor-facing Markdown and use-case documentation.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +SKIP_DIRS = {".git", "node_modules", ".venv", ".uv-cache"} +USE_CASE_TABLES = { + Path("README.md"): "## Use Cases", + Path("use-cases/README.md"): "## Use Cases", +} +PRIMARY_LINK_RE = re.compile( + r"^\[(?:Code|Plugin|Live Demo|Learn more)\]\(([^)]+)\)", + flags=re.M, +) + + +def _markdown_files() -> list[Path]: + return sorted( + path + for path in Path(".").rglob("*.md") + if not any(part in SKIP_DIRS for part in path.parts) + ) + + +def _check_active_relative_links() -> list[str]: + missing: list[str] = [] + root = Path.cwd().resolve() + for path in _markdown_files(): + active = re.sub(r"", "", path.read_text(), flags=re.S) + for raw in re.findall(r"\[[^\]]*\]\(([^)]+)\)", active): + link = raw.split("#", 1)[0] + if not link or link.startswith(("http://", "https://", "mailto:")): + continue + + target = (path.parent / link).resolve() + try: + target.relative_to(root) + except ValueError: + missing.append(f"{path}: {raw} -> outside repository") + continue + + if not target.exists(): + missing.append(f"{path}: {raw} -> missing") + return missing + + +def _check_use_case_banner_links() -> tuple[list[str], list[str]]: + failures: list[str] = [] + warnings: list[str] = [] + + for path, heading in USE_CASE_TABLES.items(): + text = path.read_text() + start = text.find(heading) + if start == -1: + failures.append(f"{path}: missing {heading}") + continue + + table_start = text.find("", start) + table_end = text.find("
", table_start) + if table_start == -1 or table_end == -1: + failures.append(f"{path}: missing use-case table") + continue + + table = text[table_start:table_end] + cells = re.findall(r"]*>(.*?)", table, flags=re.S) + for index, cell in enumerate(cells, start=1): + title_match = re.search(r"####\s+(.+)", cell) + title = title_match.group(1).strip() if title_match else f"use case {index}" + banner_match = re.search(r"\[!\[[^\]]*\]\([^)]+\)\]\(([^)]+)\)", cell) + primary_match = PRIMARY_LINK_RE.search(cell) + + if not banner_match and primary_match: + warnings.append(f"{path}: {title}: primary link has no linked banner") + elif banner_match and not primary_match: + failures.append(f"{path}: {title}: missing primary link") + elif ( + banner_match + and primary_match + and banner_match.group(1) != primary_match.group(1) + ): + failures.append( + f"{path}: {title}: banner link {banner_match.group(1)} " + f"does not match primary link {primary_match.group(1)}" + ) + + return failures, warnings + + +def main() -> int: + failures = _check_active_relative_links() + use_case_failures, warnings = _check_use_case_banner_links() + failures.extend(use_case_failures) + + if warnings: + print("\n".join(f"warning: {warning}" for warning in warnings)) + + if failures: + print("\n".join(failures)) + return 1 + + print("Documentation checks passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_core/test_persistence/test_locking.py b/tests/unit/test_core/test_persistence/test_locking.py index 62a2ff0..05e0618 100644 --- a/tests/unit/test_core/test_persistence/test_locking.py +++ b/tests/unit/test_core/test_persistence/test_locking.py @@ -2,7 +2,8 @@ from __future__ import annotations -import multiprocessing +import subprocess +import sys import time from pathlib import Path @@ -11,6 +12,88 @@ import pytest from everos.core.persistence import LockError, MemoryRoot, memory_root_lock +_LOCK_HOLDER_SCRIPT = """ +import fcntl +import os +import sys +import time + +from pathlib import Path + +lock_path, ready_path, release_path = sys.argv[1:] +Path(lock_path).parent.mkdir(parents=True, exist_ok=True) +fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644) +try: + fcntl.flock(fd, fcntl.LOCK_EX) + Path(ready_path).write_text("ready") + while not Path(release_path).exists(): + time.sleep(0.05) +finally: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) +""" +LOCK_HOLDER_READY_TIMEOUT = 5.0 + + +async def _assert_subprocess_ready( + ready_path: Path, + proc: subprocess.Popen[str], +) -> None: + deadline = time.monotonic() + LOCK_HOLDER_READY_TIMEOUT + while time.monotonic() < deadline: + if await anyio.to_thread.run_sync(ready_path.exists): + return + if proc.poll() is not None: + stdout, stderr = proc.communicate() + raise AssertionError( + "subprocess exited before acquiring lock " + f"(exitcode={proc.returncode}, stdout={stdout!r}, stderr={stderr!r})" + ) + await anyio.sleep(0.05) + + proc.terminate() + stdout, stderr = proc.communicate(timeout=1) + raise AssertionError( + "subprocess failed to acquire lock " + f"(exitcode={proc.returncode}, stdout={stdout!r}, stderr={stderr!r})" + ) + + +def _spawn_lock_holder(mr: MemoryRoot) -> tuple[subprocess.Popen[str], Path, Path]: + ready_path = mr.root / ".test-lock-ready" + release_path = mr.root / ".test-lock-release" + proc = subprocess.Popen( + [ + sys.executable, + "-c", + _LOCK_HOLDER_SCRIPT, + str(mr.lock_file), + str(ready_path), + str(release_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return proc, ready_path, release_path + + +async def _start_lock_holder(mr: MemoryRoot) -> tuple[subprocess.Popen[str], Path]: + proc, ready_path, release_path = _spawn_lock_holder(mr) + await _assert_subprocess_ready(ready_path, proc) + return proc, release_path + + +def _stop_lock_holder(proc: subprocess.Popen[str], release_path: Path) -> None: + release_path.write_text("release") + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.terminate() + proc.wait(timeout=5) + async def test_lock_creates_anchor_file(tmp_path: Path) -> None: mr = MemoryRoot(tmp_path) @@ -27,60 +110,30 @@ async def test_lock_acquire_release_acquire(tmp_path: Path) -> None: pass -def _hold_lock(memory_root_path: str, ready: object, release: object) -> None: - """Subprocess helper: acquire blocking lock, signal, wait, release. - - The subprocess runs its own event loop via :func:`anyio.run` since - :func:`memory_root_lock` is now async. - """ - - async def _run() -> None: - mr = MemoryRoot(memory_root_path) - async with memory_root_lock(mr, blocking=True): - ready.set() - # Use a thread-offloaded wait so we don't block the event loop. - await anyio.to_thread.run_sync(release.wait, 5) - - anyio.run(_run) - - async def test_nonblocking_raises_when_held_by_other_process(tmp_path: Path) -> None: """Different process holding the lock β†’ blocking=False raises LockError.""" mr = MemoryRoot(tmp_path) - ctx = multiprocessing.get_context("spawn") - ready = ctx.Event() - release = ctx.Event() - proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) - proc.start() + proc, release_path = await _start_lock_holder(mr) try: - assert ready.wait(timeout=5), "subprocess failed to acquire lock" with pytest.raises(LockError): async with memory_root_lock(mr, blocking=False): pass finally: - release.set() - proc.join(timeout=5) - if proc.is_alive(): - proc.terminate() + _stop_lock_holder(proc, release_path) async def test_blocking_waits_for_release(tmp_path: Path) -> None: """Different process holding lock + main process blocking=True waits.""" mr = MemoryRoot(tmp_path) - ctx = multiprocessing.get_context("spawn") - ready = ctx.Event() - release = ctx.Event() - proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) - proc.start() + proc, release_path = await _start_lock_holder(mr) try: - assert ready.wait(timeout=5) # Schedule the subprocess to release shortly; main process should # acquire the lock after that. release_started = time.monotonic() def release_after_short_delay() -> None: time.sleep(0.2) - release.set() + release_path.write_text("release") import threading @@ -90,7 +143,4 @@ async def test_blocking_waits_for_release(tmp_path: Path) -> None: # Should have waited at least roughly the delay. assert elapsed >= 0.1 finally: - release.set() - proc.join(timeout=5) - if proc.is_alive(): - proc.terminate() + _stop_lock_holder(proc, release_path)