@deftai/directive-content 0.59.0 → 0.60.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 (184) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +48 -58
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scm/github.md +20 -2
  10. package/tasks/change.yml +16 -31
  11. package/tasks/ci.yml +8 -0
  12. package/tasks/commit.yml +12 -19
  13. package/tasks/core.yml +10 -0
  14. package/tasks/engine.yml +42 -0
  15. package/tasks/framework.yml +3 -0
  16. package/tasks/install.yml +20 -19
  17. package/tasks/migrate.yml +26 -15
  18. package/tasks/project.yml +16 -0
  19. package/tasks/toolchain.yml +15 -5
  20. package/tasks/vbrief.yml +4 -3
  21. package/tasks/verify.yml +12 -14
  22. package/scripts/_agents_md.py +0 -494
  23. package/scripts/_cache_fetch.py +0 -635
  24. package/scripts/_cache_quota.py +0 -529
  25. package/scripts/_cache_refresh.py +0 -163
  26. package/scripts/_cache_validate.py +0 -209
  27. package/scripts/_content_root.py +0 -42
  28. package/scripts/_doctor_state.py +0 -277
  29. package/scripts/_event_detect.py +0 -305
  30. package/scripts/_events.py +0 -514
  31. package/scripts/_lifecycle_hygiene.py +0 -568
  32. package/scripts/_pathspec.py +0 -91
  33. package/scripts/_policy_show_cli.py +0 -266
  34. package/scripts/_precutover.py +0 -92
  35. package/scripts/_project_context.py +0 -224
  36. package/scripts/_project_definition_io.py +0 -164
  37. package/scripts/_relocate_snapshot.py +0 -209
  38. package/scripts/_relocate_states.py +0 -343
  39. package/scripts/_resolve_preflight_path.py +0 -152
  40. package/scripts/_safe_subprocess.py +0 -167
  41. package/scripts/_session_start_hook.py +0 -205
  42. package/scripts/_sor_gate_diff.py +0 -365
  43. package/scripts/_stdio_utf8.py +0 -59
  44. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  45. package/scripts/_triage_classify_cli.py +0 -122
  46. package/scripts/_triage_queue_cli.py +0 -625
  47. package/scripts/_triage_scope_cli.py +0 -343
  48. package/scripts/_triage_scope_drift_cli.py +0 -121
  49. package/scripts/_triage_scope_ignores.py +0 -286
  50. package/scripts/_triage_scope_milestone.py +0 -432
  51. package/scripts/_triage_scope_mutations.py +0 -337
  52. package/scripts/_triage_scope_renderers.py +0 -207
  53. package/scripts/_triage_smoketest_stages.py +0 -674
  54. package/scripts/_triage_subscribe_cli.py +0 -140
  55. package/scripts/_triage_welcome_cli.py +0 -421
  56. package/scripts/_vbrief_build.py +0 -239
  57. package/scripts/_vbrief_fidelity.py +0 -479
  58. package/scripts/_vbrief_legacy.py +0 -589
  59. package/scripts/_vbrief_reconciliation.py +0 -883
  60. package/scripts/_vbrief_routing.py +0 -277
  61. package/scripts/_vbrief_safety.py +0 -778
  62. package/scripts/_vbrief_sources.py +0 -312
  63. package/scripts/_vbrief_speckit.py +0 -262
  64. package/scripts/_vbrief_story_quality.py +0 -353
  65. package/scripts/_vbrief_validation.py +0 -299
  66. package/scripts/build_dist.py +0 -412
  67. package/scripts/cache.py +0 -1078
  68. package/scripts/cache_scanner.py +0 -745
  69. package/scripts/candidates_log.py +0 -432
  70. package/scripts/capacity_backfill.py +0 -680
  71. package/scripts/capacity_show.py +0 -653
  72. package/scripts/ci_local.py +0 -689
  73. package/scripts/code_structure_validate.py +0 -765
  74. package/scripts/codebase_default_extractor.py +0 -495
  75. package/scripts/codebase_map.py +0 -304
  76. package/scripts/codebase_map_fresh.py +0 -104
  77. package/scripts/codebase_projection_registry.py +0 -94
  78. package/scripts/codebase_provider.py +0 -582
  79. package/scripts/doctor.py +0 -2552
  80. package/scripts/framework_commands.py +0 -505
  81. package/scripts/gh_rest.py +0 -882
  82. package/scripts/github_auth_modes.py +0 -437
  83. package/scripts/github_body.py +0 -292
  84. package/scripts/ip_risk.py +0 -531
  85. package/scripts/issue_emit.py +0 -670
  86. package/scripts/issue_ingest.py +0 -1064
  87. package/scripts/migrate_preflight.py +0 -418
  88. package/scripts/migrate_vbrief.py +0 -2677
  89. package/scripts/monitor_pr.py +0 -401
  90. package/scripts/pack_migrate_lessons.py +0 -336
  91. package/scripts/pack_migrate_patterns.py +0 -254
  92. package/scripts/pack_migrate_rules.py +0 -350
  93. package/scripts/pack_migrate_skills.py +0 -423
  94. package/scripts/pack_migrate_strategies.py +0 -311
  95. package/scripts/pack_migrate_swarm_spec.py +0 -250
  96. package/scripts/pack_render.py +0 -434
  97. package/scripts/packs_slice.py +0 -712
  98. package/scripts/platform_capabilities.py +0 -336
  99. package/scripts/policy.py +0 -2826
  100. package/scripts/policy_set.py +0 -324
  101. package/scripts/pr_check_closing_keywords.py +0 -524
  102. package/scripts/pr_check_protected_issues.py +0 -267
  103. package/scripts/pr_merge_readiness.py +0 -1004
  104. package/scripts/pr_wait_mergeable.py +0 -669
  105. package/scripts/prd_render.py +0 -159
  106. package/scripts/preflight_architecture_sor.py +0 -974
  107. package/scripts/preflight_branch.py +0 -289
  108. package/scripts/preflight_cache.py +0 -974
  109. package/scripts/preflight_gh.py +0 -721
  110. package/scripts/preflight_implementation.py +0 -272
  111. package/scripts/preflight_story_start.py +0 -838
  112. package/scripts/preflight_wip_cap.py +0 -149
  113. package/scripts/probe_session.py +0 -545
  114. package/scripts/project_render.py +0 -293
  115. package/scripts/quarantine_ext.py +0 -237
  116. package/scripts/reconcile_issues.py +0 -1442
  117. package/scripts/refresh-path.ps1 +0 -107
  118. package/scripts/release.py +0 -2030
  119. package/scripts/release_e2e.py +0 -1011
  120. package/scripts/release_publish.py +0 -486
  121. package/scripts/release_rollback.py +0 -980
  122. package/scripts/relocate.py +0 -1034
  123. package/scripts/resolve_changelog_unreleased.py +0 -667
  124. package/scripts/resolve_version.py +0 -490
  125. package/scripts/resume_conditions.py +0 -706
  126. package/scripts/ritual_sentinel.py +0 -609
  127. package/scripts/roadmap_render.py +0 -635
  128. package/scripts/rule_ownership_lint.py +0 -325
  129. package/scripts/scm.py +0 -591
  130. package/scripts/scope_audit_log.py +0 -387
  131. package/scripts/scope_decompose.py +0 -654
  132. package/scripts/scope_demote.py +0 -509
  133. package/scripts/scope_lifecycle.py +0 -1126
  134. package/scripts/scope_undo.py +0 -772
  135. package/scripts/session_start.py +0 -406
  136. package/scripts/setup_ghx.py +0 -339
  137. package/scripts/setup_windows.ps1 +0 -220
  138. package/scripts/slice_audit.py +0 -585
  139. package/scripts/slice_record.py +0 -530
  140. package/scripts/slice_record_existing.py +0 -692
  141. package/scripts/slug_normalize.py +0 -178
  142. package/scripts/spec_render.py +0 -477
  143. package/scripts/spec_validate.py +0 -238
  144. package/scripts/subagent_monitor.py +0 -658
  145. package/scripts/swarm_complete_cohort.py +0 -644
  146. package/scripts/swarm_launch.py +0 -1206
  147. package/scripts/swarm_readiness.py +0 -554
  148. package/scripts/swarm_verify_review_clean.py +0 -438
  149. package/scripts/swarm_worktrees.py +0 -497
  150. package/scripts/toolchain-check.py +0 -52
  151. package/scripts/triage_actions.py +0 -871
  152. package/scripts/triage_bootstrap.py +0 -1153
  153. package/scripts/triage_bulk.py +0 -630
  154. package/scripts/triage_classify.py +0 -932
  155. package/scripts/triage_help.py +0 -1685
  156. package/scripts/triage_queue.py +0 -1944
  157. package/scripts/triage_reconcile.py +0 -581
  158. package/scripts/triage_refresh.py +0 -643
  159. package/scripts/triage_scope.py +0 -999
  160. package/scripts/triage_scope_drift.py +0 -575
  161. package/scripts/triage_smoketest.py +0 -396
  162. package/scripts/triage_subscribe.py +0 -399
  163. package/scripts/triage_summary.py +0 -1011
  164. package/scripts/triage_welcome.py +0 -1178
  165. package/scripts/ts_check_lane.py +0 -86
  166. package/scripts/validate-links.py +0 -64
  167. package/scripts/validate_strategy_output.py +0 -212
  168. package/scripts/vbrief_activate.py +0 -228
  169. package/scripts/vbrief_migrate_conformance.py +0 -368
  170. package/scripts/vbrief_reconcile_graph.py +0 -306
  171. package/scripts/vbrief_reconcile_labels.py +0 -460
  172. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  173. package/scripts/vbrief_validate.py +0 -1144
  174. package/scripts/verify-stubs.py +0 -61
  175. package/scripts/verify_capacity.py +0 -160
  176. package/scripts/verify_encoding.py +0 -699
  177. package/scripts/verify_hooks_installed.py +0 -206
  178. package/scripts/verify_investigation.py +0 -360
  179. package/scripts/verify_judgment_gates.py +0 -827
  180. package/scripts/verify_no_task_runtime.py +0 -171
  181. package/scripts/verify_scm_boundary.py +0 -509
  182. package/scripts/verify_session_ritual.py +0 -389
  183. package/scripts/verify_tools.py +0 -426
  184. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,980 +0,0 @@
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())