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