24 KiB
AGENTS.md — Project Conventions for GRM
Build & Test Commands
make setup # Create venv, install deps, set up hooks, install CI tools
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make molecule # All 6 scenarios on Ubuntu 22.04
make molecule-all # All 6 scenarios on all 4 supported OSes
make test-all # pytest-cov + molecule
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun)
make workflow-check # workflow-lint + workflow-dryrun
make setup automatically installs all development tools:
- Python deps via
pip install -e .[dev](includes devx from Gitea PyPI registry, configured bymake configure-gitea-pypi) - Post-install setup via
devx.tools.setup --skip-install(ansible-galaxy, pre-commit hooks, tea CLI login) - checkmake via
devx.tools.install_checkmake(Makefile linter) - actionlint, git-cliff, act_runner, tea via
devx.tools.install_tools(CI/CD tools to ~/.local/bin)
Workflow Verification (Before Push)
Workflow YAML files (.gitea/workflows/*.yml) are verified with two tools:
-
actionlint — Static linter that catches syntax errors, invalid expressions, unknown keys, type mismatches, and shellcheck issues. Config:
.gitea/actionlint.yaml(registers customdockerrunner label). Installed automatically bymake setupviadevx.tools.install_tools. -
act_runner exec --dryrun — Gitea's own runner in dry-run mode. Validates job dependencies, step ordering, and Docker image selection without starting containers. Installed automatically by
make setup.
Both run via make workflow-check and are part of make lint-all.
The pre-commit hook runs actionlint automatically when workflow files change.
The CI quality job runs make setup (which installs all tools) then make lint-all.
CI also runs a best-effort make workflow-dryrun step (skipped if act_runner is not installed in the CI Docker image).
Architecture
- Python CLI (
src/gitea_runner_manager/) — Click-based CLI that delegates to Ansible - Ansible Role (
ansible/roles/gitea-runner/) — Idempotent role for rootless Docker runner setup - devx package (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- Versioning (
cliff.toml) — git-cliff configuration for automated semver versioning from conventional commits
PR Workflow (Mandatory)
Every change to master goes through this workflow. No exceptions.
Branch Protection (Required Gitea Settings)
Branch protection and labels are automatically configured by
devx.tools.configure_repo (run as python -m devx.tools.configure_repo),
which runs as a configure-repo job in
the post-merge workflow on every push to master.
The following rules are enforced for master:
- Require pull request: No direct pushes to master
- Require approval review: At least 1
APPROVEreview before merge - Require status checks: CI quality + molecule tests must pass
- Block force pushes: No history rewriting on master
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
1. Create Vikunja Task
Create a task in Vikunja project 6 via make create-task -- --title "Task title" --description "<h2>...</h2>" (requires VIKUNJA_TOKEN in .env). This prints the GRM-N identifier and next-step instructions.
2. Create Branch
git checkout master && git pull
git checkout -b GRM-N-short-description
3. Implement Changes
- Write code following conventions below
- Write/update tests (100% coverage required)
- Update documentation (CHANGELOG, README, AGENTS.md as needed)
4. Commit (Conventional Commits)
Branch commits use conventional commit format (no GRM-N: prefix):
feat: add new feature
fix: resolve bug
docs: update README
5. Push and Create PR
- Push:
git push -u origin HEAD(pre-push hook validates Vikunja task existence viadevx.tools.pre_push_check) - Create PR:
make create-pr(creates a PR with titleGRM-N: <vikunja task title>, auto-derived from the branch name and Vikunja task) - Or both in one step:
make push-with-pr - PR body: summary of changes,
Closes GRM-N - Add
ready-to-mergelabel only after review is complete
6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
Review checklist: Every PR is reviewed against REVIEW_CHECKLIST.md — 13 categories covering architecture, code quality, security, i18n, testing, performance, UX, documentation, workflow compliance, maintainability, resource management, backwards compatibility, and logging.
Automated review (CI pr-review job): Every PR triggers an automated
review via devx.ci.pr_review (run as python -m devx.ci.pr_review). This job posts a review with
COMMENT (no issues) or REQUEST_CHANGES (issues found) based on
the [auto] items in the checklist:
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
- Best practices (no
print(), no bareexcept, noTODO/FIXME, no functions > 50 lines) - Security (no hardcoded secrets, no
shell=True, noeval/exec) - i18n (no raw strings in
click.echo()without_()wrapper) - Resource management (no
open()withoutwith, noPopen()without cleanup) - Documentation (source changes must include doc updates)
- Test coverage (source changes must include test updates)
- Commit conventions (conventional commit format on PR commits)
The automated review posts inline comments on specific lines and
includes a link to the full checklist. The agent must address all
REQUEST_CHANGES issues before proceeding.
Manual review (agent): After the automated review passes, the agent
must go through every category in REVIEW_CHECKLIST.md and verify
the [manual] items by reviewing the full diff
(git diff master...HEAD).
Post review comments using devx.ci.pr_review (run as python -m devx.ci.pr_review):
REPO_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
7. Address Review Comments
Fix each comment one by one, commit, and push. Re-review until satisfied.
8. Approve and Merge
Once all checklist items are verified and comments are addressed, post
an approval review with --checklist-confirmed and --checklist-categories:
REPO_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event APPROVE --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "All 13 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
The --checklist-confirmed flag is required for APPROVE events —
it attests that the reviewer has gone through every checklist category.
The --checklist-categories flag is also required — it must list at
least 8 of the 13 category numbers, ensuring the reviewer actually
checked each category rather than rubber-stamping. The review body must
be substantive (> 50 characters) — trivial approvals like "LGTM" are
rejected.
Then add the ready-to-merge label. The auto-merge workflow will:
- Validate PR title format (
GRM-N: <vikunja task title>) and match against Vikunja task title - Check that at least one substantive APPROVE review exists (body > 20 chars or has inline comments)
- Wait for all CI checks to pass (including the
pr-reviewjob) - Squash-merge with title:
GRM-N: <conventional commit message> - The post-merge workflow marks the Vikunja task as done
- The release workflow automatically versions, tags, and publishes (see below)
Important
: Never manually merge PRs via the API. Always use the auto-merge workflow by adding the
ready-to-mergelabel. Manual merges bypass theGRM-N: <conventional>format enforcement, producing incorrectly named commits. The auto-merge script validates the PR title matches the Vikunja task ID and conventional commit format before merging.
CI Path Filtering
The CI workflow includes a detect-changes job that checks whether any files
under ansible/ or .ansible-lint have changed. If no Ansible files are
changed, molecule tests are skipped — this prevents non-Ansible changes
(e.g., Python scripts, workflow YAML, docs) from being blocked by molecule
test infrastructure flakiness.
Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners
dynamically via devx.molecule.discover_runners. The discover-runners
job queries the Gitea API for runners at all levels (repo, org, instance)
and generates a dynamic matrix. If the API can't see instance-level runners
(no admin scope), it falls back to the MOLECULE_RUNNERS repo variable,
then to a default of 3.
When adding/removing Gitea runners:
- Repo/org-level runners are auto-detected via the API
- For instance-level runners, update the
MOLECULE_RUNNERSrepo variable - The workflow automatically scales the matrix to match available runners
Automated Release Pipeline
After a PR is merged to master, the post-merge workflow
(.gitea/workflows/post-merge.yml) runs automatically. This single
workflow consolidates release, wiki sync, badge generation, and
Vikunja task updates:
-
detect-type — Checks if the commit is a regular merge or a release commit (
release: vX.Y.Z). All subsequent jobs skip for release commits (the[skip ci]tag also prevents re-triggering). -
release — Runs
devx.ci.releasewhich:- Checks for user-facing changes via
devx.ci.classify_changes— if only workflow/infrastructure files changed (.gitea/,docs/,tests/,AGENTS.md,Makefile, etc.), the release is skipped entirely — no version bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes. - Uses git-cliff to calculate the next semver version from conventional commits
- Updates
__version__insrc/gitea_runner_manager/__init__.py(single source of truth) - Updates
CHANGELOG.mdwith the new version section - Runs
make lint-ruffandmake pytest-covto verify the release is healthy - If lint or tests fail, aborts immediately — no commit, no tag
- Commits with
release: vX.Y.Z [skip ci]prefix (the[skip ci]prevents re-triggering post-merge on the release commit) - Creates an annotated tag
vX.Y.Zon the release commit - Pushes both the commit and tag to master
--skip-testsflag bypasses test verification (emergency use only, not recommended)- Loops are prevented by
has_unreleased_changes— after a release commit is tagged, the next run finds no unreleased changes and exits
- Checks for user-facing changes via
-
sync-wiki — Syncs documentation to the Gitea wiki. Runs for ALL non-release commits (not just when release succeeds), so docs-only changes still update the wiki.
-
badges — Generates and pushes quality badge SVGs to the
badgesbranch. Usesif: always()so it runs on every push, including release commits. The script fetches the latest master before generating badges to pick up any release commits. -
vikunja — Marks the corresponding Vikunja task as done. Runs for ALL non-release commits (not just when release succeeds), so infrastructure-only changes still update the task tracker.
The tag push triggers the publish workflow (.gitea/workflows/publish.yml)
which builds and publishes the package to PyPI.
Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project
classifies changes into two categories using devx.ci.classify_changes:
Classification strategy (safe-by-default): Any file NOT in the explicit
workflow-only allowlist is treated as user-facing. This prevents new file
types from accidentally skipping releases. Classification is config-driven
via [tool.devx.classify] in pyproject.toml.
Workflow-only paths (infrastructure → no release needed):
.gitea/**— Gitea Actions workflowsscripts/**— Dev tools and CI/CD automation (not part of installed package)docs/**— Documentationtests/**— Test filesAGENTS.md,README.md,CHANGELOG.md,TROUBLESHOOTING.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,REVIEW_CHECKLIST.md— Project docsMakefile,cliff.toml,uv.lock— Build tooling.pre-commit-config.yaml,.ruff.toml,.ansible-lint,.checkmake.ini,.editorconfig— Lint config.env.example,.gitignore,.gitattributes— Config.devin/**— Agent/CI tooling confighooks/**— Git hooksactivate.sh,activate.fish,activate.zsh— Generated venv scripts
User-facing paths (tool changes → release needed) — everything else:
src/gitea_runner_manager/**— Python CLI source (except__init__.pyandapi_clients.py)ansible/**— Ansible rolepyproject.toml— Package metadata- Any new file type not in the allowlist
devx module structure (installed from git, not in this repo):
devx.ci.*— CI/CD automation (run by workflows): release, publish, auto_merge, classify_changes, detect_release_commit, push_badges, doc_coverage, sync_wiki, distribute_molecule, molecule_ci_guard, discover_runners, notify_failure, post_merge, pr_review, validate_commit_msgdevx.tools.*— Dev tools (run locally): check_test_speed, configure_repo, install_checkmake, install_tools, setup, generate_badgesdevx.molecule.*— Molecule helpers: molecule_all, platforms, discover_runners, distribute_molecule, molecule_ci_guarddevx.gitea_cli— Tea CLI wrapperdevx.i18n— i18n translation systemdevx.config— Shared configuration (DEVX_* env vars)devx.api_clients— GiteaClient, VikunjaClientdevx.exceptions— APIError and other exceptions
CI behavior based on classification:
- Molecule tests: Only run when
ansible/or.ansible-lintfiles change - Release dry-run: Only runs when user-facing files change
- Quality job (lint, unit tests, coverage, doc-coverage): Always runs
- Release workflow: Skips entirely when no user-facing files changed since last tag
AI agents must follow these rules:
- When working on workflow/CI/docs-only changes, use
ci:ordocs:commit prefixes - Do NOT bump the version or create tags for workflow-only changes
- The
classify_changesmodule enforces this automatically — no manual intervention needed
Source Code Separation and devx Integration
The codebase enforces strict separation between the GRM tool and the devx package:
Directory Layout
| Directory | Purpose | Release impact |
|---|---|---|
src/gitea_runner_manager/ |
User-facing GRM CLI tool | Changes trigger release |
devx package (installed from git) |
Reusable CI/CD and dev tools | Not in this repo (no release impact) |
ansible/ |
Ansible role for runner setup | Changes trigger release |
Import Rules
src/gitea_runner_manager/NEVER imports from devx — the GRM tool is self-contained- devx MAY import from
gitea_runner_manager— one-way dependency (devx uses the tool's API clients, config, i18n) - Cross-module imports within devx are allowed (devx modules importing from other devx modules) and must be documented
devx.gitea_cliis a shared wrapper around theteaCLI — devx modules import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
tea CLI Integration
The tea Gitea CLI tool is used for Gitea API interactions in devx. It is installed by devx.tools.install_tools and configured by devx.tools.setup (login profile from .env REPO_TOKEN).
devx.gitea_cli — Python wrapper around tea CLI with JSON output parsing:
TeaCLI.create_issue()— Create issues with labelsTeaCLI.list_labels()/TeaCLI.create_label()/TeaCLI.add_label()— Label managementTeaCLI.create_pr()/TeaCLI.merge_pr()/TeaCLI.review_pr()— Pull request operationsTeaCLI.create_release()/TeaCLI.list_releases()— Release managementTeaCLI.list_branches()— Branch listing
Modules using tea (via devx.gitea_cli):
devx.ci.publish— Creates Gitea releases viatea releases createdevx.ci.notify_failure— Creates issues viatea issues create(falls back toGiteaClientif tea not installed)devx.tools.configure_repo— Creates labels viatea labels create(falls back toGiteaClientif tea fails; branch protection still usesGiteaClientsince tea only supports basic protect/unprotect)
Operations still using GiteaClient (not supported by tea):
- PR reviews (
devx.ci.pr_review) — tea v0.14.1 only supports interactive reviews - Wiki page management (
devx.ci.sync_wiki) - Commit status checks (
devx.ci.auto_merge) - Runner discovery (
devx.molecule.discover_runners) - Branch protection with detailed config (
devx.tools.configure_repo) - PR file/commit listing (
devx.ci.pr_review)
PYTHONPATH Configuration
Since devx is installed as a package (via pip install from git), it is importable directly. Workflows only need PYTHONPATH=src when a devx module imports from gitea_runner_manager:
| PYTHONPATH | When to use | Example modules |
|---|---|---|
src |
Module imports from gitea_runner_manager |
devx.ci.auto_merge, devx.ci.pr_review, devx.ci.pr_review, devx.ci.sync_wiki, devx.ci.post_merge, devx.ci.classify_changes, devx.molecule.discover_runners, devx.ci.doc_coverage |
| (none) | Module has no GRM imports | devx.ci.detect_release_commit, devx.molecule.distribute_molecule, devx.molecule.molecule_ci_guard, devx.ci.push_badges, devx.ci.validate_commit_msg |
In workflows, always use env: blocks (not inline PYTHONPATH=value):
- name: Run module
env:
PYTHONPATH: src
run: python -m devx.ci.example
Locally, devx is installed as a package, so only PYTHONPATH=src is needed if importing from gitea_runner_manager.
Shared Constants
devx.molecule.platforms is the single source of truth for the molecule
platform matrix. Both devx.molecule.distribute_molecule (CI) and
devx.molecule.molecule_all (dev tool) import PLATFORMS from it — this
avoids dev tools importing directly from CI modules.
- Publish workflow (
.gitea/workflows/publish.yml):- Triggers on tag push (
v*) - Validates
PYPI_TOKENis set (warns if missing) - Builds the Python package
- Optionally publishes to PyPI (if
PYPI_TOKENis set) - Creates a Gitea release with git-cliff-generated release notes
- On failure, creates a Gitea issue via
devx.ci.notify_failure
- Triggers on tag push (
git-cliff Commit Preprocessing
Merge commits on master have the format GRM-N: <conventional commit>. The
GRM-N: prefix is not a valid conventional commit prefix, so cliff.toml
includes a commit_preprocessors entry that strips it before parsing. This
ensures all merged work appears in the changelog.
Version Bumping Rules (git-cliff)
| Commit type | Version bump |
|---|---|
feat: |
minor (0.X.0) |
fix: |
patch (0.0.X) |
feat!: or BREAKING CHANGE |
minor (pre-1.0: major would be 1.0.0) |
chore:, ci:, docs: |
no bump (excluded by cliff.toml) |
The version source is __version__ in src/gitea_runner_manager/__init__.py, read by setuptools via dynamic = ["version"] in pyproject.toml. The release script only updates __init__.py — no need to touch pyproject.toml. grm --version reports this version.
Title Format Summary
| What | Format | Example |
|---|---|---|
| Branch name | GRM-N-short-description |
GRM-33-add-pr-review-step |
| Branch commits | <conventional commit> |
feat: add review script |
| PR title | GRM-N: <vikunja task title> |
GRM-33: Add mandatory PR review step |
| Merge commit | GRM-N: <conventional commit> |
GRM-33: feat: add review script |
Configuration
The devx package is configured via DEVX_* environment variables:
DEVX_TASK_PREFIX=GRM— Prefix for Vikunja task identifiersDEVX_VIKUNJA_PROJECT_ID=6— Vikunja project ID for task trackingDEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py— Path to the version source file
Change classification is config-driven via [tool.devx.classify] in pyproject.toml, which defines the workflow-only and user-facing path patterns.
Key Conventions
- Python 3.12+ required (ruff/pyright target
py312) - 100% test coverage required (
--cov-fail-under=100) - Conventional commits on feature branches (no
GRM-N:prefix) - Branch names must include
GRM-Ntask ID - Line length: 120 chars
- Secrets are passed via temp JSON files, never on the command line (CWE-214)
- CI triggers only on
openedandsynchronizePR events (notlabeled)
Ansible Role Structure
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
install_runner.ymlhandles: download, config, validate, register, servicemain.ymlhandles: prune, integration_test (NOT install_runner — avoids duplicates)systemctl --usertasks must be guarded bydocker_rootless_setup- Template creation tasks are NOT guarded by
docker_rootless_setup(they just create files)
Molecule Scenarios
6 scenarios: default, multi-instance, lifecycle, template-content, deregister, update
4 platforms: ubuntu-2204, ubuntu-2404, debian-12, archlinux
Platform list is defined in devx.molecule.platforms (single source of truth)
Known Issues
ansible-lintmay warn aboutcommand-instead-of-moduleforsystemctl --usercalls — this is expected (systemd module doesn't support user services) and skipped in.ansible-lint- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
Documentation-as-Code
All documentation lives in /docs/ and is synced to the Gitea wiki automatically.
Structure
docs/
├── index.md # Wiki homepage
├── mapping.json # File-to-wiki-page title mapping
├── user/ # User documentation
│ ├── getting-started.md
│ ├── installation.md
│ ├── cli-commands.md
│ ├── troubleshooting.md
│ └── faq.md
└── tech/ # Technical documentation
├── architecture.md
├── development-setup.md
├── ci-cd-workflow.md
├── testing-strategy.md
├── decision-log.md
└── contributing.md
Wiki Sync
- On merge to master:
sync-wiki.ymlworkflow runsdevx.ci.sync_wikiwhich pushes all/docs/content to the Gitea wiki via API - On release tag: Same sync runs, plus the wiki is tagged with the release version
mapping.jsonmaps each file path to a wiki page title (e.g.,user/getting-started.md→Getting-Started)- README.md is a lean entry point with links to the wiki — no detailed content
Documentation Coverage
devx.ci.doc_coveragechecks that all CLI commands, Python modules, and CI scripts are documented- Runs as a CI step in the quality job with
--fail-on-missing(blocks CI if docs are missing) - Enforced: 100% coverage for public CLI commands and major architectural components
Updating Documentation
- Edit files in
/docs/ - If adding a new page, add it to
docs/mapping.json - Commit and create a PR (standard PR workflow)
- On merge, wiki is automatically synced