@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,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())
|