@deftai/directive-content 0.55.2 → 0.56.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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,1011 @@
1
+ #!/usr/bin/env python3
2
+ """release_e2e.py -- Auto-create + auto-destroy temp-repo release rehearsal (#716, #720).
3
+
4
+ Companion to ``scripts/release.py`` per the #716 safety hardening Q1
5
+ decision (auto-create + auto-destroy temp repo). ``task release:e2e``
6
+ provisions a private GitHub repo named
7
+ ``deftai-release-test-<timestamp>-<uuid6>``, runs the full release
8
+ pipeline against it, then destroys the repo via ``gh repo delete --yes``
9
+ in a ``try/finally`` so cleanup runs even when the test fails.
10
+
11
+ Pipeline (#720 deepening) and env hygiene (#728 cycle 2)
12
+ --------------------------------------------------------
13
+ The rehearsal step was previously a smoke-test existence check
14
+ (``gh repo view``); per #720 it now mirrors the directive repo into
15
+ the temp remote and exercises the actual ``task release`` pipeline
16
+ end-to-end. Per the #728 cycle-2 Greptile review, every subprocess
17
+ that could resolve a project root (``clone_repo_to_temp``,
18
+ ``dispatch_task_release``, ``dispatch_task_release_rollback``) must
19
+ also pin ``DEFT_PROJECT_ROOT=<clone_dir>`` so an operator with that
20
+ environment variable already exported in their shell does NOT have
21
+ ``task release`` resolve back to the real directive repo and push
22
+ spurious ``v0.0.1`` artefacts to ``deftai/directive``.
23
+
24
+ 1. Generate a unique repo slug (``deftai-release-test-<timestamp>-<uuid6>``)
25
+ 2. ``gh repo create --private deftai/<slug> --description "..."``
26
+ 3. Mirror the current directive repo into the temp remote and exercise
27
+ the release pipeline:
28
+
29
+ a. ``git clone <project_root> <tmpdir>`` -- shallow-style local clone
30
+ (operates on the on-disk repo so we do not depend on network).
31
+ b. ``git -C <tmpdir> remote set-url origin <temp-repo-url>`` -- point
32
+ origin at the auto-created temp remote.
33
+ c. ``git -C <tmpdir> push origin refs/heads/*:refs/heads/*
34
+ refs/tags/*:refs/tags/*`` -- populate the temp remote with every
35
+ branch and tag using explicit refspecs. We deliberately avoid
36
+ ``git push --mirror`` here because ``--mirror`` also pushes
37
+ ``refs/remotes/*`` (the local clone's remote-tracking refs);
38
+ GitHub's receive-pack rejects writes to that namespace and the
39
+ whole rehearsal would fail at the push step. Explicit refspecs
40
+ cover the two namespaces we actually care about (heads + tags)
41
+ without leaking remote-tracking refs.
42
+ d. ``task release -- 0.0.1 --repo deftai/<slug> --skip-ci --skip-build``
43
+ -- run the full 10-step pipeline against the temp repo. ``--skip-ci``
44
+ and ``--skip-build`` (#720, see ``scripts/release.py``) keep the
45
+ wall-clock manageable; CI / build semantics are covered by the
46
+ unit-test suite at every commit on master.
47
+ e. ``gh release view v0.0.1 --repo deftai/<slug>`` -- assert
48
+ ``isDraft=true`` and ``tagName == v0.0.1`` (the production draft
49
+ lifecycle, #716).
50
+ f. ``git -C <tmpdir> ls-remote --tags origin v0.0.1`` -- assert the
51
+ tag exists on the temp remote.
52
+ g. ``rehearse_npm_publish`` (#1910) -- mirror ``npm-publish.yml`` against
53
+ the clone: ``pnpm install`` + ``pnpm -w run build``, align the four
54
+ ``@deftai/directive*`` ``package.json`` versions + resolve the
55
+ ``workspace:`` protocol, then ``npm publish --dry-run --access public
56
+ --tag e2e-rehearsal`` per package in dependency order (types -> core ->
57
+ content -> cli). This
58
+ catches a broken ``files`` allowlist, version drift, or dependency-order
59
+ bug BEFORE a real ``v*`` tag fires the publish workflow, without ever
60
+ touching the real registry. Soft-skips when ``npm`` is absent from PATH;
61
+ suppressed entirely by ``--skip-npm`` (install+build exceed the <90s
62
+ fast budget that ``--skip-ci``/``--skip-build`` protect).
63
+ h. ``task release:rollback -- 0.0.1 --repo deftai/<slug>`` -- exercise
64
+ the rollback path against a known-state release (#725 forward-revert
65
+ flow on a protected default branch).
66
+
67
+ 4. ``gh repo delete deftai/<slug> --yes`` -- ALWAYS in a finally clause
68
+ 5. If delete fails, surface a one-line manual cleanup hint and continue
69
+ so the test result still reaches stdout
70
+
71
+ Wall-clock (#720)
72
+ -----------------
73
+ ``--skip-ci`` and ``--skip-build`` keep the rehearsal wall-clock under
74
+ 90 seconds on a typical operator machine. Skipping these is safe inside
75
+ the rehearsal because (a) CI runs at every commit on master via
76
+ ``.github/workflows/ci.yml``; (b) build artefacts are not needed for
77
+ the draft-release verification step; (c) the unit-test suite covers
78
+ both paths in isolation.
79
+
80
+ Exit codes
81
+ ----------
82
+ 0 -- rehearsal succeeded; cleanup succeeded (or surfaced as a warning)
83
+ 1 -- rehearsal failed; cleanup ran regardless
84
+ 2 -- config / argument error (gh missing, owner unset, ...)
85
+
86
+ Mockability
87
+ -----------
88
+ Every side-effecting step (``provision_temp_repo`` / ``destroy_temp_repo``
89
+ / ``clone_repo_to_temp`` / ``set_origin_to_temp_repo`` / ``push_mirror``
90
+ / ``dispatch_task_release`` / ``verify_draft_release`` / ``verify_tag``
91
+ / ``rehearse_npm_publish`` / ``dispatch_task_release_rollback``) is an
92
+ isolated function so tests can replace it with a mock; CI exercises the
93
+ orchestration without ever cloning, pushing, or hitting real GitHub.
94
+
95
+ Refs #720 (pipeline-mirror deepening), #716 (canonical spec; safety
96
+ hardening Item 4 of 7), #722 (subprocess PATHEXT fix; release._resolve_gh
97
+ helper re-used here), #725 (forward-revert + normal push in rollback),
98
+ #74 (foundation), #233, #642, #635, #709, #710.
99
+ """
100
+
101
+ from __future__ import annotations
102
+
103
+ import argparse
104
+ import contextlib
105
+ import datetime as _dt
106
+ import io
107
+ import json
108
+ import os
109
+ import shutil # noqa: F401 -- kept for tests that monkeypatch release_e2e.shutil.which
110
+ import subprocess
111
+ import sys
112
+ import tempfile
113
+ import threading
114
+ import uuid
115
+ from collections.abc import Callable
116
+ from dataclasses import dataclass
117
+ from pathlib import Path
118
+ from typing import TextIO
119
+
120
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
121
+
122
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
123
+
124
+ reconfigure_stdio()
125
+
126
+ import release # noqa: E402
127
+ import release_rollback # noqa: E402
128
+
129
+ EXIT_OK = release.EXIT_OK
130
+ EXIT_VIOLATION = release.EXIT_VIOLATION
131
+ EXIT_CONFIG_ERROR = release.EXIT_CONFIG_ERROR
132
+
133
+ DEFAULT_OWNER = "deftai"
134
+ REPO_SLUG_PREFIX = "deftai-release-test-"
135
+
136
+ # #720: the rehearsal version is a fixed sentinel rather than a real
137
+ # release version. ``0.0.1`` is far enough below any real deft release
138
+ # that an operator scrolling release notes can immediately recognise it
139
+ # as a rehearsal artefact -- and ``X.Y.Z`` matches the strict semver
140
+ # regex enforced by ``release._validate_version``.
141
+ REHEARSAL_VERSION = "0.0.1"
142
+
143
+ # #1910: dependency order for the npm publish dry-run rehearsal, mirroring
144
+ # .github/workflows/npm-publish.yml (types -> core -> content -> cli).
145
+ NPM_PUBLISH_PACKAGES = ("types", "core", "content", "cli")
146
+ # #1925: throwaway dist-tag so npm publish --dry-run does not try to apply
147
+ # ``latest`` to the fixed rehearsal sentinel ``0.0.1`` once real packages
148
+ # exist at a higher version on the registry.
149
+ NPM_E2E_REHEARSAL_TAG = "e2e-rehearsal"
150
+ NPM_INSTALL_TIMEOUT_SECONDS = 600
151
+ NPM_BUILD_TIMEOUT_SECONDS = 600
152
+ NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS = 180
153
+
154
+ RELEASE_ENTRYPOINT_TIMEOUT_SECONDS = 600.0
155
+ ROLLBACK_ENTRYPOINT_TIMEOUT_SECONDS = 300.0
156
+ ENTRYPOINT_TIMEOUT_EXIT_CODE = 124
157
+ _ENTRYPOINT_PROCESS_STATE_LOCK = threading.Lock()
158
+ _ENTRYPOINT_ACTIVE_RESTORE_OWNER: object | None = None
159
+
160
+
161
+ # ---- Data classes -----------------------------------------------------------
162
+
163
+
164
+ @dataclass
165
+ class E2EConfig:
166
+ owner: str
167
+ project_root: Path
168
+ dry_run: bool
169
+ keep_repo: bool # When True, skip cleanup (manual debugging only)
170
+ skip_npm: bool = False # When True, skip the npm publish dry-run step (#1910)
171
+ # Optional override slug (test injection). If None, a fresh slug is
172
+ # generated per run.
173
+ repo_slug: str | None = None
174
+
175
+
176
+ # ---- argument parsing -------------------------------------------------------
177
+
178
+
179
+ def _build_parser() -> argparse.ArgumentParser:
180
+ parser = argparse.ArgumentParser(
181
+ prog="release_e2e",
182
+ description=(
183
+ "End-to-end release rehearsal against an auto-created+destroyed "
184
+ "temp GitHub repo (#716 safety hardening Q1)."
185
+ ),
186
+ )
187
+ parser.add_argument(
188
+ "--owner",
189
+ default=DEFAULT_OWNER,
190
+ metavar="OWNER",
191
+ help=f"GitHub owner under which to create the temp repo (default: {DEFAULT_OWNER}).",
192
+ )
193
+ parser.add_argument(
194
+ "--dry-run",
195
+ action="store_true",
196
+ help="Print the pipeline plan without invoking gh.",
197
+ )
198
+ parser.add_argument(
199
+ "--keep-repo",
200
+ action="store_true",
201
+ help=(
202
+ "Skip destroying the temp repo at the end (use only when "
203
+ "manually debugging a failed rehearsal; remember to clean "
204
+ "up by hand)."
205
+ ),
206
+ )
207
+ parser.add_argument(
208
+ "--project-root",
209
+ type=Path,
210
+ default=None,
211
+ metavar="PATH",
212
+ help="Repository root (default: $DEFT_PROJECT_ROOT or scripts/.. ).",
213
+ )
214
+ parser.add_argument(
215
+ "--skip-npm",
216
+ action="store_true",
217
+ help=(
218
+ "Skip the npm publish dry-run rehearsal step (#1910). The step "
219
+ "installs + builds the workspace, which exceeds the <90s fast "
220
+ "budget that --skip-ci/--skip-build protect; use this to keep the "
221
+ "rehearsal fast. The step also soft-skips on its own when npm is "
222
+ "absent from PATH."
223
+ ),
224
+ )
225
+ return parser
226
+
227
+
228
+ # ---- helpers ----------------------------------------------------------------
229
+
230
+
231
+ def _emit(label: str, status: str) -> None:
232
+ print(f"[e2e] {label}... {status}", file=sys.stderr)
233
+
234
+
235
+ def generate_repo_slug() -> str:
236
+ """Generate a unique temp repo slug.
237
+
238
+ Format: ``deftai-release-test-<YYYYMMDDHHMMSS>-<uuid6>``.
239
+ The timestamp aids visual sorting in `gh repo list` if cleanup ever
240
+ fails; the uuid6 suffix ensures uniqueness across rapid re-runs.
241
+ """
242
+ timestamp = _dt.datetime.now(_dt.UTC).strftime("%Y%m%d%H%M%S")
243
+ suffix = uuid.uuid4().hex[:6]
244
+ return f"{REPO_SLUG_PREFIX}{timestamp}-{suffix}"
245
+
246
+
247
+ def provision_temp_repo(owner: str, slug: str) -> tuple[bool, str]:
248
+ """Invoke ``gh repo create --private <owner>/<slug>``.
249
+
250
+ Returns ``(ok, reason)``. The remote is created empty; downstream
251
+ pipeline steps (clone, push, etc.) are responsible for populating
252
+ it.
253
+ """
254
+ gh_path = release._resolve_gh()
255
+ if gh_path is None:
256
+ return False, "gh CLI not found on PATH"
257
+ full = f"{owner}/{slug}"
258
+ cmd = [
259
+ gh_path, "repo", "create", full,
260
+ "--private",
261
+ "--description", "Auto-generated release-rehearsal repo (deft #716); safe to delete.",
262
+ ]
263
+ try:
264
+ result = subprocess.run(
265
+ cmd,
266
+ capture_output=True,
267
+ text=True,
268
+ timeout=120,
269
+ check=False,
270
+ env=os.environ.copy(),
271
+ )
272
+ except FileNotFoundError:
273
+ return False, "gh CLI not found on PATH"
274
+ if result.returncode != 0:
275
+ return False, f"gh repo create failed: {result.stderr.strip()}"
276
+ return True, f"created {full} (private)"
277
+
278
+
279
+ def destroy_temp_repo(owner: str, slug: str) -> tuple[bool, str]:
280
+ """Invoke ``gh repo delete <owner>/<slug> --yes``.
281
+
282
+ Best-effort: returns False with a diagnostic if the delete fails so
283
+ the caller can surface a manual cleanup hint without crashing the
284
+ overall pipeline.
285
+ """
286
+ gh_path = release._resolve_gh()
287
+ if gh_path is None:
288
+ return False, "gh CLI not found on PATH"
289
+ full = f"{owner}/{slug}"
290
+ cmd = [gh_path, "repo", "delete", full, "--yes"]
291
+ try:
292
+ result = subprocess.run(
293
+ cmd,
294
+ capture_output=True,
295
+ text=True,
296
+ timeout=120,
297
+ check=False,
298
+ env=os.environ.copy(),
299
+ )
300
+ except FileNotFoundError:
301
+ return False, "gh CLI not found on PATH"
302
+ if result.returncode != 0:
303
+ return False, f"gh repo delete failed: {result.stderr.strip()}"
304
+ return True, f"deleted {full}"
305
+
306
+
307
+ # ---- Rehearsal step helpers (#720) -----------------------------------------
308
+
309
+
310
+ def clone_repo_to_temp(
311
+ project_root: Path, target_dir: Path
312
+ ) -> tuple[bool, str]:
313
+ """Clone the local directive repo into ``target_dir`` (#720, #728).
314
+
315
+ Uses ``git clone <project_root> <target_dir>`` so the rehearsal does
316
+ not depend on network access during the clone step (the temp remote
317
+ is populated via the explicit-refspec push in ``push_mirror``
318
+ afterwards). The clone produces a normal working tree with a
319
+ populated ``refs/remotes/origin/*``; that is intentional -- the
320
+ rehearsal needs a working tree to run ``task release`` against, and
321
+ the remote-tracking refs do NOT leak to the GitHub temp repo because
322
+ ``push_mirror`` uses explicit ``refs/heads/*`` + ``refs/tags/*``
323
+ refspecs (see ``push_mirror``'s docstring for the receive-pack
324
+ rationale).
325
+
326
+ Per #728 cycle 2 we also pin ``DEFT_PROJECT_ROOT=<target_dir>`` in
327
+ the subprocess env so an operator with that variable already
328
+ exported in their shell cannot accidentally cause helpers further
329
+ down the rehearsal pipeline to resolve back to the real directive
330
+ repo.
331
+ """
332
+ env = os.environ.copy()
333
+ env["DEFT_PROJECT_ROOT"] = str(target_dir)
334
+ result = subprocess.run(
335
+ ["git", "clone", str(project_root), str(target_dir)],
336
+ capture_output=True,
337
+ text=True,
338
+ timeout=300,
339
+ check=False,
340
+ env=env,
341
+ )
342
+ if result.returncode != 0:
343
+ return False, f"git clone failed: {result.stderr.strip()}"
344
+ return True, f"cloned {project_root} -> {target_dir}"
345
+
346
+
347
+ def set_origin_to_temp_repo(
348
+ clone_dir: Path, owner: str, slug: str
349
+ ) -> tuple[bool, str]:
350
+ """Point the clone's origin at the auto-created temp repo (#720).
351
+
352
+ Uses the canonical https URL shape so the rehearsal works on hosts
353
+ that lack an SSH key registered with GitHub (which is the typical
354
+ Windows operator environment for this project).
355
+ """
356
+ url = f"https://github.com/{owner}/{slug}.git"
357
+ result = release._run_git(clone_dir, "remote", "set-url", "origin", url)
358
+ if result.returncode != 0:
359
+ return False, (
360
+ f"git remote set-url failed: {result.stderr.strip()}"
361
+ )
362
+ return True, f"origin -> {url}"
363
+
364
+
365
+ def push_mirror(clone_dir: Path) -> tuple[bool, str]:
366
+ """Populate the temp remote with branches and tags from the clone (#720).
367
+
368
+ Pushes every branch and every tag from the local clone to the
369
+ auto-created temp remote using two explicit refspecs:
370
+ ``refs/heads/*:refs/heads/*`` and ``refs/tags/*:refs/tags/*``.
371
+
372
+ The function name retains the historical ``push_mirror`` label so
373
+ callers and tests stay stable, but the implementation deliberately
374
+ avoids ``git push --mirror``. ``--mirror`` is documented to push
375
+ every ref under ``refs/`` -- including ``refs/remotes/*``, the
376
+ local clone's remote-tracking refs. GitHub's receive-pack rejects
377
+ writes to that namespace, so a real ``--mirror`` push from a
378
+ non-bare clone (which is what we have here -- ``git clone
379
+ <project_root> <clone_dir>`` produces a normal working clone with
380
+ a populated ``refs/remotes/origin/*``) would fail every real
381
+ ``task release:e2e`` run at this step. Explicit refspecs cover
382
+ the two namespaces the subsequent rehearsal cares about (branches
383
+ + tags) without leaking remote-tracking refs.
384
+ """
385
+ result = release._run_git(
386
+ clone_dir,
387
+ "push",
388
+ "origin",
389
+ "refs/heads/*:refs/heads/*",
390
+ "refs/tags/*:refs/tags/*",
391
+ )
392
+ if result.returncode != 0:
393
+ return False, f"git push (heads+tags refspecs) failed: {result.stderr.strip()}"
394
+ return True, "pushed heads + tags to temp origin"
395
+
396
+
397
+ def _call_release_entrypoint(
398
+ entrypoint: Callable[[list[str] | None], int],
399
+ argv: list[str],
400
+ *,
401
+ clone_dir: Path,
402
+ timeout: float | None = None,
403
+ ) -> tuple[int, str]:
404
+ """Run a release entrypoint in-process with subprocess-style bounds.
405
+
406
+ The original task-backed dispatch capped release at 600s and rollback at
407
+ 300s. In-process calls need the same fail-closed behavior so a hung release
408
+ command cannot block the e2e rehearsal forever.
409
+ """
410
+ if timeout is None:
411
+ timeout = RELEASE_ENTRYPOINT_TIMEOUT_SECONDS
412
+ old_cwd = Path.cwd()
413
+ old_project_root = os.environ.get("DEFT_PROJECT_ROOT")
414
+ real_stdout, real_stderr = sys.stdout, sys.stderr
415
+ result: dict[str, tuple[int, str]] = {}
416
+ restore_owner = object()
417
+ timed_out = threading.Event()
418
+
419
+ def _activate_process_state() -> bool:
420
+ global _ENTRYPOINT_ACTIVE_RESTORE_OWNER # noqa: PLW0603
421
+
422
+ with _ENTRYPOINT_PROCESS_STATE_LOCK:
423
+ if timed_out.is_set():
424
+ return False
425
+ _ENTRYPOINT_ACTIVE_RESTORE_OWNER = restore_owner
426
+ os.environ["DEFT_PROJECT_ROOT"] = str(clone_dir)
427
+ os.chdir(clone_dir)
428
+ return True
429
+
430
+ def _restore_process_state() -> None:
431
+ global _ENTRYPOINT_ACTIVE_RESTORE_OWNER # noqa: PLW0603
432
+
433
+ with _ENTRYPOINT_PROCESS_STATE_LOCK:
434
+ if _ENTRYPOINT_ACTIVE_RESTORE_OWNER is not restore_owner:
435
+ return
436
+ _ENTRYPOINT_ACTIVE_RESTORE_OWNER = None
437
+ os.chdir(old_cwd)
438
+ if old_project_root is None:
439
+ os.environ.pop("DEFT_PROJECT_ROOT", None)
440
+ else:
441
+ os.environ["DEFT_PROJECT_ROOT"] = old_project_root
442
+
443
+ @contextlib.contextmanager
444
+ def _redirect_entrypoint_stdio(stdout: io.StringIO, stderr: io.StringIO):
445
+ previous_stdout: TextIO | None = None
446
+ previous_stderr: TextIO | None = None
447
+ active = False
448
+ with _ENTRYPOINT_PROCESS_STATE_LOCK:
449
+ if _ENTRYPOINT_ACTIVE_RESTORE_OWNER is restore_owner:
450
+ previous_stdout = sys.stdout
451
+ previous_stderr = sys.stderr
452
+ sys.stdout, sys.stderr = stdout, stderr
453
+ active = True
454
+ try:
455
+ yield active
456
+ finally:
457
+ if active:
458
+ with _ENTRYPOINT_PROCESS_STATE_LOCK:
459
+ if (
460
+ _ENTRYPOINT_ACTIVE_RESTORE_OWNER is restore_owner
461
+ and previous_stdout is not None
462
+ and previous_stderr is not None
463
+ ):
464
+ sys.stdout, sys.stderr = previous_stdout, previous_stderr
465
+
466
+ def _timeout_process_state() -> None:
467
+ global _ENTRYPOINT_ACTIVE_RESTORE_OWNER # noqa: PLW0603
468
+
469
+ with _ENTRYPOINT_PROCESS_STATE_LOCK:
470
+ timed_out.set()
471
+ if _ENTRYPOINT_ACTIVE_RESTORE_OWNER is not restore_owner:
472
+ return
473
+ _ENTRYPOINT_ACTIVE_RESTORE_OWNER = None
474
+ # Own cleanup after timeout; the daemon's later finally block must
475
+ # not restore over any subsequent release entrypoint or capture.
476
+ sys.stdout, sys.stderr = real_stdout, real_stderr
477
+ os.chdir(old_cwd)
478
+ if old_project_root is None:
479
+ os.environ.pop("DEFT_PROJECT_ROOT", None)
480
+ else:
481
+ os.environ["DEFT_PROJECT_ROOT"] = old_project_root
482
+
483
+ def _worker() -> None:
484
+ stdout = io.StringIO()
485
+ stderr = io.StringIO()
486
+ try:
487
+ if not _activate_process_state():
488
+ return
489
+ with _redirect_entrypoint_stdio(stdout, stderr) as stdio_active:
490
+ if not stdio_active:
491
+ return
492
+ code = entrypoint(argv)
493
+ except SystemExit as exc:
494
+ raw = exc.code
495
+ code = raw if isinstance(raw, int) else (0 if raw is None else 1)
496
+ except Exception as exc: # noqa: BLE001 -- e2e diagnostics must record failures
497
+ message = f"{type(exc).__name__}: {exc}"
498
+ captured_stderr = stderr.getvalue()
499
+ stderr_value = f"{captured_stderr}\n{message}" if captured_stderr else message
500
+ result["value"] = (EXIT_VIOLATION, stderr_value or stdout.getvalue())
501
+ return
502
+ finally:
503
+ _restore_process_state()
504
+ output = stderr.getvalue() or stdout.getvalue()
505
+ result["value"] = (int(code or 0), output)
506
+
507
+ worker = threading.Thread(target=_worker, name="deft-release-entrypoint", daemon=True)
508
+ worker.start()
509
+ worker.join(timeout)
510
+ if worker.is_alive():
511
+ # A hung worker may still hold process-global stdout/stderr redirects.
512
+ # Claim cleanup before fail-closed; the daemon's later context exit is
513
+ # owner-aware and will not restore over a subsequent capture.
514
+ _timeout_process_state()
515
+ label = getattr(entrypoint, "__name__", "entrypoint")
516
+ return ENTRYPOINT_TIMEOUT_EXIT_CODE, f"{label} timed out after {timeout:g}s"
517
+ return result.get("value", (EXIT_VIOLATION, "entrypoint produced no result"))
518
+
519
+
520
+ def dispatch_task_release(
521
+ clone_dir: Path, version: str, repo: str
522
+ ) -> tuple[bool, str]:
523
+ """Invoke release.py inside the clone with skip flags and the
524
+ vBRIEF-drift override (#720, #728, post-#754 harness fix).
525
+
526
+ The full dispatched argv is
527
+ ``release.py <version> --repo <repo> --skip-ci --skip-build --allow-vbrief-drift``.
528
+
529
+ Skipping CI + build keeps the rehearsal wall-clock manageable; both
530
+ are covered by the unit-test suite. The 10-step pipeline still
531
+ exercises CHANGELOG promotion, ROADMAP refresh, commit, tag, atomic
532
+ push, and ``gh release create --draft``.
533
+
534
+ #728 cycle 2: ``env["DEFT_PROJECT_ROOT"] = str(clone_dir)`` is
535
+ explicitly pinned BEFORE invoking ``task release``. The release CLI
536
+ resolves its repository root via ``DEFT_PROJECT_ROOT`` (when set) ->
537
+ ``--project-root`` -> the script's own parent. If the operator's
538
+ shell already exported ``DEFT_PROJECT_ROOT`` (a common pattern
539
+ when running deft itself out of a worktree), the rehearsal
540
+ subprocess would resolve back to the REAL directive repo and the
541
+ rest of the pipeline (CHANGELOG promotion, commit, tag, ``git push
542
+ --atomic origin master v0.0.1``) would mutate ``deftai/directive``
543
+ instead of the temp clone. Pinning the env var to ``clone_dir``
544
+ eliminates that ambient-state hazard regardless of the operator's
545
+ shell setup.
546
+
547
+ Post-#754 harness fix: ``--allow-vbrief-drift`` is passed because
548
+ the temp rehearsal repo is auto-created empty (zero issues) and
549
+ the inverted-lookup vBRIEF-lifecycle-sync gate (#754) classifies
550
+ every referenced issue number as NOT_FOUND -> Section (c) mismatch
551
+ against an empty target. The gate has no meaningful signal in the
552
+ rehearsal context, so the explicit-acknowledgment escape hatch is
553
+ the correct surface to bypass it. The production cut path (against
554
+ the real repo with real issues) does NOT pass this flag and remains
555
+ fully gated. Without this flag, every ``task release:e2e`` invocation
556
+ since #734 landed has failed at the inner Step 3 lifecycle gate.
557
+ """
558
+ argv = [
559
+ version,
560
+ "--repo", repo,
561
+ "--skip-ci",
562
+ "--skip-build",
563
+ "--allow-vbrief-drift",
564
+ ]
565
+ code, output = _call_release_entrypoint(
566
+ release.main,
567
+ argv,
568
+ clone_dir=clone_dir,
569
+ timeout=RELEASE_ENTRYPOINT_TIMEOUT_SECONDS,
570
+ )
571
+ if code != 0:
572
+ return False, (
573
+ f"release.py failed (exit {code}): "
574
+ f"{output.strip()}"
575
+ )
576
+ return True, f"release.py {version} --repo {repo} (draft) ran clean"
577
+
578
+
579
+ def verify_draft_release(
580
+ owner: str, slug: str, version: str
581
+ ) -> tuple[bool, str]:
582
+ """Assert ``gh release view`` reports the draft for ``v<version>`` (#720).
583
+
584
+ Verifies (a) the release exists, (b) ``isDraft == true``, (c)
585
+ ``tagName == v<version>``. Anything else returns False so the
586
+ rehearsal fails loudly.
587
+ """
588
+ gh_path = release._resolve_gh()
589
+ if gh_path is None:
590
+ return False, "gh CLI not found on PATH"
591
+ tag = f"v{version}"
592
+ full = f"{owner}/{slug}"
593
+ cmd = [
594
+ gh_path, "release", "view", tag,
595
+ "--repo", full,
596
+ "--json", "isDraft,tagName,name,url",
597
+ ]
598
+ try:
599
+ result = subprocess.run(
600
+ cmd,
601
+ capture_output=True,
602
+ text=True,
603
+ timeout=60,
604
+ check=False,
605
+ env=os.environ.copy(),
606
+ )
607
+ except FileNotFoundError:
608
+ return False, "gh CLI not found on PATH"
609
+ if result.returncode != 0:
610
+ return False, f"gh release view failed: {result.stderr.strip()}"
611
+ try:
612
+ payload = json.loads(result.stdout)
613
+ except json.JSONDecodeError as exc:
614
+ return False, f"gh release view returned non-JSON: {exc}"
615
+ if not payload.get("isDraft"):
616
+ return False, (
617
+ f"draft verify FAIL: expected isDraft=true on {full} {tag}, "
618
+ f"got {payload!r}"
619
+ )
620
+ if payload.get("tagName") != tag:
621
+ return False, (
622
+ f"draft verify FAIL: expected tagName={tag!r} on {full}, "
623
+ f"got tagName={payload.get('tagName')!r}"
624
+ )
625
+ return True, f"verified draft {tag} on {full}"
626
+
627
+
628
+ def verify_tag(clone_dir: Path, version: str) -> tuple[bool, str]:
629
+ """Assert the tag ``v<version>`` exists on the temp remote (#720).
630
+
631
+ Uses ``git ls-remote --tags origin`` so the assertion is independent
632
+ of the local tag database (the local clone may have already pushed +
633
+ cleaned up; what matters is the remote ref).
634
+ """
635
+ tag = f"v{version}"
636
+ result = release._run_git(
637
+ clone_dir, "ls-remote", "--tags", "origin", f"refs/tags/{tag}"
638
+ )
639
+ if result.returncode != 0:
640
+ return False, f"git ls-remote failed: {result.stderr.strip()}"
641
+ if not result.stdout.strip():
642
+ return False, f"tag verify FAIL: {tag} not present on temp origin"
643
+ return True, f"verified tag {tag} present on temp origin"
644
+
645
+
646
+ # ---- npm publish dry-run rehearsal (#1910) ---------------------------------
647
+
648
+
649
+ def _resolve_pnpm() -> list[str] | None:
650
+ """Resolve a pnpm invocation prefix for the clone build (#1910).
651
+
652
+ Prefers a ``pnpm`` binary on PATH; falls back to ``corepack pnpm`` so a
653
+ Node install that ships corepack (the npm-publish.yml path) works without
654
+ a globally-installed pnpm. Returns ``None`` when neither is available.
655
+ """
656
+ pnpm = shutil.which("pnpm")
657
+ if pnpm:
658
+ return [pnpm]
659
+ corepack = shutil.which("corepack")
660
+ if corepack:
661
+ return [corepack, "pnpm"]
662
+ return None
663
+
664
+
665
+ def _run_npm_step(
666
+ cmd: list[str], cwd: Path, env: dict[str, str], label: str, timeout: int
667
+ ) -> tuple[bool, str]:
668
+ """Run one npm/pnpm subprocess step and normalise it to ``(ok, reason)``.
669
+
670
+ Uses ``encoding="utf-8", errors="replace"`` per the #1366 safe-capture
671
+ rule so an undecodable byte in npm/pnpm output cannot crash the reader.
672
+ """
673
+ try:
674
+ result = subprocess.run(
675
+ cmd,
676
+ cwd=str(cwd),
677
+ capture_output=True,
678
+ text=True,
679
+ encoding="utf-8",
680
+ errors="replace",
681
+ timeout=timeout,
682
+ check=False,
683
+ env=env,
684
+ )
685
+ except FileNotFoundError:
686
+ return False, f"{label}: command not found ({cmd[0]})"
687
+ except subprocess.TimeoutExpired:
688
+ return False, f"{label}: timed out after {timeout}s"
689
+ if result.returncode != 0:
690
+ detail = (result.stderr or result.stdout or "").strip()
691
+ return False, f"{label} failed (exit {result.returncode}): {detail[-500:]}"
692
+ return True, f"{label} OK"
693
+
694
+
695
+ def align_npm_package_versions(clone_dir: Path, version: str) -> tuple[bool, str]:
696
+ """Bump the four published package versions + resolve workspace deps (#1910).
697
+
698
+ Mirrors the npm-publish.yml "Align package versions with release tag" and
699
+ "Resolve workspace protocol for npm publish" steps: every published
700
+ ``package.json`` is set to ``<version>`` and any ``workspace:`` dependency
701
+ spec is rewritten to ``^<version>`` (npm cannot publish the pnpm
702
+ ``workspace:`` protocol verbatim).
703
+
704
+ Folds in the scope item-4 version-alignment assertion: after writing,
705
+ each manifest is read back and must report exactly ``<version>`` so a
706
+ drift / malformed-manifest bug surfaces in the rehearsal rather than at
707
+ real tag time.
708
+ """
709
+ for pkg in NPM_PUBLISH_PACKAGES:
710
+ manifest = clone_dir / "packages" / pkg / "package.json"
711
+ try:
712
+ data = json.loads(manifest.read_text(encoding="utf-8"))
713
+ except (OSError, json.JSONDecodeError) as exc:
714
+ return False, f"version-align FAIL: cannot read packages/{pkg}/package.json: {exc}"
715
+ data["version"] = version
716
+ deps = data.get("dependencies")
717
+ if isinstance(deps, dict):
718
+ for name, spec in list(deps.items()):
719
+ if isinstance(spec, str) and spec.startswith("workspace:"):
720
+ deps[name] = f"^{version}"
721
+ try:
722
+ manifest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
723
+ readback = json.loads(manifest.read_text(encoding="utf-8"))
724
+ except (OSError, json.JSONDecodeError) as exc:
725
+ return False, f"version-align FAIL: cannot write packages/{pkg}/package.json: {exc}"
726
+ if readback.get("version") != version:
727
+ return False, (
728
+ f"version-align FAIL: packages/{pkg} version="
729
+ f"{readback.get('version')!r} != {version!r}"
730
+ )
731
+ return True, f"aligned 4 package versions to {version} (+ resolved workspace protocol)"
732
+
733
+
734
+ def rehearse_npm_publish(clone_dir: Path, version: str) -> tuple[bool, str]:
735
+ """Dry-run the npm publish for the four @deftai/directive* packages (#1910).
736
+
737
+ Mirrors ``.github/workflows/npm-publish.yml`` so a broken ``files``
738
+ allowlist, version drift, or dependency-order bug surfaces in
739
+ ``task release:e2e`` BEFORE a real ``v*`` tag fires the publish
740
+ workflow -- without ever touching the real registry (every package is
741
+ published with ``--dry-run``).
742
+
743
+ Steps, all inside the throwaway clone:
744
+
745
+ 1. Resolve ``npm``. SOFT-SKIP (returns ``ok=True``) when npm is absent so
746
+ Node-less operators are not blocked -- symmetric to ``--skip-npm``.
747
+ 2. Resolve pnpm (or ``corepack pnpm``) and ``pnpm install
748
+ --frozen-lockfile``; the fresh ``git clone`` has no ``node_modules``.
749
+ 3. ``pnpm -w run build``; ``dist/`` must exist for the dist-only ``files``
750
+ allowlist to produce a meaningful tarball.
751
+ 4. Align the four ``package.json`` versions to ``<version>`` and resolve
752
+ the ``workspace:`` protocol (folds in the item-4 version assertion).
753
+ 5. ``npm publish --dry-run --access public --tag e2e-rehearsal`` per
754
+ package in dependency order (types -> core -> content -> cli). The
755
+ throwaway dist-tag bypasses npm's implicit-``latest`` check when the
756
+ rehearsal sentinel version is below the highest published version
757
+ (#1925).
758
+
759
+ Returns ``(ok, reason)`` like ``verify_draft_release`` / ``verify_tag`` so
760
+ the orchestrator and tests treat it uniformly. Because installing +
761
+ building the workspace blows the <90s rehearsal budget, the orchestrator
762
+ only runs this step when ``--skip-npm`` is NOT set.
763
+ """
764
+ npm_path = shutil.which("npm")
765
+ if npm_path is None:
766
+ return True, "SKIP (npm not on PATH; Node-less operator)"
767
+ pnpm_cmd = _resolve_pnpm()
768
+ if pnpm_cmd is None:
769
+ return False, (
770
+ "npm present but neither pnpm nor corepack is on PATH -- "
771
+ "cannot build the workspace for the dry-run"
772
+ )
773
+
774
+ env = os.environ.copy()
775
+ env["DEFT_PROJECT_ROOT"] = str(clone_dir)
776
+
777
+ ok, reason = _run_npm_step(
778
+ [*pnpm_cmd, "install", "--frozen-lockfile"],
779
+ clone_dir, env, "pnpm install", NPM_INSTALL_TIMEOUT_SECONDS,
780
+ )
781
+ if not ok:
782
+ return False, reason
783
+ ok, reason = _run_npm_step(
784
+ [*pnpm_cmd, "-w", "run", "build"],
785
+ clone_dir, env, "pnpm build", NPM_BUILD_TIMEOUT_SECONDS,
786
+ )
787
+ if not ok:
788
+ return False, reason
789
+ ok, reason = align_npm_package_versions(clone_dir, version)
790
+ if not ok:
791
+ return False, reason
792
+ for pkg in NPM_PUBLISH_PACKAGES:
793
+ pkg_dir = clone_dir / "packages" / pkg
794
+ ok, reason = _run_npm_step(
795
+ [
796
+ npm_path,
797
+ "publish",
798
+ "--dry-run",
799
+ "--access",
800
+ "public",
801
+ "--tag",
802
+ NPM_E2E_REHEARSAL_TAG,
803
+ ],
804
+ pkg_dir,
805
+ env,
806
+ f"npm publish --dry-run --tag {NPM_E2E_REHEARSAL_TAG} packages/{pkg}",
807
+ NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS,
808
+ )
809
+ if not ok:
810
+ return False, reason
811
+ return True, (
812
+ "npm publish --dry-run clean for 4 packages "
813
+ f"(types -> core -> content -> cli) at v{version}"
814
+ )
815
+
816
+
817
+ def dispatch_task_release_rollback(
818
+ clone_dir: Path, version: str, repo: str
819
+ ) -> tuple[bool, str]:
820
+ """Invoke release_rollback.py ``<version> --repo <repo>`` (#720, #728).
821
+
822
+ Exercises the rollback path against the temp repo so a regression in
823
+ the state-aware unwind (states 1-3) surfaces in the e2e job rather
824
+ than during a real production rollback.
825
+
826
+ #728 cycle 2: same ``DEFT_PROJECT_ROOT`` pinning rationale as
827
+ ``dispatch_task_release`` -- without the override, an operator with
828
+ ``DEFT_PROJECT_ROOT`` exported in their shell would have the
829
+ rollback subprocess resolve to the real directive repo, producing
830
+ either a false VIOLATION (release-prep SHA cannot be resolved) or
831
+ -- worse -- mutating the real repo's history.
832
+ """
833
+ argv = [version, "--repo", repo]
834
+ code, output = _call_release_entrypoint(
835
+ release_rollback.main,
836
+ argv,
837
+ clone_dir=clone_dir,
838
+ timeout=ROLLBACK_ENTRYPOINT_TIMEOUT_SECONDS,
839
+ )
840
+ if code != 0:
841
+ return False, (
842
+ f"release_rollback.py failed (exit {code}): "
843
+ f"{output.strip()}"
844
+ )
845
+ return True, f"release_rollback.py {version} --repo {repo} ran clean"
846
+
847
+
848
+ def run_rehearsal(
849
+ owner: str, slug: str, project_root: Path,
850
+ version: str = REHEARSAL_VERSION,
851
+ *,
852
+ skip_npm: bool = False,
853
+ ) -> tuple[bool, str]:
854
+ """Execute the full pipeline-mirror rehearsal (#720, #1910).
855
+
856
+ Orchestrates the rehearsal steps inside a
857
+ ``tempfile.TemporaryDirectory``: clone -> set-origin -> push-mirror ->
858
+ task release -> verify draft -> verify tag -> (npm publish dry-run) ->
859
+ task release:rollback. On the first step failure, short-circuits and
860
+ returns the diagnostic; the caller is responsible for cleanup of the
861
+ temp GitHub repo (run_e2e wraps this in ``try/finally``).
862
+
863
+ Per #1910 the npm publish dry-run step is inserted after ``verify tag``
864
+ and before ``task release:rollback`` unless ``skip_npm`` is set; it
865
+ soft-skips internally when ``npm`` is absent from PATH so Node-less
866
+ operators are not blocked.
867
+
868
+ Pre-#720 this function was a smoke-test ``gh repo view`` (existence
869
+ check only). The deeper flow surfaces real regressions in the
870
+ release pipeline before they hit master.
871
+ """
872
+ repo_full = f"{owner}/{slug}"
873
+ with tempfile.TemporaryDirectory(prefix="deft-e2e-") as tmpdir:
874
+ clone_dir = Path(tmpdir) / "clone"
875
+ steps: list[tuple[str, Callable[[], tuple[bool, str]]]] = [
876
+ ("clone", lambda: clone_repo_to_temp(project_root, clone_dir)),
877
+ ("set-origin", lambda: set_origin_to_temp_repo(clone_dir, owner, slug)),
878
+ ("push-mirror", lambda: push_mirror(clone_dir)),
879
+ ("task release", lambda: dispatch_task_release(clone_dir, version, repo_full)),
880
+ ("verify draft", lambda: verify_draft_release(owner, slug, version)),
881
+ ("verify tag", lambda: verify_tag(clone_dir, version)),
882
+ ]
883
+ if not skip_npm:
884
+ steps.append(
885
+ ("npm publish dry-run", lambda: rehearse_npm_publish(clone_dir, version))
886
+ )
887
+ steps.append(
888
+ (
889
+ "task release:rollback",
890
+ lambda: dispatch_task_release_rollback(clone_dir, version, repo_full),
891
+ )
892
+ )
893
+ for label, step in steps:
894
+ ok, reason = step()
895
+ _emit(f" rehearsal step: {label}", f"{'OK' if ok else 'FAIL'} ({reason})")
896
+ if not ok:
897
+ return False, f"{label}: {reason}"
898
+ npm_note = " (npm dry-run skipped)" if skip_npm else " -> npm publish dry-run"
899
+ return True, (
900
+ f"pipeline-mirror rehearsal succeeded against {repo_full} "
901
+ f"({len(steps)} steps; clone -> push heads+tags -> task release -> "
902
+ f"verify draft+tag{npm_note} -> rollback)"
903
+ )
904
+
905
+
906
+ # ---- pipeline ---------------------------------------------------------------
907
+
908
+
909
+ def run_e2e(config: E2EConfig) -> int:
910
+ """Execute the e2e rehearsal pipeline; returns the process exit code.
911
+
912
+ The function is intentionally structured as ``provision -> rehearse
913
+ -> destroy`` with the cleanup in a ``finally`` block so a failed
914
+ rehearsal still triggers ``gh repo delete``. If the cleanup itself
915
+ fails, a warning is printed but the rehearsal's own exit code wins
916
+ so the operator does not see "rehearsal failed" reported as
917
+ "cleanup failed".
918
+ """
919
+ slug = config.repo_slug or generate_repo_slug()
920
+ owner = config.owner
921
+
922
+ if config.dry_run:
923
+ _emit(
924
+ "Provision temp repo",
925
+ f"DRYRUN (would run `gh repo create --private {owner}/{slug}`)",
926
+ )
927
+ npm_plan = (
928
+ "task release:rollback"
929
+ if config.skip_npm
930
+ else "npm publish dry-run (4 packages) -> task release:rollback"
931
+ )
932
+ _emit(
933
+ "Rehearsal",
934
+ (
935
+ "DRYRUN (would run pipeline-mirror rehearsal: clone -> "
936
+ "push heads+tags -> task release -> verify draft + tag -> "
937
+ f"{npm_plan} against temp repo)"
938
+ ),
939
+ )
940
+ _emit(
941
+ "Destroy temp repo",
942
+ f"DRYRUN (would run `gh repo delete {owner}/{slug} --yes`)",
943
+ )
944
+ return EXIT_OK
945
+
946
+ # Provision.
947
+ ok, reason = provision_temp_repo(owner, slug)
948
+ if not ok:
949
+ _emit(f"Provision {owner}/{slug}", f"FAIL ({reason})")
950
+ return EXIT_VIOLATION
951
+ _emit(f"Provision {owner}/{slug}", f"OK ({reason})")
952
+
953
+ rehearsal_rc = EXIT_OK
954
+ try:
955
+ ok, reason = run_rehearsal(
956
+ owner, slug, config.project_root, skip_npm=config.skip_npm
957
+ )
958
+ if ok:
959
+ _emit("Rehearsal", f"OK ({reason})")
960
+ else:
961
+ _emit("Rehearsal", f"FAIL ({reason})")
962
+ rehearsal_rc = EXIT_VIOLATION
963
+ finally:
964
+ if config.keep_repo:
965
+ _emit(
966
+ f"Destroy {owner}/{slug}",
967
+ "SKIP (--keep-repo set; manual cleanup required: "
968
+ f"gh repo delete {owner}/{slug} --yes)",
969
+ )
970
+ else:
971
+ ok, reason = destroy_temp_repo(owner, slug)
972
+ if ok:
973
+ _emit(f"Destroy {owner}/{slug}", f"OK ({reason})")
974
+ else:
975
+ # Cleanup failure does NOT override the rehearsal exit
976
+ # code; we surface a warning + manual cleanup hint and
977
+ # let the rehearsal's status stand.
978
+ _emit(
979
+ f"Destroy {owner}/{slug}",
980
+ f"WARN ({reason}); manual cleanup hint: "
981
+ f"gh repo delete {owner}/{slug} --yes",
982
+ )
983
+
984
+ return rehearsal_rc
985
+
986
+
987
+ # ---- main -------------------------------------------------------------------
988
+
989
+
990
+ def main(argv: list[str] | None = None) -> int:
991
+ parser = _build_parser()
992
+ args = parser.parse_args(argv)
993
+
994
+ if not args.owner:
995
+ print("Error: --owner must be a non-empty string.", file=sys.stderr)
996
+ return EXIT_CONFIG_ERROR
997
+
998
+ project_root = release._resolve_project_root(args.project_root)
999
+
1000
+ config = E2EConfig(
1001
+ owner=args.owner,
1002
+ project_root=project_root,
1003
+ dry_run=args.dry_run,
1004
+ keep_repo=args.keep_repo,
1005
+ skip_npm=args.skip_npm,
1006
+ )
1007
+ return run_e2e(config)
1008
+
1009
+
1010
+ if __name__ == "__main__":
1011
+ sys.exit(main())