Compare commits

...
5 Commits
Author SHA1 Message Date
grm-ci-bot 7fe85423b4 release: v0.2.2
Publish Release / publish (push) Failing after 25s
2026-06-21 21:17:11 +02:00
emil 76d9983514 GRM-35: fix: bypass commit-msg hook for release commits
Release commits use --no-verify to bypass the commit-msg hook since they are generated by the release script, not by a developer.

Closes GRM-35
2026-06-21 19:15:55 +00:00
emil 3dcdde80ad GRM-35: fix: enforce tests pass before tagging a release
The release script now runs lint and tests before committing or tagging. If either fails, the release is aborted. Also fixes test_cli_version to use __version__ dynamically.

Closes GRM-35
2026-06-21 19:10:30 +00:00
grm-ci-bot 2e5ca5a88f release: v0.2.1
Publish Release / publish (push) Failing after 25s
2026-06-21 19:53:32 +02:00
emil e7f8e4ac66 GRM-35: fix: strip git-cliff header from CHANGELOG.md updates
The update_changelog function now strips the git-cliff header before inserting into CHANGELOG.md, preventing duplicate headers.

Closes GRM-35
2026-06-21 17:53:18 +00:00
7 changed files with 241 additions and 14 deletions
+4 -2
View File
@@ -12,6 +12,8 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.REPO_TOKEN }}
- name: Set up environment
run: make setup
- name: Install git-cliff - name: Install git-cliff
run: | run: |
GIT_CLIFF_VERSION="2.13.0" GIT_CLIFF_VERSION="2.13.0"
@@ -23,8 +25,6 @@ jobs:
chmod +x "$HOME/.local/bin/git-cliff" chmod +x "$HOME/.local/bin/git-cliff"
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/git-cliff" --version "$HOME/.local/bin/git-cliff" --version
- name: Install Python dependencies
run: python3 -m pip install --break-system-packages requests python-dotenv click
- name: Configure git - name: Configure git
run: | run: |
git config user.name "grm-ci-bot" git config user.name "grm-ci-bot"
@@ -33,6 +33,7 @@ jobs:
env: env:
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate
python3 scripts/release.py python3 scripts/release.py
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
@@ -40,6 +41,7 @@ jobs:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate
python3 scripts/notify_failure.py \ python3 scripts/notify_failure.py \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
+4
View File
@@ -124,13 +124,17 @@ After a PR is merged to master, the release pipeline runs automatically:
1. **Release workflow** (`.gitea/workflows/release.yml`): 1. **Release workflow** (`.gitea/workflows/release.yml`):
- Triggers on push to master - Triggers on push to master
- Sets up full dev environment (`make setup`) so lint and tests can run
- Runs `scripts/release.py` which uses **git-cliff** to: - Runs `scripts/release.py` which uses **git-cliff** to:
- Calculate the next semver version from conventional commits since the last tag - Calculate the next semver version from conventional commits since the last tag
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth) - Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
- Update `CHANGELOG.md` with the new version section - Update `CHANGELOG.md` with the new version section
- **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
- If lint or tests fail, **abort immediately** — no commit, no tag
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`) - Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
- Create an annotated tag `vX.Y.Z` on the release commit - Create an annotated tag `vX.Y.Z` on the release commit
- Push both the commit and tag to master - Push both the commit and tag to master
- `--skip-tests` flag 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 - Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
- On failure, creates a Gitea issue via `scripts/notify_failure.py` - On failure, creates a Gitea issue via `scripts/notify_failure.py`
+11 -2
View File
@@ -2,9 +2,18 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
# Changelog ## [0.2.2] - 2026-06-21
All notable changes to this project will be documented in this file. ### Bug Fixes
- Enforce tests pass before tagging a release
- Bypass commit-msg hook for release commits
## [0.2.1] - 2026-06-21
### Bug Fixes
- Strip git-cliff header from CHANGELOG.md updates
## [0.2.0] - 2026-06-21 ## [0.2.0] - 2026-06-21
+62 -6
View File
@@ -8,6 +8,12 @@ source of truth, read by setuptools via ``dynamic = ["version"]``) and
with the changelog as the tag message, and pushes both to trigger the publish with the changelog as the tag message, and pushes both to trigger the publish
workflow. workflow.
**Test enforcement**: Before committing or tagging, the script runs
``make lint-ruff`` and ``make pytest-cov`` to verify the release is healthy.
If either fails, the release is aborted — no commit, no tag. This ensures
we never release a version that fails tests. Use ``--skip-tests`` only for
emergency releases (not recommended).
The ``release:`` prefix (instead of ``chore(release):``) keeps the history The ``release:`` prefix (instead of ``chore(release):``) keeps the history
clean while still being descriptive. Loops are prevented by the clean while still being descriptive. Loops are prevented by the
``has_unreleased_changes`` check — after a release commit is tagged, the next ``has_unreleased_changes`` check — after a release commit is tagged, the next
@@ -18,7 +24,7 @@ last tag, it exits with a message and does nothing. If the tag already exists
(e.g., from a partial previous run), it skips tag creation and only pushes. (e.g., from a partial previous run), it skips tag creation and only pushes.
Usage: Usage:
REPO_TOKEN=<token> python3 scripts/release.py [--dry-run] REPO_TOKEN=<token> python3 scripts/release.py [--dry-run] [--skip-tests]
""" """
from __future__ import annotations from __future__ import annotations
@@ -138,10 +144,15 @@ def update_init_version(new_version: str) -> None:
def update_changelog(changelog: str) -> None: def update_changelog(changelog: str) -> None:
"""Prepend the new changelog section to CHANGELOG.md. """Prepend the new changelog section to CHANGELOG.md.
If the file doesn't exist, create it with the changelog as the sole content. The changelog from git-cliff may include a header (e.g., "# Changelog").
If it exists, insert the new version section after the header (before the This function strips everything before the first ``## [`` version section
first existing version section). before inserting, to avoid duplicating the header.
""" """
# Strip git-cliff header — keep only from the first version section
section_match = re.search(r"^## \[", changelog, flags=re.MULTILINE)
if section_match:
changelog = changelog[section_match.start() :]
try: try:
with open(CHANGELOG_FILE) as f: with open(CHANGELOG_FILE) as f:
existing = f.read() existing = f.read()
@@ -167,6 +178,9 @@ def commit_release_changes(new_version: str) -> bool:
"""Stage version file and changelog, then create a release commit. """Stage version file and changelog, then create a release commit.
Uses ``release:`` prefix (not ``chore(release):``) for clarity. Uses ``release:`` prefix (not ``chore(release):``) for clarity.
The commit is created with ``--no-verify`` to bypass the commit-msg hook
(which requires ``GRM-N:`` prefix for master commits) since release
commits are a special case generated by the release script.
Returns True if a commit was created, False if there were no staged changes. Returns True if a commit was created, False if there were no staged changes.
""" """
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE]) run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
@@ -174,10 +188,39 @@ def commit_release_changes(new_version: str) -> bool:
if status.returncode == 0: if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date.")) click.echo(_("No staged changes — version and changelog already up to date."))
return False return False
run_cmd(["git", "commit", "-m", f"release: v{new_version}"]) run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
return True return True
def run_tests() -> None:
"""Run lint and tests to verify the release is healthy.
This is called *after* version files are updated but *before* the tag is
created, ensuring we never tag a release that fails tests.
"""
click.echo(_("Running lint checks..."))
lint = run_cmd(["make", "lint-ruff"], check=False)
if lint.returncode != 0:
raise click.ClickException(
_(
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
stderr=lint.stderr.strip() if lint.stderr else lint.stdout.strip(),
)
)
click.echo(_("Lint passed."))
click.echo(_("Running tests..."))
tests = run_cmd(["make", "pytest-cov"], check=False)
if tests.returncode != 0:
raise click.ClickException(
_(
"Tests failed — refusing to release. Fix test failures first.\n{stderr}",
stderr=tests.stderr.strip() if tests.stderr else tests.stdout.strip(),
)
)
click.echo(_("Tests passed."))
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool: def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
"""Create an annotated tag with the changelog as message and push it. """Create an annotated tag with the changelog as message and push it.
@@ -201,7 +244,13 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
@click.command() @click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.") @click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
def main(dry_run: bool) -> None: @click.option(
"--skip-tests",
is_flag=True,
default=False,
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
)
def main(dry_run: bool, skip_tests: bool) -> None:
# Ensure we're on master # Ensure we're on master
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
if branch != "master": if branch != "master":
@@ -246,6 +295,13 @@ def main(dry_run: bool) -> None:
update_changelog(changelog) update_changelog(changelog)
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE)) click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
# Verify tests pass BEFORE committing or tagging.
# This ensures we never release a version that fails tests.
if skip_tests:
click.echo(_("WARNING: --skip-tests passed — skipping test verification."))
else:
run_tests()
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):') # Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
committed = commit_release_changes(new_version) committed = commit_release_changes(new_version)
if committed: if committed:
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners.""" """Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.2.0" __version__ = "0.2.2"
+2 -1
View File
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
from click.testing import CliRunner from click.testing import CliRunner
from gitea_runner_manager import __version__
from gitea_runner_manager.cli import cli from gitea_runner_manager.cli import cli
@@ -12,7 +13,7 @@ class TestCLI:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--version"]) result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "0.1.0" in result.output assert __version__ in result.output
@patch("gitea_runner_manager.cli.RunnerManager") @patch("gitea_runner_manager.cli.RunnerManager")
def test_install(self, mock_manager_class: MagicMock) -> None: def test_install(self, mock_manager_class: MagicMock) -> None:
+157 -2
View File
@@ -15,6 +15,7 @@ from scripts.release import (
has_unreleased_changes, has_unreleased_changes,
main, main,
run_cmd, run_cmd,
run_tests,
tag_exists, tag_exists,
update_changelog, update_changelog,
update_init_version, update_init_version,
@@ -186,6 +187,30 @@ class TestUpdateChangelog:
assert "Some intro text" in content assert "Some intro text" in content
assert "## [0.2.0]" in content assert "## [0.2.0]" in content
def test_strips_git_cliff_header(self, tmp_path, monkeypatch) -> None:
"""git-cliff output includes a header — should be stripped before inserting."""
changelog_file = tmp_path / "CHANGELOG.md"
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file))
# Simulate git-cliff output with header
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
update_changelog(cliff_output)
content = changelog_file.read_text()
# Header should appear only once (from the existing file)
assert content.count("# Changelog") == 1
assert "## [0.2.0]" in content
assert "new thing" in content
def test_strips_header_when_creating_new_file(self, tmp_path, monkeypatch) -> None:
"""When creating a new file, strip the git-cliff header."""
changelog_file = tmp_path / "CHANGELOG.md"
monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file))
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
update_changelog(cliff_output)
content = changelog_file.read_text()
assert "# Changelog" not in content
assert "## [0.2.0]" in content
class TestCommitReleaseChanges: class TestCommitReleaseChanges:
@patch("scripts.release.run_cmd") @patch("scripts.release.run_cmd")
@@ -196,7 +221,7 @@ class TestCommitReleaseChanges:
assert result is True assert result is True
calls = [c.args[0] for c in mock_run_cmd.call_args_list] calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "-m", "release: v0.2.0"] in calls assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
@patch("scripts.release.run_cmd") @patch("scripts.release.run_cmd")
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None: def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
@@ -205,7 +230,7 @@ class TestCommitReleaseChanges:
result = commit_release_changes("0.1.0") result = commit_release_changes("0.1.0")
assert result is False assert result is False
calls = [c.args[0] for c in mock_run_cmd.call_args_list] calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "commit", "-m", "release: v0.1.0"] not in calls assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls
class TestCreateAndPushTag: class TestCreateAndPushTag:
@@ -245,6 +270,28 @@ class TestCreateAndPushTag:
mock_run_cmd.assert_not_called() mock_run_cmd.assert_not_called()
class TestRunTests:
@patch("scripts.release.run_cmd")
def test_lint_and_tests_pass(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
run_tests() # should not raise
@patch("scripts.release.run_cmd")
def test_lint_fails_raises(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="lint error")
with pytest.raises(click.ClickException, match="Lint failed"):
run_tests()
@patch("scripts.release.run_cmd")
def test_tests_fail_raises(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # lint passes
MagicMock(returncode=1, stdout="", stderr="test failure"), # tests fail
]
with pytest.raises(click.ClickException, match="Tests failed"):
run_tests()
class TestMain: class TestMain:
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("scripts.release.run_cmd") @patch("scripts.release.run_cmd")
@@ -327,6 +374,7 @@ class TestMain:
mock_tag.assert_not_called() mock_tag.assert_not_called()
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("scripts.release.run_tests")
@patch("scripts.release.create_and_push_tag", return_value=True) @patch("scripts.release.create_and_push_tag", return_value=True)
@patch("scripts.release.commit_release_changes", return_value=True) @patch("scripts.release.commit_release_changes", return_value=True)
@patch("scripts.release.update_changelog") @patch("scripts.release.update_changelog")
@@ -347,6 +395,7 @@ class TestMain:
mock_update_changelog: MagicMock, mock_update_changelog: MagicMock,
mock_commit: MagicMock, mock_commit: MagicMock,
mock_tag: MagicMock, mock_tag: MagicMock,
mock_run_tests: MagicMock,
) -> None: ) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner() runner = CliRunner()
@@ -355,10 +404,12 @@ class TestMain:
assert "Bumping version" in result.output assert "Bumping version" in result.output
mock_update_init.assert_called_once_with("0.2.0") mock_update_init.assert_called_once_with("0.2.0")
mock_update_changelog.assert_called_once_with("changelog") mock_update_changelog.assert_called_once_with("changelog")
mock_run_tests.assert_called_once()
mock_commit.assert_called_once_with("0.2.0") mock_commit.assert_called_once_with("0.2.0")
mock_tag.assert_called_once_with("0.2.0", "changelog", False) mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("scripts.release.run_tests")
@patch("scripts.release.create_and_push_tag", return_value=False) @patch("scripts.release.create_and_push_tag", return_value=False)
@patch("scripts.release.commit_release_changes", return_value=False) @patch("scripts.release.commit_release_changes", return_value=False)
@patch("scripts.release.update_changelog") @patch("scripts.release.update_changelog")
@@ -379,6 +430,7 @@ class TestMain:
mock_update_changelog: MagicMock, mock_update_changelog: MagicMock,
mock_commit: MagicMock, mock_commit: MagicMock,
mock_tag: MagicMock, mock_tag: MagicMock,
mock_run_tests: MagicMock,
) -> None: ) -> None:
"""When tag already exists, still update files but report existing tag.""" """When tag already exists, still update files but report existing tag."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -387,3 +439,106 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "already existed" in result.output assert "already existed" in result.output
mock_tag.assert_called_once_with("0.1.0", "changelog", False) mock_tag.assert_called_once_with("0.1.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("scripts.release.create_and_push_tag", return_value=True)
@patch("scripts.release.commit_release_changes", return_value=True)
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
def test_full_flow_skip_tests(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
) -> None:
"""--skip-tests bypasses test verification."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--skip-tests"])
assert result.exit_code == 0
assert "WARNING: --skip-tests" in result.output
# run_tests should NOT be called — verify no "make lint-ruff" or "make pytest-cov" calls
make_calls = [c.args[0] for c in mock_run_cmd.call_args_list if c.args[0][:1] == ["make"]]
assert make_calls == []
@patch.dict("os.environ", {})
@patch("scripts.release.create_and_push_tag")
@patch("scripts.release.commit_release_changes")
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
def test_tests_fail_aborts_before_tag(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
) -> None:
"""If tests fail, release aborts — no commit, no tag."""
# First call: git rev-parse (master), then make lint-ruff (success),
# then make pytest-cov (failure)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr="test failure"),
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Tests failed" in result.output
mock_commit.assert_not_called()
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("scripts.release.create_and_push_tag")
@patch("scripts.release.commit_release_changes")
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
def test_lint_fail_aborts_before_tag(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
) -> None:
"""If lint fails, release aborts — no commit, no tag."""
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr="lint error"),
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Lint failed" in result.output
mock_commit.assert_not_called()
mock_tag.assert_not_called()