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:
+75
-18
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user