ci: harden contributor checks (#254)

* ci: harden contributor checks

* ci: pin setup-uv action release

* ci: split workflow checks

* docs: clarify required checks
This commit is contained in:
Elliot Chen
2026-06-06 10:47:16 +08:00
committed by GitHub
parent 3527ea3eb2
commit 873e7535fb
12 changed files with 529 additions and 225 deletions

39
.github/BRANCH_PROTECTION.md vendored Normal file
View File

@ -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.

View File

@ -24,6 +24,7 @@
## Checklist ## Checklist
- [ ] I kept the change scoped to the relevant area. - [ ] 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 updated docs, examples, or setup notes when behavior changed.
- [ ] I added or updated tests when the change affects behavior. - [ ] I added or updated tests when the change affects behavior.
- [ ] I did not commit secrets, `.env` files, dependency folders, or generated output. - [ ] I did not commit secrets, `.env` files, dependency folders, or generated output.

View File

@ -2,7 +2,7 @@ name: CI
on: on:
push: push:
branches: [main, dev, master] branches: [main]
pull_request: pull_request:
# Cancel superseded runs on the same ref to save CI minutes. # Cancel superseded runs on the same ref to save CI minutes.
@ -14,14 +14,14 @@ permissions:
contents: read contents: read
jobs: jobs:
ci: lint:
name: lint + test + integration name: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v4 uses: astral-sh/setup-uv@v8.2.0
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: uv.lock cache-dependency-glob: uv.lock
@ -35,8 +35,62 @@ jobs:
- name: Lint (ruff + import-linter + datetime + openapi drift) - name: Lint (ruff + import-linter + datetime + openapi drift)
run: make lint 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 - name: Unit tests
run: make test 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 - name: Integration tests
run: make integration 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

28
.github/workflows/commits.yml vendored Normal file
View File

@ -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

View File

@ -7,6 +7,7 @@ on:
- ".github/ISSUE_TEMPLATE/**" - ".github/ISSUE_TEMPLATE/**"
- ".github/PULL_REQUEST_TEMPLATE.md" - ".github/PULL_REQUEST_TEMPLATE.md"
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- "scripts/check_docs.py"
push: push:
branches: [main] branches: [main]
paths: paths:
@ -14,6 +15,7 @@ on:
- ".github/ISSUE_TEMPLATE/**" - ".github/ISSUE_TEMPLATE/**"
- ".github/PULL_REQUEST_TEMPLATE.md" - ".github/PULL_REQUEST_TEMPLATE.md"
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
- "scripts/check_docs.py"
permissions: permissions:
contents: read contents: read
@ -28,110 +30,5 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Validate active relative Markdown links - name: Validate Markdown docs
run: | run: make docs-check
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("<table>", start)
table_end = text.find("</table>", 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"<td[^>]*>(.*?)</td>", 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}" }'

6
.gitignore vendored
View File

@ -50,6 +50,10 @@ coverage.xml
*.py,cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
.ruff_cache/
.import_linter_cache/
.uv-cache/
.package-smoke/
# Translations # Translations
*.mo *.mo
@ -146,6 +150,8 @@ dmypy.json
!.claude/settings.json !.claude/settings.json
.claude/settings.local.json .claude/settings.local.json
.claude/worktrees/ .claude/worktrees/
.worktrees/
worktrees/
# Runtime data (the default memory root + local databases) # Runtime data (the default memory root + local databases)
.everos/ .everos/

View File

@ -5,10 +5,9 @@ on this project.
## How EverOS accepts contributions ## How EverOS accepts contributions
EverOS follows an **"open source, not open contribution"** model (similar to EverOS accepts **curated pull request contributions**. The EverMind core team
SQLite). The codebase is developed and maintained by the EverMind core team, and reviews every change for product fit, architecture consistency, licensing, and
we **do not merge external pull requests**. This keeps copyright provenance long-term maintainability before it is merged.
clean and the architecture coherent.
What we actively welcome from the community: 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) | | 🐛 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) | | 💡 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) | | ❓ 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 Large architectural changes should start as an issue or discussion before code.
> to this policy. Please open an issue instead — it is the fastest path to Small documentation, test, CI, and bug-fix PRs can be opened directly.
> getting a change in.
## Reporting a bug ## Reporting a bug
@ -40,15 +38,16 @@ Use the [feature request template](https://github.com/EverMind-AI/EverOS/issues/
- Proposed API or behavior - Proposed API or behavior
- Backward-compatibility considerations - 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 1. Create a branch from `main`.
**in the issue**. Treat it as a proposal: the core team will review it, adapt it 2. Keep the change scoped to one purpose.
to the project's conventions, and land the actual commit (crediting you in the 3. Run `make ci` locally before requesting review.
commit message / changelog). 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 By submitting a pull request, you agree that your contribution is licensed under
> EverOS under the project's [Apache-2.0](LICENSE) license. the project's [Apache-2.0](LICENSE) license.
## Reporting security issues ## Reporting security issues
@ -114,24 +113,26 @@ make format # ruff fix + format
make lint # ruff check + import-linter make lint # ruff check + import-linter
``` ```
### Branch strategy (GitFlow Lite) ### Branch strategy
| Branch | Role | | Branch | Role |
|---|---| |---|---|
| `master` | Released stable | | `main` | Default and protected branch |
| `dev` | Default integration branch | | `feat/<scope>-<desc>` | Feature work |
| `feat/<scope>-<desc>` | New features (from dev → dev) | | `fix/<scope>-<desc>` | Bug fixes |
| `fix/<scope>-<desc>` | Bug fixes (from dev → dev) | | `docs/<scope>-<desc>` | Documentation-only changes |
| `hotfix/<scope>-<desc>` | Emergency fixes (from master → master + dev) | | `ci/<scope>-<desc>` | 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 ### Commit messages
**[Conventional Commits](https://www.conventionalcommits.org)**: `<type>[(scope)]: <description>` **[Conventional Commits](https://www.conventionalcommits.org)**: `<type>[(scope)]: <description>`
(e.g. `feat: add agentic rerank`, `fix(search): guard empty profile`). Enforced (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 locally by `gitlint` in the `commit-msg` hook and in GitHub Actions by the
type list: [.claude/skills/commit/SKILL.md](.claude/skills/commit/SKILL.md). `Commit lint` workflow. Use `/commit` for guided generation; full type list:
[.claude/skills/commit/SKILL.md](.claude/skills/commit/SKILL.md).
### Testing ### Testing

View File

@ -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: help:
@echo "Targets:" @echo "Targets:"
@echo " install Install deps + pre-commit hooks (full dev setup)" @echo " install Install deps + pre-commit hooks (full dev setup)"
@echo " install-deps Install deps only (uv sync --frozen, used by CI)" @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 " 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-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 " 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" @echo " openapi Regenerate docs/openapi.json from the FastAPI app"
@ -12,8 +14,9 @@ help:
@echo " format Format src/tests with ruff" @echo " format Format src/tests with ruff"
@echo " test pytest tests/unit" @echo " test pytest tests/unit"
@echo " integration pytest tests/integration" @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 " 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" @echo " clean Remove caches"
# Sync deps from uv.lock; CI calls this directly. --frozen means "lock is the # 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/check_datetime_discipline.py
uv run python scripts/dump_openapi.py --check 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 # Advisory CJK scan (see .claude/rules/language-policy.md). Deliberately NOT
# wired into `lint` / `ci`: the policy is enforced by review and the rules # 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. # doc, not a hard gate. Run on demand when touching potentially-CJK files.
@ -69,6 +79,14 @@ test:
integration: integration:
uv run pytest tests/integration -v 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 # Coverage runs unit + integration so the number matches what CI's `test` and
# `integration` jobs actually exercise. Threshold starts at 80% (unit-only is # `integration` jobs actually exercise. Threshold starts at 80% (unit-only is
# currently 87%, unit+integration 91% — 80% leaves ~10pp headroom for normal # currently 87%, unit+integration 91% — 80% leaves ~10pp headroom for normal
@ -76,9 +94,9 @@ integration:
cov: cov:
uv run pytest tests/unit tests/integration --cov=src/everos --cov-report=term-missing --cov-branch --cov-fail-under=80 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: 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 d -name __pycache__ -exec rm -rf {} +
find . -type f -name '*.pyc' -delete find . -type f -name '*.pyc' -delete

View File

@ -94,15 +94,15 @@ Reasons this is documented separately:
│ ┌─ CI/CD (GitHub Actions) ───────────────────────────────────┐ │ │ ┌─ CI/CD (GitHub Actions) ───────────────────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ CI: .github/workflows/ci.yml lint / test / integ │ │ │ │ CI: .github/workflows/ci.yml lint / test / integ │ │
│ │ Docs: .github/workflows/docs.yml Markdown link check │ │ │ │ Docs: .github/workflows/docs.yml Markdown + YAML check │ │
│ │ Both invoke Makefile targets; the Makefile is the │ │ │ │ Gates invoke Makefile targets; the Makefile is the │ │
│ │ single source of truth for commands. │ │ │ │ single source of truth for commands. │ │
│ │ │ │ │ │ │ │
│ └────────────────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ┌─ Collaboration workflow ───────────────────────────────────┐ │ │ ┌─ Collaboration workflow ───────────────────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ Branch model: dev / master (GitFlow Lite) │ │ │ │ Branch model: protected main + short-lived PR branches │ │
│ │ PR template: .github/PULL_REQUEST_TEMPLATE.md │ │ │ │ PR template: .github/PULL_REQUEST_TEMPLATE.md │ │
│ │ ISSUE_TEMPLATE: bug / feature / use-case / docs / config │ │ │ │ ISSUE_TEMPLATE: bug / feature / use-case / docs / config │ │
│ │ CONTRIBUTING.md: contributor onboarding │ │ │ │ CONTRIBUTING.md: contributor onboarding │ │
@ -160,7 +160,7 @@ matching `.py` file.
| Command | Purpose | When to use | | Command | Purpose | When to use |
|---|---|---| |---|---|---|
| `/commit` | Generate a Conventional Commits message | After a focused change, ready to commit | | `/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 | | `/pr` | Open a GitHub PR with the repo template | Ready to merge |
Skills and rules use **independent loading mechanisms**: rules auto-load 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 lint ruff + import-linter + datetime discipline + openapi drift
make test pytest tests/unit make test pytest tests/unit
make integration pytest tests/integration 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 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 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/) │ │ GitHub Actions (.github/workflows/) │
│ ci.yml push (main/dev/master) + PR │ ci.yml push (main) + PR
│ ├ make install-deps (uv sync --frozen) │ ├ lint make lint
│ ├ make lint (ruff + import-linter + │ ├ unit tests make test
│ datetime + openapi drift) ├ integration tests make integration
├ make test (pytest tests/unit) └ package build make package
│ └ make integration (pytest tests/integration) │
│ docs.yml Markdown link check + issue-template YAML │ │ docs.yml Markdown link check + issue-template YAML │
│ └ make docs-check │
│ commits.yml Conventional Commit subject check │
│ └ make check-commits │
│ │ │ │
│ Consistency: │ │ Consistency: │
│ ├ astral-sh/setup-uv (cache keyed by uv.lock) │ │ ├ 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 | | OpenAPI drift | `make lint` (dump_openapi.py --check) | schema ≠ committed openapi.json |
| Unit | `make test` (pytest tests/unit) | any failure | | Unit | `make test` (pytest tests/unit) | any failure |
| Integration | `make integration` (pytest tests/integration) | 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. 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` Commit message format is enforced locally via `gitlint` in the `commit-msg`
pre-commit stage; it does not run in CI. pre-commit stage and remotely via the `Commit lint` workflow.
### 6.3 Branch protection ### 6.3 Branch protection
| Branch | Rule | | Branch | Rule |
|---|---| |---|---|
| **master** | branch protection: PR + 1 review + green CI; no direct push | | **main** | branch protection: PR + two reviews + green required checks; no direct push |
| **dev** | same as above | | feat / fix / docs / ci | contributor branches; merge through PR |
| feat / fix / hotfix | free push; rebase parent before merge |
--- ---
## 7. Collaboration workflow ## 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 main ●────●────●────●────► protected, releasable
▲ ▲ ▲ ▲ ▲ ▲
│ release PR │ release PR │ release PR └─ PR from ci/*
│ (dev→master+tag) │ (dev→master+tag) │ (dev→master+tag) │ └────── PR from fix/*
master ●──────────────────────●─────────────●──────────────────●──────────────────────────────────●────► stable / released └─────────── PR from feat/*
│ ▲ │ │
│ │ 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"
``` ```
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 ### 7.2 PR template
@ -415,8 +401,9 @@ ci: CI configuration
revert: revert a previous commit revert: revert a previous commit
``` ```
`gitlint` enforces the format **locally** via its `contrib-title-conventional-commits` `gitlint` enforces the format locally via its `contrib-title-conventional-commits`
rule in the commit-msg pre-commit stage. See 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). [../.claude/skills/commit/SKILL.md](../.claude/skills/commit/SKILL.md).
--- ---

View File

@ -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: <type>[(scope)][!]: <description>\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())

109
scripts/check_docs.py Normal file
View File

@ -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("<table>", start)
table_end = text.find("</table>", 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"<td[^>]*>(.*?)</td>", 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())

View File

@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
import multiprocessing import subprocess
import sys
import time import time
from pathlib import Path from pathlib import Path
@ -11,6 +12,88 @@ import pytest
from everos.core.persistence import LockError, MemoryRoot, memory_root_lock 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: async def test_lock_creates_anchor_file(tmp_path: Path) -> None:
mr = MemoryRoot(tmp_path) mr = MemoryRoot(tmp_path)
@ -27,60 +110,30 @@ async def test_lock_acquire_release_acquire(tmp_path: Path) -> None:
pass 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: async def test_nonblocking_raises_when_held_by_other_process(tmp_path: Path) -> None:
"""Different process holding the lock → blocking=False raises LockError.""" """Different process holding the lock → blocking=False raises LockError."""
mr = MemoryRoot(tmp_path) mr = MemoryRoot(tmp_path)
ctx = multiprocessing.get_context("spawn") proc, release_path = await _start_lock_holder(mr)
ready = ctx.Event()
release = ctx.Event()
proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release))
proc.start()
try: try:
assert ready.wait(timeout=5), "subprocess failed to acquire lock"
with pytest.raises(LockError): with pytest.raises(LockError):
async with memory_root_lock(mr, blocking=False): async with memory_root_lock(mr, blocking=False):
pass pass
finally: finally:
release.set() _stop_lock_holder(proc, release_path)
proc.join(timeout=5)
if proc.is_alive():
proc.terminate()
async def test_blocking_waits_for_release(tmp_path: Path) -> None: async def test_blocking_waits_for_release(tmp_path: Path) -> None:
"""Different process holding lock + main process blocking=True waits.""" """Different process holding lock + main process blocking=True waits."""
mr = MemoryRoot(tmp_path) mr = MemoryRoot(tmp_path)
ctx = multiprocessing.get_context("spawn") proc, release_path = await _start_lock_holder(mr)
ready = ctx.Event()
release = ctx.Event()
proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release))
proc.start()
try: try:
assert ready.wait(timeout=5)
# Schedule the subprocess to release shortly; main process should # Schedule the subprocess to release shortly; main process should
# acquire the lock after that. # acquire the lock after that.
release_started = time.monotonic() release_started = time.monotonic()
def release_after_short_delay() -> None: def release_after_short_delay() -> None:
time.sleep(0.2) time.sleep(0.2)
release.set() release_path.write_text("release")
import threading 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. # Should have waited at least roughly the delay.
assert elapsed >= 0.1 assert elapsed >= 0.1
finally: finally:
release.set() _stop_lock_holder(proc, release_path)
proc.join(timeout=5)
if proc.is_alive():
proc.terminate()