From 58137137af4b671fd8f816c5ccd98563f1e83c4c Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 21 Oct 2026 00:01:40 +0200 Subject: [PATCH] fix(governance): forward --bootstrap-native-adoption from goal adopt (ticket-191) A committed repository adopted with goal governance adopt could not allocate its first ticket: the adoption delta is pending on main and no bootstrap-adoption receipt existed (wellmanifest/new-project#499). The pinned generator has supported --bootstrap-native-adoption since 0.20.84, but goal never forwarded it. goal governance adopt now forwards an explicit --bootstrap-native-adoption, rejects it with --pre-commit, and after adopting a repository whose HEAD has no .governance/manifest.json prints the first-ticket route instead of leaving the operator at pending-delta. Co-Authored-By: Claude Opus 5.5 --- goal/cli/governance_cmd.py | 39 ++++++++++++- project/TICKETS.md | 2 + project/ticket-191/README.md | 20 +++++++ project/ticket-191/intent.json | 84 +++++++++++++++++++++++++++ tests/test_governance_cmd.py | 103 +++++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 project/ticket-191/README.md create mode 100644 project/ticket-191/intent.json diff --git a/goal/cli/governance_cmd.py b/goal/cli/governance_cmd.py index d11b7df..1b7c659 100644 --- a/goal/cli/governance_cmd.py +++ b/goal/cli/governance_cmd.py @@ -636,6 +636,16 @@ def plan_adoption_command(catalog, catalog_sha256, target_root, target_revision) raise click.exceptions.Exit(2) +def _governance_committed(target: Path) -> bool: + """True when HEAD already contains an adopted governance manifest.""" + result = subprocess.run( + ["git", "-C", str(target), "cat-file", "-e", "HEAD:.governance/manifest.json"], + capture_output=True, + check=False, + ) + return result.returncode == 0 + + @governance.command("adopt") @click.option( "--standard-repository", @@ -678,6 +688,15 @@ def plan_adoption_command(catalog, catalog_sha256, target_root, target_revision) "generator must record non-production provenance." ), ) +@click.option( + "--bootstrap-native-adoption", + is_flag=True, + help=( + "First adoption into a committed repository without governance: record " + "the installer receipt that the first --bootstrap-adoption-digest " + "ticket allocation requires." + ), +) @click.option( "--pre-commit", is_flag=True, @@ -695,6 +714,7 @@ def adopt( check, upgrade, allow_unpublished_for_testing, + bootstrap_native_adoption, pre_commit, ticket, ): @@ -710,10 +730,12 @@ def adopt( raise click.UsageError("--check and --upgrade are mutually exclusive") if pre_commit and (not latest or not ticket): raise click.UsageError("--pre-commit requires --latest and --ticket") - if pre_commit and (check or upgrade or allow_unpublished_for_testing): + if pre_commit and ( + check or upgrade or allow_unpublished_for_testing or bootstrap_native_adoption + ): raise click.UsageError( - "--pre-commit cannot be combined with --check, --upgrade, or " - "--allow-unpublished-for-testing" + "--pre-commit cannot be combined with --check, --upgrade, " + "--allow-unpublished-for-testing, or --bootstrap-native-adoption" ) target = target_root.resolve() @@ -763,6 +785,9 @@ def adopt( command.append("--upgrade") if allow_unpublished_for_testing: command.append("--allow-unpublished-for-testing") + if bootstrap_native_adoption: + command.append("--bootstrap-native-adoption") + fresh_repository = not check and not _governance_committed(target) result = subprocess.run(command, capture_output=True, text=True, check=False) if result.stdout: @@ -771,6 +796,14 @@ def adopt( click.echo(result.stderr, err=True, nl=False) if result.returncode != 0: raise click.exceptions.Exit(result.returncode) + if fresh_repository and not bootstrap_native_adoption: + click.echo( + "note: this repository has no committed governance; its first ticket " + "allocation needs the bootstrap receipt. Rerun with " + "--bootstrap-native-adoption, then allocate with " + "./project/new-ticket.sh --bootstrap-adoption-digest .", + err=True, + ) if pre_commit: click.echo( f"prepared standard update {current_revision}..{source_revision} " diff --git a/project/TICKETS.md b/project/TICKETS.md index 3ce460a..5ea518b 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -131,4 +131,6 @@ This file indexes governance tickets without taking ownership of | **ticket-185** | [`README.md`](./ticket-185/README.md) | - | - | - | - | - | | **ticket-186** | [`README.md`](./ticket-186/README.md) | - | - | - | - | - | | **ticket-187** | [`README.md`](./ticket-187/README.md) | - | - | - | - | - | +| **ticket-188** | [`README.md`](./ticket-188/README.md) | - | - | - | - | - | +| **ticket-190** | [`README.md`](./ticket-190/README.md) | - | - | - | - | - | diff --git a/project/ticket-191/README.md b/project/ticket-191/README.md new file mode 100644 index 0000000..31c863f --- /dev/null +++ b/project/ticket-191/README.md @@ -0,0 +1,20 @@ +# Ticket 191: Forward bootstrap-native-adoption from goal governance adopt (new-project 499) + +- **ID**: ticket-191 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-20 + +## Goal and scope + +To be completed from human-owned input. + +## Acceptance criteria + +- [ ] AC-01: Scope is approved by a human owner. + +## 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-191/intent.json b/project/ticket-191/intent.json new file mode 100644 index 0000000..fb513c9 --- /dev/null +++ b/project/ticket-191/intent.json @@ -0,0 +1,84 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-191", + "summary": "Forward bootstrap-native-adoption from goal governance adopt (new-project 499)", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-191/**", + "TODO.md", + "project/TICKETS.md", + "goal/cli/governance_cmd.py", + "tests/test_governance_cmd.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "297279a030a3182baa634c0b6bc7ea8ce34e48c2", + "targetBranch": "main", + "outcome": "goal governance adopt can perform the first adoption of a committed repository without governance so its first ticket can be allocated, and tells the operator when that route is needed.", + "nonGoals": [ + "No automatic bootstrap without the explicit flag", + "No change to the new-project generator or allocator", + "No writer, review or publication authority from the receipt" + ], + "complexity": "S", + "estimatedMinutes": 45, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Forward an explicit --bootstrap-native-adoption flag to the pinned create_adoption_lock.py, reject it with --pre-commit, and print a stderr note after a successful non-check adoption whose HEAD has no .governance/manifest.json.", + "components": [ + { + "name": "governance-adopt-cli", + "paths": [ + "goal/cli/governance_cmd.py", + "tests/test_governance_cmd.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [ + "goal governance adopt --bootstrap-native-adoption" + ], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the commit; the generator flag remains usable directly." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest tests/test_governance_cmd.py" + ], + "evidence": "project/ticket-191/README.md" + }, + { + "criterion": "AC-02", + "commands": [ + "./project/governance-check.sh" + ], + "evidence": "project/ticket-191/README.md" + } + ] + } +} diff --git a/tests/test_governance_cmd.py b/tests/test_governance_cmd.py index db0fbfe..3414717 100644 --- a/tests/test_governance_cmd.py +++ b/tests/test_governance_cmd.py @@ -74,8 +74,11 @@ def make_standard(tmp_path): parser.add_argument('--check', action='store_true') parser.add_argument('--upgrade', action='store_true') parser.add_argument('--allow-unpublished-for-testing', action='store_true') +parser.add_argument('--bootstrap-native-adoption', action='store_true') args = parser.parse_args() target = Path(args.target_root) +if args.bootstrap_native_adoption: + (target / '.bootstrap-native').write_text('explicit\\n', encoding='utf-8') if args.allow_unpublished_for_testing: (target / '.candidate-testing').write_text('explicit\\n', encoding='utf-8') if args.check: @@ -1035,3 +1038,103 @@ def test_rejects_check_with_upgrade(tmp_path): assert result.exit_code == 2 assert "mutually exclusive" in result.output + + +def _adopt(standard, revision, target, *extra): + return CliRunner().invoke( + main, + [ + "governance", + "adopt", + "--standard-repository", + str(standard), + "--source-revision", + revision, + "--target-root", + str(target), + *extra, + ], + ) + + +def _committed_repository(path, *, governance=False): + path.mkdir() + _git(path, "init", "--quiet") + _git(path, "config", "user.email", "goal-governance@example.invalid") + _git(path, "config", "user.name", "goal-governance-test") + (path / "README.md").write_text("fixture\n", encoding="utf-8") + if governance: + (path / ".governance").mkdir() + (path / ".governance" / "manifest.json").write_text("{}\n", encoding="utf-8") + _git(path, "add", ".") + _git(path, "commit", "--quiet", "-m", "fixture") + return path + + +def test_bootstrap_native_adoption_is_forwarded_without_note(tmp_path): + standard, revision = make_standard(tmp_path) + target = _committed_repository(tmp_path / "target") + + result = _adopt(standard, revision, target, "--bootstrap-native-adoption") + + assert result.exit_code == 0, result.output + assert (target / ".bootstrap-native").read_text(encoding="utf-8") == "explicit\n" + assert "--bootstrap-adoption-digest" not in result.output + + +def test_fresh_repository_without_bootstrap_flag_names_the_first_ticket_route(tmp_path): + standard, revision = make_standard(tmp_path) + target = _committed_repository(tmp_path / "target") + + result = _adopt(standard, revision, target) + + assert result.exit_code == 0, result.output + assert not (target / ".bootstrap-native").exists() + assert "--bootstrap-native-adoption" in result.output + assert "--bootstrap-adoption-digest" in result.output + + +def test_repository_with_committed_governance_gets_no_bootstrap_note(tmp_path): + standard, revision = make_standard(tmp_path) + target = _committed_repository(tmp_path / "target", governance=True) + + result = _adopt(standard, revision, target, "--upgrade") + + assert result.exit_code == 0, result.output + assert "--bootstrap-native-adoption" not in result.output + + +def test_check_gets_no_bootstrap_note(tmp_path): + standard, revision = make_standard(tmp_path) + target = _committed_repository(tmp_path / "target") + + result = _adopt(standard, revision, target, "--check") + + assert result.exit_code == 1 + assert "--bootstrap-native-adoption" not in result.output + + +def test_pre_commit_rejects_bootstrap_native_adoption(tmp_path): + standard, _revision = make_standard(tmp_path) + target = _committed_repository(tmp_path / "target") + + result = CliRunner().invoke( + main, + [ + "governance", + "adopt", + "--standard-repository", + str(standard), + "--latest", + "--pre-commit", + "--ticket", + "ticket-001", + "--target-root", + str(target), + "--bootstrap-native-adoption", + ], + ) + + assert result.exit_code == 2 + assert "cannot be combined" in result.output + assert "--bootstrap-native-adoption" in result.output