@deftai/directive-content 0.55.2 → 0.56.1

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,2030 @@
1
+ #!/usr/bin/env python3
2
+ """release.py -- Automate the v0.X.Y release flow (#74).
3
+
4
+ Wraps the mechanical steps of cutting a deft release into a single
5
+ deterministic Python entry-point so contributors do not have to remember
6
+ the order: pre-flight -> CI -> CHANGELOG promote -> ROADMAP refresh ->
7
+ build dist -> tag -> push tag -> GitHub release.
8
+
9
+ The script is intentionally side-effect-loud (every step prints
10
+ ``[N/M] <step>... <result>`` so operators can tail it during a release)
11
+ and supports a ``--dry-run`` mode that prints the full plan without
12
+ touching the filesystem or invoking any external command.
13
+
14
+ Background
15
+ ----------
16
+ Issue #74 ("chore: automate release process and CI changelog
17
+ enforcement") flagged the manual release flow as error-prone. PR #73
18
+ documented the convention in ``scm/changelog.md`` but relied on human
19
+ discipline. The vBRIEF
20
+ ``vbrief/pending/2026-04-23-233-more-determinism-full-initiative-phase-0-spec.vbrief.json``
21
+ ``task-release`` plan.item carries the Action ("automate the v0.X.Y
22
+ release flow -- tag, build, dist, CHANGELOG promote, ROADMAP
23
+ move-to-completed") and Acceptance ("`task release -- 0.21.0` produces
24
+ a clean tag + GitHub release on a dry-run fixture; tests/cli/test_release.py
25
+ covers CHANGELOG promotion and ROADMAP move-to-completed").
26
+
27
+ Per the canonical [#642 workflow comment]
28
+ (https://github.com/deftai/directive/issues/642#issuecomment-4330742436)
29
+ locked decision and the Rule Authority [AXIOM] block in ``main.md``,
30
+ deterministic / Taskfile encodings rank above prose: this script is the
31
+ deterministic encoding of the release flow, surfaced via
32
+ ``task release -- <version>`` (see ``tasks/release.yml``).
33
+
34
+ Usage
35
+ -----
36
+ uv run python scripts/release.py 0.21.0
37
+ uv run python scripts/release.py 0.21.0 --dry-run
38
+ uv run python scripts/release.py 0.21.0 --skip-tag --skip-release
39
+ uv run python scripts/release.py 0.21.0 --repo deftai/directive
40
+ uv run python scripts/release.py 0.21.0 --allow-dirty
41
+ uv run python scripts/release.py 0.21.0 --no-draft # rare direct-publish
42
+
43
+ Exit codes
44
+ ----------
45
+ 0 -- release flow completed successfully (or dry-run preview ok)
46
+ 1 -- pre-flight or pipeline-step violation (dirty tree, wrong branch,
47
+ CI failure, CHANGELOG lacks [Unreleased], gh release failure ...)
48
+ 2 -- config / argument error (malformed version, repo unresolvable,
49
+ CHANGELOG malformed, ...)
50
+
51
+ Draft default (#716 safety hardening)
52
+ -------------------------------------
53
+ ``gh release create`` is invoked with ``--draft`` by default so the
54
+ *artifact production* phase (which fires release.yml CI and uploads
55
+ binaries) is decoupled from the *consumer-visibility* phase. Pair this
56
+ script with ``scripts/release_publish.py`` (``task release:publish --
57
+ <version>``) to flip the draft to public after manual review of the
58
+ binaries / notes / asset list. ``--no-draft`` opts back into the
59
+ prior direct-publish behavior (only intended for automated security
60
+ patches where there is no review gate).
61
+
62
+ Refs #74, #233, #642, #635, #709 (Repair Authority [AXIOM]),
63
+ #710 (data-file-conventions check follow-up), #716 (safety hardening).
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import argparse
69
+ import contextlib
70
+ import datetime as _dt
71
+ import json
72
+ import os
73
+ import re
74
+ import shutil
75
+ import subprocess
76
+ import sys
77
+ import tempfile
78
+ import time
79
+ from collections.abc import Callable
80
+ from dataclasses import dataclass
81
+ from pathlib import Path
82
+
83
+ # Make sibling scripts importable both when run as __main__ and when imported by tests.
84
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
85
+
86
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
87
+ from framework_commands import run_framework_command # noqa: E402
88
+ from resolve_version import ( # noqa: E402
89
+ NonPublishableVersionError,
90
+ to_pep440,
91
+ )
92
+
93
+ reconfigure_stdio()
94
+
95
+ # ---- Exit codes -------------------------------------------------------------
96
+
97
+ EXIT_OK = 0
98
+ EXIT_VIOLATION = 1
99
+ EXIT_CONFIG_ERROR = 2
100
+
101
+ # ---- Constants --------------------------------------------------------------
102
+
103
+ DEFAULT_REPO = "deftai/directive"
104
+ DEFAULT_BASE_BRANCH = "master"
105
+
106
+ # #1413: maintainer-mode GitHub releases lead with a standard
107
+ # "Upgrading from an older version?" banner, sourced from this editable
108
+ # template (relative to the project root) and prepended to the release
109
+ # notes that ``gh release create`` receives. The banner is GitHub-release-
110
+ # body-only -- it is NEVER injected into CHANGELOG.md -- and is applied
111
+ # only when cutting the canonical directive framework (repo == DEFAULT_REPO);
112
+ # consumer-mode releases (a non-deftai/directive repo) are unaffected.
113
+ _UPGRADE_BANNER_RELPATH = ".github/release-notes/upgrade-banner.md"
114
+
115
+ # Strict semver pattern (no pre-release / build metadata; deft tags are X.Y.Z).
116
+ _VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
117
+ _TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
118
+ _UNRELEASED_RE = re.compile(r"^##\s+\[Unreleased\]\s*$", re.MULTILINE)
119
+ _UNRELEASED_LINK_RE = re.compile(
120
+ r"^\[Unreleased\]:\s+https?://github\.com/[^/]+/[^/]+/compare/v(?P<prev>\d+\.\d+\.\d+)\.\.\.HEAD\s*$",
121
+ re.MULTILINE,
122
+ )
123
+
124
+ FRESH_UNRELEASED_BLOCK = (
125
+ "## [Unreleased]\n"
126
+ "\n"
127
+ "### Added\n"
128
+ "\n"
129
+ "### Changed\n"
130
+ "\n"
131
+ "### Fixed\n"
132
+ "\n"
133
+ "### Removed\n"
134
+ )
135
+
136
+
137
+ # ---- Data classes -----------------------------------------------------------
138
+
139
+
140
+ @dataclass
141
+ class ReleaseConfig:
142
+ version: str
143
+ repo: str
144
+ base_branch: str
145
+ project_root: Path
146
+ dry_run: bool
147
+ skip_tag: bool
148
+ skip_release: bool
149
+ allow_dirty: bool
150
+ # #716: default-draft so the GitHub release lands as an unpublished
151
+ # draft until ``task release:publish`` flips it. Operators can opt
152
+ # out via --no-draft (rare; e.g. automated security patches).
153
+ draft: bool = True
154
+ # #720: e2e-rehearsal escape hatches. ``--skip-ci`` skips Step 3
155
+ # (task ci:local / task check fallback) so the rehearsal does not
156
+ # re-run CI inside an auto-created temp repo (CI semantics are
157
+ # covered by the unit tests at every commit on master). ``--skip-build``
158
+ # skips Step 6 (task build) similarly. Defaults preserve pre-#720
159
+ # behaviour: both run unless the operator explicitly opts out.
160
+ skip_ci: bool = False
161
+ skip_build: bool = False
162
+ # release-narrative-gap: optional one-line operator-authored summary
163
+ # injected as a Markdown blockquote at the top of the promoted
164
+ # CHANGELOG ``[<version>]`` section. None preserves pre-existing
165
+ # behaviour byte-for-byte. The same blockquote naturally flows
166
+ # through to the GitHub release body (via ``_section_for_version``)
167
+ # and is the canonical source for the Phase 8 Slack ``*Summary*:``
168
+ # slot per ``skills/deft-directive-release/SKILL.md``.
169
+ summary: str | None = None
170
+ # #734: vBRIEF-lifecycle reconciliation gate escape hatch. The
171
+ # pipeline runs ``check_vbrief_lifecycle_sync`` between Step 2
172
+ # (branch guard) and Step 4 (CI) so a release cannot ship with
173
+ # closed-issue vBRIEFs still living in proposed/ / pending/ /
174
+ # active/. The flag is the explicit-acknowledgment escape hatch
175
+ # (analogous to ``--allow-dirty`` for the dirty-tree gate) for
176
+ # cases where the operator has reviewed the drift and chooses to
177
+ # proceed -- e.g. a hot-fix release where the lifecycle reconcile
178
+ # is intentionally deferred to the next refinement pass.
179
+ allow_vbrief_drift: bool = False
180
+
181
+
182
+ # ---- argument parsing -------------------------------------------------------
183
+
184
+
185
+ def _build_parser() -> argparse.ArgumentParser:
186
+ parser = argparse.ArgumentParser(
187
+ prog="release",
188
+ description=(
189
+ "Automate the v0.X.Y release flow (#74): pre-flight, CI, CHANGELOG "
190
+ "promote, ROADMAP refresh, build, tag, push, gh release. Halt-friendly: "
191
+ "supports --dry-run / --skip-tag / --skip-release for safe rehearsals."
192
+ ),
193
+ )
194
+ parser.add_argument(
195
+ "version",
196
+ help="Release version, e.g. 0.21.0 (no leading 'v', strict X.Y.Z).",
197
+ )
198
+ parser.add_argument(
199
+ "--dry-run",
200
+ action="store_true",
201
+ help="Print the full release plan without writing files or invoking external commands.",
202
+ )
203
+ parser.add_argument(
204
+ "--skip-tag",
205
+ action="store_true",
206
+ help="Do not invoke git tag / git push origin <tag> (still updates CHANGELOG).",
207
+ )
208
+ parser.add_argument(
209
+ "--skip-release",
210
+ action="store_true",
211
+ help="Do not invoke gh release create.",
212
+ )
213
+ parser.add_argument(
214
+ "--allow-dirty",
215
+ action="store_true",
216
+ help="Bypass the dirty-tree pre-flight (use only for rehearsals).",
217
+ )
218
+ parser.add_argument(
219
+ "--allow-vbrief-drift",
220
+ action="store_true",
221
+ default=False,
222
+ help=(
223
+ "Bypass the vBRIEF-lifecycle sync pre-flight gate (#734). "
224
+ "Use only when the operator has reviewed the drift and "
225
+ "explicitly accepts that closed-issue vBRIEFs may still "
226
+ "live in non-terminal folders. The clean path is to "
227
+ "run `task reconcile:issues -- --apply-lifecycle-fixes` "
228
+ "first."
229
+ ),
230
+ )
231
+ # #720: e2e-rehearsal escape hatches.
232
+ parser.add_argument(
233
+ "--skip-ci",
234
+ action="store_true",
235
+ help=(
236
+ "Skip Step 3 (task ci:local / task check fallback). Used by "
237
+ "`task release:e2e` to keep wall-clock manageable inside the "
238
+ "auto-created temp repo (CI semantics are covered by the "
239
+ "unit-test suite, not the e2e rehearsal)."
240
+ ),
241
+ )
242
+ parser.add_argument(
243
+ "--skip-build",
244
+ action="store_true",
245
+ help=(
246
+ "Skip Step 6 (task build). Used by `task release:e2e` to keep "
247
+ "wall-clock manageable; build artefacts are not needed for the "
248
+ "draft-release verification step."
249
+ ),
250
+ )
251
+ # #716: default-draft. ``--no-draft`` opts out (rare; security patches).
252
+ parser.add_argument(
253
+ "--no-draft",
254
+ action="store_false",
255
+ dest="draft",
256
+ default=True,
257
+ help=(
258
+ "Publish the GitHub release immediately instead of creating a draft "
259
+ "(default: --draft, paired with `task release:publish -- <version>`)."
260
+ ),
261
+ )
262
+ parser.add_argument(
263
+ "--repo",
264
+ default=None,
265
+ metavar="OWNER/REPO",
266
+ help=(
267
+ "Override the GitHub repository (default: resolved from `git remote get-url origin`, "
268
+ f"falling back to {DEFAULT_REPO!r})."
269
+ ),
270
+ )
271
+ parser.add_argument(
272
+ "--base-branch",
273
+ default=DEFAULT_BASE_BRANCH,
274
+ metavar="BRANCH",
275
+ help=f"Expected base branch for releases (default: {DEFAULT_BASE_BRANCH}).",
276
+ )
277
+ parser.add_argument(
278
+ "--project-root",
279
+ type=Path,
280
+ default=None,
281
+ metavar="PATH",
282
+ help=(
283
+ "Repository root (default: $DEFT_PROJECT_ROOT or the parent of the scripts/ "
284
+ "directory)."
285
+ ),
286
+ )
287
+ parser.add_argument(
288
+ "--summary",
289
+ default=None,
290
+ metavar="TEXT",
291
+ help=(
292
+ "Optional one-line summary to inject as a Markdown blockquote at "
293
+ "the top of the promoted CHANGELOG section. Flows through to the "
294
+ "GitHub release body and the Slack announcement template (Phase 8). "
295
+ "Recommended length 80-160 chars."
296
+ ),
297
+ )
298
+ return parser
299
+
300
+
301
+ # ---- Helpers ----------------------------------------------------------------
302
+
303
+
304
+ def _resolve_project_root(arg_root: Path | None) -> Path:
305
+ if arg_root is not None:
306
+ return arg_root.resolve()
307
+ env_root = os.environ.get("DEFT_PROJECT_ROOT")
308
+ if env_root:
309
+ return Path(env_root).resolve()
310
+ return Path(__file__).resolve().parent.parent
311
+
312
+
313
+ def _resolve_repo(arg_repo: str | None, project_root: Path) -> str:
314
+ """Resolve OWNER/REPO via flag > git remote > DEFAULT_REPO fallback."""
315
+ if arg_repo:
316
+ return arg_repo
317
+ try:
318
+ result = subprocess.run(
319
+ ["git", "-C", str(project_root), "remote", "get-url", "origin"],
320
+ capture_output=True,
321
+ text=True,
322
+ timeout=10,
323
+ check=False,
324
+ )
325
+ except (FileNotFoundError, subprocess.TimeoutExpired):
326
+ return DEFAULT_REPO
327
+ if result.returncode != 0:
328
+ return DEFAULT_REPO
329
+ url = result.stdout.strip()
330
+ # Accept https://github.com/OWNER/REPO(.git)? and git@github.com:OWNER/REPO(.git)?
331
+ match = re.match(
332
+ r"^(?:https?://github\.com/|git@github\.com:)(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
333
+ url,
334
+ )
335
+ if not match:
336
+ return DEFAULT_REPO
337
+ return f"{match.group('owner')}/{match.group('repo')}"
338
+
339
+
340
+ def _validate_version(version: str) -> None:
341
+ """Raise ValueError if the version does not match strict X.Y.Z semver."""
342
+ if not _VERSION_RE.match(version):
343
+ raise ValueError(
344
+ f"Invalid version {version!r}. Expected strict semver X.Y.Z "
345
+ f"(no leading 'v', no pre-release suffix)."
346
+ )
347
+
348
+
349
+ def is_prerelease_tag(version: str) -> bool:
350
+ """Return True when ``version`` carries a SemVer pre-release suffix (#425).
351
+
352
+ A SemVer pre-release is everything after the first ``-`` that follows the
353
+ core ``X.Y.Z`` version (``-rc.N``, ``-beta.N``, ``-alpha.N``, ...). This
354
+ pure tag-based decision drives the ``--prerelease`` flag passed to
355
+ ``gh release create`` so RC / beta / alpha cuts are flagged as GitHub
356
+ pre-releases automatically instead of requiring a manual
357
+ ``gh release edit --prerelease`` after every cut. It mirrors the
358
+ workflow-side ``prerelease: ${{ contains(github.ref_name, '-') }}`` so
359
+ both release-creation paths agree.
360
+
361
+ A leading ``v`` is tolerated so callers may pass either the tag
362
+ (``v0.20.0-rc.1``) or the bare version (``0.20.0-rc.1``).
363
+
364
+ Examples
365
+ --------
366
+ ``v0.20.0-rc.1`` -> True
367
+ ``v1.0.0-alpha.3`` -> True
368
+ ``0.20.0-beta.2`` -> True
369
+ ``v0.20.0`` -> False
370
+ ``0.20.0`` -> False
371
+ """
372
+ candidate = version.strip()
373
+ if candidate.startswith("v"):
374
+ candidate = candidate[1:]
375
+ return "-" in candidate
376
+
377
+
378
+ def _today_iso() -> str:
379
+ return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%d")
380
+
381
+
382
+ # ---- gh CLI resolution (Windows PATHEXT fix, #721) -------------------------
383
+
384
+
385
+ def _resolve_gh() -> str | None:
386
+ """Resolve the absolute path to the ``gh`` CLI binary.
387
+
388
+ On Windows, ``gh`` is installed as ``gh.cmd`` (a shell-launcher shim).
389
+ Python's ``subprocess.run(["gh", ...])`` does NOT honor PATHEXT when
390
+ resolving ``argv[0]`` via the OS's CreateProcess path, so the launcher
391
+ cannot be found even when ``gh`` works fine from the operator's
392
+ terminal. ``shutil.which`` DOES honor PATHEXT, so resolving once via
393
+ this helper and passing the absolute path as ``argv[0]`` (e.g.
394
+ ``C:\\Program Files\\GitHub CLI\\gh.cmd``) makes the four release
395
+ scripts work uniformly across Windows / macOS / Linux (#721).
396
+
397
+ Returns the absolute path string when ``gh`` is on PATH, or ``None``
398
+ when it is not -- callers MUST surface the canonical
399
+ ``"gh CLI not found on PATH"`` reason on ``None`` to keep error
400
+ messages stable for tests and operators.
401
+ """
402
+ return shutil.which("gh")
403
+
404
+
405
+ # ---- Step 1/2 -- git pre-flight --------------------------------------------
406
+
407
+ #: Programmatic use of the #747 branch-protection env-var bypass (#867).
408
+ #: The release pipeline is the canonical authorised commit-on-master path
409
+ #: (Steps 9/10/11 commit + tag + push release artifacts on master by
410
+ #: design); the #747 detection-bound gate has no carve-out for it. The
411
+ #: documented operator-side emergency-escape hatch
412
+ #: (``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1`` per ``scripts/policy.py::ENV_BYPASS``)
413
+ #: is reused programmatically in the subprocess env -- this is NOT a new
414
+ #: bypass, just scoped use of the existing approved escape hatch. The
415
+ #: parent-process ``os.environ`` is intentionally NEVER mutated; the
416
+ #: env-var lives only in the subprocess env passed via ``env=`` so a
417
+ #: stale value cannot leak into a subsequent operator shell session.
418
+ _BRANCH_GATE_BYPASS_ENV = "DEFT_ALLOW_DEFAULT_BRANCH_COMMIT"
419
+
420
+ #: Programmatic use of the #1019 destructive-gh-verb env-var bypass.
421
+ #: Same pattern as #867 above, applied to the #1019 ``.githooks/pre-push``
422
+ #: gate that refuses pushes to the default branch (force-push or otherwise).
423
+ #: The release pipeline's Step 11 atomic push on master triggers the gate's
424
+ #: ``force_push_default`` detection; without this carve-out the cut halts
425
+ #: at Step 11 with no path forward except a manual env-var override.
426
+ #: Surfaced during the v0.28.0 cut session 2026-05-11 (the release that
427
+ #: introduced #1019); fix lands in the same release to keep master never
428
+ #: in a release-blocking-itself state. The CHANGELOG entry for #1019
429
+ #: documents the env-var as the canonical bypass mirroring
430
+ #: ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT``; this carve-out makes the
431
+ #: release-pipeline integration explicit. Parent ``os.environ`` is
432
+ #: intentionally NEVER mutated, mirroring the #867 contract.
433
+ _DESTRUCTIVE_GH_GATE_BYPASS_ENV = "DEFT_ALLOW_DESTRUCTIVE_GH_VERBS"
434
+
435
+
436
+ def _release_subprocess_env() -> dict[str, str]:
437
+ """Return a copy of ``os.environ`` with the release-pipeline gate bypasses set.
438
+
439
+ The returned dict is suitable for passing as ``env=`` to
440
+ ``subprocess.run``/``_run_git`` for the release-pipeline mutations on
441
+ master (commit + tag + push). The parent-process environment is left
442
+ untouched so the bypasses cannot leak to subsequent operator commands.
443
+
444
+ Two bypasses are set:
445
+
446
+ - ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1`` (#867) -- recognised by the
447
+ #747 branch-protection gate at commit + push time.
448
+ - ``DEFT_ALLOW_DESTRUCTIVE_GH_VERBS=1`` (added in v0.28.0 alongside
449
+ #1019) -- recognised by the #1019 destructive-gh-verb pre-push gate
450
+ so the pipeline's atomic push of master + the annotated tag is not
451
+ refused by the new gate's ``force_push_default`` classifier.
452
+
453
+ Both bypasses are scoped uses of documented operator-side escape
454
+ hatches, not new bypasses.
455
+ """
456
+ env = os.environ.copy()
457
+ env[_BRANCH_GATE_BYPASS_ENV] = "1"
458
+ env[_DESTRUCTIVE_GH_GATE_BYPASS_ENV] = "1"
459
+ return env
460
+
461
+
462
+ def _run_git(
463
+ project_root: Path,
464
+ *args: str,
465
+ check: bool = False,
466
+ env: dict[str, str] | None = None,
467
+ ) -> subprocess.CompletedProcess:
468
+ return subprocess.run(
469
+ ["git", "-C", str(project_root), *args],
470
+ capture_output=True,
471
+ text=True,
472
+ timeout=30,
473
+ check=check,
474
+ env=env,
475
+ )
476
+
477
+
478
+ def check_git_clean(project_root: Path) -> tuple[bool, str]:
479
+ result = _run_git(project_root, "status", "--porcelain")
480
+ if result.returncode != 0:
481
+ return False, f"git status failed: {result.stderr.strip()}"
482
+ output = result.stdout.strip()
483
+ if output:
484
+ return False, output
485
+ return True, ""
486
+
487
+
488
+ def current_branch(project_root: Path) -> str:
489
+ result = _run_git(project_root, "branch", "--show-current")
490
+ if result.returncode != 0:
491
+ return ""
492
+ return result.stdout.strip()
493
+
494
+
495
+ # ---- Step 3 -- vBRIEF lifecycle sync (#734) --------------------------------
496
+
497
+
498
+ def check_vbrief_lifecycle_sync(
499
+ project_root: Path, repo: str
500
+ ) -> tuple[bool, int, str]:
501
+ """Reconcile vBRIEF references against open GitHub issues (#734).
502
+
503
+ Wraps ``scripts/reconcile_issues.py`` so the release pipeline can
504
+ refuse to cut a release while there are closed-issue vBRIEFs still
505
+ living in non-terminal lifecycle folders -- the v0.21.0 cut
506
+ surfaced 13 stranded vBRIEFs (8 cycle-relevant + 5 historical
507
+ residue) post-publish, the recurrence record this gate prevents.
508
+
509
+ Inverted-lookup direction (#754): the gate queries the state of
510
+ just the vBRIEF-referenced issues via ``fetch_issue_states``
511
+ (batched ``gh api graphql``) instead of fetching every open issue
512
+ in the repo and filtering. Cost scales by
513
+ ``O(vBRIEF-referenced-issue-count)`` rather than
514
+ ``O(repo-open-issue-count)``, retiring the prior 200-issue
515
+ pagination cap that produced false-positive mismatch floods on
516
+ repos with >200 open issues.
517
+
518
+ Returns ``(ok, mismatch_count, reason)``:
519
+ - ``ok=True, mismatch_count=0`` -- clean (Section (c) is empty).
520
+ - ``ok=False, mismatch_count=N`` -- N closed-issue vBRIEFs are NOT
521
+ in ``completed/`` or ``cancelled/``; operator must run
522
+ ``task reconcile:issues -- --apply-lifecycle-fixes`` (or pass
523
+ ``--allow-vbrief-drift`` to override).
524
+ - ``ok=False, mismatch_count=-1`` -- configuration error (vbrief
525
+ directory missing, ``gh`` unavailable, etc.).
526
+
527
+ The function delegates to the existing ``reconcile_issues``
528
+ helpers so a single source of truth governs both the standalone
529
+ CLI and the pipeline gate.
530
+ """
531
+ # Local import to avoid pulling reconcile_issues + its transitive
532
+ # imports at module load time (fast unit-test startup matters in
533
+ # this codebase). The script-relative import path mirrors the
534
+ # convention used by the e2e harness and rollback helpers.
535
+ scripts_dir = Path(__file__).resolve().parent
536
+ if str(scripts_dir) not in sys.path:
537
+ sys.path.insert(0, str(scripts_dir))
538
+ try:
539
+ import reconcile_issues # type: ignore # noqa: PLC0415
540
+ except ImportError as exc:
541
+ return False, -1, f"reconcile_issues import failed: {exc}"
542
+
543
+ vbrief_dir = project_root / "vbrief"
544
+ if not vbrief_dir.is_dir():
545
+ return False, -1, f"vbrief directory not found at {vbrief_dir}"
546
+
547
+ issue_to_vbriefs = reconcile_issues.scan_vbrief_dir(vbrief_dir)
548
+ # #754: inverted lookup -- query just the vBRIEF-referenced subset
549
+ # via batched GraphQL. Bounded by O(vBRIEF-count) regardless of
550
+ # repo open-issue count.
551
+ issue_state_map = reconcile_issues.fetch_issue_states(
552
+ repo, set(issue_to_vbriefs.keys()), cwd=project_root
553
+ )
554
+ if issue_state_map is None:
555
+ return False, -1, "failed to fetch issue states from gh"
556
+
557
+ report = reconcile_issues.reconcile(issue_to_vbriefs, issue_state_map)
558
+ # Section (c) entries that are NOT already terminal -- the
559
+ # apply-mode candidates. Reverse mismatches (issues that reopened
560
+ # after a vBRIEF landed in completed/ or cancelled/) are intentionally NOT
561
+ # counted here per #734 (operator decision; report-only).
562
+ mismatches = [
563
+ rel
564
+ for entry in report.get("no_open_issue", [])
565
+ for rel in entry.get("vbrief_files", [])
566
+ if not reconcile_issues.is_terminal_lifecycle_path(rel)
567
+ ]
568
+ count = len(mismatches)
569
+ if count == 0:
570
+ return True, 0, "no mismatches"
571
+ return False, count, (
572
+ f"{count} closed-issue vBRIEF(s) not in completed/ or cancelled/: "
573
+ f"{', '.join(mismatches[:5])}"
574
+ + (" ..." if count > 5 else "")
575
+ )
576
+
577
+
578
+ # ---- Step 4 -- tag availability pre-flight (#784) --------------------------
579
+
580
+
581
+ def check_tag_available(
582
+ version: str, repo: str, project_root: Path
583
+ ) -> tuple[bool, str]:
584
+ """Refuse early when v<version> already exists locally, on origin, or as a GitHub release.
585
+
586
+ Read-only check -- safe on every dry-run; no network mutation.
587
+ Three failure surfaces, each producing a distinct actionable reason
588
+ so the operator can target the recovery (the most common cause is a
589
+ typo of the prior release version):
590
+
591
+ 1. **Local tag** -- ``git tag -l v<version>`` lists the tag. ``git
592
+ tag`` at the legacy Step 9 would fail; the operator would already
593
+ have an unpushed wrong-version commit + orphaned dist artifact.
594
+ 2. **Remote tag on origin** -- ``git ls-remote --tags origin
595
+ refs/tags/v<version>`` returns the ref. ``git push --atomic`` at
596
+ the legacy Step 10 would fail.
597
+ 3. **Published GitHub release** -- ``gh release view v<version>``
598
+ exits 0. Tag may have been created via ``gh release create``
599
+ directly without a corresponding ref under ``refs/tags/``.
600
+
601
+ Surfaced 2026-05-01 during the v0.23.0 release attempt where the
602
+ operator typed ``0.22.0`` (the prior release from 12 hours earlier);
603
+ the legacy pipeline ran 8 steps before failing at git tag, requiring
604
+ ``git reset --hard`` recovery.
605
+
606
+ ``gh`` not on PATH is intentionally NOT a failure: the helper passes
607
+ with a UNVERIFIED caveat in the reason (parallel to the
608
+ ``verify_release_draft`` (#724) gh-missing path). Local + remote git
609
+ surfaces still gate the gate, so the most common typo case remains
610
+ caught even on gh-less hosts.
611
+
612
+ Refs #784, #74 (release pipeline parent), #734 (sibling pre-flight
613
+ gate -- vBRIEF lifecycle sync).
614
+ """
615
+ tag = f"v{version}"
616
+
617
+ # 1. Local tag -- git tag -l <tag> prints the tag name on a hit.
618
+ local = _run_git(project_root, "tag", "-l", tag)
619
+ if local.returncode != 0:
620
+ return False, f"git tag -l failed: {local.stderr.strip()}"
621
+ if local.stdout.strip() == tag:
622
+ return False, (
623
+ f"local tag {tag} already exists; choose a different version "
624
+ f"(operator typo of a prior release is the most likely cause)"
625
+ )
626
+
627
+ # 2. Remote tag on origin -- ls-remote prints `<sha>\trefs/tags/<tag>` on a hit.
628
+ # ls-remote can fail for non-conflict reasons (no origin remote configured,
629
+ # network down, auth failure). Treat any non-zero exit as UNVERIFIED rather
630
+ # than a hard FAIL -- mirrors the gh-not-found carve-out below. The local
631
+ # tag check is the primary surface; remote / gh are defense-in-depth, so a
632
+ # "could not check this surface" outcome SHOULD warn-and-continue rather
633
+ # than block the release. (The dirty-tree gate at Step 1 and branch gate
634
+ # at Step 2 will have already caught the more catastrophic
635
+ # not-a-git-repository case before we get here.)
636
+ remote = _run_git(
637
+ project_root, "ls-remote", "--tags", "origin", f"refs/tags/{tag}"
638
+ )
639
+ remote_unverified_note = ""
640
+ if remote.returncode != 0:
641
+ stderr = (remote.stderr or "").strip()
642
+ remote_unverified_note = (
643
+ f" (remote UNVERIFIED -- git ls-remote failed: "
644
+ f"{stderr.splitlines()[0] if stderr else 'no stderr'})"
645
+ )
646
+ elif f"refs/tags/{tag}" in remote.stdout:
647
+ return False, (
648
+ f"remote tag {tag} already exists on origin; "
649
+ f"choose a different version"
650
+ )
651
+
652
+ # 3. Published GitHub release (defense in depth).
653
+ gh_path = _resolve_gh()
654
+ if gh_path is None:
655
+ return True, (
656
+ f"local clean{remote_unverified_note} (gh CLI not on PATH; "
657
+ f"GitHub release surface UNVERIFIED -- install gh or pass "
658
+ f"--skip-release to suppress this caveat)"
659
+ )
660
+ try:
661
+ gh = subprocess.run(
662
+ [
663
+ gh_path,
664
+ "release",
665
+ "view",
666
+ tag,
667
+ "--repo",
668
+ repo,
669
+ "--json",
670
+ "tagName",
671
+ ],
672
+ capture_output=True,
673
+ text=True,
674
+ timeout=30,
675
+ check=False,
676
+ )
677
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
678
+ # gh CLI vanished between the which() probe and the invocation
679
+ # (or hung). Treat as UNVERIFIED rather than a release-exists
680
+ # false positive: the issue body's "gh-CLI not-found != release-
681
+ # exists" carve-out applies here too.
682
+ return True, (
683
+ f"local clean{remote_unverified_note} (gh probe failed: {exc}; "
684
+ f"GitHub release surface UNVERIFIED)"
685
+ )
686
+ if gh.returncode == 0:
687
+ return False, (
688
+ f"GitHub release {tag} already exists on {repo}; "
689
+ f"choose a different version"
690
+ )
691
+ # Non-zero rc on a missing release is the OK path.
692
+ return (
693
+ True,
694
+ f"local clean{remote_unverified_note}; no GitHub release {tag} on {repo}",
695
+ )
696
+
697
+
698
+ # ---- Step 5 -- CI ----------------------------------------------------------
699
+
700
+
701
+ def run_ci(project_root: Path) -> tuple[bool, str]:
702
+ """Run the local CI entrypoint without requiring go-task (#1659)."""
703
+ import ci_local # noqa: PLC0415
704
+
705
+ code = ci_local.main(["--root", str(project_root)])
706
+ if code != 0:
707
+ return False, f"ci:local failed (exit {code})"
708
+ return True, "ran ci:local"
709
+
710
+
711
+ # ---- Step 4 -- CHANGELOG promotion -----------------------------------------
712
+
713
+
714
+ def _split_body_and_links(text: str) -> tuple[str, str]:
715
+ """Split CHANGELOG content into (body, link-footer).
716
+
717
+ The link footer is the trailing block of `[X.Y.Z]: url` lines. We split
718
+ on the FIRST link line so we can inject a new line at the top of the
719
+ block while preserving comment markers (e.g. ``<!-- ... -->``) that may
720
+ be interleaved with the link list.
721
+ """
722
+ lines = text.splitlines(keepends=True)
723
+ first_link_idx: int | None = None
724
+ for idx, line in enumerate(lines):
725
+ if line.startswith("[Unreleased]:") or re.match(r"^\[\d+\.\d+\.\d+\]:", line):
726
+ first_link_idx = idx
727
+ break
728
+ if first_link_idx is None:
729
+ return text, ""
730
+ body = "".join(lines[:first_link_idx])
731
+ footer = "".join(lines[first_link_idx:])
732
+ return body, footer
733
+
734
+
735
+ def _extract_previous_version(footer: str) -> str | None:
736
+ """Return the previous version from the existing ``[Unreleased]:`` link, or None."""
737
+ match = _UNRELEASED_LINK_RE.search(footer)
738
+ if match:
739
+ return match.group("prev")
740
+ return None
741
+
742
+
743
+ def promote_changelog(
744
+ text: str,
745
+ version: str,
746
+ repo: str,
747
+ today: str,
748
+ summary: str | None = None,
749
+ ) -> str:
750
+ """Promote ``[Unreleased]`` to ``[<version>] - <today>`` and refresh the link footer.
751
+
752
+ Raises ValueError when the input lacks an ``[Unreleased]`` heading or
753
+ appears malformed.
754
+
755
+ When ``summary`` is a non-empty string, a one-line Markdown blockquote
756
+ (``> <summary>``) is injected directly after the new
757
+ ``## [<version>] - <today>`` heading and before the first sub-section
758
+ so the promoted block reads::
759
+
760
+ ## [<version>] - <date>
761
+
762
+ > <summary>
763
+
764
+ ### Added
765
+ - ...
766
+
767
+ The blockquote is sandwiched by blank lines for proper Keep-a-Changelog
768
+ rendering. The summary is treated as inline Markdown and preserved
769
+ verbatim (operators may include ``**bold**``, ``[link](url)``, etc.).
770
+ Newlines in the summary cause a ``ValueError`` -- the slot is
771
+ explicitly single-line per the release-narrative-gap scope vBRIEF.
772
+ Empty / ``None`` summary preserves pre-existing behaviour byte-for-byte
773
+ (no blockquote is emitted).
774
+ """
775
+ if not _UNRELEASED_RE.search(text):
776
+ raise ValueError("CHANGELOG.md does not contain a '## [Unreleased]' heading.")
777
+
778
+ if summary is not None and ("\n" in summary or "\r" in summary):
779
+ raise ValueError(
780
+ "--summary is single-line; got embedded newline. "
781
+ "Author the blockquote on a single line."
782
+ )
783
+
784
+ body, footer = _split_body_and_links(text)
785
+
786
+ # Promote: rename heading + insert fresh empty Unreleased block above.
787
+ promoted_heading = f"## [{version}] - {today}"
788
+ if summary:
789
+ # Sandwich the blockquote with blank lines so Keep-a-Changelog
790
+ # renders it as a real blockquote (a ``>`` line glued to the
791
+ # heading or the first sub-section can break Markdown rendering
792
+ # in some clients). Layout in the substitution result:
793
+ #
794
+ # ## [<v>] - <d>\n <- heading
795
+ # \n <- blank line
796
+ # > <summary>\n <- blockquote line + newline
797
+ # <next char from body, which is "\n### Added...">
798
+ #
799
+ # so the rendered shape is heading / blank / > summary / blank /
800
+ # ### Added. The trailing ``\n`` we append below combines with
801
+ # the single ``\n`` left in body after the regex (the
802
+ # ``_UNRELEASED_RE`` ``\s*`` greedy-then-backtrack consumes one
803
+ # of the two ``\n``s following ``## [Unreleased]``) to form the
804
+ # blank line.
805
+ promoted_heading = f"{promoted_heading}\n\n> {summary}\n"
806
+ fresh_block = FRESH_UNRELEASED_BLOCK.rstrip() + "\n\n"
807
+ # P1 (#730 Greptile): use a callable replacement so Python's ``re``
808
+ # module does NOT interpret backslash sequences in the operator's
809
+ # summary as group backreferences (``\1``-``\9``, ``\g<name>``).
810
+ # ``_UNRELEASED_RE`` has no capture groups, so a literal-string
811
+ # replacement containing e.g. ``"\\1"`` would raise an uncaught
812
+ # ``re.error: invalid group reference`` -- ugly traceback that
813
+ # bypasses the ``ValueError`` newline guard. A lambda repl returns
814
+ # the value verbatim and skips all backslash interpretation.
815
+ replacement = fresh_block + promoted_heading
816
+ new_body, count = _UNRELEASED_RE.subn(
817
+ lambda _match: replacement,
818
+ body,
819
+ count=1,
820
+ )
821
+ if count != 1:
822
+ raise ValueError("Failed to locate exactly one '## [Unreleased]' heading.")
823
+
824
+ # Refresh the link footer.
825
+ prev = _extract_previous_version(footer)
826
+ new_unreleased_link = (
827
+ f"[Unreleased]: https://github.com/{repo}/compare/v{version}...HEAD"
828
+ )
829
+ if prev:
830
+ version_link = (
831
+ f"[{version}]: https://github.com/{repo}/compare/v{prev}...v{version}"
832
+ )
833
+ else:
834
+ version_link = (
835
+ f"[{version}]: https://github.com/{repo}/releases/tag/v{version}"
836
+ )
837
+ if footer:
838
+ footer_lines = footer.splitlines(keepends=True)
839
+ # Replace the existing [Unreleased]: line (assumed first link) and
840
+ # prepend the new version-link line immediately after it.
841
+ replaced = False
842
+ new_footer_lines: list[str] = []
843
+ for line in footer_lines:
844
+ if not replaced and line.startswith("[Unreleased]:"):
845
+ new_footer_lines.append(new_unreleased_link + "\n")
846
+ new_footer_lines.append(version_link + "\n")
847
+ replaced = True
848
+ continue
849
+ new_footer_lines.append(line)
850
+ if not replaced:
851
+ # No prior [Unreleased]: line; prepend both lines.
852
+ new_footer_lines = [new_unreleased_link + "\n", version_link + "\n"] + footer_lines
853
+ new_footer = "".join(new_footer_lines)
854
+ else:
855
+ new_footer = new_unreleased_link + "\n" + version_link + "\n"
856
+
857
+ return new_body + new_footer
858
+
859
+
860
+ def _section_for_version(text: str, version: str) -> str:
861
+ """Extract the body of ``## [<version>] - <date>`` for use as release notes."""
862
+ pattern = re.compile(
863
+ rf"^##\s+\[{re.escape(version)}\][^\n]*\n(?P<body>.*?)(?=^##\s+\[|\Z)",
864
+ re.MULTILINE | re.DOTALL,
865
+ )
866
+ match = pattern.search(text)
867
+ if not match:
868
+ return ""
869
+ return match.group("body").strip()
870
+
871
+
872
+ def _prepend_upgrade_banner(notes: str, repo: str, project_root: Path) -> str:
873
+ """Lead maintainer-mode GitHub release notes with the upgrade banner (#1413).
874
+
875
+ Pure function: given the assembled release ``notes``, the resolved
876
+ ``repo`` slug, and the ``project_root``, return ``banner + "\\n\\n" +
877
+ notes`` when BOTH conditions hold:
878
+
879
+ 1. **Maintainer mode** -- ``repo`` is the canonical directive framework
880
+ slug (``DEFAULT_REPO`` == ``deftai/directive``). A consumer-mode
881
+ cut (any other ``owner/repo``) returns ``notes`` unchanged so a
882
+ downstream project that vendors the release pipeline never inherits
883
+ deft's upgrade guidance.
884
+ 2. **Template present** -- the editable banner template exists and is
885
+ readable at ``<project_root>/.github/release-notes/upgrade-banner.md``.
886
+
887
+ The banner is GitHub-release-body-only: it is prepended to the notes
888
+ passed to ``create_github_release`` and is NEVER written back into
889
+ CHANGELOG.md. Line endings in the template are normalised to ``\\n``
890
+ and the trailing whitespace is trimmed so the banner joins the notes
891
+ with exactly one blank line regardless of how the template was saved
892
+ (CRLF on a Windows checkout, etc.).
893
+
894
+ Graceful degradation: a missing or unreadable template returns
895
+ ``notes`` unchanged and NEVER raises -- a release must not be blocked
896
+ because the optional banner could not be loaded.
897
+ """
898
+ if repo != DEFAULT_REPO:
899
+ return notes
900
+ banner_path = project_root / _UPGRADE_BANNER_RELPATH
901
+ try:
902
+ banner = banner_path.read_text(encoding="utf-8")
903
+ except OSError:
904
+ # Missing / unreadable template (FileNotFoundError, PermissionError,
905
+ # IsADirectoryError, ...). The banner is best-effort; never block a
906
+ # release on its absence.
907
+ return notes
908
+ banner = banner.replace("\r\n", "\n").strip()
909
+ if not banner:
910
+ return notes
911
+ return f"{banner}\n\n{notes}"
912
+
913
+
914
+ # ---- Step 5 -- ROADMAP refresh ---------------------------------------------
915
+
916
+
917
+ def refresh_roadmap(project_root: Path) -> tuple[bool, str]:
918
+ """Re-render ROADMAP.md via the Python roadmap renderer.
919
+
920
+ ``scripts/roadmap_render.py`` already aggregates ``vbrief/pending/``
921
+ (Active) and ``vbrief/completed/`` (Completed) idempotently, so the
922
+ release script trusts the renderer rather than mutating the file
923
+ directly. vBRIEFs that should appear in ``## Completed`` are expected
924
+ to have been moved via ``task scope:complete`` in advance.
925
+ """
926
+ import roadmap_render # noqa: PLC0415
927
+
928
+ ok, msg = roadmap_render.render_roadmap(
929
+ str(project_root / "vbrief" / "pending"),
930
+ str(project_root / "ROADMAP.md"),
931
+ completed_dir=str(project_root / "vbrief" / "completed"),
932
+ )
933
+ if not ok:
934
+ return False, f"roadmap:render failed: {msg}"
935
+ return True, "ROADMAP.md re-rendered"
936
+
937
+
938
+ # ---- Step 6 -- build dist --------------------------------------------------
939
+
940
+
941
+ def run_build(project_root: Path, version: str | None = None) -> tuple[bool, str]:
942
+ """Run ``deft build`` for the release, pinning the artifact version (#723).
943
+
944
+ The Taskfile resolves its ``VERSION`` variable via the inline POSIX
945
+ ``sh:`` block in ``Taskfile.yml`` ``vars: VERSION``, which honors
946
+ ``DEFT_RELEASE_VERSION`` over the latest annotated git tag (mirrored
947
+ in ``scripts/resolve_version.py`` for Python callers + tests).
948
+ Setting the env var here makes the in-flight release version (e.g.
949
+ ``0.21.0``) the canonical source for the artifact filename so
950
+ ``dist/deft-{version}.zip`` always matches the requested release
951
+ rather than a stale Taskfile literal or the most-recent tag (which
952
+ lags the in-flight tag during ``task release``).
953
+
954
+ ``version`` may be ``None`` for callers that want the resolver
955
+ default (git tag -> dev fallback). When ``version`` is falsy, any
956
+ inherited ``DEFT_RELEASE_VERSION`` value is explicitly stripped from
957
+ the subprocess env -- otherwise a stale value leaked from the parent
958
+ shell (e.g. an interrupted prior ``task release`` run that exported
959
+ the var into the operator's session) would silently re-introduce the
960
+ exact stale-version bug #723 just closed.
961
+
962
+ Contract:
963
+ - ``version`` truthy: subprocess env carries
964
+ ``DEFT_RELEASE_VERSION=<version>``.
965
+ - ``version`` falsy / ``None``: subprocess env carries NO
966
+ ``DEFT_RELEASE_VERSION`` (any inherited value is removed).
967
+ """
968
+ if version:
969
+ previous = os.environ.get("DEFT_RELEASE_VERSION")
970
+ os.environ["DEFT_RELEASE_VERSION"] = version
971
+ else:
972
+ previous = os.environ.pop("DEFT_RELEASE_VERSION", None)
973
+ # Strip any inherited value so version=None means "let the resolver
974
+ # decide" (git tag -> dev fallback) and never
975
+ # "use whatever leaked from the parent shell" -- see #723.
976
+ try:
977
+ result = run_framework_command(
978
+ "build",
979
+ project_root=project_root,
980
+ framework_root=project_root,
981
+ )
982
+ finally:
983
+ if previous is None:
984
+ os.environ.pop("DEFT_RELEASE_VERSION", None)
985
+ else:
986
+ os.environ["DEFT_RELEASE_VERSION"] = previous
987
+ if result.code != 0:
988
+ return False, f"build failed (exit {result.code})"
989
+ suffix = f" (DEFT_RELEASE_VERSION={version})" if version else ""
990
+ return True, f"build ran clean{suffix}"
991
+
992
+
993
+ # ---- Step 5 -- pyproject.toml [project].version sync (#771) ----------------
994
+
995
+ # Single ``version = "X.Y.Z"`` line under the ``[project]`` section. We do
996
+ # NOT use ``tomllib`` to write because it is read-only in stdlib, and we do
997
+ # NOT bring in a TOML writer dep just to flip one literal -- the regex
998
+ # below targets the canonical Keep-a-pyproject ``[project]`` block shape
999
+ # (the same shape ``uv init`` / PEP 621 examples emit) and rewrites only
1000
+ # the FIRST ``version = "..."`` line that follows the ``[project]`` table
1001
+ # header. Other ``version`` keys (e.g. inside ``[tool.poetry]`` / vendored
1002
+ # tool configs) are left untouched.
1003
+ _PYPROJECT_VERSION_LINE_RE = re.compile(r'version\s*=\s*"[^"]*"')
1004
+
1005
+
1006
+ def update_pyproject_version(text: str, version: str) -> str:
1007
+ """Rewrite ``[project].version`` in pyproject.toml content (#771).
1008
+
1009
+ Pure function: takes the full file content + the resolved release
1010
+ version (PEP 440-normalized; the caller is responsible for the
1011
+ normalization, see ``scripts.resolve_version.to_pep440``) and
1012
+ returns the new content. Operates on the FIRST ``version = "..."``
1013
+ line under the ``[project]`` section; sub-tables (e.g.
1014
+ ``[tool.poetry]`` ``version``) are intentionally untouched.
1015
+
1016
+ Idempotent: if the line is already at the requested version, the
1017
+ return value equals ``text`` byte-for-byte.
1018
+
1019
+ Raises ``ValueError`` when the input has no ``[project]`` section or
1020
+ the section has no ``version`` key -- the release pipeline treats
1021
+ this as a config error so misconfigured projects do not silently
1022
+ skip the sync.
1023
+ """
1024
+ if not isinstance(text, str):
1025
+ raise ValueError(f"text must be a string, got {type(text).__name__}")
1026
+ if not isinstance(version, str) or not version.strip():
1027
+ raise ValueError("version must be a non-empty string")
1028
+
1029
+ lines = text.splitlines(keepends=True)
1030
+ in_project_section = False
1031
+ for idx, line in enumerate(lines):
1032
+ stripped = line.strip()
1033
+ # Comment / blank lines do not change section state.
1034
+ if not stripped or stripped.startswith("#"):
1035
+ continue
1036
+ # Detect a TOML table header. Match exactly ``[project]`` (not
1037
+ # ``[project.scripts]`` etc.) -- those subtables can carry their
1038
+ # own ``version`` keys we MUST NOT clobber.
1039
+ if stripped.startswith("[") and stripped.endswith("]"):
1040
+ in_project_section = stripped == "[project]"
1041
+ continue
1042
+ if in_project_section and _PYPROJECT_VERSION_LINE_RE.match(stripped):
1043
+ new_line = _PYPROJECT_VERSION_LINE_RE.sub(
1044
+ f'version = "{version}"', line, count=1
1045
+ )
1046
+ if new_line == line:
1047
+ return text
1048
+ lines[idx] = new_line
1049
+ return "".join(lines)
1050
+ raise ValueError(
1051
+ "pyproject.toml has no [project] section with a version key"
1052
+ )
1053
+
1054
+
1055
+ # ---- Step 7/8 -- commit + tag + push ---------------------------------------
1056
+
1057
+
1058
+ # Files written by the release pipeline (steps 4 + 5) that MUST be committed
1059
+ # before tagging so the annotated tag and GitHub release point at the
1060
+ # CHANGELOG-promoted / ROADMAP-refreshed commit (#74 Greptile P1).
1061
+ #
1062
+ # ``pyproject.toml`` joins the set in #771 because Step 5 now also syncs
1063
+ # ``[project].version`` from the resolved release version (PEP 440
1064
+ # normalized via ``scripts.resolve_version.to_pep440``). The helper
1065
+ # below stages it conditionally on existence so projects without a
1066
+ # pyproject (the synthetic test fixtures) keep working unchanged.
1067
+ #
1068
+ # ``uv.lock`` joins the set in #774 (Greptile P1) because Step 5 now
1069
+ # also runs ``uv lock`` to regenerate the lockfile after the pyproject
1070
+ # version write -- without staging it the released tag would record a
1071
+ # pyproject at the new version and a uv.lock still pinning the old
1072
+ # version, causing every subsequent ``uv lock --check`` (and any
1073
+ # downstream ``uv sync --frozen`` consumer) to fail post-pipeline.
1074
+ _RELEASE_ARTIFACTS = ("CHANGELOG.md", "ROADMAP.md", "pyproject.toml", "uv.lock")
1075
+
1076
+
1077
+ def _release_commit_subject(version: str) -> str:
1078
+ """Return the canonical subject line for the release commit."""
1079
+ return f"chore(release): v{version} -- promote CHANGELOG + ROADMAP"
1080
+
1081
+
1082
+ def commit_release_artifacts(
1083
+ project_root: Path, version: str
1084
+ ) -> tuple[bool, str]:
1085
+ """Stage and commit CHANGELOG.md / ROADMAP.md before tagging.
1086
+
1087
+ Without this step the annotated tag would land on the pre-release HEAD
1088
+ commit -- meaning the tagged commit and GitHub release would be anchored
1089
+ to content that predates the CHANGELOG promotion, AND the working tree
1090
+ would remain dirty after the pipeline (#74 Greptile P1).
1091
+
1092
+ Stages only the canonical release artifacts (CHANGELOG.md / ROADMAP.md)
1093
+ so any unrelated changes the operator left in the tree are NOT silently
1094
+ swept into the release commit. If neither file actually changed, the
1095
+ function reports a clean no-op so callers can proceed to tagging without
1096
+ a bogus empty commit.
1097
+ """
1098
+ paths_to_stage = [
1099
+ path
1100
+ for path in _RELEASE_ARTIFACTS
1101
+ if (project_root / path).is_file()
1102
+ ]
1103
+ if not paths_to_stage:
1104
+ return True, "no release artifacts to commit (none exist)"
1105
+
1106
+ add = _run_git(project_root, "add", "--", *paths_to_stage)
1107
+ if add.returncode != 0:
1108
+ return False, f"git add failed: {add.stderr.strip()}"
1109
+
1110
+ # Confirm something is actually staged before committing -- a no-op
1111
+ # `git commit` would otherwise return non-zero with "nothing to commit".
1112
+ diff = _run_git(project_root, "diff", "--cached", "--quiet")
1113
+ if diff.returncode == 0:
1114
+ return True, "release artifacts already up-to-date; no commit needed"
1115
+
1116
+ subject = _release_commit_subject(version)
1117
+ # #867: pass DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1 in subprocess env so the
1118
+ # #747 pre-commit hook recognises the release pipeline as the canonical
1119
+ # authorised commit-on-master path; parent os.environ is left untouched.
1120
+ commit = _run_git(
1121
+ project_root, "commit", "-m", subject, env=_release_subprocess_env()
1122
+ )
1123
+ if commit.returncode != 0:
1124
+ return False, f"git commit failed: {commit.stderr.strip()}"
1125
+ return True, f"committed release artifacts ({subject})"
1126
+
1127
+
1128
+ def create_tag(project_root: Path, version: str) -> tuple[bool, str]:
1129
+ tag = f"v{version}"
1130
+ # #867: pass DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1 -- defence-in-depth in case
1131
+ # a future tag-side hook is wired into the #747 enforcement surface.
1132
+ result = _run_git(
1133
+ project_root,
1134
+ "tag",
1135
+ "-a",
1136
+ tag,
1137
+ "-m",
1138
+ f"Release {tag}",
1139
+ env=_release_subprocess_env(),
1140
+ )
1141
+ if result.returncode != 0:
1142
+ return False, f"git tag failed: {result.stderr.strip()}"
1143
+ return True, f"created tag {tag}"
1144
+
1145
+
1146
+ def push_release(
1147
+ project_root: Path, version: str, base_branch: str
1148
+ ) -> tuple[bool, str]:
1149
+ """Push the release commit + the annotated tag to ``origin`` atomically.
1150
+
1151
+ The branch update is published BEFORE the tag (`--atomic`) so the tag
1152
+ always resolves to a publicly-fetchable commit on ``origin/<base>``.
1153
+ Without the branch push the tag would dangle on origin until the next
1154
+ push of the branch, breaking ``gh release create --notes-from-tag`` and
1155
+ `git describe` for downstream consumers.
1156
+ """
1157
+ tag = f"v{version}"
1158
+ # #867: pass DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1 in subprocess env so the
1159
+ # #747 pre-push hook recognises the release pipeline as the canonical
1160
+ # authorised push-from-master path; parent os.environ is left untouched.
1161
+ result = _run_git(
1162
+ project_root,
1163
+ "push",
1164
+ "--atomic",
1165
+ "origin",
1166
+ base_branch,
1167
+ tag,
1168
+ env=_release_subprocess_env(),
1169
+ )
1170
+ if result.returncode != 0:
1171
+ return False, f"git push failed: {result.stderr.strip()}"
1172
+ return True, f"pushed {base_branch} + {tag} to origin"
1173
+
1174
+
1175
+ # Backwards-compatible alias for callers (and tests) that still reference
1176
+ # the original symbol name.
1177
+ def push_tag(project_root: Path, version: str) -> tuple[bool, str]:
1178
+ """Deprecated alias kept for backwards compatibility.
1179
+
1180
+ Prefer ``push_release`` which atomically pushes the release branch and
1181
+ its annotated tag together (#74 Greptile P1). This shim exists so
1182
+ pre-existing callers that reference ``push_tag`` continue to work; new
1183
+ code MUST call ``push_release`` directly.
1184
+ """
1185
+ return push_release(project_root, version, DEFAULT_BASE_BRANCH)
1186
+
1187
+
1188
+ # ---- Step 9 -- gh release create -------------------------------------------
1189
+
1190
+
1191
+ def create_github_release(
1192
+ project_root: Path,
1193
+ version: str,
1194
+ repo: str,
1195
+ notes: str,
1196
+ *,
1197
+ draft: bool = True,
1198
+ prerelease: bool = False,
1199
+ ) -> tuple[bool, str]:
1200
+ """Create the GitHub release tagged ``v<version>``.
1201
+
1202
+ ``draft`` defaults to True (#716 safety hardening): the release is
1203
+ created in draft state so binaries upload via release.yml CI but the
1204
+ artifact is not yet visible to consumers. ``task release:publish --
1205
+ <version>`` flips the draft to public after manual review.
1206
+
1207
+ ``prerelease`` defaults to False; when True the release is created
1208
+ with ``--prerelease`` so SemVer pre-release tags (``-rc.N`` / ``-beta.N``
1209
+ / ``-alpha.N``) are flagged as GitHub pre-releases automatically (#425).
1210
+ Callers derive the boolean from the tag via ``is_prerelease_tag`` so this
1211
+ path agrees with the workflow-side ``prerelease: ${{ contains(...) }}``.
1212
+
1213
+ Notes-file path (#731): when ``notes`` is non-empty we materialise
1214
+ it to a UTF-8 temp file and pass ``--notes-file <path>`` to ``gh``
1215
+ rather than ``--notes "<text>"``. Inlining a multi-KB CHANGELOG
1216
+ section as a single argv element overflows the Windows command-line
1217
+ buffer (~32 KB) and surfaces from CreateProcess as
1218
+ ``FileNotFoundError(winerror=206, ERROR_FILENAME_EXCED_RANGE)``. The
1219
+ temp file is cleaned up in a ``try/finally`` regardless of
1220
+ subprocess outcome (success, non-zero exit, FileNotFoundError, any
1221
+ other exception). When ``notes`` is empty we fall through to
1222
+ ``--generate-notes`` (gh-side auto-generation from PR titles since
1223
+ the previous tag) so the release body is never blank.
1224
+ """
1225
+ gh_path = _resolve_gh()
1226
+ if gh_path is None:
1227
+ return False, "gh CLI not found on PATH"
1228
+ tag = f"v{version}"
1229
+ cmd = [
1230
+ gh_path, "release", "create", tag,
1231
+ "--repo", repo,
1232
+ "--title", tag,
1233
+ ]
1234
+ if draft:
1235
+ cmd.append("--draft")
1236
+ if prerelease:
1237
+ cmd.append("--prerelease")
1238
+
1239
+ # Materialise notes to a UTF-8 temp file when non-empty so the
1240
+ # gh release create command line stays well under the OS argv cap
1241
+ # (~32 KB on Windows; ARG_MAX 128 KB-2 MB elsewhere). The previous
1242
+ # ``--notes <text>`` shape blew up on the v0.21.0 e2e cut against
1243
+ # deft's own CHANGELOG (#731).
1244
+ notes_file: Path | None = None
1245
+ if notes:
1246
+ # delete=False because we close the handle BEFORE invoking gh:
1247
+ # Windows holds an exclusive lock on a NamedTemporaryFile while
1248
+ # it is open, which would prevent gh from reading the file.
1249
+ # Cleanup happens in the finally block below.
1250
+ #
1251
+ # Greptile P2 (#732 review): assign ``notes_file`` BEFORE the
1252
+ # write so the outer ``finally`` cleanup can still find the
1253
+ # path if ``fh.write(notes)`` raises (e.g. disk-full OSError).
1254
+ # The file already exists on disk at this point (delete=False),
1255
+ # so leaving ``notes_file = None`` would orphan the temp file.
1256
+ with tempfile.NamedTemporaryFile(
1257
+ mode="w",
1258
+ encoding="utf-8",
1259
+ newline="",
1260
+ suffix=".md",
1261
+ delete=False,
1262
+ ) as fh:
1263
+ notes_file = Path(fh.name)
1264
+ fh.write(notes)
1265
+ cmd.extend(["--notes-file", str(notes_file)])
1266
+ else:
1267
+ cmd.append("--generate-notes")
1268
+
1269
+ try:
1270
+ try:
1271
+ result = subprocess.run(
1272
+ cmd,
1273
+ cwd=str(project_root),
1274
+ capture_output=True,
1275
+ text=True,
1276
+ timeout=120,
1277
+ check=False,
1278
+ env=os.environ.copy(),
1279
+ )
1280
+ except FileNotFoundError as exc:
1281
+ # Windows error 206 (ERROR_FILENAME_EXCED_RANGE) surfaces as
1282
+ # FileNotFoundError because Python's CreateProcess wrapper
1283
+ # maps it that way. Distinguish the cmd-line-overflow case
1284
+ # from a genuinely missing gh binary so operators see an
1285
+ # accurate diagnostic instead of being mis-pointed at the
1286
+ # #722 PATHEXT shim (#731).
1287
+ if getattr(exc, "winerror", None) == 206:
1288
+ return False, (
1289
+ "gh release create command line exceeded Windows "
1290
+ "limit (winerror 206, ERROR_FILENAME_EXCED_RANGE). "
1291
+ "This should be mitigated by the --notes-file "
1292
+ "switch landed in #731 -- if you still see this "
1293
+ "with notes already in a file, file a follow-up."
1294
+ )
1295
+ return False, "gh CLI not found on PATH"
1296
+ if result.returncode != 0:
1297
+ return False, f"gh release create failed: {result.stderr.strip()}"
1298
+ flags = [
1299
+ label
1300
+ for label, enabled in (("draft", draft), ("prerelease", prerelease))
1301
+ if enabled
1302
+ ]
1303
+ suffix = f" ({', '.join(flags)})" if flags else ""
1304
+ return True, f"created GitHub release {tag}{suffix}"
1305
+ finally:
1306
+ if notes_file is not None:
1307
+ # Cleanup is best-effort; an undeleted temp file in the OS
1308
+ # temp dir is a housekeeping issue, not a release-pipeline
1309
+ # failure (ruff SIM105: contextlib.suppress over try/pass).
1310
+ with contextlib.suppress(OSError):
1311
+ notes_file.unlink(missing_ok=True)
1312
+
1313
+
1314
+ # ---- Step 11 -- post-create verify-isDraft gate (#724) ---------------------
1315
+
1316
+
1317
+ VERIFY_DRAFT_MAX_ATTEMPTS = 5
1318
+ VERIFY_DRAFT_INTERVAL_SECONDS = 1.0
1319
+
1320
+
1321
+ def _gh_release_view_is_draft(
1322
+ gh_path: str, version: str, repo: str, project_root: Path
1323
+ ) -> tuple[str, str]:
1324
+ """Return ``(state, detail)`` for a single isDraft probe.
1325
+
1326
+ ``state`` is one of:
1327
+ - ``"draft"``: release exists with isDraft=true (verified safe).
1328
+ - ``"public"``: release exists with isDraft=false (defense-in-depth
1329
+ flip required).
1330
+ - ``"not-found"``: gh reported the release does not exist yet.
1331
+ - ``"error"``: gh failed for an unrelated reason; ``detail`` carries
1332
+ the stderr line.
1333
+ """
1334
+ tag = f"v{version}"
1335
+ cmd = [
1336
+ gh_path, "release", "view", tag,
1337
+ "--repo", repo,
1338
+ "--json", "isDraft",
1339
+ ]
1340
+ try:
1341
+ result = subprocess.run(
1342
+ cmd,
1343
+ cwd=str(project_root),
1344
+ capture_output=True,
1345
+ text=True,
1346
+ timeout=30,
1347
+ check=False,
1348
+ env=os.environ.copy(),
1349
+ )
1350
+ except FileNotFoundError:
1351
+ return "error", "gh CLI not found on PATH"
1352
+ except subprocess.TimeoutExpired:
1353
+ return "error", "gh release view timed out"
1354
+ if result.returncode != 0:
1355
+ stderr = (result.stderr or "").strip()
1356
+ # gh exits non-zero with a "release not found" / "not found"
1357
+ # diagnostic when the tag has no release yet -- treat that as
1358
+ # the not-found state so the verify gate can keep polling.
1359
+ if "not found" in stderr.lower() or "release not found" in stderr.lower():
1360
+ return "not-found", stderr
1361
+ return "error", stderr
1362
+ try:
1363
+ payload = json.loads(result.stdout or "{}")
1364
+ except json.JSONDecodeError as exc:
1365
+ return "error", f"unparseable gh JSON: {exc}"
1366
+ is_draft = payload.get("isDraft")
1367
+ if is_draft is True:
1368
+ return "draft", ""
1369
+ if is_draft is False:
1370
+ return "public", ""
1371
+ return "error", f"isDraft missing from gh response: {payload!r}"
1372
+
1373
+
1374
+ def _gh_release_flip_to_draft(
1375
+ gh_path: str, version: str, repo: str, project_root: Path
1376
+ ) -> tuple[bool, str]:
1377
+ """Invoke ``gh release edit v<version> --draft=true``."""
1378
+ tag = f"v{version}"
1379
+ cmd = [
1380
+ gh_path, "release", "edit", tag,
1381
+ "--repo", repo,
1382
+ "--draft=true",
1383
+ ]
1384
+ try:
1385
+ result = subprocess.run(
1386
+ cmd,
1387
+ cwd=str(project_root),
1388
+ capture_output=True,
1389
+ text=True,
1390
+ timeout=30,
1391
+ check=False,
1392
+ env=os.environ.copy(),
1393
+ )
1394
+ except FileNotFoundError:
1395
+ return False, "gh CLI not found on PATH"
1396
+ except subprocess.TimeoutExpired:
1397
+ return False, "gh release edit timed out"
1398
+ if result.returncode != 0:
1399
+ return False, f"gh release edit failed: {(result.stderr or '').strip()}"
1400
+ return True, f"flipped {tag} to draft"
1401
+
1402
+
1403
+ def verify_release_draft(
1404
+ project_root: Path,
1405
+ version: str,
1406
+ repo: str,
1407
+ *,
1408
+ max_attempts: int = VERIFY_DRAFT_MAX_ATTEMPTS,
1409
+ interval: float = VERIFY_DRAFT_INTERVAL_SECONDS,
1410
+ sleep: Callable[[float], None] | None = None,
1411
+ ) -> tuple[bool, str]:
1412
+ """Verify the freshly-created release actually landed in draft state (#724).
1413
+
1414
+ Polls ``gh release view v<version> --json isDraft`` up to
1415
+ ``max_attempts`` times with ``interval`` seconds between attempts (5s
1416
+ total budget by default). Three terminal states:
1417
+
1418
+ - ``"draft"``: release exists with ``isDraft=true``. Returns
1419
+ ``(True, "verified draft")`` -- the happy path.
1420
+ - ``"public"``: release exists with ``isDraft=false``. Immediately
1421
+ invokes ``gh release edit --draft=true`` and emits a ``WARNING``
1422
+ line citing #724. Returns ``(True, "flipped to draft (...)")`` on
1423
+ successful flip; ``(False, ...)`` only if the flip itself fails.
1424
+ - ``"not-found"`` after every poll: the release record has not
1425
+ propagated yet (release.yml CI may still be processing). Returns
1426
+ ``(True, "not found within budget; release.yml may still be
1427
+ processing")`` -- emits a WARN line but does not fail the pipeline,
1428
+ since the create call itself exited 0.
1429
+
1430
+ The auto-flip is defense-in-depth: it covers the case where the
1431
+ create command exited 0 but the release somehow landed as public
1432
+ (e.g. operator-error variant of #724 where an alternate code path
1433
+ sent the release without ``--draft``). It is a no-op on the happy
1434
+ path and never fires when the create call itself failed.
1435
+ """
1436
+ if max_attempts <= 0:
1437
+ return True, "verify gate disabled (max_attempts <= 0)"
1438
+ sleep_fn = sleep if sleep is not None else time.sleep
1439
+ gh_path = _resolve_gh()
1440
+ if gh_path is None:
1441
+ # Surface a non-fatal warning -- the verify gate is best-effort
1442
+ # defense-in-depth and the create call already exited 0.
1443
+ print(
1444
+ "WARNING: cannot verify draft state (gh CLI not found on PATH); "
1445
+ "defense-in-depth gate skipped (see #724)",
1446
+ file=sys.stderr,
1447
+ )
1448
+ return True, "gh CLI not found on PATH; verify gate skipped"
1449
+ last_state = ""
1450
+ last_detail = ""
1451
+ for attempt in range(1, max_attempts + 1):
1452
+ state, detail = _gh_release_view_is_draft(
1453
+ gh_path, version, repo, project_root
1454
+ )
1455
+ last_state, last_detail = state, detail
1456
+ if state == "draft":
1457
+ return True, f"verified draft on attempt {attempt}/{max_attempts}"
1458
+ if state == "public":
1459
+ print(
1460
+ f"WARNING: release v{version} landed as public; "
1461
+ f"flipping to draft (defense-in-depth, see #724)",
1462
+ file=sys.stderr,
1463
+ )
1464
+ ok, reason = _gh_release_flip_to_draft(
1465
+ gh_path, version, repo, project_root
1466
+ )
1467
+ if ok:
1468
+ return True, f"flipped to draft ({reason})"
1469
+ return False, reason
1470
+ # not-found / error -- keep polling; sleep between attempts only
1471
+ # while we still have budget. ``sleep_fn`` is typed as
1472
+ # ``Callable[[float], None]`` so callers (production: ``time.sleep``;
1473
+ # tests: 1-arg stubs like ``lambda _s: None`` or
1474
+ # ``lambda s: sleeps.append(s)``) all accept the interval argument.
1475
+ if attempt < max_attempts:
1476
+ sleep_fn(interval)
1477
+ if last_state == "not-found":
1478
+ print(
1479
+ f"WARNING: release v{version} not found within "
1480
+ f"{max_attempts}*{interval}s budget; release.yml CI may still "
1481
+ f"be processing (see #724)",
1482
+ file=sys.stderr,
1483
+ )
1484
+ return True, "not found within budget; verify gate inconclusive"
1485
+ print(
1486
+ f"WARNING: verify gate could not confirm draft state for v{version}: "
1487
+ f"last state {last_state!r}; detail: {last_detail} (see #724)",
1488
+ file=sys.stderr,
1489
+ )
1490
+ return True, f"inconclusive ({last_state}); verify gate skipped"
1491
+
1492
+
1493
+ # ---- Step 5 -- uv.lock regeneration (#774) ---------------------------------
1494
+
1495
+
1496
+ def run_uv_lock(project_root: Path) -> tuple[bool, str]:
1497
+ """Regenerate ``uv.lock`` after the pyproject ``[project].version`` sync.
1498
+
1499
+ The release pipeline rewrites ``[project].version`` in pyproject.toml
1500
+ in Step 5 (#771). Without a matching ``uv lock`` invocation, the
1501
+ lockfile would still record the OLD version while pyproject records
1502
+ the NEW one -- producing a release commit + annotated tag where
1503
+ ``uv lock --check`` (and any ``uv sync --frozen`` consumer) fails
1504
+ post-pipeline. Greptile P1 from #774 surfaced this gap.
1505
+
1506
+ Contract:
1507
+ - No pyproject.toml present -- clean skip (no lockfile to keep in
1508
+ sync with a missing root metadata file).
1509
+ - ``uv`` binary not on PATH -- clean skip with a non-fatal warning;
1510
+ the pipeline cannot regenerate a lockfile without the tool, but
1511
+ the pyproject sync itself already landed and a downstream
1512
+ operator can run ``uv lock`` manually before pushing the tag.
1513
+ - ``uv lock`` non-zero exit -- terminal failure; the operator must
1514
+ resolve the lock conflict before the release can ship.
1515
+ - Happy path -- returns ``(True, "uv.lock regenerated")``; the
1516
+ commit step then stages uv.lock alongside the other release
1517
+ artifacts (#774 _RELEASE_ARTIFACTS).
1518
+ """
1519
+ if not (project_root / "pyproject.toml").is_file():
1520
+ return True, "no pyproject.toml; skipping uv lock"
1521
+ uv_path = shutil.which("uv")
1522
+ if uv_path is None:
1523
+ # Best-effort: surface a warning but do not fail. The pyproject
1524
+ # sync already succeeded; an operator running the release on a
1525
+ # host without uv can regenerate the lockfile manually.
1526
+ print(
1527
+ "WARNING: uv binary not on PATH; skipping uv.lock regeneration "
1528
+ "(see #774). Run `uv lock` manually before pushing the release tag.",
1529
+ file=sys.stderr,
1530
+ )
1531
+ return True, "uv binary not on PATH; skipping uv lock"
1532
+ try:
1533
+ result = subprocess.run(
1534
+ [uv_path, "lock"],
1535
+ cwd=str(project_root),
1536
+ capture_output=True,
1537
+ text=True,
1538
+ timeout=300,
1539
+ check=False,
1540
+ )
1541
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
1542
+ return False, f"uv lock failed: {exc}"
1543
+ if result.returncode != 0:
1544
+ stderr = (result.stderr or "").strip()
1545
+ return False, f"uv lock failed (exit {result.returncode}): {stderr}"
1546
+ return True, "uv.lock regenerated"
1547
+
1548
+
1549
+ # ---- Step 5 -- pyproject sync helper (#771) --------------------------------
1550
+
1551
+
1552
+ def _sync_pyproject_for_release(
1553
+ pyproject_path: Path,
1554
+ version: str,
1555
+ *,
1556
+ dry_run: bool,
1557
+ ) -> tuple[str, str | None]:
1558
+ """Compute the pyproject ``[project].version`` sync outcome (#771).
1559
+
1560
+ Returns ``(note, new_text)`` where ``note`` is a short operator-
1561
+ readable status string the pipeline embeds in the Step 5 label, and
1562
+ ``new_text`` is the rewritten file content to write (``None`` when
1563
+ no write is required -- e.g. dry-run, missing pyproject, or
1564
+ non-publishable version).
1565
+
1566
+ Outcomes:
1567
+ - ``"pyproject [project].version -> 0.21.0"`` -- happy path
1568
+ - ``"pyproject already at 0.21.0"`` -- idempotent no-op
1569
+ - ``"no pyproject.toml; skipping sync"`` -- file absent
1570
+ - ``"non-publishable tag <reason>; skipping pyproject sync"`` --
1571
+ ``test.N`` and other ``NonPublishableVersionError`` cases per
1572
+ ``scripts.resolve_version`` Phase B
1573
+ - ``"FAIL (...)"`` -- terminal config error; pipeline halts
1574
+
1575
+ The release pipeline catches ``NonPublishableVersionError`` here and
1576
+ treats it as a clean skip rather than a failure: a disposable test
1577
+ tag (``v0.0.0-test.1`` from ``task release:e2e``) MUST never propagate
1578
+ into ``[project].version`` even if the rest of the pipeline runs.
1579
+ Generic ``ValueError`` (malformed ``[project]`` section, missing
1580
+ version key) IS terminal -- the misconfiguration must be fixed before
1581
+ a release can ship.
1582
+ """
1583
+ if not pyproject_path.is_file():
1584
+ return "no pyproject.toml; skipping sync", None
1585
+ try:
1586
+ pep_version = to_pep440(version)
1587
+ except NonPublishableVersionError as exc:
1588
+ return (
1589
+ f"non-publishable tag ({exc}); skipping pyproject sync",
1590
+ None,
1591
+ )
1592
+ except ValueError as exc:
1593
+ # Malformed input -- the pipeline already validated strict
1594
+ # X.Y.Z via ``_validate_version``, so this branch is
1595
+ # defensive: if to_pep440's contract widens we surface the
1596
+ # parse error rather than silently skip.
1597
+ return f"FAIL (cannot normalize version to PEP 440: {exc})", None
1598
+
1599
+ original = pyproject_path.read_text(encoding="utf-8")
1600
+ try:
1601
+ new_text = update_pyproject_version(original, pep_version)
1602
+ except ValueError as exc:
1603
+ return f"FAIL (pyproject.toml: {exc})", None
1604
+
1605
+ if new_text == original:
1606
+ return f"pyproject already at {pep_version}", None
1607
+ if dry_run:
1608
+ return f"pyproject [project].version -> {pep_version}", None
1609
+ return f"pyproject [project].version -> {pep_version}", new_text
1610
+
1611
+
1612
+ # ---- Pipeline orchestration ------------------------------------------------
1613
+
1614
+
1615
+ _TOTAL_STEPS = 13
1616
+
1617
+
1618
+ def _emit(step: int, label: str, status: str, *, file=None) -> None:
1619
+ # Resolve sys.stderr at call time so test capture (pytest's capsys, which
1620
+ # rebinds sys.stderr per-test) sees emitted lines. Binding the default at
1621
+ # function-definition time would freeze the original stderr captured at
1622
+ # module load and bypass capsys.
1623
+ target = file if file is not None else sys.stderr
1624
+ print(f"[{step}/{_TOTAL_STEPS}] {label}... {status}", file=target)
1625
+
1626
+
1627
+ def run_pipeline(config: ReleaseConfig) -> int:
1628
+ """Execute the release pipeline; returns the process exit code."""
1629
+ project_root = config.project_root
1630
+ version = config.version
1631
+ today = _today_iso()
1632
+ changelog_path = project_root / "CHANGELOG.md"
1633
+
1634
+ # Step 1: dirty-tree guard.
1635
+ label = "Pre-flight git status"
1636
+ if config.dry_run:
1637
+ _emit(1, label, f"DRYRUN (would run `git status --porcelain` in {project_root})")
1638
+ else:
1639
+ ok, output = check_git_clean(project_root)
1640
+ if ok:
1641
+ _emit(1, label, "OK (tree clean)")
1642
+ elif config.allow_dirty:
1643
+ _emit(1, label, f"WARN (dirty, --allow-dirty set):\n{output}")
1644
+ else:
1645
+ _emit(
1646
+ 1,
1647
+ label,
1648
+ "FAIL (working tree is dirty; commit/stash or pass --allow-dirty)",
1649
+ )
1650
+ print(output, file=sys.stderr)
1651
+ return EXIT_VIOLATION
1652
+
1653
+ # Step 2: branch guard.
1654
+ label = f"Pre-flight branch == {config.base_branch}"
1655
+ if config.dry_run:
1656
+ _emit(2, label, f"DRYRUN (would assert current branch == {config.base_branch})")
1657
+ else:
1658
+ branch = current_branch(project_root)
1659
+ if branch == config.base_branch:
1660
+ _emit(2, label, f"OK (on {branch})")
1661
+ else:
1662
+ _emit(
1663
+ 2,
1664
+ label,
1665
+ f"FAIL (on {branch!r}; expected {config.base_branch!r})",
1666
+ )
1667
+ return EXIT_VIOLATION
1668
+
1669
+ # Step 3: vBRIEF lifecycle sync (#734).
1670
+ label = "Pre-flight vBRIEF lifecycle sync"
1671
+ if config.allow_vbrief_drift:
1672
+ _emit(3, label, "SKIP (--allow-vbrief-drift)")
1673
+ elif config.dry_run:
1674
+ _emit(
1675
+ 3,
1676
+ label,
1677
+ "DRYRUN (would scan vbrief/ + gh open issues for closed-issue mismatches)",
1678
+ )
1679
+ else:
1680
+ ok, mismatch_count, reason = check_vbrief_lifecycle_sync(
1681
+ project_root, config.repo
1682
+ )
1683
+ if ok:
1684
+ _emit(3, label, "OK (no mismatches)")
1685
+ elif mismatch_count == -1:
1686
+ _emit(3, label, f"FAIL ({reason})")
1687
+ return EXIT_CONFIG_ERROR
1688
+ else:
1689
+ _emit(
1690
+ 3,
1691
+ label,
1692
+ (
1693
+ f"FAIL ({mismatch_count} mismatches; "
1694
+ "run task reconcile:issues -- --apply-lifecycle-fixes "
1695
+ "to fix, or pass --allow-vbrief-drift to override)"
1696
+ ),
1697
+ )
1698
+ print(reason, file=sys.stderr)
1699
+ return EXIT_VIOLATION
1700
+
1701
+ # Step 4: tag availability pre-flight (#784) -- refuse early before any
1702
+ # state mutation when v<version> already exists locally, on origin, or
1703
+ # as a published GitHub release. Read-only; safe on every dry-run.
1704
+ label = "Pre-flight tag availability"
1705
+ if config.dry_run:
1706
+ _emit(
1707
+ 4,
1708
+ label,
1709
+ (
1710
+ f"DRYRUN (would verify v{version} tag not present locally / "
1711
+ f"on origin / as GitHub release on {config.repo})"
1712
+ ),
1713
+ )
1714
+ else:
1715
+ ok, reason = check_tag_available(version, config.repo, project_root)
1716
+ if ok:
1717
+ _emit(4, label, f"OK ({reason})")
1718
+ else:
1719
+ _emit(4, label, f"FAIL ({reason})")
1720
+ return EXIT_VIOLATION
1721
+
1722
+ # Step 5: CI.
1723
+ label = "Pre-flight CI (task ci:local | fallback task check)"
1724
+ if config.skip_ci:
1725
+ # #720: e2e rehearsal opts out -- CI is covered by the unit-test
1726
+ # suite at every commit on master, not by re-running it inside
1727
+ # the auto-created temp repo.
1728
+ _emit(5, label, "SKIP (--skip-ci)")
1729
+ elif config.dry_run:
1730
+ _emit(5, label, "DRYRUN (would run task ci:local with task check fallback)")
1731
+ else:
1732
+ ok, reason = run_ci(project_root)
1733
+ if ok:
1734
+ _emit(5, label, f"OK ({reason})")
1735
+ else:
1736
+ _emit(5, label, f"FAIL ({reason})")
1737
+ return EXIT_VIOLATION
1738
+
1739
+ # Step 6: CHANGELOG promotion.
1740
+ label = "CHANGELOG promotion"
1741
+ if not changelog_path.is_file():
1742
+ _emit(6, label, f"FAIL (CHANGELOG.md not found at {changelog_path})")
1743
+ return EXIT_CONFIG_ERROR
1744
+ original_changelog = changelog_path.read_text(encoding="utf-8")
1745
+ try:
1746
+ promoted_changelog = promote_changelog(
1747
+ original_changelog,
1748
+ version,
1749
+ config.repo,
1750
+ today,
1751
+ summary=config.summary,
1752
+ )
1753
+ except ValueError as exc:
1754
+ _emit(6, label, f"FAIL ({exc})")
1755
+ return EXIT_CONFIG_ERROR
1756
+ # Surface whether a summary was supplied so operators can validate
1757
+ # the wording during Phase 2 dry-run before any file is written
1758
+ # (release-narrative-gap scope vBRIEF).
1759
+ if config.summary:
1760
+ truncated = config.summary[:60]
1761
+ # P2 (#730 Greptile): variable name ``ellipsis`` shadows the
1762
+ # Python builtin (the type of ``...``). Rename to
1763
+ # ``truncation_suffix`` to avoid the shadow.
1764
+ truncation_suffix = "..." if len(config.summary) > 60 else ""
1765
+ summary_note = f' summary: "{truncated}{truncation_suffix}"'
1766
+ else:
1767
+ summary_note = " no summary"
1768
+ # #771: also sync pyproject.toml [project].version from the resolved
1769
+ # release version (PEP 440 normalized via
1770
+ # ``scripts.resolve_version.to_pep440``). Disposable / test-only tags
1771
+ # (``test.N``) raise ``NonPublishableVersionError`` and the sync is
1772
+ # explicitly skipped so PyPI / consumer-visible metadata is not
1773
+ # polluted with throwaway versions. The pyproject sync is bundled
1774
+ # into the CHANGELOG-promotion step (rather than a new step) so the
1775
+ # operator-readable status string surfaces the pyproject-side
1776
+ # outcome inline. The step number was 5 pre-#784 and is now 6 after
1777
+ # the new tag-availability pre-flight gate (Step 4) bumped
1778
+ # _TOTAL_STEPS 12 -> 13.
1779
+ pyproject_path = project_root / "pyproject.toml"
1780
+ pyproject_note, promoted_pyproject = _sync_pyproject_for_release(
1781
+ pyproject_path, version, dry_run=config.dry_run
1782
+ )
1783
+ if pyproject_note.startswith("FAIL"):
1784
+ _emit(6, label, pyproject_note)
1785
+ return EXIT_CONFIG_ERROR
1786
+
1787
+ if config.dry_run:
1788
+ _emit(
1789
+ 6,
1790
+ label,
1791
+ f"DRYRUN (would rewrite {changelog_path.name}: "
1792
+ f"## [Unreleased] -> ## [{version}] - {today}; new compare link added;"
1793
+ f"{summary_note}; {pyproject_note}; "
1794
+ f"would run `uv lock` to refresh uv.lock to {version})",
1795
+ )
1796
+ else:
1797
+ changelog_path.write_text(promoted_changelog, encoding="utf-8")
1798
+ uv_lock_note = "uv.lock unchanged (pyproject not modified)"
1799
+ if promoted_pyproject is not None:
1800
+ pyproject_path.write_text(promoted_pyproject, encoding="utf-8")
1801
+ # #774: pyproject [project].version was rewritten -- regenerate
1802
+ # uv.lock so the lockfile records the same version. Without
1803
+ # this every future ``task release`` produces a release
1804
+ # commit + tag where pyproject and uv.lock disagree and
1805
+ # downstream ``uv lock --check`` fails.
1806
+ uv_ok, uv_lock_note = run_uv_lock(project_root)
1807
+ if not uv_ok:
1808
+ _emit(6, label, f"FAIL ({uv_lock_note})")
1809
+ return EXIT_VIOLATION
1810
+ _emit(
1811
+ 6,
1812
+ label,
1813
+ f"OK (## [{version}] - {today};{summary_note}; "
1814
+ f"{pyproject_note}; {uv_lock_note})",
1815
+ )
1816
+
1817
+ # Step 7: ROADMAP refresh.
1818
+ label = "ROADMAP refresh (task roadmap:render)"
1819
+ if config.dry_run:
1820
+ _emit(7, label, "DRYRUN (would run task roadmap:render)")
1821
+ else:
1822
+ ok, reason = refresh_roadmap(project_root)
1823
+ if ok:
1824
+ _emit(7, label, f"OK ({reason})")
1825
+ else:
1826
+ _emit(7, label, f"FAIL ({reason})")
1827
+ return EXIT_VIOLATION
1828
+
1829
+ # Step 8: build dist (#723: pin DEFT_RELEASE_VERSION so the artifact
1830
+ # filename matches the in-flight release version, not the stale
1831
+ # Taskfile literal or the most-recent git tag; #720: --skip-build
1832
+ # opts out for e2e rehearsals where build artefacts are not needed
1833
+ # for the draft-release verification step).
1834
+ label = f"Build dist (task build, DEFT_RELEASE_VERSION={version})"
1835
+ if config.skip_build:
1836
+ _emit(8, label, "SKIP (--skip-build)")
1837
+ elif config.dry_run:
1838
+ _emit(
1839
+ 8,
1840
+ label,
1841
+ f"DRYRUN (would run `task build` with DEFT_RELEASE_VERSION={version})",
1842
+ )
1843
+ else:
1844
+ ok, reason = run_build(project_root, version)
1845
+ if ok:
1846
+ _emit(8, label, f"OK ({reason})")
1847
+ else:
1848
+ _emit(8, label, f"FAIL ({reason})")
1849
+ return EXIT_VIOLATION
1850
+
1851
+ # Step 9: commit release artifacts (CHANGELOG + ROADMAP) before tagging
1852
+ # so the annotated tag and GitHub release anchor at the promoted commit
1853
+ # rather than the pre-release HEAD (#74 Greptile P1). Skipped together
1854
+ # with tagging when --skip-tag is set, since a committed-but-untagged
1855
+ # state would still leave the working tree dirty post-pipeline.
1856
+ label = f"Commit release artifacts ({', '.join(_RELEASE_ARTIFACTS)})"
1857
+ if config.skip_tag:
1858
+ _emit(9, label, "SKIP (--skip-tag)")
1859
+ elif config.dry_run:
1860
+ _emit(
1861
+ 9,
1862
+ label,
1863
+ f"DRYRUN (would run `git add {' '.join(_RELEASE_ARTIFACTS)}` + "
1864
+ f"`git commit -m '{_release_commit_subject(version)}'`)",
1865
+ )
1866
+ else:
1867
+ ok, reason = commit_release_artifacts(project_root, version)
1868
+ if ok:
1869
+ _emit(9, label, f"OK ({reason})")
1870
+ else:
1871
+ _emit(9, label, f"FAIL ({reason})")
1872
+ return EXIT_VIOLATION
1873
+
1874
+ # Step 10: git tag.
1875
+ label = f"Tag v{version}"
1876
+ if config.skip_tag:
1877
+ _emit(10, label, "SKIP (--skip-tag)")
1878
+ elif config.dry_run:
1879
+ _emit(10, label, f"DRYRUN (would run `git tag -a v{version} -m 'Release v{version}'`)")
1880
+ else:
1881
+ ok, reason = create_tag(project_root, version)
1882
+ if ok:
1883
+ _emit(10, label, f"OK ({reason})")
1884
+ else:
1885
+ _emit(10, label, f"FAIL ({reason})")
1886
+ return EXIT_VIOLATION
1887
+
1888
+ # Step 11: push branch + tag atomically.
1889
+ label = f"Push {config.base_branch} + v{version} to origin (atomic)"
1890
+ if config.skip_tag:
1891
+ _emit(11, label, "SKIP (--skip-tag)")
1892
+ elif config.dry_run:
1893
+ _emit(
1894
+ 11,
1895
+ label,
1896
+ f"DRYRUN (would run `git push --atomic origin {config.base_branch} v{version}`)",
1897
+ )
1898
+ else:
1899
+ ok, reason = push_release(project_root, version, config.base_branch)
1900
+ if ok:
1901
+ _emit(11, label, f"OK ({reason})")
1902
+ else:
1903
+ _emit(11, label, f"FAIL ({reason})")
1904
+ return EXIT_VIOLATION
1905
+
1906
+ # Step 12: GitHub release. #425: flag SemVer pre-release tags
1907
+ # (``-rc.N`` / ``-beta.N`` / ``-alpha.N``) as GitHub pre-releases
1908
+ # automatically so RC cuts no longer require a manual
1909
+ # ``gh release edit --prerelease``. The decision mirrors the
1910
+ # workflow-side ``prerelease: ${{ contains(github.ref_name, '-') }}``.
1911
+ prerelease = is_prerelease_tag(version)
1912
+ draft_suffix = " (draft)" if config.draft else " (PUBLIC)"
1913
+ prerelease_suffix = " (prerelease)" if prerelease else ""
1914
+ label = f"GitHub release v{version}{draft_suffix}{prerelease_suffix}"
1915
+ create_succeeded = False
1916
+ if config.skip_release:
1917
+ _emit(12, label, "SKIP (--skip-release)")
1918
+ elif config.dry_run:
1919
+ draft_flag = " --draft" if config.draft else ""
1920
+ prerelease_flag = " --prerelease" if prerelease else ""
1921
+ _emit(
1922
+ 12,
1923
+ label,
1924
+ (
1925
+ f"DRYRUN (would run `gh release create v{version} "
1926
+ f"--repo {config.repo}{draft_flag}{prerelease_flag} ...`)"
1927
+ ),
1928
+ )
1929
+ else:
1930
+ notes = _section_for_version(promoted_changelog, version)
1931
+ # #1413: lead maintainer-mode (deftai/directive) release notes with
1932
+ # the standard upgrade-guidance banner sourced from
1933
+ # .github/release-notes/upgrade-banner.md. No-op for consumer-mode
1934
+ # repos and when the template is absent (graceful degradation).
1935
+ notes = _prepend_upgrade_banner(notes, config.repo, project_root)
1936
+ ok, reason = create_github_release(
1937
+ project_root,
1938
+ version,
1939
+ config.repo,
1940
+ notes,
1941
+ draft=config.draft,
1942
+ prerelease=prerelease,
1943
+ )
1944
+ if ok:
1945
+ _emit(12, label, f"OK ({reason})")
1946
+ create_succeeded = True
1947
+ else:
1948
+ _emit(12, label, f"FAIL ({reason})")
1949
+ return EXIT_VIOLATION
1950
+
1951
+ # Step 13: post-create verify-isDraft gate (#724). Defense in depth
1952
+ # against the v0.21.0 incident where a manual recovery created a
1953
+ # public release for ~90s before being flipped. Skipped when the
1954
+ # create step itself was skipped, when the operator opted into a
1955
+ # direct-publish via --no-draft, and during dry-run.
1956
+ label = f"Verify draft state of v{version} (#724 defense-in-depth)"
1957
+ if config.skip_release:
1958
+ _emit(13, label, "SKIP (--skip-release)")
1959
+ elif not config.draft:
1960
+ _emit(13, label, "SKIP (--no-draft; intentional public release)")
1961
+ elif config.dry_run:
1962
+ _emit(
1963
+ 13,
1964
+ label,
1965
+ (
1966
+ f"DRYRUN (would poll `gh release view v{version} --json isDraft`"
1967
+ f" up to {VERIFY_DRAFT_MAX_ATTEMPTS}x at {VERIFY_DRAFT_INTERVAL_SECONDS}s"
1968
+ " intervals; auto-flip via `gh release edit --draft=true` on isDraft=false)"
1969
+ ),
1970
+ )
1971
+ elif not create_succeeded:
1972
+ # Should be unreachable -- the create branch returns
1973
+ # EXIT_VIOLATION on failure -- but guard explicitly for the
1974
+ # benefit of unit-test stubs that bypass the early return.
1975
+ _emit(13, label, "SKIP (release was not created in this run)")
1976
+ else:
1977
+ ok, reason = verify_release_draft(
1978
+ project_root, version, config.repo
1979
+ )
1980
+ if ok:
1981
+ _emit(13, label, f"OK ({reason})")
1982
+ else:
1983
+ _emit(13, label, f"FAIL ({reason})")
1984
+ return EXIT_VIOLATION
1985
+
1986
+ print(
1987
+ f"Release v{version} pipeline complete "
1988
+ f"(dry_run={config.dry_run}, skip_tag={config.skip_tag}, "
1989
+ f"skip_release={config.skip_release}).",
1990
+ file=sys.stderr,
1991
+ )
1992
+ return EXIT_OK
1993
+
1994
+
1995
+ # ---- main -------------------------------------------------------------------
1996
+
1997
+
1998
+ def main(argv: list[str] | None = None) -> int:
1999
+ parser = _build_parser()
2000
+ args = parser.parse_args(argv)
2001
+
2002
+ try:
2003
+ _validate_version(args.version)
2004
+ except ValueError as exc:
2005
+ print(f"Error: {exc}", file=sys.stderr)
2006
+ return EXIT_CONFIG_ERROR
2007
+
2008
+ project_root = _resolve_project_root(args.project_root)
2009
+ repo = _resolve_repo(args.repo, project_root)
2010
+
2011
+ config = ReleaseConfig(
2012
+ version=args.version,
2013
+ repo=repo,
2014
+ base_branch=args.base_branch,
2015
+ project_root=project_root,
2016
+ dry_run=args.dry_run,
2017
+ skip_tag=args.skip_tag,
2018
+ skip_release=args.skip_release,
2019
+ allow_dirty=args.allow_dirty,
2020
+ draft=args.draft,
2021
+ skip_ci=args.skip_ci,
2022
+ skip_build=args.skip_build,
2023
+ summary=args.summary,
2024
+ allow_vbrief_drift=args.allow_vbrief_drift,
2025
+ )
2026
+ return run_pipeline(config)
2027
+
2028
+
2029
+ if __name__ == "__main__":
2030
+ sys.exit(main())