@deftai/directive-content 0.55.1 → 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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +2 -2
|
@@ -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())
|