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
36 changes: 22 additions & 14 deletions goal/cli/governance_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from goal.config import load_config
from goal.governance.delivery import (
GOVERNANCE_PACKAGE_FILES,
SOURCE_HUB_WORKSPACE_CHECKER,
authorize_hook_push,
check_delivery_hook,
governance_diagnostic_guidance,
Expand Down Expand Up @@ -390,21 +391,21 @@ def _reject_reserved_validator_options(arguments):
def governance_check(target_root, validator_args):
"""Run the deterministic validator from the adopted governance package."""
target = target_root.resolve()
if is_new_project_source_hub(target):
if validator_args:
raise click.UsageError(
"source-hub health does not accept target-validator arguments"
)
result = run_source_hub_health(target)
if result.stdout:
click.echo(result.stdout, nl=False)
if result.stderr:
click.echo(result.stderr, err=True, nl=False)
if result.returncode != 0:
raise click.exceptions.Exit(result.returncode)
return
missing = missing_governance_package_files(target)
if missing:
if is_new_project_source_hub(target):
if validator_args:
raise click.UsageError(
"source-hub health does not accept target-validator arguments"
)
result = run_source_hub_health(target)
if result.stdout:
click.echo(result.stdout, nl=False)
if result.stderr:
click.echo(result.stderr, err=True, nl=False)
if result.returncode != 0:
raise click.exceptions.Exit(result.returncode)
return
raise click.ClickException(
"adopted governance package is incomplete; missing: "
+ ", ".join(missing)
Expand Down Expand Up @@ -472,7 +473,14 @@ def workspace_check(target_root, workspace_root, allow, output_format):
"""Run the read-only checker from the adopted governance package."""
target = target_root.resolve()
checker = target / WORKSPACE_LIFECYCLE_CHECKER
if not checker.is_file():
if is_new_project_source_hub(target):
hub_checker = target / SOURCE_HUB_WORKSPACE_CHECKER
if not hub_checker.is_file():
raise click.ClickException(
f"source-hub workspace lifecycle checker is missing: {SOURCE_HUB_WORKSPACE_CHECKER}"
)
checker = hub_checker
elif not checker.is_file():
raise click.ClickException(
f"adopted workspace lifecycle checker is missing: {WORKSPACE_LIFECYCLE_CHECKER}; "
"run `goal governance adopt` with a published source revision"
Expand Down
115 changes: 100 additions & 15 deletions goal/governance/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
"governance/manifest.default.json",
"scripts/governance_check.py",
)
SOURCE_HUB_MANIFEST = "governance/manifest.hub.json"
SOURCE_HUB_STACK_PROFILES = "governance/stack-profiles.json"
SOURCE_HUB_WORK_CLASSIFICATION = "governance/work-classification.dsl.json"
SOURCE_HUB_WORKSPACE_CHECKER = "scripts/workspace_lifecycle_check.py"
SOURCE_HUB_DIAGNOSTICS = "governance/diagnostics.json"
SOURCE_HUB_WORKFLOW = ".github/workflows/ci.yml"
SOURCE_HUB_REQUIRED_CHECKS = "scripts/check_required_checks.py"
SOURCE_HUB_JSON_DIRECTORY = "governance"
Expand Down Expand Up @@ -613,6 +618,8 @@ def _safe_runbook(root: Path, raw_path: Any) -> str | None:
def governance_diagnostic_guidance(root: Path, output: str) -> list[str]:
"""Resolve safe canonical v2 guidance for codes emitted by a validator."""
catalog_path = root / GOVERNANCE_DIAGNOSTICS
if not catalog_path.is_file() and is_new_project_source_hub(root):
catalog_path = root / SOURCE_HUB_DIAGNOSTICS
try:
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
Expand Down Expand Up @@ -647,22 +654,89 @@ def governance_diagnostic_guidance(root: Path, output: str) -> list[str]:
return guidance


def _governance_gate(root: Path, *, base: str | None = None) -> None:
missing = missing_governance_package_files(root)
def _source_hub_candidate_gate(
root: Path, *, base: str, head: str | None = None
) -> None:
missing = [
relative
for relative in (
SOURCE_HUB_MANIFEST,
SOURCE_HUB_STACK_PROFILES,
"scripts/governance_check.py",
)
if not (root / relative).is_file()
]
if missing:
if is_new_project_source_hub(root):
result = run_source_hub_health(root)
if result.returncode == 0:
return
detail = (
"\n".join(
part.strip()
for part in (result.stdout, result.stderr)
if part.strip()
)
or "source-hub health failed"
raise click.ClickException(
f"{SOURCE_HUB_DIAGNOSTIC}: missing source-hub candidate contract: "
+ ", ".join(missing)
)
resolved_head = head or _git_value("rev-parse", "HEAD", cwd=root)
arguments = [
sys.executable,
str(root / "scripts" / "governance_check.py"),
"--root",
str(root),
"--manifest",
SOURCE_HUB_MANIFEST,
"--stack-profiles",
SOURCE_HUB_STACK_PROFILES,
]
if (root / SOURCE_HUB_WORK_CLASSIFICATION).is_file():
arguments.extend(["--work-classification", SOURCE_HUB_WORK_CLASSIFICATION])
arguments.extend(
[
"--base",
base,
"--head",
resolved_head,
"--actor",
"ci",
"--format",
"text",
]
)
result = _run(arguments, cwd=root)
if result.returncode != 0:
detail = (
"\n".join(
part.strip()
for part in (result.stdout, result.stderr)
if part.strip()
)
or "source-hub candidate governance failed"
)
from goal.governance.remediation import publish_governance_remediation

remediation = publish_governance_remediation(root, detail)
if remediation.detail and remediation.proposal_path is not None:
detail += f"\nGoal remediation: {remediation.detail}"
guidance = governance_diagnostic_guidance(root, detail)
if guidance:
detail += "\n" + "\n".join(guidance)
raise click.ClickException(detail)


def _governance_gate(
root: Path, *, base: str | None = None, head: str | None = None
) -> None:
if is_new_project_source_hub(root):
if base is not None:
_source_hub_candidate_gate(root, base=base, head=head)
result = run_source_hub_health(root)
if result.returncode == 0:
return
detail = (
"\n".join(
part.strip()
for part in (result.stdout, result.stderr)
if part.strip()
)
raise click.ClickException(detail)
or "source-hub health failed"
)
raise click.ClickException(detail)
missing = missing_governance_package_files(root)
if missing:
raise click.ClickException(
"GOV-MANIFEST-001: governance delivery requires a complete adopted "
"package; missing: "
Expand Down Expand Up @@ -1080,8 +1154,15 @@ def resolve_pull_request_ticket(
cwd: Path | None = None,
) -> str | None:
"""Resolve one governed ticket before pull-request workflow mutations."""
if policy.mode != "pull-request" or ticket:
if policy.mode != "pull-request":
return ticket
if ticket is not None:
cleaned = str(ticket).strip()
if re.fullmatch(r"ticket-[0-9]{3,}", cleaned) is None:
raise click.ClickException(
"pull-request delivery requires canonical `--ticket ticket-NNN`"
)
return cleaned

root = _repository_root(cwd)
active: list[str] = []
Expand Down Expand Up @@ -1544,6 +1625,10 @@ def deliver_pull_request(
) -> tuple[str, str]:
"""Push a controlled head branch and create or reuse its pull request."""
root = _repository_root(cwd)
if policy.require_clean_governance and is_new_project_source_hub(root):
validation_base = _pull_request_validation_base(policy, root)
head_sha = _git_value("rev-parse", "HEAD", cwd=root)
_source_hub_candidate_gate(root, base=validation_base, head=head_sha)
head = _publication_head(policy, ticket, root)
current = _git_value("branch", "--show-current", cwd=root)
push_arguments = ["git", "push"]
Expand Down
54 changes: 45 additions & 9 deletions goal/push/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,10 @@ def _resume_pending_pull_request(
head=head,
)
click.echo(click.style(f"Pull request: {pr_url}", fg="green"))
if "_start_time" in ctx_obj:
elapsed = time.time() - ctx_obj["_start_time"]
ctx_obj["_elapsed_time"] = elapsed
click.echo(click.style(f"\n⏱️ Total time: {elapsed:.1f}s", fg="cyan"))


def _push_completion_release_tag(delivery: Any, new_version: str) -> bool:
Expand Down Expand Up @@ -648,6 +652,10 @@ def _run_test_stage_or_exit(
os.environ["GOAL_SKIP_COSTS_BADGE"] = goal_skip_costs_badge

if test_exit_code != 0 and ctx_obj["yes"]:
if "_start_time" in ctx_obj:
elapsed = time.time() - ctx_obj["_start_time"]
ctx_obj["_elapsed_time"] = elapsed
click.echo(click.style(f"\n⏱️ Total time: {elapsed:.1f}s", fg="cyan"))
click.echo(
click.style("Aborting workflow because tests failed.", fg="red", bold=True)
)
Expand Down Expand Up @@ -679,6 +687,10 @@ def execute_push_workflow(
) -> None:
"""Execute the complete push workflow."""

start_time = time.time()
ctx_obj["_start_time"] = start_time
ctx_obj["_preflight_time"] = 0.0

# Keep the invariant at the workflow boundary as well as in the Click
# context. This covers library callers and compatibility shims that pass
# only ``all_flags`` instead of going through ``_configure_main_context``.
Expand Down Expand Up @@ -736,13 +748,31 @@ def execute_push_workflow(
no_publish or ctx_obj.get("no_publish", False)
):
raise click.ClickException("publish-only conflicts with --no-publish")
validate_delivery_ready(delivery)
if delivery.mode == "pull-request" and ticket is not None:
ticket = resolve_pull_request_ticket(delivery, ticket)
preflight_start = time.time()
try:
validate_delivery_ready(delivery)
finally:
ctx_obj["_preflight_time"] = (
ctx_obj.get("_preflight_time", 0.0) + (time.time() - preflight_start)
)
ctx_obj["_elapsed_time"] = time.time() - start_time
ctx_obj["delivery_mode"] = delivery.mode
else:
no_change = validate_legacy_governance(
check_no_change=all_flags
and not any((ticket, force, explicit_force_publish, ctx_obj.get("version")))
)
preflight_start = time.time()
try:
no_change = validate_legacy_governance(
check_no_change=all_flags
and not any(
(ticket, force, explicit_force_publish, ctx_obj.get("version"))
)
)
finally:
ctx_obj["_preflight_time"] = (
ctx_obj.get("_preflight_time", 0.0) + (time.time() - preflight_start)
)
ctx_obj["_elapsed_time"] = time.time() - start_time
if no_change is True:
if all_flags and _has_committed_unreleased_sources():
click.echo(
Expand All @@ -755,8 +785,6 @@ def execute_push_workflow(

_validate_toml_or_exit(dry_run)

start_time = time.time()

_initialize_context(ctx_obj, bump, message, yes, markdown)

yes = ctx_obj["yes"]
Expand All @@ -772,7 +800,8 @@ def execute_push_workflow(
)

if delivery is not None:
ticket = resolve_pull_request_ticket(delivery, ticket)
if ticket is None:
ticket = resolve_pull_request_ticket(delivery, ticket)
if delivery.mode == "pull-request" and ticket is None:
disagreeing = _managed_version_values()
if len(disagreeing) > 1:
Expand Down Expand Up @@ -896,7 +925,14 @@ def execute_push_workflow(
sys.exit(2)

if delivery is not None and delivery.mode == "pull-request":
validate_delivery_ready(delivery)
preflight_start = time.time()
try:
validate_delivery_ready(delivery)
finally:
ctx_obj["_preflight_time"] = (
ctx_obj.get("_preflight_time", 0.0) + (time.time() - preflight_start)
)
ctx_obj["_elapsed_time"] = time.time() - start_time

if not dry_run:
run_git("add", "-A")
Expand Down
Loading
Loading