Compare commits

...
4 Commits
Author SHA1 Message Date
grm-ci-bot a0b03f01ef release: v0.5.0
Publish Release / publish (push) Failing after 15s
Sync Wiki / sync-wiki (push) Successful in 1m43s
2026-06-22 00:00:01 +02:00
emil 3f5808d6be GRM-42: fix: rewrite changelog and re-tag releases at user-facing milestones 2026-06-21 21:58:29 +00:00
emil 60c94b2b93 GRM-42: fix: clean up infrastructure-only releases and fix release classification 2026-06-21 21:46:37 +00:00
emil 2ca56ed317 GRM-41: feat: enforce commit naming conventions and workflow discipline 2026-06-21 21:33:01 +00:00
10 changed files with 210 additions and 138 deletions
+27
View File
@@ -94,6 +94,33 @@ jobs:
echo "No user-facing files changed — skipping release dry-run."
fi
validate-merge:
if: github.event_name == 'push'
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Validate commit message format
run: |
set -euo pipefail
MSG=$(git log -1 --pretty=%s)
echo "Commit message: $MSG"
# Allowed formats:
# GRM-N <type>: <description> (squash-merge)
# release: vX.Y.Z (release commits)
# GRM-N <type>: <description> (#M) (squash-merge with PR ref)
if echo "$MSG" | grep -qE '^GRM-[0-9]+ [a-z]+: .+'; then
echo "OK: GRM-N <conventional> format"
elif echo "$MSG" | grep -qE '^release: v[0-9]+\.[0-9]+\.[0-9]+'; then
echo "OK: release commit format"
else
echo "FAIL: commit message does not follow naming convention"
echo "Expected: GRM-N <type>: <description> or release: vX.Y.Z"
echo "Got: $MSG"
exit 1
fi
discover-runners:
needs: [detect-changes]
if: needs.detect-changes.outputs.ansible-changed == 'true'
+8 -2
View File
@@ -103,13 +103,19 @@ REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
```
Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format and match against Vikunja task title
1. **Validate** PR title format (`GRM-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one APPROVE review exists
3. Wait for all CI checks to pass
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated, no colon after GRM-N)
5. The post-merge workflow marks the Vikunja task as done
6. 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-merge` label. Manual merges bypass the
> `GRM-N <conventional>` format enforcement, producing incorrectly named commits.
> The CI `validate-merge` job checks every push to master and will fail if a
> commit message doesn't match `GRM-N <type>: <description>` or `release: vX.Y.Z`.
### CI Path Filtering
The CI workflow includes a `detect-changes` job that checks whether any files
+118 -107
View File
@@ -2,147 +2,158 @@
All notable changes to this project will be documented in this file.
## [0.4.0] - 2026-06-21
## [0.5.0] - 2026-06-21
### Features
- Add mandatory PR review step to workflow
- Add automated semver versioning, tagging, and releases with git-cliff
- Fix 12 critical workflow gaps in release pipeline
- Implement documentation-as-code with wiki sync and doc-coverage
- Smart CI and release skipping for workflow-only changes
- Enforce commit naming conventions and workflow discipline
### Bug Fixes
- Move release commit skip check into release.py
- Install git-cliff to user-writable dir and fix archlinux idempotence
- Use mktemp for git-cliff extraction to avoid file conflicts
- Use full path for git-cliff version check in install step
- Handle same-version update in release.py
- Skip commit when version file unchanged in release.py
- Release push permission and notify_failure label IDs
- Strip git-cliff header from CHANGELOG.md updates
- Enforce tests pass before tagging a release
- Bypass commit-msg hook for release commits
- Use correct Gitea 1.26 wiki API endpoints
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
- Use content_base64 for Gitea wiki API, add --verify flag (#33)
- Wiki links, add --strict integrity check for wiki sync (#34)
### Other
- V0.3.2
- Clean up infrastructure-only releases and fix release classification
- Rewrite changelog and re-tag releases at user-facing milestones
### Refactor
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
## [0.3.2] - 2026-06-21
## [0.4.0] - 2026-06-21
### Bug Fixes
### Rootless Docker Support
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
- Full rootless Docker installation and configuration via Ansible
- `docker_rootless_setup` variable controls whether rootless Docker tasks run
- User setup tasks (subuid/subgid, lingering, dockerd-rootless)
- Proper gating of all Docker-dependent and `systemctl --user` tasks
### Other
### Runner Labels
- Smart CI and release skipping for workflow-only changes
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
- `--labels` option on `grm install` — specify runner labels (e.g., `--labels "ubuntu-latest:docker://node:20"`)
- Labels passed through to runner config YAML
## [0.3.1] - 2026-06-21
### Security Fix (CWE-214)
### Bug Fixes
- **Critical**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line
- Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`
- This prevents secrets from being visible in the process list (`ps aux`)
- Use correct Gitea 1.26 wiki API endpoints
### Configuration via Environment Variables
- API URLs and repo configuration in `config.py` are now overridable via environment variables:
- `GRM_GITEA_API_URL`
- `GRM_VIKUNJA_API_URL`
- `GRM_REPO_OWNER`
- `GRM_REPO_NAME`
- `GRM_VIKUNJA_PROJECT_ID`
### Ansible Role Improvements
- Dead code cleanup (removed `config.yml`, legacy system-level service, duplicate task includes)
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup
- Arch Linux: `gnupg` package name fix, pacman cache handling
- Docker APT repository: deb822 format, proper GPG handling, arch mapping
- Idempotence fixes for user_setup and download tasks
## [0.3.0] - 2026-06-21
### Features
### New CLI Options
- Implement documentation-as-code with wiki sync and doc-coverage
- `--force` flag on `grm remove` — remove a runner even when the host is unreachable (skips Ansible playbook, only deregisters via API)
- `--url` option — override the Gitea URL for any command (useful for multiple Gitea instances)
- `--ask-become-pass` is now the default behavior (no need to pass it explicitly)
## [0.2.2] - 2026-06-21
### Status Detection Fixes
### Bug Fixes
- `grm list` now correctly retrieves runner status (was showing "unknown" for active runners)
- Docker mode status detection via `docker inspect`
- Host/user context added to status output
- Enforce tests pass before tagging a release
- Bypass commit-msg hook for release commits
### Output Improvements
## [0.2.1] - 2026-06-21
- Colorized output for better visual feedback (green/red/yellow)
- Translated operation reports for success and failure cases
- Dual logging: `click.echo()` for user-facing messages, `logging` for debug
- `GRM_LOG_LEVEL` environment variable for controlling verbosity
- Full i18n support (all user-facing strings translated)
### Bug Fixes
### Internal Refactoring
- Strip git-cliff header from CHANGELOG.md updates
- Validation moved from CLI layer to business layer
- Centralized API clients and HTTP status codes
- User-friendly Click errors with i18n
## [0.2.0] - 2026-06-21
### New CLI Commands
- `grm start <host>` — start a runner's systemd service
- `grm stop <host>` — stop a runner's systemd service
- `grm enable <host>` — enable a runner to start on boot
- `grm disable <host>` — disable a runner from starting on boot
- `grm status <host>` — check runner service status
- `grm remove <host>` — deregister and remove a runner
- `grm list-runners` — list all runners from the local registry
### Runner Registry
- Runners are tracked in `~/.config/grm/runners.toml` for simplified CLI usage
- No need to specify `--url`, `--user`, `--key` for every command — the registry remembers
### Multi-Instance Support
- systemd template units (`gitea-runner@.service`) for running multiple runners per host
- Per-instance config and data directories
### Ansible Role Improvements
- Parameterized all hardcoded configuration values as Ansible variables
- Idempotence fixes for repeated runs
- Runner config converted from TOML to YAML format
- Registration timeout to prevent indefinite hangs
- Docker container entrypoint override and working directory fix for `.runner` persistence
## [0.1.0] - 2026-06-21
### Initial Release
The first release of GRM, a lean CLI for managing Gitea Actions runners via SSH.
### CLI Commands
- `grm install <host>` — install and register a Gitea Runner on a remote host via SSH
- `grm token` — generate a registration token via the Gitea API
- `grm list` — list all registered runners
- `grm update <host>` — update a runner to the latest version
### Ansible Role
- Installs Gitea Runner binary in binary or Docker mode
- Registers runner with Gitea instance
- Configures systemd service
- Supports Arch Linux, Ubuntu, and Debian
### Features
- Fix 12 critical workflow gaps in release pipeline
### Bug Fixes
- Release push permission and notify_failure label IDs
## [Unreleased]
### Added
- **Automated semver versioning and releases**: `scripts/release.py` — CI script that uses git-cliff to calculate the next version from conventional commits, update version files, create a release commit, tag, and push.
- `cliff.toml` — git-cliff configuration for conventional commit parsing, semver bumping, and changelog generation.
- Release workflow (`.gitea/workflows/release.yml`) — triggers on push to master, runs `scripts/release.py` to automatically version and tag releases.
- `publish.py` now uses git-cliff to generate release notes for Gitea releases (falls back to generic message if git-cliff is not available).
- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth — release script only updates `__init__.py`).
- **Mandatory PR review step**: `scripts/review_pr.py` — CLI to post Gitea PR reviews (COMMENT, APPROVE, REQUEST_CHANGES) with inline comments via `--comments-json` or `--comments-stdin`.
- `GiteaClient.get_pr_files`, `GiteaClient.get_pr_commits`, `GiteaClient.create_review` — API methods for PR review workflow.
- `VikunjaClient.get_task` — fetch a single task by numeric ID.
- PR title format: `GRM-N: <vikunja task title>` (colon-separated, human-friendly).
- Merge commit format: `GRM-N <conventional commit message>` (space-separated, conventional).
- `auto_merge.py` now extracts the conventional commit message from PR commits and constructs the merge title as `GRM-N <conventional commit>`.
- `post_merge.py` `extract_conventional_msg` now handles both legacy (`GRM-N: <msg>`) and current (`GRM-N <msg>`) merge commit formats.
- Full PR workflow documented in `AGENTS.md` and `README.md` (Vikunja task → branch → implement → commit → PR → review → address comments → approve → merge).
### Changed
- Parameterized all hardcoded configuration values as Ansible variables in `defaults/main.yml`:
- `gitea_runner_data_dir` — Runtime data directory
- `gitea_runner_config_dir` — Config directory
- `gitea_runner_binary_path` — Binary install path
- `gitea_runner_prune_until` — Prune age filter
- `gitea_runner_prune_schedule` — Prune timer schedule
- `gitea_runner_prune_label` — Docker label for pruning
- `gitea_runner_service_restart_sec` — systemd restart interval
- `gitea_runner_service_user` — Service user
- `gitea_runner_log_level` — Runner log level
- `gitea_runner_container_label` — Container label
- `gitea_runner_file` — Runner metadata file
- `docker_gpg_key_path` — Docker GPG key path
- Added `console_scripts` entry point in `pyproject.toml` (`grm = "gitea_runner_manager.cli:cli"`).
- Added shared `molecule/common/prepare.yml` to eliminate duplicated prepare playbooks.
- Extracted repeated systemd availability check into `tasks/systemd_check.yml`.
- Added idempotence checks to all Molecule scenarios.
- Comprehensive README overhaul with Architecture, Configuration, Development, Testing, and Troubleshooting sections.
- API URLs and repo configuration in `config.py` are now overridable via environment variables (`GRM_GITEA_API_URL`, `GRM_VIKUNJA_API_URL`, `GRM_REPO_OWNER`, `GRM_REPO_NAME`, `GRM_VIKUNJA_PROJECT_ID`).
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup.
### Security
- **Critical fix**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line (CWE-214). Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
### Changed
- Replaced legacy runner terminology with `gitea_runner` / `gitea-runner` / `Gitea Runner`.
- Updated default Docker image from `gitea/gitea_runner` to `gitea/runner`.
- `Makefile` now uses the installed `grm` console script instead of `python grm`.
- `pyproject.toml` ruff and pyright target versions updated from `py311` to `py312` to match `requires-python = ">=3.12"`.
- `BRANCH_PROTECTION_CONFIG` updated with correct Gitea Actions status check contexts (including `(pull_request)` suffix) and `required_approvals: 0` for auto-merge.
- `CONVENTIONAL_RE` no longer matches `BREAKING CHANGE` as a commit type (it is a footer, not a type).
- `rootless_docker.yml` apt cache update now only runs when the Docker repo file changes (idempotent, but always refreshes on first add).
- `service.yml` and `prune.yml` template creation tasks are not guarded by `docker_rootless_setup` (templates just create files, they don't need Docker; molecule tests set `docker_rootless_setup: false` but still verify the service file exists).
- `molecule_all.sh` now sources the platform list from `distribute_molecule.py` to avoid duplication.
### Removed
- Deleted `setup.py` (redundant with `pyproject.toml`).
- Deleted `grm` shell entrypoint script (replaced by `console_scripts`).
- Deleted `initial-plan.md` and `tests/integration/test_provision.py` (dead code).
- Removed empty `__init__.py` files from `tests/` directories.
- Removed unused `runner_validated` fact from `validate.yml`.
- Removed duplicate `prune.yml` and `integration_test.yml` includes from `install_runner.yml` (already included from `main.yml`).
- Removed dead `tasks/config.yml` (never included by any playbook).
- Removed dead `templates/gitea-runner.service.j2` (legacy system-level service, replaced by rootless `gitea-runner-user.service.j2`).
- Removed dead "Reload systemd" handler (system-level reload, never notified, wrong scope for user services).
- Removed dead `scripts/run_molecule_parallel.py` and its test (replaced by `molecule_ci_guard.py`).
### Fixed
- Molecule idempotence failures caused by non-idempotent service restart.
- Missing `/etc/docker` directory handling in Molecule tests.
- `ansible-lint` formatting warnings (yaml empty lines).
- Verify playbooks now explicitly load role defaults so parameterized variables are available during verification.
- Duplicate execution of prune and integration test tasks during installation (were included from both `main.yml` and `install_runner.yml`).
- apt cache update reporting `changed` on every run due to `cache_valid_time: 0`.
- SSH-based remote execution via Ansible
- Automatic registration token generation
- Docker and binary installation modes
- Integration test verification after installation
+10 -9
View File
@@ -46,19 +46,20 @@ commit_preprocessors = [
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->Documentation" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->Miscellaneous Tasks" },
# Skip infrastructure-only commits — they don't affect users
{ message = "^doc", skip = true },
{ message = "^test", skip = true },
{ message = "^style", skip = true },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
# Skip release commits — they are release artifacts, not features
{ message = "^release:", skip = true },
{ body = ".*security", group = "<!-- 8 -->Security" },
{ message = "^revert", group = "<!-- 9 -->Revert" },
{ message = ".*", group = "<!-- 10 -->Other" },
# Skip anything that doesn't match above — safe default
{ message = ".*", skip = true },
]
[bump]
+2 -2
View File
@@ -50,7 +50,7 @@ gitea_runner_manager = ["translations.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src", "."]
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov=scripts/ci --cov-report=term-missing --cov-fail-under=100"
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100"
markers = [
"integration: marks tests as integration tests (not counted in coverage)",
]
@@ -68,6 +68,6 @@ quote-style = "double"
indent-style = "space"
[tool.pyright]
include = ["src", "scripts", "scripts/ci"]
include = ["src", "scripts"]
pythonVersion = "3.12"
strict = ["src/gitea_runner_manager"]
+9 -8
View File
@@ -17,9 +17,8 @@ Classification strategy (safe-by-default):
Workflow-only paths (infrastructure → no release needed):
- .gitea/workflows/** — Gitea Actions workflows
- scripts/ci/**CI/CD automation scripts
- scripts/*.sh — Shell scripts (setup, molecule runners)
- scripts/__init__.py — Package init for scripts
- scripts/** All scripts (CI/CD, dev tools, setup)
- src/gitea_runner_manager/__init__.py — Version file (release artifact)
- docs/** — Documentation
- tests/** — Test files
- hooks/** — Git hooks
@@ -38,7 +37,7 @@ Classification strategy (safe-by-default):
Everything else is user-facing (tool changes → release needed),
including but not limited to:
- src/gitea_runner_manager/** — Python CLI source
- src/gitea_runner_manager/*.py — Python CLI source (except __init__.py)
- ansible/** — Ansible role
- pyproject.toml — Package metadata
- Any new file type not in the allowlist
@@ -63,10 +62,12 @@ WORKFLOW_ONLY_PATTERNS = frozenset(
[
# CI/CD infrastructure
".gitea/",
"scripts/ci/",
"scripts/setup.sh",
"scripts/molecule_all.sh",
"scripts/__init__.py",
# All scripts are infrastructure (CI/CD, dev tools, setup)
# User-facing code lives in src/gitea_runner_manager/
"scripts/",
# Version file — only contains __version__, not user-facing code.
# Version bumps are a release artifact, not a feature.
"src/gitea_runner_manager/__init__.py",
# Documentation
"docs/",
"AGENTS.md",
+13 -2
View File
@@ -78,8 +78,19 @@ def main(commit_msg: str, commit_sha: str) -> None:
task_id = extract_task_id(commit_msg)
if not task_id:
click.echo(_("No task ID in commit message, skipping Vikunja update. All good — nothing to do here!"))
return
# Allow release commits without GRM-N prefix
first_line = commit_msg.split("\n")[0]
if re.match(r"^release: v\d+\.\d+\.\d+", first_line):
click.echo(_("Release commit without task ID, skipping Vikunja update."))
return
# Non-release commits must have GRM-N prefix — fail loudly
raise click.ClickException(
_(
"No task ID (GRM-N) found in commit message: {msg}\n"
"All non-release commits on master must follow format: GRM-N <type>: <description>",
msg=first_line,
)
)
client = VikunjaClient(VIKUNJA_API_URL, token)
vikunja_task_id = 0
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.4.0"
__version__ = "0.5.0"
+11 -6
View File
@@ -34,12 +34,12 @@ class TestIsUserFacing:
def test_ci_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/ci/release.py") is False
def test_dev_scripts_are_user_facing(self) -> None:
"""Dev scripts (check_test_speed, configure_repo) are NOT in the
workflow-only allowlist, so they default to user-facing."""
assert is_user_facing("scripts/check_test_speed.py") is True
assert is_user_facing("scripts/configure_repo.py") is True
assert is_user_facing("scripts/install_checkmake.py") is True
def test_dev_scripts_are_not_user_facing(self) -> None:
"""All scripts under scripts/ are infrastructure (CI/CD, dev tools).
User-facing code lives in src/gitea_runner_manager/."""
assert is_user_facing("scripts/check_test_speed.py") is False
assert is_user_facing("scripts/configure_repo.py") is False
assert is_user_facing("scripts/install_checkmake.py") is False
def test_shell_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/setup.sh") is False
@@ -48,6 +48,11 @@ class TestIsUserFacing:
def test_scripts_init_is_not_user_facing(self) -> None:
assert is_user_facing("scripts/__init__.py") is False
def test_version_file_is_not_user_facing(self) -> None:
"""__init__.py only contains __version__ — a release artifact,
not user-facing code. Version bumps alone should not trigger releases."""
assert is_user_facing("src/gitea_runner_manager/__init__.py") is False
def test_docs_are_not_user_facing(self) -> None:
assert is_user_facing("docs/user/getting-started.md") is False
+11 -1
View File
@@ -130,10 +130,20 @@ class TestMain:
assert "VIKUNJA_TOKEN" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_task_id_skips(self) -> None:
def test_no_task_id_non_release_fails(self) -> None:
"""Non-release commits without GRM-N prefix should fail."""
runner = CliRunner()
result = runner.invoke(main, ["fix: resolve bug"])
assert result.exit_code == 1
assert "No task ID" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_release_commit_without_task_id_skips(self) -> None:
"""Release commits without GRM-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["release: v0.3.2"])
assert result.exit_code == 0
assert "Release commit" in result.output
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})