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