@deftai/directive-content 0.55.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,980 @@
1
+ #!/usr/bin/env python3
2
+ """release_rollback.py -- State-aware release unwind (#716, #725).
3
+
4
+ Companion to ``scripts/release.py`` and ``scripts/release_publish.py``
5
+ per the #716 safety hardening Q3 decision. ``task release:rollback --
6
+ <version>`` performs a state-aware unwind, tailoring its action to one
7
+ of four detected post-release states:
8
+
9
+ +----+----------------------------------------+--------------------------------------------+
10
+ | # | Detected state | Action |
11
+ +====+========================================+============================================+
12
+ | 1 | Local commit + tag, no push | resolve release-prep SHA + git tag -d + |
13
+ | | | git revert <sha> --no-edit |
14
+ | 2 | Tag pushed, no release | resolve release-prep SHA + git push |
15
+ | | | --delete origin v* + tag -d + |
16
+ | | | git revert <sha> --no-edit + git push |
17
+ | | | origin <base> (no force) |
18
+ | 3 | Release published, downloads <= guard | resolve release-prep SHA + gh release |
19
+ | | | delete --yes --cleanup-tag + git revert |
20
+ | | | <sha> --no-edit + git push origin <base> |
21
+ | | | (no force) |
22
+ | 4 | Release published, downloads > guard | Refuse unless --allow-data-loss; recommend |
23
+ | | | hot-fix-path (next patch with withdrawal |
24
+ | | | note) |
25
+ +----+----------------------------------------+--------------------------------------------+
26
+
27
+ Forward-revert + normal push (#725)
28
+ -----------------------------------
29
+ Prior to #725 the unwind used ``git reset --hard HEAD~1`` and
30
+ ``git push --force-with-lease origin <base>``. Both were unsafe:
31
+
32
+ - ``HEAD~1`` is the wrong target whenever ANY commit lands between
33
+ release-prep and the rollback invocation (a normal operational
34
+ scenario -- fix a release defect via PR, then decide to rollback).
35
+ Live demonstration: PR #722 merged on top of release-prep ``6573335``;
36
+ ``task release:rollback`` then reset master from ``94d1aa5`` ->
37
+ ``6573335``, unwinding PR #722 instead of release-prep.
38
+ - ``--force-with-lease`` is rejected by GitHub branch-protection rules
39
+ that disallow force-push on ``master`` (the default for protected
40
+ branches), so the rollback aborts after ``gh release delete`` already
41
+ succeeded -- leaving the operator in a half-rolled-back state.
42
+
43
+ #725 fix: resolve the actual release-prep commit SHA (``git rev-parse
44
+ v<version>^{commit}`` first; ``git log --grep='^chore(release):
45
+ v<version>'`` fallback) BEFORE the tag is deleted, then run ``git
46
+ revert <sha> --no-edit`` (forward commit, branch-protection-compatible).
47
+ Push is a normal ``git push origin <base>`` (no ``--force``).
48
+
49
+ Manual recovery on revert conflict
50
+ ----------------------------------
51
+ ``git revert`` can conflict when an intervening commit touched a file
52
+ the release-prep commit also touched (e.g. CHANGELOG.md / ROADMAP.md
53
+ edited by an out-of-band hot-fix between release-prep and rollback).
54
+ The script aborts the revert (``git revert --abort``) and refuses with
55
+ an operator-readable diagnostic. Manual recovery::
56
+
57
+ 1. git revert <release-prep-sha> --no-edit # re-run, observe conflicts
58
+ 2. Resolve conflicts in CHANGELOG.md / ROADMAP.md (or whatever).
59
+ - Restore the pre-release Unreleased section that the release
60
+ commit replaced (look for it on the parent commit).
61
+ - Drop the new ## [<version>] heading.
62
+ 3. git add <resolved-files>
63
+ 4. git revert --continue # produces the revert commit
64
+ 5. git push origin <base-branch>
65
+
66
+ The SHA is logged via ``[rollback] Resolve release-prep SHA... OK
67
+ (<sha>)`` so the operator can copy it out of the script's stderr.
68
+
69
+ Time-windowed download-count guard (Q3)
70
+ ---------------------------------------
71
+ The threshold for "low download count" varies with release age::
72
+
73
+ release_age = now - release.created_at
74
+ if release_age < 5_minutes:
75
+ threshold = 0 # nobody noticed yet; safe
76
+ elif release_age < 30_minutes:
77
+ threshold = max(args.allow_low_downloads, 10) # filter bots
78
+ else:
79
+ require(args.allow_data_loss, "release > 30 min old")
80
+
81
+ Three escape hatches (progressive warnings):
82
+ - ``--allow-low-downloads N`` -- accept up to N downloads
83
+ - ``--allow-data-loss`` -- accept any download count (consumer impact)
84
+ - ``--force-strict-0`` -- override time-window; require exactly 0 regardless
85
+ of release age (use for security-incident hot-rollbacks)
86
+
87
+ Race-condition mitigation
88
+ -------------------------
89
+ GitHub's release-asset download_count is eventually-consistent (~30s
90
+ cache staleness). The guard reads ``download_count`` once, sleeps
91
+ 5 seconds, reads again; only proceeds if BOTH reads agree below the
92
+ threshold (catches a download arriving between read 1 and the rollback
93
+ action).
94
+
95
+ Three-state exit codes
96
+ ----------------------
97
+ 0 -- rollback completed (or already-clean no-op)
98
+ 1 -- refusal due to guard (downloads > threshold without escape hatch),
99
+ or step-level failure (gh / git failure during unwind)
100
+ 2 -- config / argument error (malformed version, repo unresolvable, ...)
101
+
102
+ Refs #725 (HEAD~1 + force-push fix), #716 (canonical spec; safety
103
+ hardening Item 3 of 7), #722 (subprocess PATHEXT fix; release._resolve_gh
104
+ helper), #74 (foundation), #233, #642, #635, #709, #710.
105
+ """
106
+
107
+ from __future__ import annotations
108
+
109
+ import argparse
110
+ import contextlib
111
+ import datetime as _dt
112
+ import json
113
+ import os
114
+ import shutil # noqa: F401 -- kept for tests that monkeypatch release_rollback.shutil.which
115
+ import subprocess
116
+ import sys
117
+ import time
118
+ from dataclasses import dataclass
119
+ from pathlib import Path
120
+
121
+ # Make sibling scripts importable so we can re-use _resolve_repo /
122
+ # _resolve_project_root / _validate_version + the EXIT_* constants from
123
+ # release.py without duplicating them.
124
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
125
+
126
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
127
+
128
+ reconfigure_stdio()
129
+
130
+ import release # noqa: E402
131
+
132
+ EXIT_OK = release.EXIT_OK
133
+ EXIT_VIOLATION = release.EXIT_VIOLATION
134
+ EXIT_CONFIG_ERROR = release.EXIT_CONFIG_ERROR
135
+
136
+ # ---- Constants --------------------------------------------------------------
137
+
138
+ # Download-count guard time windows. Comments use minutes for readability;
139
+ # the constants are seconds so we can compare against `(now - created_at).seconds`.
140
+ _FIVE_MINUTES_SECONDS = 5 * 60
141
+ _THIRTY_MINUTES_SECONDS = 30 * 60
142
+
143
+ # Default threshold inside the 5-30 minute window (filters bot fetches that
144
+ # typically scrape new releases for indexing).
145
+ _DEFAULT_BOT_THRESHOLD = 10
146
+
147
+ # Race-condition double-read sleep duration. GitHub's release asset
148
+ # download_count cache typically takes ~30s to invalidate; 5s gives
149
+ # downstream callers a chance to surface a fresh count without
150
+ # meaningfully extending rollback wall-clock time.
151
+ _DOUBLE_READ_SLEEP_SECONDS = 5
152
+
153
+
154
+ # ---- Data classes -----------------------------------------------------------
155
+
156
+
157
+ @dataclass
158
+ class RollbackConfig:
159
+ version: str
160
+ repo: str
161
+ base_branch: str
162
+ project_root: Path
163
+ dry_run: bool
164
+ allow_low_downloads: int # 0 = no override
165
+ allow_data_loss: bool
166
+ force_strict_0: bool
167
+ # When True, skip the wall-clock sleep between download_count reads
168
+ # (used by tests to keep latency negligible without disabling the
169
+ # double-read semantic).
170
+ skip_sleep: bool = False
171
+
172
+
173
+ # ---- argument parsing -------------------------------------------------------
174
+
175
+
176
+ def _build_parser() -> argparse.ArgumentParser:
177
+ parser = argparse.ArgumentParser(
178
+ prog="release_rollback",
179
+ description=(
180
+ "State-aware release unwind (#716 safety hardening). Detects "
181
+ "one of four post-release states (local-only / tag-pushed / "
182
+ "released-low-downloads / released-high-downloads) and applies "
183
+ "the matching tiered recovery."
184
+ ),
185
+ )
186
+ parser.add_argument(
187
+ "version",
188
+ help="Release version, e.g. 0.21.0 (no leading 'v', strict X.Y.Z).",
189
+ )
190
+ parser.add_argument(
191
+ "--dry-run",
192
+ action="store_true",
193
+ help="Print the rollback plan without invoking gh / git side-effects.",
194
+ )
195
+ parser.add_argument(
196
+ "--repo",
197
+ default=None,
198
+ metavar="OWNER/REPO",
199
+ help="Override repo (default: resolved from `git remote get-url origin`).",
200
+ )
201
+ parser.add_argument(
202
+ "--base-branch",
203
+ default=release.DEFAULT_BASE_BRANCH,
204
+ metavar="BRANCH",
205
+ help=f"Base branch (default: {release.DEFAULT_BASE_BRANCH}).",
206
+ )
207
+ parser.add_argument(
208
+ "--project-root",
209
+ type=Path,
210
+ default=None,
211
+ metavar="PATH",
212
+ help="Repository root (default: $DEFT_PROJECT_ROOT or scripts/.. ).",
213
+ )
214
+ parser.add_argument(
215
+ "--allow-low-downloads",
216
+ type=int,
217
+ default=0,
218
+ metavar="N",
219
+ help=(
220
+ "Accept up to N downloads (defaults to the time-window-derived "
221
+ "value). The maximum of this flag and the time-window default "
222
+ "wins, so passing N=5 with a 10-min-old release still allows up "
223
+ f"to {_DEFAULT_BOT_THRESHOLD}."
224
+ ),
225
+ )
226
+ parser.add_argument(
227
+ "--allow-data-loss",
228
+ action="store_true",
229
+ help=(
230
+ "Accept any download count; explicit acknowledgment of consumer "
231
+ "impact. Required when the release is > 30 minutes old."
232
+ ),
233
+ )
234
+ parser.add_argument(
235
+ "--force-strict-0",
236
+ action="store_true",
237
+ help=(
238
+ "Override the time-window: require exactly 0 downloads regardless "
239
+ "of release age. Use for security-incident hot-rollbacks where "
240
+ "even bot scrapes are unacceptable."
241
+ ),
242
+ )
243
+ return parser
244
+
245
+
246
+ # ---- gh helpers -------------------------------------------------------------
247
+
248
+
249
+ def _gh_release_view_json(version: str, repo: str) -> tuple[bool, dict | None, str]:
250
+ """Fetch full release metadata as JSON; returns (ok, payload, reason).
251
+
252
+ Includes ``createdAt`` and the ``assets[]`` array (each with
253
+ ``downloadCount``). Used by the guard logic.
254
+ """
255
+ gh_path = release._resolve_gh()
256
+ if gh_path is None:
257
+ return False, None, "gh CLI not found on PATH"
258
+ tag = f"v{version}"
259
+ cmd = [
260
+ gh_path, "release", "view", tag,
261
+ "--repo", repo,
262
+ "--json", "isDraft,name,tagName,createdAt,publishedAt,assets,url",
263
+ ]
264
+ try:
265
+ result = subprocess.run(
266
+ cmd,
267
+ capture_output=True,
268
+ text=True,
269
+ timeout=60,
270
+ check=False,
271
+ env=os.environ.copy(),
272
+ )
273
+ except FileNotFoundError:
274
+ return False, None, "gh CLI not found on PATH"
275
+ if result.returncode != 0:
276
+ stderr = (result.stderr or "").strip()
277
+ return False, None, stderr
278
+ try:
279
+ return True, json.loads(result.stdout), ""
280
+ except json.JSONDecodeError as exc:
281
+ return False, None, f"non-JSON: {exc}"
282
+
283
+
284
+ def gh_release_exists(version: str, repo: str) -> tuple[str, dict | None, str]:
285
+ """Returns ('exists', payload, '') / ('not-found', None, '...') / ('error', None, '...')."""
286
+ ok, payload, reason = _gh_release_view_json(version, repo)
287
+ if ok:
288
+ return "exists", payload, ""
289
+ lowered = reason.lower()
290
+ if "not found" in lowered:
291
+ return "not-found", None, reason
292
+ return "error", None, reason
293
+
294
+
295
+ def gh_release_delete(version: str, repo: str) -> tuple[bool, str]:
296
+ gh_path = release._resolve_gh()
297
+ if gh_path is None:
298
+ return False, "gh CLI not found on PATH"
299
+ tag = f"v{version}"
300
+ cmd = [
301
+ gh_path, "release", "delete", tag,
302
+ "--repo", repo,
303
+ "--yes",
304
+ "--cleanup-tag",
305
+ ]
306
+ try:
307
+ result = subprocess.run(
308
+ cmd,
309
+ capture_output=True,
310
+ text=True,
311
+ timeout=60,
312
+ check=False,
313
+ env=os.environ.copy(),
314
+ )
315
+ except FileNotFoundError:
316
+ return False, "gh CLI not found on PATH"
317
+ if result.returncode != 0:
318
+ return False, f"gh release delete failed: {result.stderr.strip()}"
319
+ return True, f"deleted release {tag} (with tag cleanup)"
320
+
321
+
322
+ # ---- git helpers ------------------------------------------------------------
323
+
324
+
325
+ def git_tag_exists_local(project_root: Path, version: str) -> bool:
326
+ tag = f"v{version}"
327
+ result = release._run_git(project_root, "tag", "-l", tag)
328
+ return bool(result.stdout.strip())
329
+
330
+
331
+ def git_tag_exists_origin(project_root: Path, version: str) -> bool:
332
+ tag = f"v{version}"
333
+ result = release._run_git(
334
+ project_root, "ls-remote", "--tags", "origin", f"refs/tags/{tag}"
335
+ )
336
+ return bool(result.stdout.strip())
337
+
338
+
339
+ def git_delete_local_tag(project_root: Path, version: str) -> tuple[bool, str]:
340
+ tag = f"v{version}"
341
+ result = release._run_git(project_root, "tag", "-d", tag)
342
+ if result.returncode != 0:
343
+ return False, f"git tag -d failed: {result.stderr.strip()}"
344
+ return True, f"deleted local tag {tag}"
345
+
346
+
347
+ def git_delete_remote_tag(project_root: Path, version: str) -> tuple[bool, str]:
348
+ tag = f"v{version}"
349
+ result = release._run_git(
350
+ project_root, "push", "--delete", "origin", tag
351
+ )
352
+ if result.returncode != 0:
353
+ return False, f"git push --delete failed: {result.stderr.strip()}"
354
+ return True, f"deleted remote tag {tag}"
355
+
356
+
357
+ # Subject prefix for the auto-generated release-prep commit. Mirrors
358
+ # `scripts/release.py::_release_commit_subject` but kept as a private
359
+ # constant here so resolve_release_prep_sha does not have to import the
360
+ # subject-builder helper at module scope (release.py is already imported
361
+ # above for shared helpers; this avoids creating a tighter coupling).
362
+ _RELEASE_COMMIT_SUBJECT_PREFIX = "chore(release): v"
363
+
364
+
365
+ def resolve_release_prep_sha(
366
+ project_root: Path, version: str
367
+ ) -> tuple[str, str]:
368
+ """Resolve the release-prep commit SHA for ``v<version>`` (#725).
369
+
370
+ Returns ``(sha, reason)``. ``sha`` is the empty string when neither
371
+ probe resolves; ``reason`` carries a one-line operator-readable
372
+ diagnostic (empty on success).
373
+
374
+ Probe order:
375
+
376
+ 1. ``git rev-parse v<version>^{commit}`` -- works whenever the local
377
+ tag still points at the release-prep commit (states 1, 2, and 3
378
+ BEFORE ``gh release delete --cleanup-tag`` removes the remote
379
+ ref; callers MUST resolve before the tag is deleted).
380
+ 2. ``git log --grep='^chore(release): v<version>' --format=%H -n 1``
381
+ -- fallback that walks back from HEAD looking for the canonical
382
+ release-commit subject. Useful when the tag is missing (e.g. the
383
+ operator deleted it manually before invoking the rollback).
384
+
385
+ The pre-#725 implementation used ``git reset --hard HEAD~1`` which
386
+ silently picked the wrong commit whenever ANY commit landed between
387
+ release-prep and rollback (a normal operational scenario). #725
388
+ replaces that with this resolved-SHA helper + a forward ``git
389
+ revert`` so the unwind targets the right commit regardless of
390
+ intervening history.
391
+ """
392
+ tag = f"v{version}"
393
+ rev_parse = release._run_git(
394
+ project_root, "rev-parse", f"{tag}^{{commit}}"
395
+ )
396
+ if rev_parse.returncode == 0:
397
+ sha = (rev_parse.stdout or "").strip()
398
+ if sha:
399
+ return sha, ""
400
+
401
+ # Fallback: --grep walks back from HEAD looking for the canonical
402
+ # release-commit subject (see scripts/release.py::_release_commit_subject).
403
+ grep_pattern = f"^{_RELEASE_COMMIT_SUBJECT_PREFIX}{version}"
404
+ grep = release._run_git(
405
+ project_root,
406
+ "log",
407
+ "--grep",
408
+ grep_pattern,
409
+ "--format=%H",
410
+ "-n",
411
+ "1",
412
+ )
413
+ if grep.returncode == 0:
414
+ # Single strip + splitlines; the pre-#720 form ran .strip() twice
415
+ # with diverging condition vs. value expressions which Greptile
416
+ # flagged as confusing on PR #728.
417
+ lines = (grep.stdout or "").strip().splitlines()
418
+ if lines:
419
+ sha = lines[0]
420
+ if sha:
421
+ return sha, ""
422
+
423
+ return "", (
424
+ f"could not resolve release-prep SHA for v{version} "
425
+ f"(tried `git rev-parse {tag}^{{commit}}` and "
426
+ f"`git log --grep={grep_pattern!r}`)"
427
+ )
428
+
429
+
430
+ def git_revert_release_commit(
431
+ project_root: Path, release_prep_sha: str
432
+ ) -> tuple[bool, str]:
433
+ """Forward-revert the release-prep commit (#725).
434
+
435
+ Runs ``git revert <release_prep_sha> --no-edit``. On conflict (revert
436
+ cannot apply cleanly because an intervening commit touched the same
437
+ files), runs ``git revert --abort`` to restore a clean working tree
438
+ and returns ``(False, manual-recovery hint)`` so the caller can
439
+ refuse the rollback rather than leave the operator in a half-applied
440
+ state. The hint points at the Manual recovery section in the module
441
+ docstring.
442
+
443
+ Replaces the pre-#725 ``git reset --hard HEAD~1`` flow which (a)
444
+ silently unwound the wrong commit when intervening commits existed
445
+ and (b) required a force-push to land on origin (rejected by GitHub
446
+ branch-protection rules disallowing force-push). The forward revert
447
+ is auditable, branch-protection-compatible, and safe across
448
+ intervening history.
449
+ """
450
+ result = release._run_git(
451
+ project_root, "revert", release_prep_sha, "--no-edit"
452
+ )
453
+ if result.returncode == 0:
454
+ return True, (
455
+ f"reverted release-prep commit {release_prep_sha[:12]} "
456
+ f"(forward revert; no force-push required)"
457
+ )
458
+ # Conflict path: abort the in-progress revert so the working tree is
459
+ # clean for the operator's manual recovery, then refuse with a
460
+ # diagnostic + pointer to the script docstring.
461
+ abort = release._run_git(project_root, "revert", "--abort")
462
+ abort_note = ""
463
+ if abort.returncode != 0:
464
+ abort_note = (
465
+ f" (additionally, `git revert --abort` failed: "
466
+ f"{abort.stderr.strip()})"
467
+ )
468
+ stderr = (result.stderr or "").strip()
469
+ return False, (
470
+ f"git revert {release_prep_sha[:12]} conflicted: {stderr}{abort_note}. "
471
+ f"Manual recovery: re-run `git revert {release_prep_sha} --no-edit`, "
472
+ f"resolve conflicts (typically CHANGELOG.md / ROADMAP.md), "
473
+ f"`git revert --continue`, then `git push origin <base-branch>`. "
474
+ f"See the Manual recovery section in scripts/release_rollback.py."
475
+ )
476
+
477
+
478
+ def git_push_base(
479
+ project_root: Path, base_branch: str
480
+ ) -> tuple[bool, str]:
481
+ """Push the (revert-augmented) base branch to origin (#725).
482
+
483
+ Forward-only push: ``git push origin <base_branch>`` with NO
484
+ ``--force`` / ``--force-with-lease``. Compatible with GitHub
485
+ branch-protection rules that disallow force-push on ``master``
486
+ (the default for protected branches), so the rollback flow lands
487
+ end-to-end on a protected default branch instead of failing the
488
+ second-to-last step like the pre-#725 force-with-lease path did.
489
+ """
490
+ result = release._run_git(project_root, "push", "origin", base_branch)
491
+ if result.returncode != 0:
492
+ return False, f"git push failed: {result.stderr.strip()}"
493
+ return True, f"pushed {base_branch} to origin (no force)"
494
+
495
+
496
+ # ---- guard logic ------------------------------------------------------------
497
+
498
+
499
+ def _sum_downloads(payload: dict) -> int:
500
+ """Sum the ``downloadCount`` across all assets in a release payload."""
501
+ assets = payload.get("assets", []) or []
502
+ total = 0
503
+ for asset in assets:
504
+ # gh returns the field as ``downloadCount`` (camelCase under --json).
505
+ count = asset.get("downloadCount", 0)
506
+ with contextlib.suppress(TypeError, ValueError):
507
+ total += int(count)
508
+ return total
509
+
510
+
511
+ def _release_age_seconds(payload: dict, *, now: _dt.datetime | None = None) -> int:
512
+ """Age of the release in seconds, derived from ``createdAt``.
513
+
514
+ Returns 0 when the timestamp cannot be parsed (which lets the
515
+ "release age < 5 min" branch evaluate True so the strict-0 default
516
+ threshold applies -- safe-by-default).
517
+ """
518
+ created_at = payload.get("createdAt") or payload.get("publishedAt")
519
+ if not created_at:
520
+ return 0
521
+ try:
522
+ # gh ISO-8601 with trailing Z; Python 3.11 accepts a trailing 'Z'
523
+ # via fromisoformat as long as we strip it manually for older.
524
+ if created_at.endswith("Z"):
525
+ created_at = created_at[:-1] + "+00:00"
526
+ dt = _dt.datetime.fromisoformat(created_at)
527
+ except ValueError:
528
+ return 0
529
+ now = now or _dt.datetime.now(_dt.UTC)
530
+ if dt.tzinfo is None:
531
+ dt = dt.replace(tzinfo=_dt.UTC)
532
+ delta = now - dt
533
+ return max(0, int(delta.total_seconds()))
534
+
535
+
536
+ def compute_threshold(
537
+ age_seconds: int,
538
+ *,
539
+ allow_low_downloads: int,
540
+ allow_data_loss: bool,
541
+ force_strict_0: bool,
542
+ ) -> tuple[int | None, str]:
543
+ """Compute the maximum acceptable download count given the time window.
544
+
545
+ Returns ``(threshold, reason)`` where:
546
+
547
+ - ``threshold`` is an int (the count below or equal to which rollback is
548
+ permitted), or ``None`` if rollback is unconditionally refused at the
549
+ time-window level (the operator must pass ``--allow-data-loss``).
550
+ - ``reason`` is a one-line operator-readable explanation.
551
+
552
+ ``--force-strict-0`` short-circuits the time window and always returns
553
+ threshold=0; ``--allow-data-loss`` accepts any download count.
554
+ """
555
+ if force_strict_0:
556
+ return 0, "--force-strict-0 override (require exactly 0 downloads)"
557
+ if allow_data_loss:
558
+ # int(2**31 - 1) avoids overflow surprises on 32-bit pickle paths.
559
+ return 2**31 - 1, "--allow-data-loss override (accept any count)"
560
+ if age_seconds < _FIVE_MINUTES_SECONDS:
561
+ return 0, "release age < 5 min; threshold=0 (rollback safe)"
562
+ if age_seconds < _THIRTY_MINUTES_SECONDS:
563
+ threshold = max(allow_low_downloads, _DEFAULT_BOT_THRESHOLD)
564
+ return threshold, (
565
+ f"release age 5-30 min; threshold={threshold} "
566
+ f"(filters bot fetches; --allow-low-downloads={allow_low_downloads})"
567
+ )
568
+ # 30+ minutes old: refuse without --allow-data-loss.
569
+ return None, (
570
+ "release age > 30 min; downloads likely consumer-driven. "
571
+ "Pass --allow-data-loss to acknowledge consumer impact, OR "
572
+ "abandon rollback in favour of a hot-fix release with a "
573
+ "withdrawal note in the next CHANGELOG entry."
574
+ )
575
+
576
+
577
+ def double_read_downloads(
578
+ version: str, repo: str, *, sleep_seconds: int = _DOUBLE_READ_SLEEP_SECONDS
579
+ ) -> tuple[bool, int, int, str]:
580
+ """Read ``download_count`` twice with a sleep between; require agreement.
581
+
582
+ Returns ``(ok, first_count, second_count, reason)``.
583
+
584
+ ``ok`` is True when both reads succeed AND ``second_count <=
585
+ first_count`` (a count cannot legitimately decrease over a 5-second
586
+ window without manual intervention; any decrease signals a stale
587
+ cache and rollback should re-read). Otherwise ``ok`` is False and
588
+ ``reason`` carries the diagnostic.
589
+
590
+ Tests that want to skip the wall-clock sleep can pass
591
+ ``sleep_seconds=0`` (the double-read semantic still applies).
592
+ """
593
+ ok1, payload1, reason1 = _gh_release_view_json(version, repo)
594
+ if not ok1 or payload1 is None:
595
+ return False, 0, 0, f"first read failed: {reason1}"
596
+ first_count = _sum_downloads(payload1)
597
+ if sleep_seconds > 0:
598
+ time.sleep(sleep_seconds)
599
+ ok2, payload2, reason2 = _gh_release_view_json(version, repo)
600
+ if not ok2 or payload2 is None:
601
+ return False, first_count, 0, f"second read failed: {reason2}"
602
+ second_count = _sum_downloads(payload2)
603
+ if second_count > first_count:
604
+ return False, first_count, second_count, (
605
+ f"download_count grew between reads ({first_count} -> "
606
+ f"{second_count}); a real consumer downloaded the asset during "
607
+ "the rollback window. Re-run with the new count visible."
608
+ )
609
+ return True, first_count, second_count, ""
610
+
611
+
612
+ # ---- state detection --------------------------------------------------------
613
+
614
+
615
+ def detect_state(
616
+ config: RollbackConfig,
617
+ ) -> tuple[str, dict | None, str]:
618
+ """Return the post-release state for ``v<version>``.
619
+
620
+ States:
621
+ - ``"local-only"`` -- local commit + tag, NOT pushed to origin
622
+ - ``"tag-pushed-no-release"`` -- tag exists on origin, no GH release
623
+ - ``"released"`` -- GH release exists; payload returned
624
+ - ``"absent"`` -- nothing to roll back (no local tag, no remote tag,
625
+ no release)
626
+ - ``"error"`` -- gh / git probe failed; reason carries diagnostic
627
+ """
628
+ project_root = config.project_root
629
+ version = config.version
630
+ repo = config.repo
631
+
632
+ # First check whether a GH release exists; if so, return early with the
633
+ # payload so the caller has the assets[] array for guard evaluation.
634
+ state, payload, reason = gh_release_exists(version, repo)
635
+ if state == "exists":
636
+ return "released", payload, ""
637
+ if state == "error":
638
+ # gh probe failed; surface the error so the caller can refuse rather
639
+ # than guess at local/remote state.
640
+ return "error", None, reason
641
+
642
+ # No GH release. Probe local + remote tag.
643
+ local = git_tag_exists_local(project_root, version)
644
+ remote = git_tag_exists_origin(project_root, version)
645
+ if remote:
646
+ return "tag-pushed-no-release", None, ""
647
+ if local:
648
+ return "local-only", None, ""
649
+ return "absent", None, ""
650
+
651
+
652
+ # ---- pipeline ---------------------------------------------------------------
653
+
654
+
655
+ def _emit(label: str, status: str) -> None:
656
+ print(f"[rollback] {label}... {status}", file=sys.stderr)
657
+
658
+
659
+ def _resolve_prep_sha_or_emit(
660
+ config: RollbackConfig,
661
+ ) -> tuple[str, int | None]:
662
+ """Resolve the release-prep SHA and emit a status line.
663
+
664
+ Returns ``(sha, exit_code)``. ``exit_code`` is None on success (the
665
+ caller proceeds with the SHA); when the probe fails it is
666
+ ``EXIT_VIOLATION`` so the caller can ``return rc`` immediately
667
+ without a separate emit. Used by every unwind branch (states 1, 2,
668
+ 3) to capture the SHA BEFORE any tag deletion (which would make
669
+ rev-parse fail) -- centralised here so the per-state code stays
670
+ short and the resolution-then-refuse semantics are consistent.
671
+ """
672
+ sha, reason = resolve_release_prep_sha(config.project_root, config.version)
673
+ if not sha:
674
+ _emit(f"Resolve release-prep SHA for v{config.version}", f"FAIL ({reason})")
675
+ return "", EXIT_VIOLATION
676
+ _emit(
677
+ f"Resolve release-prep SHA for v{config.version}",
678
+ f"OK ({sha})",
679
+ )
680
+ return sha, None
681
+
682
+
683
+ def _unwind_local(config: RollbackConfig) -> int:
684
+ """State 1: local commit + tag, no push.
685
+
686
+ Pre-#725 used ``git tag -d`` + ``git reset --hard HEAD~1``. #725
687
+ replaces the reset with a resolved-SHA forward revert so the
688
+ unwind targets the release-prep commit even when the operator made
689
+ additional local commits on top. No push is required (state 1
690
+ means nothing has been pushed).
691
+ """
692
+ project_root = config.project_root
693
+ version = config.version
694
+ if config.dry_run:
695
+ _emit(
696
+ f"Unwind local v{version}",
697
+ (
698
+ f"DRYRUN (would resolve release-prep SHA + run "
699
+ f"`git tag -d v{version}` + `git revert <sha> --no-edit`)"
700
+ ),
701
+ )
702
+ return EXIT_OK
703
+ # Resolve BEFORE deleting the tag (rev-parse depends on the tag).
704
+ sha, refusal = _resolve_prep_sha_or_emit(config)
705
+ if refusal is not None:
706
+ return refusal
707
+ ok, reason = git_delete_local_tag(project_root, version)
708
+ if not ok:
709
+ _emit(f"Delete local tag v{version}", f"FAIL ({reason})")
710
+ return EXIT_VIOLATION
711
+ _emit(f"Delete local tag v{version}", f"OK ({reason})")
712
+ ok, reason = git_revert_release_commit(project_root, sha)
713
+ if not ok:
714
+ _emit(f"Revert release-prep commit {sha[:12]}", f"FAIL ({reason})")
715
+ return EXIT_VIOLATION
716
+ _emit(f"Revert release-prep commit {sha[:12]}", f"OK ({reason})")
717
+ return EXIT_OK
718
+
719
+
720
+ def _unwind_tag_pushed_no_release(config: RollbackConfig) -> int:
721
+ """State 2: tag pushed, no release.
722
+
723
+ Pre-#725 deleted both tag refs, reset --hard HEAD~1, and force-pushed.
724
+ #725 deletes both tag refs, runs a forward revert against the resolved
725
+ release-prep SHA, then pushes normally (no force) so the flow is safe
726
+ across intervening commits and compatible with branch protection.
727
+ """
728
+ project_root = config.project_root
729
+ version = config.version
730
+ base_branch = config.base_branch
731
+ if config.dry_run:
732
+ _emit(
733
+ f"Unwind pushed tag v{version}",
734
+ (
735
+ f"DRYRUN (would resolve release-prep SHA + run "
736
+ f"`git push --delete origin v{version}` + "
737
+ f"`git tag -d v{version}` + `git revert <sha> --no-edit` + "
738
+ f"`git push origin {base_branch}` (no force))"
739
+ ),
740
+ )
741
+ return EXIT_OK
742
+
743
+ # Resolve BEFORE deleting either tag ref (rev-parse depends on the tag).
744
+ sha, refusal = _resolve_prep_sha_or_emit(config)
745
+ if refusal is not None:
746
+ return refusal
747
+
748
+ ok, reason = git_delete_remote_tag(project_root, version)
749
+ if not ok:
750
+ _emit(f"Delete remote tag v{version}", f"FAIL ({reason})")
751
+ return EXIT_VIOLATION
752
+ _emit(f"Delete remote tag v{version}", f"OK ({reason})")
753
+
754
+ if git_tag_exists_local(project_root, version):
755
+ ok, reason = git_delete_local_tag(project_root, version)
756
+ if not ok:
757
+ _emit(f"Delete local tag v{version}", f"FAIL ({reason})")
758
+ return EXIT_VIOLATION
759
+ _emit(f"Delete local tag v{version}", f"OK ({reason})")
760
+
761
+ ok, reason = git_revert_release_commit(project_root, sha)
762
+ if not ok:
763
+ _emit(f"Revert release-prep commit {sha[:12]}", f"FAIL ({reason})")
764
+ return EXIT_VIOLATION
765
+ _emit(f"Revert release-prep commit {sha[:12]}", f"OK ({reason})")
766
+
767
+ # Forward push (no --force / --force-with-lease). Compatible with GitHub
768
+ # branch-protection rules disallowing force-push (#725).
769
+ ok, reason = git_push_base(project_root, base_branch)
770
+ if not ok:
771
+ _emit(f"Push {base_branch} to origin", f"FAIL ({reason})")
772
+ return EXIT_VIOLATION
773
+ _emit(f"Push {base_branch} to origin", f"OK ({reason})")
774
+ return EXIT_OK
775
+
776
+
777
+ def _unwind_released(
778
+ config: RollbackConfig, payload: dict
779
+ ) -> int:
780
+ """States 3 & 4: GitHub release exists. Apply guard, then unwind."""
781
+ project_root = config.project_root
782
+ version = config.version
783
+ repo = config.repo
784
+
785
+ age_seconds = _release_age_seconds(payload)
786
+ threshold, threshold_reason = compute_threshold(
787
+ age_seconds,
788
+ allow_low_downloads=config.allow_low_downloads,
789
+ allow_data_loss=config.allow_data_loss,
790
+ force_strict_0=config.force_strict_0,
791
+ )
792
+ _emit(
793
+ f"Compute guard threshold (age={age_seconds}s)",
794
+ threshold_reason,
795
+ )
796
+ if threshold is None:
797
+ # Time-window refusal (release > 30 min old, no escape hatch).
798
+ _emit(
799
+ "Guard refusal",
800
+ "FAIL (release > 30 min old without --allow-data-loss; "
801
+ "see hot-fix-path recommendation in script docstring)",
802
+ )
803
+ return EXIT_VIOLATION
804
+
805
+ if config.dry_run:
806
+ _emit(
807
+ f"Double-read download_count (threshold={threshold})",
808
+ "DRYRUN (would read download_count, sleep 5s, re-read)",
809
+ )
810
+ _emit(
811
+ f"Delete release v{version}",
812
+ f"DRYRUN (would run `gh release delete v{version} --yes --cleanup-tag`)",
813
+ )
814
+ _emit(
815
+ f"Revert release-prep commit for v{version}",
816
+ (
817
+ f"DRYRUN (would resolve release-prep SHA + run "
818
+ f"`git revert <sha> --no-edit` + `git push origin "
819
+ f"{config.base_branch}` (no force))"
820
+ ),
821
+ )
822
+ return EXIT_OK
823
+
824
+ # Resolve the release-prep SHA BEFORE `gh release delete --cleanup-tag`
825
+ # removes the remote tag (rev-parse uses the local tag, which is still
826
+ # present at this point because the operator pushed but has not yet
827
+ # rolled back). Capturing here also defends against the local tag
828
+ # being inadvertently cleaned up by the gh call (some gh versions
829
+ # update local refs as well as remote).
830
+ sha, refusal = _resolve_prep_sha_or_emit(config)
831
+ if refusal is not None:
832
+ return refusal
833
+
834
+ sleep_seconds = 0 if config.skip_sleep else _DOUBLE_READ_SLEEP_SECONDS
835
+ ok, first_count, second_count, reason = double_read_downloads(
836
+ version, repo, sleep_seconds=sleep_seconds
837
+ )
838
+ _emit(
839
+ f"Double-read download_count (threshold={threshold})",
840
+ f"first={first_count}, second={second_count}, ok={ok}; reason: {reason or 'agreed'}",
841
+ )
842
+ if not ok:
843
+ # Race: download arrived during read window. Refuse so the operator
844
+ # re-runs with the fresh count visible.
845
+ return EXIT_VIOLATION
846
+ if max(first_count, second_count) > threshold:
847
+ _emit(
848
+ "Guard refusal",
849
+ (
850
+ f"FAIL (download_count={max(first_count, second_count)} > "
851
+ f"threshold={threshold}; pass --allow-low-downloads or "
852
+ f"--allow-data-loss to override)"
853
+ ),
854
+ )
855
+ return EXIT_VIOLATION
856
+
857
+ # Guard passed: delete the release (with cleanup-tag) and unwind the commit.
858
+ ok, reason = gh_release_delete(version, repo)
859
+ if not ok:
860
+ _emit(f"Delete release v{version}", f"FAIL ({reason})")
861
+ return EXIT_VIOLATION
862
+ _emit(f"Delete release v{version}", f"OK ({reason})")
863
+
864
+ # Tag deletion is handled by --cleanup-tag in the gh delete call; we
865
+ # don't need a separate `git push --delete`. Local tag may still
866
+ # exist (gh deletes only the remote ref); clean it up if present.
867
+ if git_tag_exists_local(project_root, version):
868
+ ok, reason = git_delete_local_tag(project_root, version)
869
+ if not ok:
870
+ _emit(f"Delete local tag v{version}", f"WARN ({reason})")
871
+ else:
872
+ _emit(f"Delete local tag v{version}", f"OK ({reason})")
873
+
874
+ ok, reason = git_revert_release_commit(project_root, sha)
875
+ if not ok:
876
+ _emit(f"Revert release-prep commit {sha[:12]}", f"FAIL ({reason})")
877
+ return EXIT_VIOLATION
878
+ _emit(f"Revert release-prep commit {sha[:12]}", f"OK ({reason})")
879
+
880
+ # Forward push (no --force / --force-with-lease). Compatible with GitHub
881
+ # branch-protection rules disallowing force-push (#725).
882
+ ok, reason = git_push_base(project_root, config.base_branch)
883
+ if not ok:
884
+ _emit(f"Push {config.base_branch} to origin", f"FAIL ({reason})")
885
+ return EXIT_VIOLATION
886
+ _emit(f"Push {config.base_branch} to origin", f"OK ({reason})")
887
+
888
+ return EXIT_OK
889
+
890
+
891
+ def run_rollback(config: RollbackConfig) -> int:
892
+ """Execute the rollback pipeline; returns the process exit code."""
893
+ if config.dry_run:
894
+ _emit(
895
+ "Detect post-release state",
896
+ f"DRYRUN (would probe gh release view v{config.version} + "
897
+ f"git tag -l + git ls-remote)",
898
+ )
899
+ # In dry-run, we still need a state to exercise the right branch.
900
+ # Default to "released" so the dry-run output covers the most
901
+ # complex path (the others print as DRYRUN inside their own
902
+ # branches as well; if the operator wants a specific branch they
903
+ # can run the script live).
904
+ # However, if we can probe live state (gh + git available), do so
905
+ # and report the actual branch. Otherwise fall back to the
906
+ # most-complex branch.
907
+ state, payload, reason = detect_state(config)
908
+ _emit("State (dry-run probe)", f"{state} ({reason or 'no reason'})")
909
+ if state == "absent":
910
+ _emit("Rollback", "DRYRUN (no-op; nothing to unwind)")
911
+ return EXIT_OK
912
+ if state == "local-only":
913
+ return _unwind_local(config)
914
+ if state == "tag-pushed-no-release":
915
+ return _unwind_tag_pushed_no_release(config)
916
+ if state == "released" and payload is not None:
917
+ return _unwind_released(config, payload)
918
+ if state == "error":
919
+ _emit("State probe", f"FAIL ({reason})")
920
+ return EXIT_VIOLATION
921
+ # Fallback for unknown state (shouldn't happen): no-op.
922
+ return EXIT_OK
923
+
924
+ state, payload, reason = detect_state(config)
925
+ _emit("Detect post-release state", f"{state} ({reason or 'ok'})")
926
+ if state == "absent":
927
+ _emit("Rollback", "NOOP (no local tag, no remote tag, no release)")
928
+ return EXIT_OK
929
+ if state == "error":
930
+ return EXIT_VIOLATION
931
+ if state == "local-only":
932
+ return _unwind_local(config)
933
+ if state == "tag-pushed-no-release":
934
+ return _unwind_tag_pushed_no_release(config)
935
+ if state == "released":
936
+ assert payload is not None
937
+ return _unwind_released(config, payload)
938
+ # Unknown state: refuse rather than guess.
939
+ _emit("Rollback", f"FAIL (unknown state {state!r})")
940
+ return EXIT_VIOLATION
941
+
942
+
943
+ # ---- main -------------------------------------------------------------------
944
+
945
+
946
+ def main(argv: list[str] | None = None) -> int:
947
+ parser = _build_parser()
948
+ args = parser.parse_args(argv)
949
+
950
+ try:
951
+ release._validate_version(args.version)
952
+ except ValueError as exc:
953
+ print(f"Error: {exc}", file=sys.stderr)
954
+ return EXIT_CONFIG_ERROR
955
+
956
+ project_root = release._resolve_project_root(args.project_root)
957
+ repo = release._resolve_repo(args.repo, project_root)
958
+
959
+ if args.allow_low_downloads < 0:
960
+ print(
961
+ f"Error: --allow-low-downloads must be >= 0 (got {args.allow_low_downloads}).",
962
+ file=sys.stderr,
963
+ )
964
+ return EXIT_CONFIG_ERROR
965
+
966
+ config = RollbackConfig(
967
+ version=args.version,
968
+ repo=repo,
969
+ base_branch=args.base_branch,
970
+ project_root=project_root,
971
+ dry_run=args.dry_run,
972
+ allow_low_downloads=args.allow_low_downloads,
973
+ allow_data_loss=args.allow_data_loss,
974
+ force_strict_0=args.force_strict_0,
975
+ )
976
+ return run_rollback(config)
977
+
978
+
979
+ if __name__ == "__main__":
980
+ sys.exit(main())