From 521eacd0ddeca6b8ce0a50fce9995283933688c9 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sun, 25 Oct 2026 11:08:05 +0100 Subject: [PATCH] fix(application): push the release tag on publish completion (ticket-210) - Governed modes skip the remote release tag before the merge (commit is not on the base branch), and the completion published the registry without any tag, so releases had to be tagged manually and could not serve as release evidence. - On publish completion goal now creates the annotated tag when missing and pushes only that tag through the governed push capability; an existing local tag is reused and push failures are reported without failing the completed publication. - Tests: 15 in the completion module (3 new); full suite green; governance check PASS. --- goal/push/core.py | 46 ++++++++++++-- project/TICKETS.md | 1 + project/ticket-210/README.md | 26 ++++++++ project/ticket-210/intent.json | 83 ++++++++++++++++++++++++++ tests/test_push_workflow_completion.py | 66 ++++++++++++++++++++ 5 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 project/ticket-210/README.md create mode 100644 project/ticket-210/intent.json diff --git a/goal/push/core.py b/goal/push/core.py index 05c036d..cfead9e 100644 --- a/goal/push/core.py +++ b/goal/push/core.py @@ -538,6 +538,38 @@ def _resume_pending_pull_request( click.echo(click.style(f"Pull request: {pr_url}", fg="green")) +def _push_completion_release_tag(delivery: Any, new_version: str) -> bool: + """Create and push the annotated release tag after a completed publish. + + Governed modes skip the remote release tag before the merge because the + commit is not on the base branch yet. Once the publication completed on + the synchronized base, the release is final evidence and the tag belongs + on that HEAD. Only the tag is pushed - no branch is ever moved. + """ + from goal.governance.delivery import authorized_push + + tag_name = f"v{new_version}" + existing = run_git("rev-parse", "-q", "--verify", f"refs/tags/{tag_name}") + if existing.returncode != 0: + created = run_git("tag", "-a", tag_name, "-m", f"Release {new_version}") + if created.returncode != 0: + click.echo( + click.style( + f"⚠ Could not create release tag {tag_name}.", fg="yellow" + ) + ) + return False + with authorized_push(delivery): + pushed = run_git("push", delivery.remote, tag_name, capture=True) + if pushed.returncode != 0: + click.echo( + click.style(f"⚠ Could not push release tag {tag_name}.", fg="yellow") + ) + return False + click.echo(click.style(f"✓ Release tag {tag_name} pushed.", fg="green")) + return True + + def _abort_if_missing_commit_title(commit_title: Optional[str]) -> bool: if commit_title: return False @@ -1223,13 +1255,19 @@ def execute_push_workflow( else: if ctx_obj.get("_publish_completion"): # The completion already finished the cycle on the synchronized - # base (governed policy skipped the remote tag; the registry - # upload is done). A pull-request epilogue has no commit to - # deliver and would only push an empty branch. + # base (the registry upload is done). A pull-request epilogue has + # no commit to deliver and would only push an empty branch. The + # release tag skipped before the merge is created here instead. + tag_pushed = False + if publish_success and not no_tag: + tag_pushed = _push_completion_release_tag(delivery, new_version) + detail = "publish completion on synchronized base" + if tag_pushed: + detail += "; release tag pushed" record_delivery_event( delivery, "published" if publish_success else "publish-skipped", - detail="publish completion on synchronized base", + detail=detail, ) click.echo( click.style( diff --git a/project/TICKETS.md b/project/TICKETS.md index afe9ad2..5f76d04 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -276,4 +276,5 @@ This file indexes governance tickets without taking ownership of | **ticket-207** | [`README.md`](./ticket-207/README.md) | - | - | - | - | - | | **ticket-208** | [`README.md`](./ticket-208/README.md) | - | - | - | - | - | | **ticket-209** | [`README.md`](./ticket-209/README.md) | - | - | - | - | - | +| **ticket-210** | [`README.md`](./ticket-210/README.md) | - | - | - | - | - | diff --git a/project/ticket-210/README.md b/project/ticket-210/README.md new file mode 100644 index 0000000..2c8ded8 --- /dev/null +++ b/project/ticket-210/README.md @@ -0,0 +1,26 @@ +# Ticket 210: Push the release tag on publish completion + +- **ID**: ticket-210 +- **Owner**: ai-claude +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-25 + +## Goal and scope + +Governed modes skip the remote release tag before the merge because the +release commit is not on the base branch yet; the completion published the +registry but left no tag, so releases had to be tagged by hand and the tag +could not serve as release evidence. On publish completion (post-merge, +successful upload) goal now creates the annotated tag when missing and pushes +only that tag under the governed push capability - no branch is ever moved. + +## Acceptance criteria + +- [ ] AC-01: Completion creates an annotated tag when missing and pushes it; an existing local tag is reused without re-creating. +- [ ] AC-02: A tag push failure is reported and does not fail the completed publication. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-210/intent.json b/project/ticket-210/intent.json new file mode 100644 index 0000000..ee95062 --- /dev/null +++ b/project/ticket-210/intent.json @@ -0,0 +1,83 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-210", + "summary": "Push the release tag on publish completion", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "health" + }, + "allowedPaths": [ + "project/ticket-210/**", + "TODO.md", + "project/TICKETS.md", + "goal/push/core.py", + "tests/test_push_workflow_completion.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "ef1c5bb3fd27ebf1baf89c1149c2db2ae88b6f12", + "targetBranch": "main", + "outcome": "Publish completion creates and pushes the annotated release tag (tag-only push under the governed capability), making the release self-evidencing without manual tagging; push failures are reported without failing the completed publication.", + "nonGoals": [ + "No branch pushes or commits on the base branch", + "No change to the pre-merge tag skip", + "No gate or policy changes" + ], + "complexity": "S", + "estimatedMinutes": 25, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Post-publish helper creates the annotated tag when missing and pushes only that ref through authorized_push.", + "components": [ + { + "name": "push-workflow", + "paths": [ + "goal/push/core.py", + "tests/test_push_workflow_completion.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Protected revert of the two files; no data migration." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest -q tests/test_push_workflow_completion.py -k completion_tag" + ], + "evidence": "receipt:goal210/tag-create-push" + }, + { + "criterion": "AC-02", + "commands": [ + "python -m pytest -q tests/test_push_workflow_completion.py", + "./project/governance-check.sh" + ], + "evidence": "receipt:goal210/regression" + } + ] + } +} diff --git a/tests/test_push_workflow_completion.py b/tests/test_push_workflow_completion.py index 9be60d4..b3eecbb 100644 --- a/tests/test_push_workflow_completion.py +++ b/tests/test_push_workflow_completion.py @@ -287,6 +287,72 @@ def test_unreleased_sources_without_prebumped_version_name_the_route(monkeypatch assert recorded == [] +def _fake_git_result(code: int): + from types import SimpleNamespace + + return SimpleNamespace(returncode=code, stdout="", stderr="") + + +def test_completion_pushes_the_release_tag(monkeypatch): + """The tag skipped before the merge is created on the completed base.""" + import contextlib + + calls: list[tuple] = [] + + def fake_run_git(*args, **kwargs): + calls.append(args) + if args[0] == "rev-parse": + return _fake_git_result(1) # tag missing locally + return _fake_git_result(0) + + monkeypatch.setattr(push_core, "run_git", fake_run_git) + monkeypatch.setattr( + "goal.governance.delivery.authorized_push", + lambda delivery: contextlib.nullcontext(), + ) + + assert push_core._push_completion_release_tag(_pull_request_policy(), "2.2.4") is True + assert ("tag", "-a", "v2.2.4", "-m", "Release 2.2.4") in calls + assert ("push", "origin", "v2.2.4") in calls + + +def test_completion_tag_reuses_an_existing_local_tag(monkeypatch): + import contextlib + + calls: list[tuple] = [] + + def fake_run_git(*args, **kwargs): + calls.append(args) + return _fake_git_result(0) # tag already exists + + monkeypatch.setattr(push_core, "run_git", fake_run_git) + monkeypatch.setattr( + "goal.governance.delivery.authorized_push", + lambda delivery: contextlib.nullcontext(), + ) + + assert push_core._push_completion_release_tag(_pull_request_policy(), "2.2.4") is True + assert not any(call[0] == "tag" for call in calls) + assert ("push", "origin", "v2.2.4") in calls + + +def test_completion_tag_reports_push_failure(monkeypatch): + import contextlib + + def fake_run_git(*args, **kwargs): + if args[0] == "push": + return _fake_git_result(1) + return _fake_git_result(0) + + monkeypatch.setattr(push_core, "run_git", fake_run_git) + monkeypatch.setattr( + "goal.governance.delivery.authorized_push", + lambda delivery: contextlib.nullcontext(), + ) + + assert push_core._push_completion_release_tag(_pull_request_policy(), "2.2.4") is False + + def test_registry_publication_pending_when_tree_ahead_of_registry(monkeypatch, tmp_path): """A publish-only release left no tag: the registry decides, not the diff.""" from types import SimpleNamespace