claude-dev-env 7.0.0 → 7.1.0

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.
Files changed (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +189 -0
  6. package/.agents/skills/session-continuity/SKILL.md +114 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +375 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +78 -0
  20. package/bin/install-session-continuity.test.mjs +42 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -615,7 +615,7 @@ def _resolved_codex_enforcer_command(config: MaterializerConfig) -> str:
615
615
 
616
616
  def _source_codex_enforcer_hook(
617
617
  all_source_manifest: dict[str, object], command: str
618
- ) -> dict[str, object]:
618
+ ) -> dict[str, object] | None:
619
619
  """Read the source enforcer hook shape and resolve its target command."""
620
620
  all_events = all_source_manifest.get("hooks")
621
621
  if not isinstance(all_events, dict):
@@ -637,11 +637,7 @@ def _source_codex_enforcer_hook(
637
637
  resolved_hook = dict(each_hook)
638
638
  resolved_hook[codex_hook_command_field_name] = command
639
639
  return resolved_hook
640
- return {
641
- "type": "command",
642
- codex_hook_command_field_name: command,
643
- "timeout": codex_hook_timeout_seconds,
644
- }
640
+ return None
645
641
 
646
642
 
647
643
  def _hook_records(raw_hooks: object) -> list[dict[str, object]]:
@@ -668,6 +664,72 @@ def _is_code_rules_enforcer_hook(all_hook_record: dict[str, object]) -> bool:
668
664
  )
669
665
 
670
666
 
667
+ def _is_managed_codex_enforcer_hook(
668
+ all_hook_record: dict[str, object], managed_command: str
669
+ ) -> bool:
670
+ """Report whether a hook record is the materializer's target command."""
671
+ command = all_hook_record.get(codex_hook_command_field_name)
672
+ if not isinstance(command, str):
673
+ return False
674
+ normalized_command = command.replace("\\", path_separator)
675
+ normalized_managed_command = managed_command.replace("\\", path_separator)
676
+ if os.name == "nt":
677
+ return normalized_command.casefold() == normalized_managed_command.casefold()
678
+ return normalized_command == normalized_managed_command
679
+
680
+
681
+ def _without_managed_codex_enforcer(
682
+ entry: object, managed_command: str
683
+ ) -> object | None:
684
+ """Remove the managed enforcer while preserving every other hook record."""
685
+ if not isinstance(entry, dict):
686
+ return entry
687
+ if entry.get("matcher") != codex_hook_matcher:
688
+ return entry
689
+ copied_entry = dict(entry)
690
+ all_hook_records = copied_entry.get(codex_hook_records_field_name)
691
+ if not isinstance(all_hook_records, list):
692
+ return copied_entry
693
+ all_retained_hooks = [
694
+ each_hook
695
+ for each_hook in all_hook_records
696
+ if not isinstance(each_hook, dict)
697
+ or not _is_managed_codex_enforcer_hook(each_hook, managed_command)
698
+ ]
699
+ if all_retained_hooks == all_hook_records:
700
+ return copied_entry
701
+ if not all_retained_hooks:
702
+ return None
703
+ copied_entry[codex_hook_records_field_name] = all_retained_hooks
704
+ return copied_entry
705
+
706
+
707
+ def _remove_managed_codex_enforcer(
708
+ all_target_manifest: dict[str, object], managed_command: str
709
+ ) -> dict[str, object]:
710
+ """Remove the retired managed enforcer from the target manifest."""
711
+ all_events = all_target_manifest.get(codex_hook_records_field_name, {})
712
+ if not isinstance(all_events, dict):
713
+ raise MaterializerError("Codex hook manifest requires a hooks object")
714
+ all_pre_tool_use = all_events.get(codex_hook_event_name)
715
+ if all_pre_tool_use is None:
716
+ return _prune_retired_codex_hooks(all_target_manifest)
717
+ if not isinstance(all_pre_tool_use, list):
718
+ raise MaterializerError("Codex hook manifest requires a PreToolUse list")
719
+ all_retained_entries: list[object] = []
720
+ for each_entry in all_pre_tool_use:
721
+ maybe_retained_entry = _without_managed_codex_enforcer(
722
+ each_entry, managed_command
723
+ )
724
+ if maybe_retained_entry is not None:
725
+ all_retained_entries.append(maybe_retained_entry)
726
+ merged_events = dict(all_events)
727
+ merged_events[codex_hook_event_name] = all_retained_entries
728
+ merged_manifest = dict(all_target_manifest)
729
+ merged_manifest[codex_hook_records_field_name] = merged_events
730
+ return _prune_retired_codex_hooks(merged_manifest)
731
+
732
+
671
733
  def _is_retired_codex_hook_command(command: object) -> bool:
672
734
  """Report whether a command names a known retired hook path."""
673
735
  if not isinstance(command, str):
@@ -764,13 +826,20 @@ def _has_modified_codex_enforcer_hook(
764
826
  current_hooks = _codex_enforcer_hooks(current_manifest)
765
827
  if not current_hooks:
766
828
  return False
767
- return current_hooks != _codex_enforcer_hooks(planned_manifest)
829
+ planned_hooks = _codex_enforcer_hooks(planned_manifest)
830
+ if all(each_hook in current_hooks for each_hook in planned_hooks):
831
+ return False
832
+ return current_hooks != planned_hooks
768
833
 
769
834
 
770
835
  def _merge_codex_hook_manifest(
771
- all_target_manifest: dict[str, object], all_focused_hook: dict[str, object]
836
+ all_target_manifest: dict[str, object],
837
+ all_focused_hook: dict[str, object] | None,
838
+ managed_command: str,
772
839
  ) -> dict[str, object]:
773
840
  """Preserve target hook order while merging one deterministic enforcer entry."""
841
+ if all_focused_hook is None:
842
+ return _remove_managed_codex_enforcer(all_target_manifest, managed_command)
774
843
  all_events = all_target_manifest.get(codex_hook_records_field_name, {})
775
844
  if not isinstance(all_events, dict):
776
845
  raise MaterializerError("Codex hook manifest requires a hooks object")
@@ -797,13 +866,17 @@ def _merge_codex_hook_manifest(
797
866
  if merged_apply_patch_entry is None:
798
867
  merged_apply_patch_entry = copied_entry
799
868
  merged_hooks = [
800
- each_hook for each_hook in all_hook_records if not _is_code_rules_enforcer_hook(each_hook)
869
+ each_hook
870
+ for each_hook in all_hook_records
871
+ if not _is_managed_codex_enforcer_hook(each_hook, managed_command)
801
872
  ]
802
873
  merged_apply_patch_entry[codex_hook_records_field_name] = merged_hooks
803
874
  merged_pre_tool_use.append(merged_apply_patch_entry)
804
875
  else:
805
876
  merged_hooks.extend(
806
- each_hook for each_hook in all_hook_records if not _is_code_rules_enforcer_hook(each_hook)
877
+ each_hook
878
+ for each_hook in all_hook_records
879
+ if not _is_managed_codex_enforcer_hook(each_hook, managed_command)
807
880
  )
808
881
  if merged_apply_patch_entry is None:
809
882
  merged_apply_patch_entry = {
@@ -831,10 +904,11 @@ def _build_codex_hook_projection(config: MaterializerConfig) -> PlannedFile | No
831
904
  if target_path.is_file()
832
905
  else {"hooks": {}}
833
906
  )
834
- focused_hook = _source_codex_enforcer_hook(
835
- source_manifest, _resolved_codex_enforcer_command(config)
907
+ managed_command = _resolved_codex_enforcer_command(config)
908
+ focused_hook = _source_codex_enforcer_hook(source_manifest, managed_command)
909
+ projected_manifest = _merge_codex_hook_manifest(
910
+ target_manifest, focused_hook, managed_command
836
911
  )
837
- projected_manifest = _merge_codex_hook_manifest(target_manifest, focused_hook)
838
912
  projected_content = json.dumps(projected_manifest, ensure_ascii=False, indent=manifest_indentation_width) + line_separator
839
913
  return PlannedFile(
840
914
  codex_hook_manifest_source_path,
@@ -849,6 +923,8 @@ def _build_codex_hook_dependency_projection(
849
923
  config: MaterializerConfig,
850
924
  ) -> list[PlannedFile]:
851
925
  """Build the reviewed source files required by the target enforcer."""
926
+ if not _has_source_codex_enforcer(config):
927
+ return []
852
928
  all_dependencies: list[PlannedFile] = []
853
929
  for each_relative_path in codex_hook_dependency_manifest:
854
930
  source_path = _validated_source_file(
@@ -871,6 +947,16 @@ def _build_codex_hook_dependency_projection(
871
947
  return all_dependencies
872
948
 
873
949
 
950
+ def _has_source_codex_enforcer(config: MaterializerConfig) -> bool:
951
+ """Report whether hooks.json registers the enforcer."""
952
+ source_path = _find_codex_hook_source(config)
953
+ if source_path is None:
954
+ return False
955
+ source_manifest = _read_json_object(source_path, "source Codex hook manifest")
956
+ managed_command = _resolved_codex_enforcer_command(config)
957
+ return _source_codex_enforcer_hook(source_manifest, managed_command) is not None
958
+
959
+
874
960
  def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
875
961
  """Discover and parse Markdown agents below the source root.
876
962
 
@@ -16,6 +16,7 @@ from durable_post_lint_config.config.constants import (
16
16
  ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS,
17
17
  ALL_POST_ACTIONS,
18
18
  ALL_PR_DESCRIPTION_ACTIONS,
19
+ ALL_RELEASE_BODY_MARKERS,
19
20
  ALL_REQUIRED_PR_DESCRIPTION_HEADINGS,
20
21
  ALL_TITLE_ACTIONS,
21
22
  BODY_FILE_ENCODING,
@@ -35,6 +36,9 @@ from durable_post_lint_config.config.constants import (
35
36
  MISSING_HEADING_MESSAGE_TEMPLATE,
36
37
  PATH_ANCHOR_CHARACTER,
37
38
  PATH_SEGMENT_START_CHARACTERS,
39
+ RELEASE_BRANCH_PREFIX,
40
+ REWRITTEN_RELEASE_BODY_CODE,
41
+ REWRITTEN_RELEASE_BODY_MESSAGE,
38
42
  TITLE_NOT_ALLOWED_MESSAGE,
39
43
  TITLE_REQUIRED_MESSAGE,
40
44
  VOLATILE_PATH_CODE,
@@ -136,9 +140,25 @@ def _title_findings(title: str | None) -> list[DurablePostFinding]:
136
140
  return [DurablePostFinding(code=INVALID_TITLE_CODE, message=INVALID_TITLE_MESSAGE)]
137
141
 
138
142
 
143
+ def _is_release_automation_branch(head_branch: str | None) -> bool:
144
+ return head_branch is not None and head_branch.startswith(RELEASE_BRANCH_PREFIX)
145
+
146
+
147
+ def _release_body_findings(body_text: str) -> list[DurablePostFinding]:
148
+ if all(each_marker in body_text for each_marker in ALL_RELEASE_BODY_MARKERS):
149
+ return []
150
+ return [
151
+ DurablePostFinding(
152
+ code=REWRITTEN_RELEASE_BODY_CODE,
153
+ message=REWRITTEN_RELEASE_BODY_MESSAGE,
154
+ )
155
+ ]
156
+
157
+
139
158
  def _body_findings(
140
159
  action: str,
141
160
  body_text: str | None,
161
+ head_branch: str | None = None,
142
162
  ) -> list[DurablePostFinding]:
143
163
  if body_text is None:
144
164
  return []
@@ -150,7 +170,9 @@ def _body_findings(
150
170
  message=BODY_MUST_NOT_BE_EMPTY_MESSAGE,
151
171
  )
152
172
  )
153
- if action in ALL_PR_DESCRIPTION_ACTIONS:
173
+ if _is_release_automation_branch(head_branch):
174
+ all_findings.extend(_release_body_findings(body_text))
175
+ elif action in ALL_PR_DESCRIPTION_ACTIONS:
154
176
  all_findings.extend(_description_heading_findings(body_text))
155
177
  if find_volatile_path_marker(body_text) is not None:
156
178
  all_findings.append(
@@ -166,10 +188,14 @@ def lint_durable_post(
166
188
  action: str,
167
189
  title: str | None,
168
190
  body_text: str | None,
191
+ head_branch: str | None = None,
169
192
  ) -> tuple[DurablePostFinding, ...]:
170
193
  """Return content findings for one locally valid GitHub post request."""
171
194
  _validate_request_shape(action, title, body_text)
172
- return (*_title_findings(title), *_body_findings(action, body_text))
195
+ return (
196
+ *_title_findings(title),
197
+ *_body_findings(action, body_text, head_branch),
198
+ )
173
199
 
174
200
 
175
201
  def _parse_arguments(all_arguments: Sequence[str]) -> argparse.Namespace:
@@ -177,6 +203,7 @@ def _parse_arguments(all_arguments: Sequence[str]) -> argparse.Namespace:
177
203
  parser.add_argument("--action", required=True)
178
204
  parser.add_argument("--title")
179
205
  parser.add_argument("--body-file", type=Path)
206
+ parser.add_argument("--head-branch")
180
207
  return parser.parse_args(list(all_arguments))
181
208
 
182
209
 
@@ -193,6 +220,7 @@ def main(all_arguments: Sequence[str]) -> int:
193
220
  action=arguments.action,
194
221
  title=arguments.title,
195
222
  body_text=body_text,
223
+ head_branch=arguments.head_branch,
196
224
  )
197
225
  except (DurablePostInputError, DurablePostUsageError) as error:
198
226
  sys.stderr.write(f"{error}\n")
@@ -94,3 +94,13 @@ TITLE_NOT_ALLOWED_MESSAGE = "this action does not accept a title"
94
94
  TITLE_REQUIRED_MESSAGE = "pr-create requires a title"
95
95
  VOLATILE_PATH_CODE = "volatile-local-path"
96
96
  VOLATILE_PATH_MESSAGE = "body contains a volatile local artifact path"
97
+ RELEASE_BRANCH_PREFIX = "release-please--branches--"
98
+ ALL_RELEASE_BODY_MARKERS: tuple[str, ...] = (
99
+ ":robot: I have created a release",
100
+ "This PR was generated with [Release Please]",
101
+ )
102
+ REWRITTEN_RELEASE_BODY_CODE = "rewritten-release-body"
103
+ REWRITTEN_RELEASE_BODY_MESSAGE = (
104
+ "release automation reads this body back to recognize its own merge; "
105
+ "keep the generated text whole"
106
+ )
@@ -3,7 +3,9 @@
3
3
  from durable_post_lint_config.config.constants import (
4
4
  ALL_CONVENTIONAL_TITLE_TYPES,
5
5
  ALL_POST_ACTIONS,
6
+ ALL_RELEASE_BODY_MARKERS,
6
7
  CONVENTIONAL_TITLE_PATTERN,
8
+ RELEASE_BRANCH_PREFIX,
7
9
  )
8
10
 
9
11
 
@@ -23,3 +25,14 @@ def test_action_inventory_covers_pull_request_issue_and_tool_posts() -> None:
23
25
  def test_title_pattern_uses_every_configured_type() -> None:
24
26
  for each_title_type in ALL_CONVENTIONAL_TITLE_TYPES:
25
27
  assert CONVENTIONAL_TITLE_PATTERN.fullmatch(f"{each_title_type}: one change")
28
+
29
+
30
+ def should_carry_both_markers_release_automation_writes() -> None:
31
+ assert ALL_RELEASE_BODY_MARKERS == (
32
+ ":robot: I have created a release",
33
+ "This PR was generated with [Release Please]",
34
+ )
35
+
36
+
37
+ def should_name_the_branch_prefix_release_automation_pushes() -> None:
38
+ assert RELEASE_BRANCH_PREFIX == "release-please--branches--"
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+
11
+ from local_report_core import PublicationOutcome, publish_local_report
12
+ from local_verification.config import SUCCESS_EXIT_CODE
13
+ from pr_verification.config.constants import GIT_BARE_SUFFIX, GITHUB_API_URL
14
+ from pr_verification.github import GitHubAppAuthenticator, GitHubError
15
+ from pr_verification.model import RepositorySettings
16
+
17
+
18
+ def main(
19
+ all_arguments: Sequence[str],
20
+ *,
21
+ stdout: TextIO = sys.stdout,
22
+ stderr: TextIO = sys.stderr,
23
+ ) -> int:
24
+ """Publish one local verification report.
25
+
26
+ Args:
27
+ all_arguments: Command line arguments.
28
+ stdout: Output stream for the publication outcome.
29
+ stderr: Output stream for startup and API errors.
30
+
31
+ Returns:
32
+ The verifier process exit code.
33
+ """
34
+ parser = _build_parser()
35
+ parsed_arguments = parser.parse_args(list(all_arguments))
36
+ try:
37
+ settings = _load_settings(parsed_arguments)
38
+ outcome = _publish_from_settings(settings, parsed_arguments)
39
+ except (OSError, TypeError, ValueError, GitHubError) as error:
40
+ stderr.write(f"{error}\n")
41
+ return 3
42
+ stdout.write(json.dumps(_outcome_mapping(outcome)) + "\n")
43
+ return SUCCESS_EXIT_CODE
44
+
45
+
46
+ def _publish_from_settings(
47
+ settings: _PublisherSettings, parsed_arguments: argparse.Namespace
48
+ ) -> PublicationOutcome:
49
+ repository = RepositorySettings(
50
+ settings.repository, settings.repository + GIT_BARE_SUFFIX
51
+ )
52
+ authenticator = GitHubAppAuthenticator(
53
+ settings.api_url,
54
+ settings.app_id,
55
+ settings.installation_id,
56
+ settings.private_key_path,
57
+ )
58
+ github = authenticator.issue_repository_api(
59
+ repository, should_write_issue_labels=True
60
+ )
61
+ return publish_local_report(
62
+ github,
63
+ repository,
64
+ settings.pull_request_number,
65
+ parsed_arguments.local_repo,
66
+ parsed_arguments.manifest,
67
+ parsed_arguments.report,
68
+ )
69
+
70
+
71
+ def _outcome_mapping(outcome: PublicationOutcome) -> dict[str, object]:
72
+ return {
73
+ "status": outcome.status.value,
74
+ "description": outcome.description,
75
+ "publishable": outcome.publishable,
76
+ }
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class _PublisherSettings:
81
+ api_url: str
82
+ app_id: int
83
+ installation_id: int
84
+ private_key_path: Path
85
+ repository: str
86
+ pull_request_number: int
87
+
88
+
89
+ def _build_parser() -> argparse.ArgumentParser:
90
+ parser = argparse.ArgumentParser(prog="cde publish-local-report")
91
+ parser.add_argument("--settings", type=Path)
92
+ parser.add_argument("--api-url")
93
+ parser.add_argument("--app-id", type=int)
94
+ parser.add_argument("--installation-id", type=int)
95
+ parser.add_argument("--private-key-path", type=Path)
96
+ parser.add_argument("--repository")
97
+ parser.add_argument("--pull-number", type=int)
98
+ parser.add_argument("--local-repo", type=Path, required=True)
99
+ parser.add_argument("--manifest", type=Path, required=True)
100
+ parser.add_argument("--report", type=Path, required=True)
101
+ return parser
102
+
103
+
104
+ def _load_settings(parsed_arguments: argparse.Namespace) -> _PublisherSettings:
105
+ all_settings: Mapping[str, object] = {}
106
+ if parsed_arguments.settings is not None:
107
+ parsed_settings = json.loads(
108
+ parsed_arguments.settings.read_text(encoding="utf-8")
109
+ )
110
+ if not isinstance(parsed_settings, Mapping):
111
+ raise ValueError("Publisher settings must be an object")
112
+ all_settings = parsed_settings
113
+ return _PublisherSettings(
114
+ _required_setting(
115
+ parsed_arguments.api_url,
116
+ all_settings,
117
+ "api_url",
118
+ GITHUB_API_URL,
119
+ ),
120
+ _required_integer_setting(parsed_arguments.app_id, all_settings, "app_id"),
121
+ _required_integer_setting(
122
+ parsed_arguments.installation_id, all_settings, "installation_id"
123
+ ),
124
+ _required_path_setting(parsed_arguments.private_key_path, all_settings),
125
+ _required_repository_setting(parsed_arguments.repository, all_settings),
126
+ _required_integer_setting(
127
+ parsed_arguments.pull_number, all_settings, "pull_number"
128
+ ),
129
+ )
130
+
131
+
132
+ def _required_setting(
133
+ explicit_setting: object,
134
+ all_settings: Mapping[str, object],
135
+ setting_name: str,
136
+ default_setting: str | None = None,
137
+ ) -> str:
138
+ selected_setting = explicit_setting
139
+ if selected_setting is None:
140
+ selected_setting = all_settings.get(setting_name, default_setting)
141
+ if not isinstance(selected_setting, str) or not selected_setting:
142
+ raise TypeError(f"Missing publisher setting: {setting_name}")
143
+ return selected_setting
144
+
145
+
146
+ def _required_repository_setting(
147
+ explicit_repository: object, all_settings: Mapping[str, object]
148
+ ) -> str:
149
+ repository_slug = _required_setting(explicit_repository, all_settings, "repository")
150
+ try:
151
+ repository_owner, repository_name = repository_slug.split("/")
152
+ except ValueError as error:
153
+ raise TypeError("Repository must use the owner/name form") from error
154
+ if not repository_owner or not repository_name:
155
+ raise TypeError("Repository must use the owner/name form")
156
+ return repository_slug
157
+
158
+
159
+ def _required_path_setting(
160
+ explicit_path: object, all_settings: Mapping[str, object]
161
+ ) -> Path:
162
+ selected_path = explicit_path
163
+ if selected_path is None:
164
+ selected_path = all_settings.get("private_key_path")
165
+ if not isinstance(selected_path, (str, Path)) or not selected_path:
166
+ raise TypeError("Missing publisher setting: private_key_path")
167
+ return Path(selected_path)
168
+
169
+
170
+ def _required_integer_setting(
171
+ explicit_setting: object,
172
+ all_settings: Mapping[str, object],
173
+ setting_name: str,
174
+ ) -> int:
175
+ selected_setting = explicit_setting
176
+ if selected_setting is None:
177
+ selected_setting = all_settings.get(setting_name)
178
+ if isinstance(selected_setting, bool) or not isinstance(selected_setting, int):
179
+ raise TypeError(f"Missing publisher setting: {setting_name}")
180
+ return selected_setting
181
+
182
+
183
+ if __name__ == "__main__":
184
+ raise SystemExit(main(sys.argv[1:]))