@opengsd/gsd-path 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +44 -0
- package/FULL.md +5 -4
- package/HOOKS.md +18 -2
- package/QUICK.md +1 -1
- package/README.md +12 -28
- package/WORKFLOW.md +8 -4
- package/package.json +1 -1
- package/scripts/_common.py +43 -0
- package/scripts/check_task_briefs.py +10 -7
- package/scripts/dispatch_driver.py +17 -5
- package/scripts/git_guard.py +18 -2
- package/scripts/integration.py +17 -4
- package/scripts/isolation.py +66 -14
- package/scripts/lean_verification.py +5 -1
- package/scripts/model_policy.py +8 -1
- package/scripts/pipeline_git.py +19 -5
- package/scripts/pipeline_state.py +3 -1
- package/scripts/state_promote.py +77 -1
- package/skills/gsd-path/BUILD.md +3 -1
- package/skills/gsd-path/PLAN.md +3 -2
- package/skills/gsd-path/SHIP.md +14 -8
- package/skills/gsd-path/references/model-policy.md +3 -1
- package/skills/gsd-path/references/operating-rules.md +3 -2
- package/skills/gsd-path/references/runtime-selection.md +2 -1
- package/skills/gsd-path/scripts/_common.py +43 -0
- package/skills/gsd-path/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path/scripts/dispatch_driver.py +17 -5
- package/skills/gsd-path/scripts/git_guard.py +18 -2
- package/skills/gsd-path/scripts/integration.py +17 -4
- package/skills/gsd-path/scripts/isolation.py +66 -14
- package/skills/gsd-path/scripts/lean_verification.py +5 -1
- package/skills/gsd-path/scripts/model_policy.py +8 -1
- package/skills/gsd-path/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path/scripts/state_promote.py +77 -1
- package/skills/gsd-path-build/SKILL.md +3 -1
- package/skills/gsd-path-build/references/model-policy.md +3 -1
- package/skills/gsd-path-build/references/operating-rules.md +3 -2
- package/skills/gsd-path-build/references/runtime-selection.md +2 -1
- package/skills/gsd-path-build/scripts/_common.py +43 -0
- package/skills/gsd-path-build/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-build/scripts/dispatch_driver.py +17 -5
- package/skills/gsd-path-build/scripts/integration.py +17 -4
- package/skills/gsd-path-build/scripts/isolation.py +66 -14
- package/skills/gsd-path-build/scripts/model_policy.py +8 -1
- package/skills/gsd-path-build/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-build/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-build/scripts/state_promote.py +77 -1
- package/skills/gsd-path-decide/references/model-policy.md +3 -1
- package/skills/gsd-path-decide/references/operating-rules.md +3 -2
- package/skills/gsd-path-decide/references/runtime-selection.md +2 -1
- package/skills/gsd-path-decide/scripts/_common.py +43 -0
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-decide/scripts/integration.py +17 -4
- package/skills/gsd-path-decide/scripts/isolation.py +66 -14
- package/skills/gsd-path-decide/scripts/model_policy.py +8 -1
- package/skills/gsd-path-decide/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-decide/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-decide/scripts/state_promote.py +77 -1
- package/skills/gsd-path-define/references/operating-rules.md +3 -2
- package/skills/gsd-path-define/references/runtime-selection.md +2 -1
- package/skills/gsd-path-define/scripts/_common.py +43 -0
- package/skills/gsd-path-define/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-define/scripts/integration.py +17 -4
- package/skills/gsd-path-define/scripts/isolation.py +66 -14
- package/skills/gsd-path-define/scripts/model_policy.py +8 -1
- package/skills/gsd-path-define/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-define/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-define/scripts/state_promote.py +77 -1
- package/skills/gsd-path-discuss/references/operating-rules.md +3 -2
- package/skills/gsd-path-discuss/references/runtime-selection.md +2 -1
- package/skills/gsd-path-discuss/scripts/_common.py +43 -0
- package/skills/gsd-path-discuss/scripts/model_policy.py +8 -1
- package/skills/gsd-path-docs-audit/references/model-policy.md +3 -1
- package/skills/gsd-path-docs-audit/references/operating-rules.md +3 -2
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +2 -1
- package/skills/gsd-path-docs-audit/scripts/_common.py +43 -0
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-docs-audit/scripts/integration.py +17 -4
- package/skills/gsd-path-docs-audit/scripts/isolation.py +66 -14
- package/skills/gsd-path-docs-audit/scripts/model_policy.py +8 -1
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +77 -1
- package/skills/gsd-path-forensics/references/operating-rules.md +3 -2
- package/skills/gsd-path-forensics/references/runtime-selection.md +2 -1
- package/skills/gsd-path-forensics/scripts/_common.py +43 -0
- package/skills/gsd-path-forensics/scripts/model_policy.py +8 -1
- package/skills/gsd-path-inspect/references/model-policy.md +3 -1
- package/skills/gsd-path-inspect/references/operating-rules.md +3 -2
- package/skills/gsd-path-inspect/references/runtime-selection.md +2 -1
- package/skills/gsd-path-inspect/scripts/_common.py +43 -0
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-inspect/scripts/integration.py +17 -4
- package/skills/gsd-path-inspect/scripts/isolation.py +66 -14
- package/skills/gsd-path-inspect/scripts/model_policy.py +8 -1
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-inspect/scripts/state_promote.py +77 -1
- package/skills/gsd-path-loop/references/operating-rules.md +3 -2
- package/skills/gsd-path-loop/references/runtime-selection.md +2 -1
- package/skills/gsd-path-loop/scripts/_common.py +43 -0
- package/skills/gsd-path-loop/scripts/model_policy.py +8 -1
- package/skills/gsd-path-migrate/references/operating-rules.md +3 -2
- package/skills/gsd-path-migrate/references/runtime-selection.md +2 -1
- package/skills/gsd-path-migrate/scripts/_common.py +43 -0
- package/skills/gsd-path-migrate/scripts/model_policy.py +8 -1
- package/skills/gsd-path-plan/SKILL.md +3 -2
- package/skills/gsd-path-plan/references/model-policy.md +3 -1
- package/skills/gsd-path-plan/references/operating-rules.md +3 -2
- package/skills/gsd-path-plan/references/runtime-selection.md +2 -1
- package/skills/gsd-path-plan/scripts/_common.py +43 -0
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-plan/scripts/integration.py +17 -4
- package/skills/gsd-path-plan/scripts/isolation.py +66 -14
- package/skills/gsd-path-plan/scripts/model_policy.py +8 -1
- package/skills/gsd-path-plan/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-plan/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-plan/scripts/state_promote.py +77 -1
- package/skills/gsd-path-research/references/model-policy.md +3 -1
- package/skills/gsd-path-research/references/operating-rules.md +3 -2
- package/skills/gsd-path-research/references/runtime-selection.md +2 -1
- package/skills/gsd-path-research/scripts/_common.py +43 -0
- package/skills/gsd-path-research/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-research/scripts/integration.py +17 -4
- package/skills/gsd-path-research/scripts/isolation.py +66 -14
- package/skills/gsd-path-research/scripts/model_policy.py +8 -1
- package/skills/gsd-path-research/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-research/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-research/scripts/state_promote.py +77 -1
- package/skills/gsd-path-roadmap/references/model-policy.md +3 -1
- package/skills/gsd-path-roadmap/references/operating-rules.md +3 -2
- package/skills/gsd-path-roadmap/references/runtime-selection.md +2 -1
- package/skills/gsd-path-roadmap/scripts/_common.py +43 -0
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-roadmap/scripts/integration.py +17 -4
- package/skills/gsd-path-roadmap/scripts/isolation.py +66 -14
- package/skills/gsd-path-roadmap/scripts/model_policy.py +8 -1
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-roadmap/scripts/state_promote.py +77 -1
- package/skills/gsd-path-ship/SKILL.md +14 -8
- package/skills/gsd-path-ship/references/model-policy.md +3 -1
- package/skills/gsd-path-ship/references/operating-rules.md +3 -2
- package/skills/gsd-path-ship/references/runtime-selection.md +2 -1
- package/skills/gsd-path-ship/scripts/_common.py +43 -0
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +10 -7
- package/skills/gsd-path-ship/scripts/integration.py +17 -4
- package/skills/gsd-path-ship/scripts/isolation.py +66 -14
- package/skills/gsd-path-ship/scripts/lean_verification.py +5 -1
- package/skills/gsd-path-ship/scripts/model_policy.py +8 -1
- package/skills/gsd-path-ship/scripts/pipeline_git.py +19 -5
- package/skills/gsd-path-ship/scripts/pipeline_state.py +3 -1
- package/skills/gsd-path-ship/scripts/state_promote.py +77 -1
- package/skills/gsd-path-undo/references/operating-rules.md +3 -2
- package/skills/gsd-path-undo/references/runtime-selection.md +2 -1
- package/skills/gsd-path-undo/scripts/_common.py +43 -0
- package/skills/gsd-path-undo/scripts/model_policy.py +8 -1
- package/skills/path/BUILD.md +3 -1
- package/skills/path/PLAN.md +3 -2
- package/skills/path/SHIP.md +14 -8
- package/skills/path/references/model-policy.md +3 -1
- package/skills/path/references/operating-rules.md +3 -2
- package/skills/path/references/runtime-selection.md +2 -1
- package/skills/path/scripts/_common.py +43 -0
- package/skills/path/scripts/check_task_briefs.py +10 -7
- package/skills/path/scripts/dispatch_driver.py +17 -5
- package/skills/path/scripts/git_guard.py +18 -2
- package/skills/path/scripts/integration.py +17 -4
- package/skills/path/scripts/isolation.py +66 -14
- package/skills/path/scripts/lean_verification.py +5 -1
- package/skills/path/scripts/model_policy.py +8 -1
- package/skills/path/scripts/pipeline_git.py +19 -5
- package/skills/path/scripts/pipeline_state.py +3 -1
- package/skills/path/scripts/state_promote.py +77 -1
|
@@ -387,7 +387,9 @@ def _lint_task(
|
|
|
387
387
|
)
|
|
388
388
|
break
|
|
389
389
|
|
|
390
|
-
|
|
390
|
+
# A landed brief is history that nobody can edit: only an unlanded brief must name present paths.
|
|
391
|
+
landed = task_id in (landed_bases or {})
|
|
392
|
+
for name in () if landed else PROSE_SECTIONS:
|
|
391
393
|
body = sections.get(name)
|
|
392
394
|
if body is None or not _clean(body):
|
|
393
395
|
continue
|
|
@@ -400,8 +402,6 @@ def _lint_task(
|
|
|
400
402
|
and token not in supplied
|
|
401
403
|
and not _supplied_contract(repo, path, token)
|
|
402
404
|
and not _pipeline_workspace_file(repo, path, token)
|
|
403
|
-
# A landed brief is history: the pipeline moves its own files (build recovery, archive) later.
|
|
404
|
-
and not (task_id in (landed_bases or {}) and _pipeline_workspace_path(repo, path, token))
|
|
405
405
|
and not _base_exists(git_root, base, token)
|
|
406
406
|
):
|
|
407
407
|
problems.append(f"## {name} names a path missing at the layer base: {token}")
|
|
@@ -411,7 +411,7 @@ def _lint_task(
|
|
|
411
411
|
block = VERIFY_BLOCK_PATTERN.search(verify_body)
|
|
412
412
|
if block is None or not block.group("block").strip():
|
|
413
413
|
problems.append("## Verify must contain a non-empty fenced bash block")
|
|
414
|
-
|
|
414
|
+
elif not landed:
|
|
415
415
|
for token in _verify_tokens(
|
|
416
416
|
block.group("block"),
|
|
417
417
|
repo=repo,
|
|
@@ -448,9 +448,10 @@ def validate_task_briefs(
|
|
|
448
448
|
|
|
449
449
|
Plain lint uses the layer base without overrides. Plan approval and dispatch
|
|
450
450
|
supply transitive dependency files for unlanded tasks and resolved historical
|
|
451
|
-
bases for landed tasks
|
|
451
|
+
bases for landed tasks. A landed brief is immutable: its `files` scope is
|
|
452
|
+
checked at that base, and the paths its prose and Verify name are not checked.
|
|
452
453
|
The caller validates the task graph and landed metadata before supplying
|
|
453
|
-
these mappings
|
|
454
|
+
these mappings.
|
|
454
455
|
"""
|
|
455
456
|
|
|
456
457
|
resolved_base = _resolve_base(root, base)
|
|
@@ -481,6 +482,9 @@ def validate_task_briefs(
|
|
|
481
482
|
for shape, task_ids in contracts.items():
|
|
482
483
|
if len(task_ids) == 1:
|
|
483
484
|
task_id = next(iter(task_ids))
|
|
485
|
+
# A landed brief is history: a later repair may reword the bullet in its partner.
|
|
486
|
+
if task_id in (landed_bases or {}):
|
|
487
|
+
continue
|
|
484
488
|
problems.append(
|
|
485
489
|
f"{task_id}: interface contract is not shared by any other task: {shape}"
|
|
486
490
|
)
|
|
@@ -592,7 +596,6 @@ def plan_validation_bases(
|
|
|
592
596
|
)
|
|
593
597
|
except IsolationError as error:
|
|
594
598
|
raise BriefError(f"{task_id} landed task has invalid historical base: {error}") from error
|
|
595
|
-
dependency_files[task_id] = set()
|
|
596
599
|
except check_handoffs.HandoffError as error:
|
|
597
600
|
raise BriefError(str(error)) from error
|
|
598
601
|
return tasks, dependency_files, landed_bases
|
|
@@ -117,9 +117,21 @@ def _refresh_stop_errors() -> None:
|
|
|
117
117
|
model_policy.PolicyError)
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
# The selected Project runtime directory once _reload_pinned_modules ran; None keeps bundled helpers.
|
|
121
|
+
_RUNTIME_ROOT: Optional[Path] = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def helper_script(name: str) -> Path:
|
|
125
|
+
"""A subprocess helper from the selected runtime when it ships one, else the bundled sibling."""
|
|
126
|
+
if _RUNTIME_ROOT is not None and (_RUNTIME_ROOT / name).is_file():
|
|
127
|
+
return _RUNTIME_ROOT / name
|
|
128
|
+
return Path(__file__).resolve().parent / name
|
|
129
|
+
|
|
130
|
+
|
|
120
131
|
def _reload_pinned_modules(runtime_root: Path) -> None:
|
|
121
132
|
global isolation, build_state, task_context, contracts, pipeline_state
|
|
122
|
-
global archive_milestone, review_findings, model_policy
|
|
133
|
+
global archive_milestone, review_findings, model_policy, _RUNTIME_ROOT
|
|
134
|
+
_RUNTIME_ROOT = runtime_root
|
|
123
135
|
if _bundled_helpers():
|
|
124
136
|
path = str(runtime_root)
|
|
125
137
|
if path not in sys.path:
|
|
@@ -981,7 +993,7 @@ class Round:
|
|
|
981
993
|
diagnose = shlex.join([
|
|
982
994
|
sys.executable,
|
|
983
995
|
"-B",
|
|
984
|
-
str(
|
|
996
|
+
str(helper_script("pipeline_diagnose.py")),
|
|
985
997
|
"diagnose",
|
|
986
998
|
"--repo",
|
|
987
999
|
str(self.primary),
|
|
@@ -1677,8 +1689,8 @@ class Review:
|
|
|
1677
1689
|
|
|
1678
1690
|
|
|
1679
1691
|
def helper_json(receipt: Dict[str, object], script: str, *arguments: str, cwd: Path) -> Dict[str, object]:
|
|
1680
|
-
"""Run a bundled
|
|
1681
|
-
command = [sys.executable, "-B", str(
|
|
1692
|
+
"""Run a helper (runtime-selected, else bundled) and return its JSON stdout; a non-zero exit is a typed stop."""
|
|
1693
|
+
command = [sys.executable, "-B", str(helper_script(script)), *arguments]
|
|
1682
1694
|
completed = subprocess.run(command, cwd=cwd, capture_output=True, encoding="utf-8", errors="replace")
|
|
1683
1695
|
step = {"script": script, "command": command, "exit_code": completed.returncode,
|
|
1684
1696
|
"stdout": completed.stdout, "stderr": completed.stderr}
|
|
@@ -2290,7 +2302,7 @@ Heavy: {'yes' if heavy else 'no'}
|
|
|
2290
2302
|
if plan_changed:
|
|
2291
2303
|
_common.atomic_write(plan_path, updated)
|
|
2292
2304
|
receipt["plan_wave"] = fix_wave
|
|
2293
|
-
lint = subprocess.run([sys.executable, "-B", str(
|
|
2305
|
+
lint = subprocess.run([sys.executable, "-B", str(helper_script("check_task_briefs.py")),
|
|
2294
2306
|
"--repo", str(primary), "--base", isolation.current_sha(primary),
|
|
2295
2307
|
"--tasks-dir", f"{options.project_dir}/tasks",
|
|
2296
2308
|
"--project-dir", options.project_dir], capture_output=True, encoding="utf-8", errors="replace")
|
|
@@ -69,6 +69,11 @@ PUBLICATION_HINT = (
|
|
|
69
69
|
"(STATE shipped/done) through archive_milestone.py integrate; findings during "
|
|
70
70
|
"ship reopen through the patch plan (see SHIP.md)"
|
|
71
71
|
)
|
|
72
|
+
AUTHORIZED_PUSH_HINT = (
|
|
73
|
+
"an owner-approved push of one exact commit to a branch other than the bound "
|
|
74
|
+
"or default branch needs refs/gsd-path/authorizations/<project>/push/<ref> "
|
|
75
|
+
"at that commit (see HOOKS.md)"
|
|
76
|
+
)
|
|
72
77
|
LEGACY_STATE_FIELDS = {
|
|
73
78
|
"pipeline",
|
|
74
79
|
"project",
|
|
@@ -621,14 +626,25 @@ def pre_push_violations(lines):
|
|
|
621
626
|
continue
|
|
622
627
|
state = state_at(local_sha)
|
|
623
628
|
owed = unshipped_bound_branch(state) if state is not None else None
|
|
624
|
-
if owed is not None:
|
|
629
|
+
if owed is not None and not authorized_publication(state, remote_ref, local_sha):
|
|
625
630
|
found.append(
|
|
626
631
|
f"{remote_ref} <- {local_sha[:12]} carries unshipped {owed} work "
|
|
627
|
-
f"({state.get('phase')}/{state.get('status')}); {PUBLICATION_HINT}"
|
|
632
|
+
f"({state.get('phase')}/{state.get('status')}); {PUBLICATION_HINT}; "
|
|
633
|
+
f"{AUTHORIZED_PUSH_HINT}"
|
|
628
634
|
)
|
|
629
635
|
return found
|
|
630
636
|
|
|
631
637
|
|
|
638
|
+
def authorized_publication(state, remote_ref, sha):
|
|
639
|
+
"""The owner authorized this exact commit on this one non-default branch."""
|
|
640
|
+
project = state.get("project")
|
|
641
|
+
if not isinstance(project, str) or not members.NAME_RE.fullmatch(project):
|
|
642
|
+
return False
|
|
643
|
+
if not remote_ref.startswith("refs/heads/") or remote_ref == f"refs/heads/{default_branch()}":
|
|
644
|
+
return False
|
|
645
|
+
return members.authorized(repo_root(), project, "push", remote_ref, sha)
|
|
646
|
+
|
|
647
|
+
|
|
632
648
|
def is_ship_commit(subject):
|
|
633
649
|
return bool(subject and subject.strip().casefold().startswith("ship:"))
|
|
634
650
|
|
|
@@ -845,6 +845,8 @@ def require_pull_request_merge(
|
|
|
845
845
|
merge_commit: str,
|
|
846
846
|
ship_commit: str,
|
|
847
847
|
remote_default: str,
|
|
848
|
+
*,
|
|
849
|
+
allow_rebase: bool = False,
|
|
848
850
|
) -> None:
|
|
849
851
|
resolved = optional_ref(project, merge_commit)
|
|
850
852
|
if resolved != merge_commit:
|
|
@@ -853,9 +855,16 @@ def require_pull_request_merge(
|
|
|
853
855
|
run_git(project, "rev-list", "--parents", "-n", "1", merge_commit),
|
|
854
856
|
"inspect pull-request merge parents",
|
|
855
857
|
).split()
|
|
856
|
-
if len(parents)
|
|
858
|
+
if allow_rebase and len(parents) == 2:
|
|
859
|
+
problem = _common.rebase_landing_problem(project, ship_commit, merge_commit)
|
|
860
|
+
if problem is not None:
|
|
861
|
+
raise ArchiveError(
|
|
862
|
+
"pull-request integration must use a two-parent merge commit "
|
|
863
|
+
f"or a rebase merge of the ship commits: {problem}"
|
|
864
|
+
)
|
|
865
|
+
elif len(parents) != 3:
|
|
857
866
|
raise ArchiveError("pull-request integration must use a two-parent merge commit")
|
|
858
|
-
|
|
867
|
+
elif parents[2] != ship_commit:
|
|
859
868
|
raise ArchiveError("pull-request merge second parent is not the ship commit")
|
|
860
869
|
first_parent = archive_milestone.require_git_success(
|
|
861
870
|
run_git(project, "rev-list", "--first-parent", remote_default),
|
|
@@ -1026,7 +1035,9 @@ def integrate_pull_request(
|
|
|
1026
1035
|
merge_commit,
|
|
1027
1036
|
)
|
|
1028
1037
|
remote_default = refresh_origin(project)["remote_default"]
|
|
1029
|
-
require_pull_request_merge(
|
|
1038
|
+
require_pull_request_merge(
|
|
1039
|
+
project, merge_commit, ship_commit, remote_default, allow_rebase=True
|
|
1040
|
+
)
|
|
1030
1041
|
pull = update_pull_request_body(
|
|
1031
1042
|
repository,
|
|
1032
1043
|
pull,
|
|
@@ -1473,7 +1484,9 @@ def validate_integrated(
|
|
|
1473
1484
|
pull["number"],
|
|
1474
1485
|
merge_commit,
|
|
1475
1486
|
)
|
|
1476
|
-
require_pull_request_merge(
|
|
1487
|
+
require_pull_request_merge(
|
|
1488
|
+
project, merge_commit, ship_commit, remote_default, allow_rebase=True
|
|
1489
|
+
)
|
|
1477
1490
|
require_annotated_tag(
|
|
1478
1491
|
project,
|
|
1479
1492
|
tag_ref,
|
|
@@ -1219,9 +1219,17 @@ def _commit_member_source(sidecar: Path, journal: Dict[str, object], allowed: Se
|
|
|
1219
1219
|
undeclared = undeclared_paths(sidecar, member_base, pending, allowed)
|
|
1220
1220
|
if undeclared:
|
|
1221
1221
|
raise IsolationError("undeclared paths: " + ", ".join(undeclared))
|
|
1222
|
-
git_output(sidecar, "add", "-A", "--", *sorted(pending))
|
|
1223
1222
|
body = member_commit_body(str(journal["task_file"]), sorted(pending), member_base, str(journal["base"]))
|
|
1224
|
-
|
|
1223
|
+
# The paths and the body grow with the change set, so both go through
|
|
1224
|
+
# stdin: a Windows command line holds about 32 KiB.
|
|
1225
|
+
for arguments, text in (
|
|
1226
|
+
(("add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"),
|
|
1227
|
+
"".join(f"{path}\0" for path in sorted(pending))),
|
|
1228
|
+
(("commit", "-q", "-F", "-"), _commit_message(str(journal["subject"]), body)),
|
|
1229
|
+
):
|
|
1230
|
+
result = run_git(sidecar, *arguments, input=text)
|
|
1231
|
+
if result.returncode != 0:
|
|
1232
|
+
raise IsolationError((result.stderr or result.stdout).strip() or "git command failed")
|
|
1225
1233
|
source = current_sha(sidecar)
|
|
1226
1234
|
if source == member_base:
|
|
1227
1235
|
raise IsolationError("no changes to land")
|
|
@@ -1669,6 +1677,25 @@ def committed_paths_since(repo: Path, base: str) -> Set[str]:
|
|
|
1669
1677
|
)
|
|
1670
1678
|
|
|
1671
1679
|
|
|
1680
|
+
def _stage_paths(repo: Path, mode: str, paths: Set[str]) -> None:
|
|
1681
|
+
"""Stage `paths` with one `git add`.
|
|
1682
|
+
|
|
1683
|
+
One call for each path fails when a file replaces a tracked directory: the
|
|
1684
|
+
first call drops the files below the directory from the index, and then
|
|
1685
|
+
their pathspecs match no file. The `./` prefix stops pathspec magic, so a
|
|
1686
|
+
name such as `:!b` cannot exclude another path of the set. Git names the
|
|
1687
|
+
path in its error text.
|
|
1688
|
+
"""
|
|
1689
|
+
if not paths:
|
|
1690
|
+
return
|
|
1691
|
+
added = run_git(
|
|
1692
|
+
repo, "add", mode, "--pathspec-from-file=-", "--pathspec-file-nul",
|
|
1693
|
+
input="".join(f"./{path}\0" for path in sorted(paths)),
|
|
1694
|
+
)
|
|
1695
|
+
if added.returncode != 0:
|
|
1696
|
+
raise IsolationError(f"could not stage the change set: {added.stderr.strip()}")
|
|
1697
|
+
|
|
1698
|
+
|
|
1672
1699
|
def ignored_legacy_ledger(repo: Path) -> bool:
|
|
1673
1700
|
"""True when a verify ledger absent from HEAD is hidden by an ignore rule.
|
|
1674
1701
|
|
|
@@ -1684,17 +1711,25 @@ def ignored_legacy_ledger(repo: Path) -> bool:
|
|
|
1684
1711
|
return run_git(repo, "check-ignore", "-q", "--no-index", "--", VERIFY_LEDGER_PATH).returncode == 0
|
|
1685
1712
|
|
|
1686
1713
|
|
|
1714
|
+
def _commit_message(subject: str, body: str) -> str:
|
|
1715
|
+
"""The message for `git commit -F -`.
|
|
1716
|
+
|
|
1717
|
+
A landing body lists every changed path. As a `-m` argument it can exceed
|
|
1718
|
+
the Windows command-line limit, so the message goes through stdin. Like
|
|
1719
|
+
`-m`, the message ends its last line.
|
|
1720
|
+
"""
|
|
1721
|
+
message = f"{subject}\n\n{body}"
|
|
1722
|
+
return message if message.endswith("\n") else message + "\n"
|
|
1723
|
+
|
|
1724
|
+
|
|
1687
1725
|
def _commit_pending(
|
|
1688
1726
|
repo: Path, pending: Set[str], subject: str, body: str, forced: Set[str] = frozenset()
|
|
1689
1727
|
) -> str:
|
|
1690
1728
|
reset = run_git(repo, "reset", "-q", "HEAD")
|
|
1691
1729
|
if reset.returncode != 0:
|
|
1692
1730
|
raise IsolationError("could not clear the index before commit")
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
added = run_git(repo, "add", mode, "--", path)
|
|
1696
|
-
if added.returncode != 0:
|
|
1697
|
-
raise IsolationError(f"could not stage {path}: {added.stderr.strip()}")
|
|
1731
|
+
_stage_paths(repo, "-A", pending - forced)
|
|
1732
|
+
_stage_paths(repo, "-f", pending & forced)
|
|
1698
1733
|
staged = _split_paths(
|
|
1699
1734
|
git_output(
|
|
1700
1735
|
repo,
|
|
@@ -1707,7 +1742,7 @@ def _commit_pending(
|
|
|
1707
1742
|
)
|
|
1708
1743
|
if staged != pending:
|
|
1709
1744
|
raise IsolationError("staged paths do not match the uncommitted change set")
|
|
1710
|
-
committed = run_git(repo, "commit", "-q", "-
|
|
1745
|
+
committed = run_git(repo, "commit", "-q", "-F", "-", input=_commit_message(subject, body))
|
|
1711
1746
|
if committed.returncode != 0:
|
|
1712
1747
|
raise IsolationError((committed.stderr or committed.stdout).strip() or "commit failed")
|
|
1713
1748
|
return current_sha(repo)
|
|
@@ -1786,18 +1821,15 @@ def commit_allowed_changes(
|
|
|
1786
1821
|
reset = run_git(repo, "reset", "-q", "HEAD")
|
|
1787
1822
|
if reset.returncode != 0:
|
|
1788
1823
|
raise IsolationError("could not clear the index before landing")
|
|
1789
|
-
|
|
1790
|
-
added = run_git(repo, "add", "-A", "--", path)
|
|
1791
|
-
if added.returncode != 0:
|
|
1792
|
-
raise IsolationError(f"could not stage {path}: {added.stderr.strip()}")
|
|
1824
|
+
_stage_paths(repo, "-A", staging)
|
|
1793
1825
|
staged = _split_paths(
|
|
1794
|
-
git_output(repo, "diff", "--cached", "--name-only", "--relative")
|
|
1826
|
+
git_output(repo, "diff", "--no-renames", "--cached", "--name-only", "--relative")
|
|
1795
1827
|
)
|
|
1796
1828
|
if staged != staging:
|
|
1797
1829
|
raise IsolationError("staged paths do not match the uncommitted change set")
|
|
1798
1830
|
verified_tree = git_output(repo, "write-tree")
|
|
1799
1831
|
verified_delta = _tree_delta(repo, base, verified_tree)
|
|
1800
|
-
committed = run_git(repo, "commit", "-q", "-
|
|
1832
|
+
committed = run_git(repo, "commit", "-q", "-F", "-", input=_commit_message(subject, body))
|
|
1801
1833
|
if committed.returncode != 0:
|
|
1802
1834
|
raise IsolationError(
|
|
1803
1835
|
(committed.stderr or committed.stdout).strip() or "commit failed"
|
|
@@ -4470,6 +4502,20 @@ def attest(
|
|
|
4470
4502
|
}
|
|
4471
4503
|
|
|
4472
4504
|
|
|
4505
|
+
def _committed_ship_active(repo: Path) -> bool:
|
|
4506
|
+
"""True when the STATE.md committed at HEAD is ship/active."""
|
|
4507
|
+
|
|
4508
|
+
result = run_git(repo, "show", "HEAD:.project/STATE.md")
|
|
4509
|
+
if result.returncode != 0:
|
|
4510
|
+
return False
|
|
4511
|
+
frontmatter = result.stdout.split("\n---", 1)[0]
|
|
4512
|
+
fields = {
|
|
4513
|
+
key: _unquote(value)
|
|
4514
|
+
for key, value in re.findall(r"(?m)^(phase|status):(.*)$", frontmatter)
|
|
4515
|
+
}
|
|
4516
|
+
return fields.get("phase") == "ship" and fields.get("status") == "active"
|
|
4517
|
+
|
|
4518
|
+
|
|
4473
4519
|
def checkpoint(
|
|
4474
4520
|
repo: Path,
|
|
4475
4521
|
expected_head: str,
|
|
@@ -4519,6 +4565,12 @@ def checkpoint(
|
|
|
4519
4565
|
"subject": subject.strip(),
|
|
4520
4566
|
}
|
|
4521
4567
|
raise IsolationError("primary HEAD differs from --expected-head")
|
|
4568
|
+
if subject.strip().startswith("build:") and _committed_ship_active(repo):
|
|
4569
|
+
raise IsolationError(
|
|
4570
|
+
"STATE is ship/active: final review creates no pre-ship commit; leave its "
|
|
4571
|
+
"outputs uncommitted for archive_milestone.py, and send findings through "
|
|
4572
|
+
"the patch plan (see SHIP.md)"
|
|
4573
|
+
)
|
|
4522
4574
|
pending = uncommitted_paths(repo)
|
|
4523
4575
|
forced: Set[str] = set()
|
|
4524
4576
|
# Build only: an approval undo resets to a parent without the ledger and would drop it.
|
|
@@ -108,10 +108,14 @@ def _final_view(relative, text, criteria, reviewed, head, surfaces=None):
|
|
|
108
108
|
fields[field] = contracts._raw_source_field(block, field, relative)
|
|
109
109
|
if "Surface" in fields and not {"Check", "Observed"}.issubset(fields):
|
|
110
110
|
raise contracts.HandoffError(f"{criterion} lacks its recorded surface walkthrough")
|
|
111
|
+
reference = f"{relative} — {criterion}"
|
|
112
|
+
if "Observed" in fields and evidence:
|
|
113
|
+
# The reviewer may cite held-out and judgment ids on the evidence line.
|
|
114
|
+
reference += f"; evidence: {evidence}"
|
|
111
115
|
lines += [f"### {criterion} — {title}", "", "- **Verdict**: met",
|
|
112
116
|
f"- **Check**: {fields.get('Check', 'none')}",
|
|
113
117
|
f"- **Observed**: {fields.get('Observed', evidence)}",
|
|
114
|
-
f"- **Reference**: {
|
|
118
|
+
f"- **Reference**: {reference}",
|
|
115
119
|
"- **Finding**: none", "- **Fix direction**: none"]
|
|
116
120
|
if "Surface" in fields:
|
|
117
121
|
expected = (surfaces or {}).get(criterion)
|
|
@@ -196,7 +196,14 @@ def task_settings(path):
|
|
|
196
196
|
|
|
197
197
|
def native_record(args):
|
|
198
198
|
"""The parent owns lifecycle; release requires its recorded terminal-result receipt."""
|
|
199
|
+
# A relative path would resolve against cwd, which may be the immutable runtime store.
|
|
200
|
+
if not args.record.is_absolute():
|
|
201
|
+
raise PolicyError(f'--record must be an absolute path: {args.record}')
|
|
199
202
|
record = args.record.resolve()
|
|
203
|
+
missing = f'release requires an existing record and terminal-result evidence: {record}'
|
|
204
|
+
# Check before log_lock creates the record directory and its lock file.
|
|
205
|
+
if args.action == 'release' and not record.exists():
|
|
206
|
+
raise PolicyError(missing)
|
|
200
207
|
with log_lock({'log': str(record)}):
|
|
201
208
|
data = read_json(record) if record.exists() else None
|
|
202
209
|
identity = {'repo': str(args.repo.resolve()), 'project_dir': args.project_dir,
|
|
@@ -209,7 +216,7 @@ def native_record(args):
|
|
|
209
216
|
raise PolicyError(f'record belongs to another assignment or milestone/track/cycle ({differing})')
|
|
210
217
|
if args.action == 'release':
|
|
211
218
|
if data is None or not args.terminal_result:
|
|
212
|
-
raise PolicyError(
|
|
219
|
+
raise PolicyError(missing)
|
|
213
220
|
data.update(active=False, terminal_result=args.terminal_result)
|
|
214
221
|
else:
|
|
215
222
|
if args.capabilities is None:
|
|
@@ -484,7 +484,12 @@ def _require_tag_integration_proof(
|
|
|
484
484
|
"1",
|
|
485
485
|
landing,
|
|
486
486
|
).stdout.split()
|
|
487
|
-
|
|
487
|
+
rebased = (
|
|
488
|
+
mode == "pull-request"
|
|
489
|
+
and len(parents) == 2
|
|
490
|
+
and _common.rebase_landing_problem(repo, ship, landing) is None
|
|
491
|
+
)
|
|
492
|
+
if not rebased and (len(parents) != 3 or parents[2] != ship):
|
|
488
493
|
raise PipelineGitError(f"{mode} integration proof has invalid merge topology")
|
|
489
494
|
first_parent = _run_git(repo, "rev-list", "--first-parent", base).stdout.splitlines()
|
|
490
495
|
if landing not in first_parent:
|
|
@@ -694,6 +699,7 @@ def retire_previous_branch(
|
|
|
694
699
|
expected_sha: str,
|
|
695
700
|
*,
|
|
696
701
|
allow_remote_absent: bool = False,
|
|
702
|
+
rebased: bool = False,
|
|
697
703
|
) -> None:
|
|
698
704
|
"""Delete an integrated previous bound branch, locally and on origin.
|
|
699
705
|
|
|
@@ -741,7 +747,8 @@ def retire_previous_branch(
|
|
|
741
747
|
if _ref_exists(repo, tracking_ref):
|
|
742
748
|
_run_git(repo, "update-ref", "-d", tracking_ref)
|
|
743
749
|
if _ref_exists(repo, local_ref):
|
|
744
|
-
|
|
750
|
+
# A rebased ship is not an ancestor of HEAD, so -d would refuse it.
|
|
751
|
+
_run_git(repo, "branch", "-D" if rebased else "-d", previous_branch)
|
|
745
752
|
|
|
746
753
|
|
|
747
754
|
def _retire_members(repo: Path, ship_sha: str) -> None:
|
|
@@ -840,9 +847,15 @@ def bind_next_milestone_branch(
|
|
|
840
847
|
landing_sha,
|
|
841
848
|
check=False,
|
|
842
849
|
)
|
|
843
|
-
|
|
850
|
+
# A forge rebase merge lands copies of the ship commits (#418); the
|
|
851
|
+
# pull-request milestone tag must then prove the landing.
|
|
852
|
+
rebased = (
|
|
853
|
+
integrated.returncode == 1
|
|
854
|
+
and _common.rebase_landing_problem(repo, ship_sha, landing_sha) is None
|
|
855
|
+
)
|
|
856
|
+
if integrated.returncode == 1 and not rebased:
|
|
844
857
|
raise PipelineGitError("milestone landing does not contain the ship commit")
|
|
845
|
-
if integrated.returncode
|
|
858
|
+
if integrated.returncode not in (0, 1):
|
|
846
859
|
detail = integrated.stderr.strip() or integrated.stdout.strip()
|
|
847
860
|
raise PipelineGitError(f"could not verify milestone landing: {detail}")
|
|
848
861
|
landing_integrated = _run_git(
|
|
@@ -858,7 +871,7 @@ def bind_next_milestone_branch(
|
|
|
858
871
|
if landing_integrated.returncode != 0:
|
|
859
872
|
detail = landing_integrated.stderr.strip() or landing_integrated.stdout.strip()
|
|
860
873
|
raise PipelineGitError(f"could not verify integrated branch: {detail}")
|
|
861
|
-
if allow_remote_absent:
|
|
874
|
+
if allow_remote_absent or rebased:
|
|
862
875
|
proven_landing = _require_tag_integration_proof(
|
|
863
876
|
repo,
|
|
864
877
|
ship_sha,
|
|
@@ -994,6 +1007,7 @@ def bind_next_milestone_branch(
|
|
|
994
1007
|
allow_remote_absent=(
|
|
995
1008
|
allow_remote_absent or journal["stage"] in {"switched", "retired"}
|
|
996
1009
|
),
|
|
1010
|
+
rebased=rebased,
|
|
997
1011
|
)
|
|
998
1012
|
_retire_members(repo, ship_sha)
|
|
999
1013
|
journal["stage"] = "retired"
|
|
@@ -1024,7 +1024,9 @@ def _bind_next_journal_recovery(
|
|
|
1024
1024
|
)
|
|
1025
1025
|
if resolved_landing.returncode != 0 or resolved_landing.stdout.strip() != landing:
|
|
1026
1026
|
raise PipelineStateError("bind-next journal landing is not an existing full SHA")
|
|
1027
|
-
if not _is_ancestor(repo, ship, landing)
|
|
1027
|
+
if not _is_ancestor(repo, ship, landing) and _common.rebase_landing_problem(
|
|
1028
|
+
repo, ship, landing
|
|
1029
|
+
) is not None:
|
|
1028
1030
|
raise PipelineStateError("bind-next journal landing does not contain ship")
|
|
1029
1031
|
if not _is_ancestor(repo, landing, base):
|
|
1030
1032
|
raise PipelineStateError("bind-next journal landing is not an ancestor of base")
|
|
@@ -343,6 +343,67 @@ def _completed_promotion(
|
|
|
343
343
|
}
|
|
344
344
|
|
|
345
345
|
|
|
346
|
+
def _ruling_rows(text: str) -> dict[str, list[str]]:
|
|
347
|
+
"""Map Queue # to the cells of each `## User rulings` table row."""
|
|
348
|
+
rows: dict[str, list[str]] = {}
|
|
349
|
+
in_rulings = False
|
|
350
|
+
for line in text.splitlines():
|
|
351
|
+
if line == "## User rulings":
|
|
352
|
+
in_rulings = True
|
|
353
|
+
continue
|
|
354
|
+
if in_rulings and line.startswith("## "):
|
|
355
|
+
break
|
|
356
|
+
if in_rulings and line.lstrip().startswith("|"):
|
|
357
|
+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
358
|
+
if len(cells) == 4 and cells[0].isdigit():
|
|
359
|
+
rows[cells[0]] = cells
|
|
360
|
+
return rows
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _superseded_carry_forward(
|
|
364
|
+
repo: Path,
|
|
365
|
+
active_state: PipelineState,
|
|
366
|
+
source: Path,
|
|
367
|
+
destination: Path,
|
|
368
|
+
) -> bool:
|
|
369
|
+
"""True when active research is only the shipped DOCS-AUDIT carry-forward
|
|
370
|
+
and the lookahead audit keeps every one of its rulings."""
|
|
371
|
+
if not active_state.archive or destination.is_symlink() or not destination.is_dir():
|
|
372
|
+
return False
|
|
373
|
+
archived = repo / active_state.archive / "research" / "DOCS-AUDIT.md"
|
|
374
|
+
active = destination / "DOCS-AUDIT.md"
|
|
375
|
+
lookahead = source / "DOCS-AUDIT.md"
|
|
376
|
+
entries = [
|
|
377
|
+
path for path in destination.iterdir() if not state_checkpoint._is_junk_file(path)
|
|
378
|
+
]
|
|
379
|
+
if entries != [active] or any(
|
|
380
|
+
path.is_symlink() or not path.is_file() for path in (archived, active, lookahead)
|
|
381
|
+
):
|
|
382
|
+
return False
|
|
383
|
+
carried = active.read_bytes()
|
|
384
|
+
if carried != archived.read_bytes():
|
|
385
|
+
return False
|
|
386
|
+
rulings = _ruling_rows(carried.decode("utf-8"))
|
|
387
|
+
if not any(cells[3] == "no" for cells in rulings.values()):
|
|
388
|
+
return False
|
|
389
|
+
kept = _ruling_rows(lookahead.read_text(encoding="utf-8"))
|
|
390
|
+
for number, cells in rulings.items():
|
|
391
|
+
other = kept.get(number)
|
|
392
|
+
# Plan may record a T### for a pending ruling; nothing else may change.
|
|
393
|
+
if (
|
|
394
|
+
other is None
|
|
395
|
+
or other[:3] != cells[:3]
|
|
396
|
+
or (
|
|
397
|
+
other[3] != cells[3]
|
|
398
|
+
and not (cells[3] == "no" and re.fullmatch(r"T\d{3}", other[3]))
|
|
399
|
+
)
|
|
400
|
+
):
|
|
401
|
+
raise PipelineStateError(
|
|
402
|
+
f"lookahead DOCS-AUDIT.md does not keep carry-forward ruling #{number}"
|
|
403
|
+
)
|
|
404
|
+
return True
|
|
405
|
+
|
|
406
|
+
|
|
346
407
|
def _prepare_promotion(
|
|
347
408
|
repo: Path,
|
|
348
409
|
project: Path,
|
|
@@ -475,12 +536,17 @@ def _prepare_promotion(
|
|
|
475
536
|
raise PipelineStateError("lookahead STATE branch and archive must be null")
|
|
476
537
|
|
|
477
538
|
tracks: dict[str, str] = {}
|
|
539
|
+
carry_forward: Optional[str] = None
|
|
478
540
|
for name in PROMOTION_TRACKS:
|
|
479
541
|
source = next_root / name
|
|
480
542
|
destination = project / name
|
|
481
543
|
if source.exists() or source.is_symlink():
|
|
482
544
|
if destination.exists() or destination.is_symlink():
|
|
483
|
-
|
|
545
|
+
if name != "research" or not _superseded_carry_forward(
|
|
546
|
+
repo, active_state, source, destination
|
|
547
|
+
):
|
|
548
|
+
raise PipelineStateError(f"promotion destination already exists: {destination}")
|
|
549
|
+
carry_forward = state_checkpoint._tree_digest(destination)
|
|
484
550
|
tracks[name] = state_checkpoint._tree_digest(source)
|
|
485
551
|
if not tracks:
|
|
486
552
|
raise PipelineStateError("lookahead track has no promotable artifacts")
|
|
@@ -507,6 +573,7 @@ def _prepare_promotion(
|
|
|
507
573
|
"landing": landing,
|
|
508
574
|
"base": base,
|
|
509
575
|
"tracks": tracks,
|
|
576
|
+
"carry_forward_sha256": carry_forward,
|
|
510
577
|
"residual_sha256": state_checkpoint._tree_digest(next_root, tuple(tracks)),
|
|
511
578
|
"next_state_sha256": state_checkpoint._sha256(next_text),
|
|
512
579
|
"active_state_sha256": state_checkpoint._sha256(active_text),
|
|
@@ -559,6 +626,15 @@ def _resume_track_moves(project: Path, journal: dict[str, object], path: Path) -
|
|
|
559
626
|
destination = project / name
|
|
560
627
|
source_exists = source.exists() or source.is_symlink()
|
|
561
628
|
destination_exists = destination.exists() or destination.is_symlink()
|
|
629
|
+
carry_forward = journal.get("carry_forward_sha256")
|
|
630
|
+
if name == "research" and source_exists and destination_exists and carry_forward:
|
|
631
|
+
if state_checkpoint._tree_digest(destination) != carry_forward:
|
|
632
|
+
raise PipelineStateError("DOCS-AUDIT carry-forward drifted during promotion")
|
|
633
|
+
# The archive and the ship commit keep the carry-forward copy.
|
|
634
|
+
shutil.rmtree(destination)
|
|
635
|
+
journal["stage"] = "removed:carry-forward"
|
|
636
|
+
pipeline_state._write_json(path, journal)
|
|
637
|
+
destination_exists = False
|
|
562
638
|
if source_exists and destination_exists:
|
|
563
639
|
raise PipelineStateError(f"promotion has both source and destination: {name}")
|
|
564
640
|
if not source_exists and not destination_exists:
|