@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.
Files changed (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env python3
2
+ """release_publish.py -- Flip a draft GitHub release to public (#716).
3
+
4
+ Companion to ``scripts/release.py`` per the #716 safety hardening.
5
+ ``task release`` lands the release as a draft so the *artifact
6
+ production* phase (release.yml CI + binary upload) is decoupled from
7
+ the *consumer-visibility* phase. After manually reviewing the draft's
8
+ binaries / notes / asset list, the operator runs::
9
+
10
+ task release:publish -- <version>
11
+
12
+ which dispatches this script to flip the release out of draft state.
13
+
14
+ Pipeline
15
+ --------
16
+ 1. Pre-flight: verify the release exists and is in draft state via the
17
+ GitHub REST API. The lookup uses a paginated list+filter against
18
+ ``GET /repos/{owner}/{repo}/releases?per_page=100`` (with
19
+ ``gh api --paginate`` following ``Link: rel="next"`` headers) and
20
+ matches the first entry whose ``tag_name`` equals ``v<version>``.
21
+ State machine:
22
+
23
+ - **not-found** -> exit 1 (cannot publish a release that does not exist)
24
+ - **already-published** -> exit 0 no-op (publish is idempotent; running
25
+ it twice is safe)
26
+ - **draft** -> proceed
27
+ 2. Flip the draft state via REST PATCH:
28
+ ``PATCH /repos/{owner}/{repo}/releases/{id}`` with ``draft=false``.
29
+ 3. Re-read the release and verify the draft state actually flipped.
30
+ 4. Print summary line; return exit 0.
31
+
32
+ REST internals (#961, #1016)
33
+ ----------------------------
34
+ The v0.26.1 publish failed (2026-05-07) at the GraphQL bucket
35
+ exhaustion mid-cascade: the legacy ``gh release view --json ...`` and
36
+ ``gh release edit ... --draft=false`` subcommands both routed through
37
+ GraphQL and failed hard when the bucket hit zero. Per ``meta/lessons.md``
38
+ ``## gh CLI GraphQL Bucket Exhaustion + REST Fallback + UTF-8 Payload
39
+ Pattern (2026-05)`` and the canonical preamble in
40
+ ``templates/agent-prompt-preamble.md`` S5 (REST-by-default rule), this
41
+ script uses ``gh api`` directly against REST endpoints, which bill
42
+ the ``core`` bucket (independent of ``graphql``).
43
+
44
+ #1016 follow-up: the v0.27.0 publish (2026-05-10) failed against a
45
+ DRAFT release because the original #961 implementation called
46
+ ``GET /repos/{owner}/{repo}/releases/tags/{tag}``, which the GitHub
47
+ REST docs explicitly limit to PUBLISHED releases ("This returns the
48
+ latest published release for the specified tag"). DRAFT releases were
49
+ filtered out at the API layer, so ``release_publish.py`` 404'd on the
50
+ canonical case it was supposed to handle. The fix (option 2 from #1016)
51
+ replaces the single ``/releases/tags/{tag}`` call with a paginated
52
+ list+filter against ``GET /repos/{owner}/{repo}/releases?per_page=100``
53
+ (via ``gh api --paginate``), then matches the first entry whose
54
+ ``tag_name`` equals the target. This stays within the REST core bucket
55
+ and surfaces drafts.
56
+
57
+ Release helpers are intentionally NOT routed through
58
+ ``scripts/gh_rest.py`` (#961) because the issue body explicitly carves
59
+ releases out as ``task release`` (#74) territory; this module owns its
60
+ two inline REST calls without extending the cross-cutting helper
61
+ surface. See module docstring of ``scripts/gh_rest.py`` for the
62
+ rationale.
63
+
64
+ The internal ``payload`` shape returned by :func:`view_release` is
65
+ normalised to the legacy field names (``isDraft``, ``tagName``,
66
+ ``url``, ``name``) regardless of which REST keys the upstream API
67
+ uses, so :func:`run_publish` and existing tests do not care that the
68
+ underlying transport changed.
69
+
70
+ Exit codes
71
+ ----------
72
+ 0 -- release published (or already-published no-op)
73
+ 1 -- pre-flight or step-level violation (release missing, gh failure,
74
+ post-edit verification mismatch)
75
+ 2 -- config / argument error (malformed version, repo unresolvable, ...)
76
+
77
+ Refs #716 (canonical spec; safety hardening Item 2 of 7),
78
+ #74 (foundation), #233, #642, #635, #709, #710,
79
+ #961 (REST internals; v0.26.1 publish failure motivating incident),
80
+ #798 (PS 5.1 non-ASCII discipline applied to JSON-payload pattern).
81
+ """
82
+
83
+ from __future__ import annotations
84
+
85
+ import argparse
86
+ import json
87
+ import os
88
+ import shutil # noqa: F401 -- kept for tests that monkeypatch release_publish.shutil.which
89
+ import subprocess
90
+ import sys
91
+ from dataclasses import dataclass
92
+ from pathlib import Path
93
+
94
+ # Make sibling scripts importable so we can re-use _resolve_repo /
95
+ # _resolve_project_root / _validate_version + the EXIT_* constants from
96
+ # release.py without duplicating them.
97
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
98
+
99
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
100
+
101
+ reconfigure_stdio()
102
+
103
+ import release # noqa: E402
104
+
105
+ # Re-export the exit codes so callers (tests + downstream) get a single
106
+ # source of truth identical to scripts/release.py.
107
+ EXIT_OK = release.EXIT_OK
108
+ EXIT_VIOLATION = release.EXIT_VIOLATION
109
+ EXIT_CONFIG_ERROR = release.EXIT_CONFIG_ERROR
110
+
111
+
112
+ # ---- Data classes -----------------------------------------------------------
113
+
114
+
115
+ @dataclass
116
+ class PublishConfig:
117
+ version: str
118
+ repo: str
119
+ project_root: Path
120
+ dry_run: bool
121
+
122
+
123
+ # ---- argument parsing -------------------------------------------------------
124
+
125
+
126
+ def _build_parser() -> argparse.ArgumentParser:
127
+ parser = argparse.ArgumentParser(
128
+ prog="release_publish",
129
+ description=(
130
+ "Flip a draft GitHub release to public (#716 safety hardening). "
131
+ "Companion to `task release` -- after reviewing the draft's "
132
+ "binaries / notes / asset list, run `task release:publish -- "
133
+ "<version>` to publish."
134
+ ),
135
+ )
136
+ parser.add_argument(
137
+ "version",
138
+ help="Release version, e.g. 0.21.0 (no leading 'v', strict X.Y.Z).",
139
+ )
140
+ parser.add_argument(
141
+ "--dry-run",
142
+ action="store_true",
143
+ help="Print the publish plan without invoking gh release edit.",
144
+ )
145
+ parser.add_argument(
146
+ "--repo",
147
+ default=None,
148
+ metavar="OWNER/REPO",
149
+ help=(
150
+ "Override the GitHub repository (default: resolved from "
151
+ "`git remote get-url origin`, falling back to "
152
+ f"{release.DEFAULT_REPO!r})."
153
+ ),
154
+ )
155
+ parser.add_argument(
156
+ "--project-root",
157
+ type=Path,
158
+ default=None,
159
+ metavar="PATH",
160
+ help=(
161
+ "Repository root (default: $DEFT_PROJECT_ROOT or the parent of "
162
+ "the scripts/ directory)."
163
+ ),
164
+ )
165
+ return parser
166
+
167
+
168
+ # ---- gh helpers -------------------------------------------------------------
169
+
170
+
171
+ def _normalise_release_payload(rest_payload: dict) -> dict:
172
+ """Map the REST release object to the legacy-shape internal payload.
173
+
174
+ The REST endpoint returns ``draft`` / ``tag_name`` / ``html_url``
175
+ while the legacy ``gh release view --json ...`` form returned
176
+ ``isDraft`` / ``tagName`` / ``url``. :func:`run_publish` and the
177
+ existing test fixtures consume the legacy field names; we normalise
178
+ once here so the transport change is an internal-implementation
179
+ detail. The REST ``id`` field is added (it had no pre-#961 analogue)
180
+ because :func:`edit_release_publish` needs it for the PATCH URL.
181
+ """
182
+ return {
183
+ "isDraft": bool(rest_payload.get("draft", False)),
184
+ "name": rest_payload.get("name"),
185
+ "tagName": rest_payload.get("tag_name"),
186
+ "url": rest_payload.get("html_url"),
187
+ "id": rest_payload.get("id"),
188
+ }
189
+
190
+
191
+ # Endpoint used by the paginated list+filter lookup (#1016). Exposed as a
192
+ # module-level constant so tests can pin the argv shape without
193
+ # duplicating the literal.
194
+ _RELEASES_LIST_ENDPOINT_TEMPLATE = "repos/{repo}/releases?per_page=100"
195
+
196
+
197
+ def _gh_api_find_release_by_tag(
198
+ gh_path: str, repo: str, tag: str
199
+ ) -> tuple[str, dict | None, str]:
200
+ """Find a release by ``tag_name`` via paginated REST list (#1016).
201
+
202
+ The original #961 implementation called
203
+ ``GET /repos/<owner>/<repo>/releases/tags/<tag>``, which the GitHub
204
+ REST docs explicitly limit to PUBLISHED releases ("This returns the
205
+ latest published release for the specified tag"). DRAFT releases
206
+ were filtered out at the API layer, so the publish flow 404'd on
207
+ its canonical input. The fix (option 2 from #1016) lists ALL
208
+ releases via ``GET /repos/<owner>/<repo>/releases?per_page=100``
209
+ (paginated; ``gh api --paginate`` follows ``Link: rel="next"``
210
+ headers automatically and concatenates page arrays into one) and
211
+ filters client-side for ``tag_name == tag``. The first match wins;
212
+ if no entry matches, the helper returns ``not-found``.
213
+
214
+ Returns ``(state, payload, reason)`` matching
215
+ :func:`view_release`'s contract:
216
+
217
+ - ``"draft"`` -- matching release with ``draft=true`` (proceed)
218
+ - ``"published"`` -- matching release with ``draft=false`` (no-op)
219
+ - ``"not-found"`` -- no entry with ``tag_name == tag`` in the list
220
+ - ``"gh-error"`` -- gh failure (CLI missing, auth, network); the
221
+ ``reason`` carries the diagnostic
222
+
223
+ ``payload`` is normalised via :func:`_normalise_release_payload` so
224
+ callers see the legacy ``isDraft`` / ``tagName`` / ``url`` / ``id``
225
+ keys regardless of REST transport.
226
+ """
227
+ endpoint = _RELEASES_LIST_ENDPOINT_TEMPLATE.format(repo=repo)
228
+ # ``--paginate`` instructs gh to follow Link: rel="next" headers and
229
+ # emit a single concatenated JSON array for array endpoints. Bumped
230
+ # timeout vs the single-tag form because multi-page traversal can
231
+ # legitimately take longer on repos with hundreds of releases.
232
+ cmd = [gh_path, "api", "--paginate", endpoint]
233
+ try:
234
+ result = subprocess.run(
235
+ cmd,
236
+ capture_output=True,
237
+ text=True,
238
+ timeout=120,
239
+ check=False,
240
+ env=os.environ.copy(),
241
+ )
242
+ except FileNotFoundError:
243
+ return "gh-error", None, "gh CLI not found on PATH"
244
+ if result.returncode != 0:
245
+ stderr = (result.stderr or "").strip()
246
+ return "gh-error", None, f"gh api {endpoint} failed: {stderr}"
247
+ try:
248
+ rest_payload = json.loads(result.stdout)
249
+ except json.JSONDecodeError as exc:
250
+ return "gh-error", None, f"gh api {endpoint} returned non-JSON: {exc}"
251
+ if not isinstance(rest_payload, list):
252
+ return "gh-error", None, (
253
+ f"gh api {endpoint} returned non-list "
254
+ f"({type(rest_payload).__name__})"
255
+ )
256
+ # First match wins. Drafts have no canonical SHA so equality on
257
+ # tag_name is the practical key per the #1016 issue body.
258
+ for entry in rest_payload:
259
+ if not isinstance(entry, dict):
260
+ continue
261
+ if entry.get("tag_name") != tag:
262
+ continue
263
+ payload = _normalise_release_payload(entry)
264
+ if payload.get("isDraft", False):
265
+ return "draft", payload, ""
266
+ return "published", payload, ""
267
+ return "not-found", None, f"release {tag} not found on {repo}"
268
+
269
+
270
+ def view_release(version: str, repo: str) -> tuple[str, dict | None, str]:
271
+ """Probe the current state of the GitHub release for ``v<version>``.
272
+
273
+ REST-routed since #961, paginated list+filter since #1016 -- uses
274
+ ``gh api --paginate repos/<owner>/<repo>/releases?per_page=100``
275
+ against the ``core`` bucket so a depleted ``graphql`` bucket cannot
276
+ stall the publish, and so DRAFT releases (which the
277
+ ``/releases/tags/{tag}`` endpoint hides) are surfaced. The internal
278
+ ``payload`` shape is normalised to the legacy field names
279
+ (``isDraft`` / ``tagName`` / ``url`` / ``name`` plus ``id`` for the
280
+ downstream PATCH).
281
+
282
+ Returns ``(state, payload, reason)`` where ``state`` is one of:
283
+
284
+ - ``"draft"`` -- release exists with isDraft=true (proceed to publish)
285
+ - ``"published"`` -- release exists with isDraft=false (already done)
286
+ - ``"not-found"`` -- no list entry matches the requested tag
287
+ - ``"gh-error"`` -- gh failed for an unexpected reason (CLI missing,
288
+ auth, network); ``reason`` carries the diagnostic
289
+ """
290
+ gh_path = release._resolve_gh()
291
+ if gh_path is None:
292
+ return "gh-error", None, "gh CLI not found on PATH"
293
+ tag = f"v{version}"
294
+ return _gh_api_find_release_by_tag(gh_path, repo, tag)
295
+
296
+
297
+ def edit_release_publish(
298
+ version: str, repo: str, release_id: int | None = None
299
+ ) -> tuple[bool, str]:
300
+ """Flip the release out of draft via REST PATCH (#961, #1016).
301
+
302
+ Replaces the legacy ``gh release edit ... --draft=false`` form
303
+ (which routed through GraphQL and failed under bucket exhaustion).
304
+ Up to two REST calls under the ``core`` bucket: (1) paginated GET
305
+ ``releases?per_page=100`` to resolve the release id (skipped when
306
+ ``release_id`` is supplied by the caller; the list+filter form
307
+ surfaces DRAFT releases that ``/releases/tags/<tag>`` would hide,
308
+ per #1016), then (2) PATCH ``releases/<id>`` with ``draft=false``.
309
+ The ``-F draft=false`` flag on ``gh api`` parses the literal
310
+ ``false`` as a boolean (not a string) per the gh CLI documentation,
311
+ so no JSON-payload tempfile is required for this single-field
312
+ mutation.
313
+
314
+ Args:
315
+ version: Release version (no leading ``v``); the tag is derived
316
+ as ``v<version>``.
317
+ repo: ``"owner/repo"`` slug.
318
+ release_id: Optional pre-resolved REST release id. When the
319
+ caller already has the id from a prior :func:`view_release`
320
+ call (the common case under :func:`run_publish`), supplying
321
+ it here elides the redundant GET. When ``None`` (default),
322
+ the helper performs the GET as before. Greptile P2-2 (#961).
323
+ """
324
+ gh_path = release._resolve_gh()
325
+ if gh_path is None:
326
+ return False, "gh CLI not found on PATH"
327
+ tag = f"v{version}"
328
+ # Step 1: resolve the release id via REST (only when caller did not
329
+ # supply one). Backward-compatible: existing callers passing only
330
+ # (version, repo) still get the lookup behaviour. Uses the same
331
+ # paginated list+filter form as :func:`view_release` so DRAFT
332
+ # releases are surfaced (#1016).
333
+ if release_id is None:
334
+ state, payload, reason = _gh_api_find_release_by_tag(
335
+ gh_path, repo, tag
336
+ )
337
+ if state == "not-found":
338
+ return False, f"release {tag} not found on {repo}"
339
+ if state == "gh-error":
340
+ return False, f"could not resolve release id: {reason}"
341
+ if not payload or payload.get("id") is None:
342
+ return False, f"release {tag} payload missing 'id' field"
343
+ release_id = payload["id"]
344
+ # Step 2: PATCH the release to flip draft=false.
345
+ endpoint = f"repos/{repo}/releases/{release_id}"
346
+ cmd = [
347
+ gh_path, "api", endpoint,
348
+ "--method", "PATCH",
349
+ "-F", "draft=false",
350
+ ]
351
+ try:
352
+ result = subprocess.run(
353
+ cmd,
354
+ capture_output=True,
355
+ text=True,
356
+ timeout=60,
357
+ check=False,
358
+ env=os.environ.copy(),
359
+ )
360
+ except FileNotFoundError:
361
+ return False, "gh CLI not found on PATH"
362
+ if result.returncode != 0:
363
+ return False, f"gh api {endpoint} (PATCH) failed: {result.stderr.strip()}"
364
+ return True, f"flipped {tag} to published"
365
+
366
+
367
+ # ---- Pipeline ---------------------------------------------------------------
368
+
369
+
370
+ def _emit(label: str, status: str) -> None:
371
+ # Resolve sys.stderr at call time (matches scripts/release.py emit pattern
372
+ # so test capture via capsys works).
373
+ print(f"[publish] {label}... {status}", file=sys.stderr)
374
+
375
+
376
+ def run_publish(config: PublishConfig) -> int:
377
+ """Execute the publish pipeline; returns the process exit code."""
378
+ version = config.version
379
+ repo = config.repo
380
+ tag = f"v{version}"
381
+
382
+ # Step 1: view current state.
383
+ label = f"View {tag} on {repo}"
384
+ if config.dry_run:
385
+ # Dry-run text mirrors the post-#1016 REST surface: a paginated
386
+ # GET against `releases?per_page=100` (core bucket) filtered
387
+ # client-side for tag_name == <tag>, followed by a PATCH against
388
+ # `releases/<id>` carrying `-F draft=false`. The single-tag form
389
+ # `releases/tags/<tag>` was removed in #1016 because it 404s on
390
+ # DRAFT releases (the canonical publish input). The legacy
391
+ # GraphQL `gh release view` / `gh release edit` forms were
392
+ # removed in #961.
393
+ _emit(
394
+ label,
395
+ (
396
+ f"DRYRUN (would run "
397
+ f"`gh api --paginate repos/{repo}/releases?per_page=100` "
398
+ f"and filter for tag_name == {tag})"
399
+ ),
400
+ )
401
+ _emit(
402
+ f"Edit {tag}",
403
+ (
404
+ f"DRYRUN (would run "
405
+ f"`gh api -X PATCH repos/{repo}/releases/<id> -F draft=false`)"
406
+ ),
407
+ )
408
+ return EXIT_OK
409
+
410
+ state, payload, reason = view_release(version, repo)
411
+ if state == "not-found":
412
+ _emit(label, f"FAIL (release {tag} not found on {repo}: {reason})")
413
+ return EXIT_VIOLATION
414
+ if state == "gh-error":
415
+ _emit(label, f"FAIL ({reason})")
416
+ return EXIT_VIOLATION
417
+ if state == "published":
418
+ _emit(label, f"NOOP ({tag} is already published; nothing to do)")
419
+ return EXIT_OK
420
+ # state == "draft" -> proceed.
421
+ assert payload is not None
422
+ _emit(label, f"OK (draft found at {payload.get('url', '<no url>')})")
423
+
424
+ # Step 2: edit to flip draft=false. Pass the already-resolved release
425
+ # id from step 1 so edit_release_publish does not re-GET (P2-2).
426
+ label = f"Edit {tag} (--draft=false)"
427
+ ok, reason = edit_release_publish(
428
+ version, repo, release_id=payload.get("id")
429
+ )
430
+ if not ok:
431
+ _emit(label, f"FAIL ({reason})")
432
+ return EXIT_VIOLATION
433
+ _emit(label, f"OK ({reason})")
434
+
435
+ # Step 3: verify the edit actually flipped the draft state. A successful
436
+ # exit from `gh release edit` does not by itself prove the state changed
437
+ # (e.g. a stale cache, a permissions silently-noop, a wrong tag); the
438
+ # post-edit re-read is defense in depth so the script never reports
439
+ # success unless the consumer-visible state matches.
440
+ label = f"Verify {tag} is published"
441
+ state2, payload2, reason2 = view_release(version, repo)
442
+ if state2 != "published":
443
+ _emit(
444
+ label,
445
+ (
446
+ f"FAIL (post-edit state is {state2!r}; expected 'published'; "
447
+ f"reason: {reason2})"
448
+ ),
449
+ )
450
+ return EXIT_VIOLATION
451
+ _emit(label, f"OK ({tag} is now public)")
452
+
453
+ print(
454
+ f"Release {tag} published successfully on {repo}.",
455
+ file=sys.stderr,
456
+ )
457
+ return EXIT_OK
458
+
459
+
460
+ # ---- main -------------------------------------------------------------------
461
+
462
+
463
+ def main(argv: list[str] | None = None) -> int:
464
+ parser = _build_parser()
465
+ args = parser.parse_args(argv)
466
+
467
+ try:
468
+ release._validate_version(args.version)
469
+ except ValueError as exc:
470
+ print(f"Error: {exc}", file=sys.stderr)
471
+ return EXIT_CONFIG_ERROR
472
+
473
+ project_root = release._resolve_project_root(args.project_root)
474
+ repo = release._resolve_repo(args.repo, project_root)
475
+
476
+ config = PublishConfig(
477
+ version=args.version,
478
+ repo=repo,
479
+ project_root=project_root,
480
+ dry_run=args.dry_run,
481
+ )
482
+ return run_publish(config)
483
+
484
+
485
+ if __name__ == "__main__":
486
+ sys.exit(main())