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)