Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions goal/push/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
26 changes: 26 additions & 0 deletions project/ticket-210/README.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 83 additions & 0 deletions project/ticket-210/intent.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
66 changes: 66 additions & 0 deletions tests/test_push_workflow_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading