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:
39
.github/BRANCH_PROTECTION.md
vendored
Normal file
39
.github/BRANCH_PROTECTION.md
vendored
Normal 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.
|
||||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -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.
|
||||||
|
|||||||
64
.github/workflows/ci.yml
vendored
64
.github/workflows/ci.yml
vendored
@ -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
28
.github/workflows/commits.yml
vendored
Normal 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
|
||||||
111
.github/workflows/docs.yml
vendored
111
.github/workflows/docs.yml
vendored
@ -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
6
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
26
Makefile
26
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:
|
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
|
||||||
|
|||||||
@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
114
scripts/check_commit_messages.py
Normal file
114
scripts/check_commit_messages.py
Normal 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
109
scripts/check_docs.py
Normal 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())
|
||||||
@ -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()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user