GRM-35: feat: fix 12 critical workflow gaps in release pipeline

Addresses all 12 critical gaps in the automated semantic versioning, tagging, and release workflow.

Closes GRM-35
This commit is contained in:
emil
2026-06-21 17:44:27 +00:00
parent ea6276ff38
commit 996a8dc806
14 changed files with 808 additions and 143 deletions
+75 -18
View File
@@ -22,8 +22,15 @@ from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import CONVENTIONAL_RE, GITEA_API_URL, TASK_ID_RE
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient
from gitea_runner_manager.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
GITEA_API_URL,
TASK_ID_RE,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
@@ -61,15 +68,62 @@ def validate_pr_title(pr_title: str, task_id: str) -> None:
if not pr_title.startswith(f"{task_id}:"):
raise click.ClickException(
_(
"Oops! PR title task ID mismatch.\n"
" Branch task ID: {task_id}\n"
" PR title: {pr_title}",
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
task_id=task_id,
pr_title=pr_title,
)
)
def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given GRM-N identifier.
Returns empty string if VIKUNJA_TOKEN is not set (skip validation).
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return ""
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return str(matches[0].get("title", ""))
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
return ""
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
"""Validate that PR title matches the Vikunja task title.
Skips validation if VIKUNJA_TOKEN is not set.
"""
vikunja_title = get_vikunja_task_title(task_id)
if not vikunja_title:
click.echo(_("Warning: could not fetch Vikunja task title, skipping title match validation."))
return
expected = f"{task_id}: {vikunja_title}"
if pr_title != expected:
raise click.ClickException(
_(
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
expected=expected,
pr_title=pr_title,
)
)
def has_approval_review(client: GiteaClient, pr_number: str) -> bool:
"""Check whether the PR has at least one APPROVE review."""
reviews = client.get_pr_reviews(pr_number)
return any(r.get("state") == "APPROVED" for r in reviews)
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
"""Extract the conventional commit message from PR commits.
@@ -128,15 +182,9 @@ def wait_for_ci(
pending = [ctx for ctx, s in ci_statuses.items() if s.get("status") in ("pending", "waiting")]
if not pending:
# All CI checks are complete — check if they all succeeded.
failed = [
ctx
for ctx, s in ci_statuses.items()
if s.get("status") not in ("success", "ok")
]
failed = [ctx for ctx, s in ci_statuses.items() if s.get("status") not in ("success", "ok")]
if failed:
click.echo(
_("CI checks failed: {failed}", failed=", ".join(sorted(failed)))
)
click.echo(_("CI checks failed: {failed}", failed=", ".join(sorted(failed))))
return False
click.echo(_("All CI checks passed."))
return True
@@ -184,6 +232,18 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str)
)
validate_pr_title(pr_title, task_id)
validate_pr_title_matches_vikunja(pr_title, task_id)
# Enforce APPROVE review before merge (Gap 2 fix)
if not has_approval_review(client, pr_number):
raise click.ClickException(
_(
"Cannot merge: PR #{pr_number} has no APPROVE review. "
"Please review and approve before adding the ready-to-merge label.",
pr_number=pr_number,
)
)
click.echo(_("PR has at least one APPROVE review."))
# Wait for CI checks to complete before attempting merge.
pr = client.get_pr(pr_number)
@@ -201,9 +261,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str)
commits = client.get_pr_commits(pr_number)
conv_msg = extract_conventional_msg(commits)
if not conv_msg:
raise click.ClickException(
_("Could not extract conventional commit message from PR commits.")
)
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
merge_title = f"{task_id} {conv_msg}"
try:
@@ -211,8 +269,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str)
except APIError as e:
raise click.ClickException(
_(
"Merge failed with HTTP {status}: {message}\n"
"Please check the PR is ready and you have merge rights.",
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
status=e.status,
message=e.message,
)
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Create a Gitea issue when a CI workflow fails.
Used by the release and publish workflows to alert on failures that would
otherwise go unnoticed in the Actions tab.
Usage:
REPO_TOKEN=<token> python3 scripts/notify_failure.py \
--repo <owner/repo> \
--run-id <run_id> \
--workflow <workflow_name> \
--commit <commit_sha>
"""
from __future__ import annotations
import os
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
load_dotenv(override=True)
@click.command()
@click.option("--repo", required=True, help="Repository in owner/name format.")
@click.option("--run-id", required=True, help="CI run ID.")
@click.option("--workflow", required=True, help="Workflow name.")
@click.option("--commit", required=True, help="Commit SHA.")
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = (
f"The **{workflow}** workflow failed.\n\n"
f"- **Run ID**: #{run_id}\n"
f"- **Commit**: `{commit[:8]}`\n"
f"- **Check the logs**: {GITEA_API_URL.replace('/api/v1', '')}/"
f"{repo}/actions/runs/{run_id}\n\n"
f"Please investigate and fix the issue."
)
try:
issue = client.create_issue(title=title, body=body, labels=["bug"])
except APIError as e:
# If labels don't exist, retry without labels
if e.status == 404:
issue = client.create_issue(title=title, body=body)
else:
raise click.ClickException(
_("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message)
) from None
click.echo(
_(
"Created issue #{issue_id}: {title}",
issue_id=issue.get("id", "?"),
title=title,
)
)
if __name__ == "__main__": # pragma: no cover
main()
+112 -49
View File
@@ -3,12 +3,19 @@
Uses git-cliff to determine the next semver version from conventional commits
since the last tag. Updates ``__version__`` in ``__init__.py`` (the single
source of truth, read by setuptools via ``dynamic = ["version"]``), creates a
release commit, tags it with the changelog as the tag message, and pushes the
tag to trigger the publish workflow.
source of truth, read by setuptools via ``dynamic = ["version"]``) and
``CHANGELOG.md``, commits them with a ``release:`` prefix, tags the commit
with the changelog as the tag message, and pushes both to trigger the publish
workflow.
The ``release:`` prefix (instead of ``chore(release):``) keeps the history
clean while still being descriptive. Loops are prevented by the
``has_unreleased_changes`` check — after a release commit is tagged, the next
run finds no unreleased changes and exits.
This script is idempotent: if there are no new conventional commits since the
last tag, it exits with a message and does nothing.
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.
Usage:
REPO_TOKEN=<token> python3 scripts/release.py [--dry-run]
@@ -27,6 +34,7 @@ from gitea_runner_manager.i18n import _
load_dotenv(override=True)
INIT_FILE = "src/gitea_runner_manager/__init__.py"
CHANGELOG_FILE = "CHANGELOG.md"
CLIFF_CONFIG = "cliff.toml"
@@ -57,6 +65,12 @@ def get_latest_tag() -> str:
return result.stdout.strip()
def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False)
return bool(result.stdout.strip())
def get_bumped_version() -> str:
"""Use git-cliff to calculate the next version from conventional commits."""
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
@@ -83,20 +97,25 @@ def get_changelog(new_version: str) -> str:
return result.stdout.strip()
def has_unreleased_changes() -> bool:
"""Check if there are conventional commits since the last tag."""
result = run_cmd(
["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG],
check=False,
)
if result.returncode != 0:
return False
def has_unreleased_changes(bumped_version: str | None = None) -> bool:
"""Check if there are conventional commits since the last tag.
If ``bumped_version`` is provided (from a prior git-cliff call), reuses it
to avoid a duplicate subprocess invocation.
"""
if bumped_version is None:
result = run_cmd(
["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG],
check=False,
)
if result.returncode != 0:
return False
bumped_version = result.stdout.strip().lstrip("v")
latest = get_latest_tag()
if not latest:
return True
bumped = result.stdout.strip().lstrip("v")
current = latest.lstrip("v")
return bumped != current
return bumped_version != current
def update_init_version(new_version: str) -> None:
@@ -116,32 +135,68 @@ def update_init_version(new_version: str) -> None:
f.write(updated)
def create_release_commit(new_version: str) -> bool:
"""Stage version file and create a release commit.
def update_changelog(changelog: str) -> None:
"""Prepend the new changelog section to CHANGELOG.md.
Returns True if a commit was created, False if there were no changes
(e.g., version file already at the target version).
If the file doesn't exist, create it with the changelog as the sole content.
If it exists, insert the new version section after the header (before the
first existing version section).
"""
run_cmd(["git", "add", INIT_FILE])
# Check if there are staged changes
try:
with open(CHANGELOG_FILE) as f:
existing = f.read()
except FileNotFoundError:
with open(CHANGELOG_FILE, "w") as f:
f.write(changelog + "\n")
return
# Find the first version section header (## [...] or ## [unreleased])
match = re.search(r"^## \[", existing, flags=re.MULTILINE)
if match:
# Insert before the first version section
pos = match.start()
updated = existing[:pos] + changelog + "\n\n" + existing[pos:]
else:
# No version sections found — append
updated = existing.rstrip() + "\n\n" + changelog + "\n"
with open(CHANGELOG_FILE, "w") as f:
f.write(updated)
def commit_release_changes(new_version: str) -> bool:
"""Stage version file and changelog, then create a release commit.
Uses ``release:`` prefix (not ``chore(release):``) for clarity.
Returns True if a commit was created, False if there were no staged changes.
"""
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
if status.returncode == 0:
# No staged changes — version file already at target
click.echo(_("Version file already at v{version}, skipping commit.", version=new_version))
click.echo(_("No staged changes — version and changelog already up to date."))
return False
run_cmd(["git", "commit", "-m", f"chore(release): prepare for v{new_version}"])
run_cmd(["git", "commit", "-m", f"release: v{new_version}"])
return True
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> None:
"""Create an annotated tag with the changelog as message and push it."""
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.
Returns True if the tag was created/pushed, False if it already existed.
"""
tag = f"v{new_version}"
if tag_exists(tag):
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
if not dry_run:
# Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", tag], check=False)
return False
tag_msg = f"Release v{new_version}\n\n{changelog}"
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
if dry_run:
click.echo(_("[dry-run] Would push tag {tag}", tag=tag))
return
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", tag])
return True
@click.command()
@@ -152,19 +207,14 @@ def main(dry_run: bool) -> None:
if branch != "master":
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
# Skip release commits to avoid infinite loops
last_msg = run_cmd(["git", "log", "-1", "--pretty=%B"]).stdout.strip()
if last_msg.startswith("chore(release):"):
click.echo(_("Last commit is a release commit. Nothing to do."))
return
# Calculate next version (single git-cliff call — Gap 7 fix)
new_version = get_bumped_version()
# Check for unreleased changes
if not has_unreleased_changes():
# Check for unreleased changes (reuses the version we just calculated)
if not has_unreleased_changes(bumped_version=new_version):
click.echo(_("No unreleased changes found. Nothing to release."))
return
# Calculate next version
new_version = get_bumped_version()
current_tag = get_latest_tag()
click.echo(
_(
@@ -182,7 +232,9 @@ def main(dry_run: bool) -> None:
if dry_run:
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
click.echo(_("[dry-run] Would create commit: chore(release): prepare for v{version}", version=new_version))
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
click.echo(_("[dry-run] Would push commit to master"))
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
return
@@ -190,24 +242,35 @@ def main(dry_run: bool) -> None:
update_init_version(new_version)
click.echo(_("Updated version in {init}", init=INIT_FILE))
# Create release commit (may be skipped if version unchanged)
committed = create_release_commit(new_version)
# Update CHANGELOG.md (Gap 3 fix)
update_changelog(changelog)
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
committed = commit_release_changes(new_version)
if committed:
click.echo(_("Created release commit."))
# Push commit to master
run_cmd(["git", "push", "origin", "master"])
click.echo(_("Pushed release commit to master."))
else:
click.echo(_("Skipping commit push — version unchanged."))
click.echo(_("Skipping commit push — no staged changes."))
# Create and push tag
create_and_push_tag(new_version, changelog, dry_run)
click.echo(
_(
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
version=new_version,
# Create and push tag (Gap 4: handles existing tag)
created = create_and_push_tag(new_version, changelog, dry_run)
if created:
click.echo(
_(
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
version=new_version,
)
)
else:
click.echo(
_(
"Tag v{version} already existed. Publish workflow should already have been triggered.",
version=new_version,
)
)
)
if __name__ == "__main__": # pragma: no cover