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
6 changes: 5 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
- [x] Deliver [ticket-017](project/ticket-017/README.md): assign the remaining
atomic release metadata to integration before ticket 012 publishes. State:
`DONE / PUBLICATION`; classification: `SERVICE / governance`.
- [ ] Deliver [ticket-018](project/ticket-018/README.md): infer the synchronized
version transition after a stale tag so post-publication metadata delivery
does not select a duplicate patch. State: `IN_PROGRESS / EDIT`;
classification: `BUG / application`.
- [ ] After governance bootstrap, execute the sequential phases defined in
[the refactoring plan](docs/GOAL_KORU_SUBACTOR_REFACTORING_PLAN.md), with one
narrowly scoped ticket active at a time.
Expand All @@ -60,7 +64,7 @@
> `ticket-006 DONE`; `ticket-007 DONE`; `ticket-009 DONE`.
> `ticket-010 DONE`; `ticket-011 DONE`; `ticket-012 IN_PROGRESS`;
> `ticket-013 DONE`; `ticket-014 CANCELLED`; `ticket-015 DONE`;
> `ticket-016 DONE`; `ticket-017 DONE`.
> `ticket-016 DONE`; `ticket-017 DONE`; `ticket-018 IN_PROGRESS`.

> **Recently shipped (manual note):** `goal all [PATHS...]` monorepo sweep —
> runs `goal -a` in every git repo with uncommitted changes under the given
Expand Down
50 changes: 50 additions & 0 deletions goal/cli/version_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,56 @@ def _version_at_ref(source: VersionSource, ref: str) -> Optional[str]:
return None if contract or value is None else normalize_version(value)


def detect_version_transition_boundary(
sources: Iterable[VersionSource], base_ref: str
) -> Optional[str]:
"""Find the first commit where all managed carriers reached HEAD's version.

A publish-only delivery can leave the latest release tag behind the version
already present in the registry. The synchronized version transition then
provides a safe lower bound for committed-source analysis. Ambiguous or
incomplete history deliberately returns ``None`` so callers can fall back
to the release tag.
"""
managed = tuple(
source for source in sources if source.managed and source.value is not None
)
current_values = {source.value for source in managed}
if len(current_values) != 1:
return None
current = next(iter(current_values))

if all(_version_at_ref(source, base_ref) == current for source in managed):
return None

paths = tuple(dict.fromkeys(source.path for source in managed))
try:
result = subprocess.run(
[
"git",
"rev-list",
"--reverse",
"--topo-order",
f"{base_ref}..HEAD",
"--",
*paths,
],
capture_output=True,
text=True,
)
except OSError:
return None
if result.returncode != 0:
return None

for commit in result.stdout.splitlines():
if commit and all(
_version_at_ref(source, commit) == current for source in managed
):
return commit
return None


def detect_git_history_baseline(sources: Iterable[VersionSource]) -> Optional[str]:
"""Find a lower HEAD/HEAD^ value that proves a local bump already happened."""
local_values = [source.value for source in sources if source.value is not None]
Expand Down
25 changes: 20 additions & 5 deletions goal/publish/changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,24 +261,39 @@ def committed_unreleased_source_files(
*,
base_ref: str | None = None,
) -> list[str]:
"""Package-source files committed since the last release tag.
"""Package-source files committed since the newest safe release evidence.

Staged-file analysis alone misses source changes that are already
committed (an agent or a second `goal -a` run after a manual commit):
the tree is clean, so the release is skipped while the registry stays
behind HEAD. This checks ``<last release tag>..HEAD`` with the same
publishable-path classifier. Returns [] when no tag exists or git fails
(callers keep the conservative skip in that case).
behind HEAD. This starts at the last release tag, or at the synchronized
current-version transition when a publish-only release left that tag
behind. Ambiguous history falls back to the tag. Returns [] when no tag
exists or git fails (callers keep the conservative skip in that case).
"""
registry_types = [t for t in project_types if t in REGISTRY_PROJECT_TYPES]
if not registry_types:
return []
ref = base_ref or _latest_release_tag()
if not ref:
return []
effective_ref = ref
try:
from goal.cli.version_state import (
collect_version_sources,
detect_version_transition_boundary,
)

transition = detect_version_transition_boundary(
collect_version_sources(), ref
)
if transition:
effective_ref = transition
except (OSError, subprocess.SubprocessError, ValueError):
effective_ref = ref
try:
proc = subprocess.run(
["git", "diff", "--name-only", f"{ref}..HEAD"],
["git", "diff", "--name-only", f"{effective_ref}..HEAD"],
capture_output=True,
text=True,
timeout=30,
Expand Down
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ This file indexes governance tickets without taking ownership of
| **ticket-015** | [`README.md`](./ticket-015/README.md) | [`preprompt.md`](./ticket-015/preprompt.md) | - | [`ai-codex.md`](./ticket-015/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-015/ai-codex-logs.txt) | [`changelog.md`](./ticket-015/changelog.md) |
| **ticket-016** | [`README.md`](./ticket-016/README.md) | [`preprompt.md`](./ticket-016/preprompt.md) | - | [`ai-codex.md`](./ticket-016/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-016/ai-codex-logs.txt) | [`changelog.md`](./ticket-016/changelog.md) |
| **ticket-017** | [`README.md`](./ticket-017/README.md) | [`preprompt.md`](./ticket-017/preprompt.md) | - | [`ai-codex.md`](./ticket-017/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-017/ai-codex-logs.txt) | [`changelog.md`](./ticket-017/changelog.md) |
| **ticket-018** | [`README.md`](./ticket-018/README.md) | [`preprompt.md`](./ticket-018/preprompt.md) | - | [`ai-codex.md`](./ticket-018/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-018/ai-codex-logs.txt) | [`changelog.md`](./ticket-018/changelog.md) |
<!-- AUTO:TICKET_INDEX:END -->
67 changes: 67 additions & 0 deletions project/ticket-018/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Ticket 018: Stop post-release duplicate version bumps

- **ID**: ticket-018
- **Owner**: unresolved:human
- **Status**: IN_PROGRESS
- **Workflow state**: EDIT
- **Created**: 2026-08-10
- **Work classification**: `BUG / application`

## Goal and scope

Prevent a metadata-only Goal run after a publish-only release from selecting
the next patch merely because the remote Git tag still names the prior release.
Committed-source analysis must recognize the commit where every managed version
carrier first reached the current version and inspect only source changes after
that boundary. A complete pre-bump ahead of the registry must continue to
request publication, and real source committed after the boundary must still
request the next release.

## Acceptance criteria

- [x] AC-01: Goal 2.1.290 was published from synchronized version metadata, but
a subsequent metadata-only `goal -a` reproduced a false 2.1.291 proposal.
- [x] AC-02: When all managed carriers reached the current version after the
last tag, source already preceding that transition is not reported again.
- [x] AC-03: Real package source committed after the transition remains
publishable, and existing pre-bump/version-resolution behavior stays green.
- [x] AC-04: Focused history/version tests and the full Python suite pass.
- [ ] AC-05: Fresh-base governance and exact-head protected delivery pass.

## Session authorization

The user explicitly requested autonomous continuation, testing and publication
of the Goal version-resolution work. This directly reproduced regression is a
bounded prerequisite and proceeds without another confirmation. Trusted merge
approval remains external and exact-head bound.

## Reproduction evidence

After PyPI exposed 2.1.290 while the latest reachable Git tag was v2.1.289,
`goal -a --no-publish --delivery-mode pull-request` reported both
`goal/__init__.py` and the already-published `goal/publish/changes.py` as new
source and selected `normal-bump -> 2.1.291`. The generated bump was reverted
before merge in PRs #32 and #33; 2.1.291 was never published.

## Implementation evidence

- Managed carriers with one synchronized current value now identify the first
post-tag commit at which all of them reached that value.
- Committed-source analysis uses that transition as its effective release
boundary and conservatively falls back to the reachable tag when history is
missing or ambiguous.
- Focused regression/version tests: `27 passed`.
- Full Python suite: `510 passed, 2 skipped`.
- Fresh-base governance: `GOV-PASS` with zero errors and warnings.

## Boundary

Only version-transition history detection, committed-source classification,
focused regression tests and this ticket's evidence may change. No package
version, dependency, registry artifact, public CLI or governance policy change
is authorized.

## Participants

- Human participant: unresolved; no user-* file was created.
- Agent participant: [ai-codex.md](ai-codex.md)
13 changes: 13 additions & 0 deletions project/ticket-018/ai-codex-logs.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- 2026-08-10: Reproduced false `normal-bump -> 2.1.291` after PyPI already
exposed 2.1.290 and publish-only intentionally left tag v2.1.289 reachable.
- 2026-08-10: Reverted both generated 2.1.291 changes before merge; no invalid
version was published.
- 2026-08-10: Started bounded ticket 018 under the user's autonomous execution
authorization on accepted base `d2523133bc57ef79d8d273a784fe8d97a0b8e89a`.
- 2026-08-10: Implemented synchronized version-transition detection and
committed-source boundary selection.
- 2026-08-10: Focused tests passed (27/27); the full Python suite passed
(510 passed, 2 skipped).
- 2026-08-10: Fresh-base governance passed with zero errors and warnings.
- 2026-08-10: The first protected-delivery attempt correctly rejected REVIEW
while implementation files were present; restored EDIT for PR delivery.
26 changes: 26 additions & 0 deletions project/ticket-018/ai-codex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
participant-id: agent:codex
participant: codex
role: agent
ticket: ticket-018
---
# Participant: codex (AI agent)

## Understanding

The committed-source detector uses only the last reachable v* tag. In
publish-only mode Goal intentionally does not push a tag, so after PyPI catches
up the detector continues to see source already included in the published
artifact and requests another patch.

## Execution plan

1. Infer the synchronized current-version transition after the prior tag.
2. Start committed-source classification at that transition.
3. Cover source before and after the boundary, plus existing pre-bump behavior.
4. Run focused/full validation and deliver through exact-head PR review.

## Blockers

- None inside the recorded intent; proceed autonomously.
- Trusted merge approval remains external and exact-head bound.
13 changes: 13 additions & 0 deletions project/ticket-018/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Ticket Changelog (ticket-018)

## [0.1.0] - 2026-08-10

- Initialized a bounded regression ticket from the reproduced post-release
duplicate-bump evidence.
- No human participant identity or content was generated.
- Added a conservative synchronized-version transition detector and used it as
the lower bound for committed-source release analysis.
- Added regressions for released source before the transition and new source
after it; focused tests passed 27/27.
- Full Python validation passed with 510 tests and 2 expected skips; fresh-base
governance passed with zero errors and warnings.
98 changes: 98 additions & 0 deletions project/ticket-018/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-018",
"summary": "Stop post-release duplicate version bumps",
"workstream": "application",
"classification": {
"kind": "BUG",
"priority": "P1",
"origin": "regression"
},
"allowedPaths": [
"goal/publish/changes.py",
"goal/cli/version_state.py",
"tests/test_committed_unreleased.py",
"project/ticket-018/**",
"TODO.md",
"project/TICKETS.md"
],
"forbiddenPaths": ["project/ticket-*/user-*.md"],
"stacks": ["python"],
"dependsOn": ["ticket-010", "ticket-012", "ticket-016"],
"conflictsWith": [],
"integrationTicket": null,
"delivery": {
"acceptedBaseSha": "d2523133bc57ef79d8d273a784fe8d97a0b8e89a",
"targetBranch": "main",
"outcome": "Post-release metadata delivery stays on the published version while source committed after the synchronized version transition still requests a new release",
"nonGoals": [
"No package version or registry publication",
"No tag creation or delivery-policy change",
"No dependency, manifest or public CLI change",
"No agent self-approval for merge"
],
"complexity": "S",
"estimatedMinutes": 30,
"budgets": {
"maxImplementationFiles": 3,
"maxAffectedComponents": 2,
"maxPublicInterfaceChanges": 0,
"maxRuntimeDependencies": 0
},
"architecture": {
"status": "accepted",
"decision": "Derive a conservative Git boundary from the first commit after the prior tag where all managed version sources equal the current version, then classify only source after that boundary",
"components": [
{
"name": "version-transition-evidence",
"paths": ["goal/cli/version_state.py"]
},
{
"name": "committed-source-classifier",
"paths": [
"goal/publish/changes.py",
"tests/test_committed_unreleased.py"
]
}
],
"responsibilityChanges": false,
"interfaceChanges": [],
"dataChanges": [],
"ui": {
"impact": "none",
"states": [],
"evidence": []
},
"rollback": "Remove the inferred transition boundary and return committed-source analysis to the last reachable release tag"
},
"runtimeDependencies": [],
"validation": [
{
"criterion": "AC-02",
"commands": [
".venv/bin/python -m pytest tests/test_committed_unreleased.py -q"
],
"evidence": "Already-published source before a synchronized version transition is not emitted after registry catch-up"
},
{
"criterion": "AC-03",
"commands": [
".venv/bin/python -m pytest tests/test_committed_unreleased.py tests/test_version_state.py -q"
],
"evidence": "Post-transition source and pre-bumped version behavior remain detectable"
},
{
"criterion": "AC-04",
"commands": [".venv/bin/python -m pytest tests -q"],
"evidence": "The complete Python suite passes"
},
{
"criterion": "AC-05",
"commands": [
"./project/governance-check.sh --base d2523133bc57ef79d8d273a784fe8d97a0b8e89a"
],
"evidence": "Governance reports zero errors and warnings before protected delivery"
}
]
}
}
12 changes: 12 additions & 0 deletions project/ticket-018/preprompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Ticket preprompt

- **Task ID**: ticket-018
- **Task title**: Stop post-release duplicate version bumps
- **Created**: 2026-08-10T10:47:00Z

Keep executable implementation outside this governance/evidence directory.
Read a human-owned user-*.md file only when one exists.
The request to continue creates SESSION_EXECUTION_AUTHORIZATION; proceed within
the recorded intent without a redundant confirmation prompt. Require new
authority for destructive action, secrets, material objective expansion and
trusted merge approval.
22 changes: 22 additions & 0 deletions tests/test_committed_unreleased.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def git_repo(tmp_path, monkeypatch):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "pkg.py").write_text("x = 1\n")
(tmp_path / "README.md").write_text("readme\n")
(tmp_path / "VERSION").write_text("1.0.0\n")
subprocess.run(["git", "add", "-A"], check=True)
subprocess.run(["git", "commit", "-qm", "init"], check=True)
subprocess.run(["git", "tag", "v1.0.0"], check=True)
Expand Down Expand Up @@ -76,3 +77,24 @@ def test_latest_release_tag_resolution(self, git_repo):
_commit(git_repo, "src/pkg.py", "x = 5\n", "feat")
subprocess.run(["git", "tag", "v1.1.0"], check=True)
assert _latest_release_tag() == "v1.1.0"

def test_source_before_synchronized_version_transition_is_released(
self, git_repo
):
_commit(git_repo, "src/pkg.py", "x = 6\n", "feat: released source")
(git_repo / "VERSION").write_text("1.0.1\n")
package = git_repo / "src" / "pkg"
package.mkdir()
(package / "__init__.py").write_text('__version__ = "1.0.1"\n')
subprocess.run(["git", "add", "-A"], check=True)
subprocess.run(["git", "commit", "-qm", "release: sync 1.0.1"], check=True)

assert committed_unreleased_source_files(["python"]) == []

def test_source_after_synchronized_version_transition_is_detected(
self, git_repo
):
_commit(git_repo, "VERSION", "1.0.1\n", "release: sync 1.0.1")
_commit(git_repo, "src/pkg.py", "x = 7\n", "feat: next source")

assert committed_unreleased_source_files(["python"]) == ["src/pkg.py"]