@deftai/directive-content 0.55.2 → 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 +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- 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/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 +1 -1
|
@@ -0,0 +1,2030 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""release.py -- Automate the v0.X.Y release flow (#74).
|
|
3
|
+
|
|
4
|
+
Wraps the mechanical steps of cutting a deft release into a single
|
|
5
|
+
deterministic Python entry-point so contributors do not have to remember
|
|
6
|
+
the order: pre-flight -> CI -> CHANGELOG promote -> ROADMAP refresh ->
|
|
7
|
+
build dist -> tag -> push tag -> GitHub release.
|
|
8
|
+
|
|
9
|
+
The script is intentionally side-effect-loud (every step prints
|
|
10
|
+
``[N/M] <step>... <result>`` so operators can tail it during a release)
|
|
11
|
+
and supports a ``--dry-run`` mode that prints the full plan without
|
|
12
|
+
touching the filesystem or invoking any external command.
|
|
13
|
+
|
|
14
|
+
Background
|
|
15
|
+
----------
|
|
16
|
+
Issue #74 ("chore: automate release process and CI changelog
|
|
17
|
+
enforcement") flagged the manual release flow as error-prone. PR #73
|
|
18
|
+
documented the convention in ``scm/changelog.md`` but relied on human
|
|
19
|
+
discipline. The vBRIEF
|
|
20
|
+
``vbrief/pending/2026-04-23-233-more-determinism-full-initiative-phase-0-spec.vbrief.json``
|
|
21
|
+
``task-release`` plan.item carries the Action ("automate the v0.X.Y
|
|
22
|
+
release flow -- tag, build, dist, CHANGELOG promote, ROADMAP
|
|
23
|
+
move-to-completed") and Acceptance ("`task release -- 0.21.0` produces
|
|
24
|
+
a clean tag + GitHub release on a dry-run fixture; tests/cli/test_release.py
|
|
25
|
+
covers CHANGELOG promotion and ROADMAP move-to-completed").
|
|
26
|
+
|
|
27
|
+
Per the canonical [#642 workflow comment]
|
|
28
|
+
(https://github.com/deftai/directive/issues/642#issuecomment-4330742436)
|
|
29
|
+
locked decision and the Rule Authority [AXIOM] block in ``main.md``,
|
|
30
|
+
deterministic / Taskfile encodings rank above prose: this script is the
|
|
31
|
+
deterministic encoding of the release flow, surfaced via
|
|
32
|
+
``task release -- <version>`` (see ``tasks/release.yml``).
|
|
33
|
+
|
|
34
|
+
Usage
|
|
35
|
+
-----
|
|
36
|
+
uv run python scripts/release.py 0.21.0
|
|
37
|
+
uv run python scripts/release.py 0.21.0 --dry-run
|
|
38
|
+
uv run python scripts/release.py 0.21.0 --skip-tag --skip-release
|
|
39
|
+
uv run python scripts/release.py 0.21.0 --repo deftai/directive
|
|
40
|
+
uv run python scripts/release.py 0.21.0 --allow-dirty
|
|
41
|
+
uv run python scripts/release.py 0.21.0 --no-draft # rare direct-publish
|
|
42
|
+
|
|
43
|
+
Exit codes
|
|
44
|
+
----------
|
|
45
|
+
0 -- release flow completed successfully (or dry-run preview ok)
|
|
46
|
+
1 -- pre-flight or pipeline-step violation (dirty tree, wrong branch,
|
|
47
|
+
CI failure, CHANGELOG lacks [Unreleased], gh release failure ...)
|
|
48
|
+
2 -- config / argument error (malformed version, repo unresolvable,
|
|
49
|
+
CHANGELOG malformed, ...)
|
|
50
|
+
|
|
51
|
+
Draft default (#716 safety hardening)
|
|
52
|
+
-------------------------------------
|
|
53
|
+
``gh release create`` is invoked with ``--draft`` by default so the
|
|
54
|
+
*artifact production* phase (which fires release.yml CI and uploads
|
|
55
|
+
binaries) is decoupled from the *consumer-visibility* phase. Pair this
|
|
56
|
+
script with ``scripts/release_publish.py`` (``task release:publish --
|
|
57
|
+
<version>``) to flip the draft to public after manual review of the
|
|
58
|
+
binaries / notes / asset list. ``--no-draft`` opts back into the
|
|
59
|
+
prior direct-publish behavior (only intended for automated security
|
|
60
|
+
patches where there is no review gate).
|
|
61
|
+
|
|
62
|
+
Refs #74, #233, #642, #635, #709 (Repair Authority [AXIOM]),
|
|
63
|
+
#710 (data-file-conventions check follow-up), #716 (safety hardening).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
from __future__ import annotations
|
|
67
|
+
|
|
68
|
+
import argparse
|
|
69
|
+
import contextlib
|
|
70
|
+
import datetime as _dt
|
|
71
|
+
import json
|
|
72
|
+
import os
|
|
73
|
+
import re
|
|
74
|
+
import shutil
|
|
75
|
+
import subprocess
|
|
76
|
+
import sys
|
|
77
|
+
import tempfile
|
|
78
|
+
import time
|
|
79
|
+
from collections.abc import Callable
|
|
80
|
+
from dataclasses import dataclass
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
|
|
83
|
+
# Make sibling scripts importable both when run as __main__ and when imported by tests.
|
|
84
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
85
|
+
|
|
86
|
+
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
87
|
+
from framework_commands import run_framework_command # noqa: E402
|
|
88
|
+
from resolve_version import ( # noqa: E402
|
|
89
|
+
NonPublishableVersionError,
|
|
90
|
+
to_pep440,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
reconfigure_stdio()
|
|
94
|
+
|
|
95
|
+
# ---- Exit codes -------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
EXIT_OK = 0
|
|
98
|
+
EXIT_VIOLATION = 1
|
|
99
|
+
EXIT_CONFIG_ERROR = 2
|
|
100
|
+
|
|
101
|
+
# ---- Constants --------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
DEFAULT_REPO = "deftai/directive"
|
|
104
|
+
DEFAULT_BASE_BRANCH = "master"
|
|
105
|
+
|
|
106
|
+
# #1413: maintainer-mode GitHub releases lead with a standard
|
|
107
|
+
# "Upgrading from an older version?" banner, sourced from this editable
|
|
108
|
+
# template (relative to the project root) and prepended to the release
|
|
109
|
+
# notes that ``gh release create`` receives. The banner is GitHub-release-
|
|
110
|
+
# body-only -- it is NEVER injected into CHANGELOG.md -- and is applied
|
|
111
|
+
# only when cutting the canonical directive framework (repo == DEFAULT_REPO);
|
|
112
|
+
# consumer-mode releases (a non-deftai/directive repo) are unaffected.
|
|
113
|
+
_UPGRADE_BANNER_RELPATH = ".github/release-notes/upgrade-banner.md"
|
|
114
|
+
|
|
115
|
+
# Strict semver pattern (no pre-release / build metadata; deft tags are X.Y.Z).
|
|
116
|
+
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
117
|
+
_TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
|
|
118
|
+
_UNRELEASED_RE = re.compile(r"^##\s+\[Unreleased\]\s*$", re.MULTILINE)
|
|
119
|
+
_UNRELEASED_LINK_RE = re.compile(
|
|
120
|
+
r"^\[Unreleased\]:\s+https?://github\.com/[^/]+/[^/]+/compare/v(?P<prev>\d+\.\d+\.\d+)\.\.\.HEAD\s*$",
|
|
121
|
+
re.MULTILINE,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
FRESH_UNRELEASED_BLOCK = (
|
|
125
|
+
"## [Unreleased]\n"
|
|
126
|
+
"\n"
|
|
127
|
+
"### Added\n"
|
|
128
|
+
"\n"
|
|
129
|
+
"### Changed\n"
|
|
130
|
+
"\n"
|
|
131
|
+
"### Fixed\n"
|
|
132
|
+
"\n"
|
|
133
|
+
"### Removed\n"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---- Data classes -----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ReleaseConfig:
|
|
142
|
+
version: str
|
|
143
|
+
repo: str
|
|
144
|
+
base_branch: str
|
|
145
|
+
project_root: Path
|
|
146
|
+
dry_run: bool
|
|
147
|
+
skip_tag: bool
|
|
148
|
+
skip_release: bool
|
|
149
|
+
allow_dirty: bool
|
|
150
|
+
# #716: default-draft so the GitHub release lands as an unpublished
|
|
151
|
+
# draft until ``task release:publish`` flips it. Operators can opt
|
|
152
|
+
# out via --no-draft (rare; e.g. automated security patches).
|
|
153
|
+
draft: bool = True
|
|
154
|
+
# #720: e2e-rehearsal escape hatches. ``--skip-ci`` skips Step 3
|
|
155
|
+
# (task ci:local / task check fallback) so the rehearsal does not
|
|
156
|
+
# re-run CI inside an auto-created temp repo (CI semantics are
|
|
157
|
+
# covered by the unit tests at every commit on master). ``--skip-build``
|
|
158
|
+
# skips Step 6 (task build) similarly. Defaults preserve pre-#720
|
|
159
|
+
# behaviour: both run unless the operator explicitly opts out.
|
|
160
|
+
skip_ci: bool = False
|
|
161
|
+
skip_build: bool = False
|
|
162
|
+
# release-narrative-gap: optional one-line operator-authored summary
|
|
163
|
+
# injected as a Markdown blockquote at the top of the promoted
|
|
164
|
+
# CHANGELOG ``[<version>]`` section. None preserves pre-existing
|
|
165
|
+
# behaviour byte-for-byte. The same blockquote naturally flows
|
|
166
|
+
# through to the GitHub release body (via ``_section_for_version``)
|
|
167
|
+
# and is the canonical source for the Phase 8 Slack ``*Summary*:``
|
|
168
|
+
# slot per ``skills/deft-directive-release/SKILL.md``.
|
|
169
|
+
summary: str | None = None
|
|
170
|
+
# #734: vBRIEF-lifecycle reconciliation gate escape hatch. The
|
|
171
|
+
# pipeline runs ``check_vbrief_lifecycle_sync`` between Step 2
|
|
172
|
+
# (branch guard) and Step 4 (CI) so a release cannot ship with
|
|
173
|
+
# closed-issue vBRIEFs still living in proposed/ / pending/ /
|
|
174
|
+
# active/. The flag is the explicit-acknowledgment escape hatch
|
|
175
|
+
# (analogous to ``--allow-dirty`` for the dirty-tree gate) for
|
|
176
|
+
# cases where the operator has reviewed the drift and chooses to
|
|
177
|
+
# proceed -- e.g. a hot-fix release where the lifecycle reconcile
|
|
178
|
+
# is intentionally deferred to the next refinement pass.
|
|
179
|
+
allow_vbrief_drift: bool = False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---- argument parsing -------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
186
|
+
parser = argparse.ArgumentParser(
|
|
187
|
+
prog="release",
|
|
188
|
+
description=(
|
|
189
|
+
"Automate the v0.X.Y release flow (#74): pre-flight, CI, CHANGELOG "
|
|
190
|
+
"promote, ROADMAP refresh, build, tag, push, gh release. Halt-friendly: "
|
|
191
|
+
"supports --dry-run / --skip-tag / --skip-release for safe rehearsals."
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"version",
|
|
196
|
+
help="Release version, e.g. 0.21.0 (no leading 'v', strict X.Y.Z).",
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument(
|
|
199
|
+
"--dry-run",
|
|
200
|
+
action="store_true",
|
|
201
|
+
help="Print the full release plan without writing files or invoking external commands.",
|
|
202
|
+
)
|
|
203
|
+
parser.add_argument(
|
|
204
|
+
"--skip-tag",
|
|
205
|
+
action="store_true",
|
|
206
|
+
help="Do not invoke git tag / git push origin <tag> (still updates CHANGELOG).",
|
|
207
|
+
)
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--skip-release",
|
|
210
|
+
action="store_true",
|
|
211
|
+
help="Do not invoke gh release create.",
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument(
|
|
214
|
+
"--allow-dirty",
|
|
215
|
+
action="store_true",
|
|
216
|
+
help="Bypass the dirty-tree pre-flight (use only for rehearsals).",
|
|
217
|
+
)
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
"--allow-vbrief-drift",
|
|
220
|
+
action="store_true",
|
|
221
|
+
default=False,
|
|
222
|
+
help=(
|
|
223
|
+
"Bypass the vBRIEF-lifecycle sync pre-flight gate (#734). "
|
|
224
|
+
"Use only when the operator has reviewed the drift and "
|
|
225
|
+
"explicitly accepts that closed-issue vBRIEFs may still "
|
|
226
|
+
"live in non-terminal folders. The clean path is to "
|
|
227
|
+
"run `task reconcile:issues -- --apply-lifecycle-fixes` "
|
|
228
|
+
"first."
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
# #720: e2e-rehearsal escape hatches.
|
|
232
|
+
parser.add_argument(
|
|
233
|
+
"--skip-ci",
|
|
234
|
+
action="store_true",
|
|
235
|
+
help=(
|
|
236
|
+
"Skip Step 3 (task ci:local / task check fallback). Used by "
|
|
237
|
+
"`task release:e2e` to keep wall-clock manageable inside the "
|
|
238
|
+
"auto-created temp repo (CI semantics are covered by the "
|
|
239
|
+
"unit-test suite, not the e2e rehearsal)."
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
parser.add_argument(
|
|
243
|
+
"--skip-build",
|
|
244
|
+
action="store_true",
|
|
245
|
+
help=(
|
|
246
|
+
"Skip Step 6 (task build). Used by `task release:e2e` to keep "
|
|
247
|
+
"wall-clock manageable; build artefacts are not needed for the "
|
|
248
|
+
"draft-release verification step."
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
# #716: default-draft. ``--no-draft`` opts out (rare; security patches).
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
"--no-draft",
|
|
254
|
+
action="store_false",
|
|
255
|
+
dest="draft",
|
|
256
|
+
default=True,
|
|
257
|
+
help=(
|
|
258
|
+
"Publish the GitHub release immediately instead of creating a draft "
|
|
259
|
+
"(default: --draft, paired with `task release:publish -- <version>`)."
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
parser.add_argument(
|
|
263
|
+
"--repo",
|
|
264
|
+
default=None,
|
|
265
|
+
metavar="OWNER/REPO",
|
|
266
|
+
help=(
|
|
267
|
+
"Override the GitHub repository (default: resolved from `git remote get-url origin`, "
|
|
268
|
+
f"falling back to {DEFAULT_REPO!r})."
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
parser.add_argument(
|
|
272
|
+
"--base-branch",
|
|
273
|
+
default=DEFAULT_BASE_BRANCH,
|
|
274
|
+
metavar="BRANCH",
|
|
275
|
+
help=f"Expected base branch for releases (default: {DEFAULT_BASE_BRANCH}).",
|
|
276
|
+
)
|
|
277
|
+
parser.add_argument(
|
|
278
|
+
"--project-root",
|
|
279
|
+
type=Path,
|
|
280
|
+
default=None,
|
|
281
|
+
metavar="PATH",
|
|
282
|
+
help=(
|
|
283
|
+
"Repository root (default: $DEFT_PROJECT_ROOT or the parent of the scripts/ "
|
|
284
|
+
"directory)."
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"--summary",
|
|
289
|
+
default=None,
|
|
290
|
+
metavar="TEXT",
|
|
291
|
+
help=(
|
|
292
|
+
"Optional one-line summary to inject as a Markdown blockquote at "
|
|
293
|
+
"the top of the promoted CHANGELOG section. Flows through to the "
|
|
294
|
+
"GitHub release body and the Slack announcement template (Phase 8). "
|
|
295
|
+
"Recommended length 80-160 chars."
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
return parser
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---- Helpers ----------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _resolve_project_root(arg_root: Path | None) -> Path:
|
|
305
|
+
if arg_root is not None:
|
|
306
|
+
return arg_root.resolve()
|
|
307
|
+
env_root = os.environ.get("DEFT_PROJECT_ROOT")
|
|
308
|
+
if env_root:
|
|
309
|
+
return Path(env_root).resolve()
|
|
310
|
+
return Path(__file__).resolve().parent.parent
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _resolve_repo(arg_repo: str | None, project_root: Path) -> str:
|
|
314
|
+
"""Resolve OWNER/REPO via flag > git remote > DEFAULT_REPO fallback."""
|
|
315
|
+
if arg_repo:
|
|
316
|
+
return arg_repo
|
|
317
|
+
try:
|
|
318
|
+
result = subprocess.run(
|
|
319
|
+
["git", "-C", str(project_root), "remote", "get-url", "origin"],
|
|
320
|
+
capture_output=True,
|
|
321
|
+
text=True,
|
|
322
|
+
timeout=10,
|
|
323
|
+
check=False,
|
|
324
|
+
)
|
|
325
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
326
|
+
return DEFAULT_REPO
|
|
327
|
+
if result.returncode != 0:
|
|
328
|
+
return DEFAULT_REPO
|
|
329
|
+
url = result.stdout.strip()
|
|
330
|
+
# Accept https://github.com/OWNER/REPO(.git)? and git@github.com:OWNER/REPO(.git)?
|
|
331
|
+
match = re.match(
|
|
332
|
+
r"^(?:https?://github\.com/|git@github\.com:)(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
|
|
333
|
+
url,
|
|
334
|
+
)
|
|
335
|
+
if not match:
|
|
336
|
+
return DEFAULT_REPO
|
|
337
|
+
return f"{match.group('owner')}/{match.group('repo')}"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _validate_version(version: str) -> None:
|
|
341
|
+
"""Raise ValueError if the version does not match strict X.Y.Z semver."""
|
|
342
|
+
if not _VERSION_RE.match(version):
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"Invalid version {version!r}. Expected strict semver X.Y.Z "
|
|
345
|
+
f"(no leading 'v', no pre-release suffix)."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def is_prerelease_tag(version: str) -> bool:
|
|
350
|
+
"""Return True when ``version`` carries a SemVer pre-release suffix (#425).
|
|
351
|
+
|
|
352
|
+
A SemVer pre-release is everything after the first ``-`` that follows the
|
|
353
|
+
core ``X.Y.Z`` version (``-rc.N``, ``-beta.N``, ``-alpha.N``, ...). This
|
|
354
|
+
pure tag-based decision drives the ``--prerelease`` flag passed to
|
|
355
|
+
``gh release create`` so RC / beta / alpha cuts are flagged as GitHub
|
|
356
|
+
pre-releases automatically instead of requiring a manual
|
|
357
|
+
``gh release edit --prerelease`` after every cut. It mirrors the
|
|
358
|
+
workflow-side ``prerelease: ${{ contains(github.ref_name, '-') }}`` so
|
|
359
|
+
both release-creation paths agree.
|
|
360
|
+
|
|
361
|
+
A leading ``v`` is tolerated so callers may pass either the tag
|
|
362
|
+
(``v0.20.0-rc.1``) or the bare version (``0.20.0-rc.1``).
|
|
363
|
+
|
|
364
|
+
Examples
|
|
365
|
+
--------
|
|
366
|
+
``v0.20.0-rc.1`` -> True
|
|
367
|
+
``v1.0.0-alpha.3`` -> True
|
|
368
|
+
``0.20.0-beta.2`` -> True
|
|
369
|
+
``v0.20.0`` -> False
|
|
370
|
+
``0.20.0`` -> False
|
|
371
|
+
"""
|
|
372
|
+
candidate = version.strip()
|
|
373
|
+
if candidate.startswith("v"):
|
|
374
|
+
candidate = candidate[1:]
|
|
375
|
+
return "-" in candidate
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _today_iso() -> str:
|
|
379
|
+
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%d")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ---- gh CLI resolution (Windows PATHEXT fix, #721) -------------------------
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _resolve_gh() -> str | None:
|
|
386
|
+
"""Resolve the absolute path to the ``gh`` CLI binary.
|
|
387
|
+
|
|
388
|
+
On Windows, ``gh`` is installed as ``gh.cmd`` (a shell-launcher shim).
|
|
389
|
+
Python's ``subprocess.run(["gh", ...])`` does NOT honor PATHEXT when
|
|
390
|
+
resolving ``argv[0]`` via the OS's CreateProcess path, so the launcher
|
|
391
|
+
cannot be found even when ``gh`` works fine from the operator's
|
|
392
|
+
terminal. ``shutil.which`` DOES honor PATHEXT, so resolving once via
|
|
393
|
+
this helper and passing the absolute path as ``argv[0]`` (e.g.
|
|
394
|
+
``C:\\Program Files\\GitHub CLI\\gh.cmd``) makes the four release
|
|
395
|
+
scripts work uniformly across Windows / macOS / Linux (#721).
|
|
396
|
+
|
|
397
|
+
Returns the absolute path string when ``gh`` is on PATH, or ``None``
|
|
398
|
+
when it is not -- callers MUST surface the canonical
|
|
399
|
+
``"gh CLI not found on PATH"`` reason on ``None`` to keep error
|
|
400
|
+
messages stable for tests and operators.
|
|
401
|
+
"""
|
|
402
|
+
return shutil.which("gh")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---- Step 1/2 -- git pre-flight --------------------------------------------
|
|
406
|
+
|
|
407
|
+
#: Programmatic use of the #747 branch-protection env-var bypass (#867).
|
|
408
|
+
#: The release pipeline is the canonical authorised commit-on-master path
|
|
409
|
+
#: (Steps 9/10/11 commit + tag + push release artifacts on master by
|
|
410
|
+
#: design); the #747 detection-bound gate has no carve-out for it. The
|
|
411
|
+
#: documented operator-side emergency-escape hatch
|
|
412
|
+
#: (``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1`` per ``scripts/policy.py::ENV_BYPASS``)
|
|
413
|
+
#: is reused programmatically in the subprocess env -- this is NOT a new
|
|
414
|
+
#: bypass, just scoped use of the existing approved escape hatch. The
|
|
415
|
+
#: parent-process ``os.environ`` is intentionally NEVER mutated; the
|
|
416
|
+
#: env-var lives only in the subprocess env passed via ``env=`` so a
|
|
417
|
+
#: stale value cannot leak into a subsequent operator shell session.
|
|
418
|
+
_BRANCH_GATE_BYPASS_ENV = "DEFT_ALLOW_DEFAULT_BRANCH_COMMIT"
|
|
419
|
+
|
|
420
|
+
#: Programmatic use of the #1019 destructive-gh-verb env-var bypass.
|
|
421
|
+
#: Same pattern as #867 above, applied to the #1019 ``.githooks/pre-push``
|
|
422
|
+
#: gate that refuses pushes to the default branch (force-push or otherwise).
|
|
423
|
+
#: The release pipeline's Step 11 atomic push on master triggers the gate's
|
|
424
|
+
#: ``force_push_default`` detection; without this carve-out the cut halts
|
|
425
|
+
#: at Step 11 with no path forward except a manual env-var override.
|
|
426
|
+
#: Surfaced during the v0.28.0 cut session 2026-05-11 (the release that
|
|
427
|
+
#: introduced #1019); fix lands in the same release to keep master never
|
|
428
|
+
#: in a release-blocking-itself state. The CHANGELOG entry for #1019
|
|
429
|
+
#: documents the env-var as the canonical bypass mirroring
|
|
430
|
+
#: ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT``; this carve-out makes the
|
|
431
|
+
#: release-pipeline integration explicit. Parent ``os.environ`` is
|
|
432
|
+
#: intentionally NEVER mutated, mirroring the #867 contract.
|
|
433
|
+
_DESTRUCTIVE_GH_GATE_BYPASS_ENV = "DEFT_ALLOW_DESTRUCTIVE_GH_VERBS"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _release_subprocess_env() -> dict[str, str]:
|
|
437
|
+
"""Return a copy of ``os.environ`` with the release-pipeline gate bypasses set.
|
|
438
|
+
|
|
439
|
+
The returned dict is suitable for passing as ``env=`` to
|
|
440
|
+
``subprocess.run``/``_run_git`` for the release-pipeline mutations on
|
|
441
|
+
master (commit + tag + push). The parent-process environment is left
|
|
442
|
+
untouched so the bypasses cannot leak to subsequent operator commands.
|
|
443
|
+
|
|
444
|
+
Two bypasses are set:
|
|
445
|
+
|
|
446
|
+
- ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1`` (#867) -- recognised by the
|
|
447
|
+
#747 branch-protection gate at commit + push time.
|
|
448
|
+
- ``DEFT_ALLOW_DESTRUCTIVE_GH_VERBS=1`` (added in v0.28.0 alongside
|
|
449
|
+
#1019) -- recognised by the #1019 destructive-gh-verb pre-push gate
|
|
450
|
+
so the pipeline's atomic push of master + the annotated tag is not
|
|
451
|
+
refused by the new gate's ``force_push_default`` classifier.
|
|
452
|
+
|
|
453
|
+
Both bypasses are scoped uses of documented operator-side escape
|
|
454
|
+
hatches, not new bypasses.
|
|
455
|
+
"""
|
|
456
|
+
env = os.environ.copy()
|
|
457
|
+
env[_BRANCH_GATE_BYPASS_ENV] = "1"
|
|
458
|
+
env[_DESTRUCTIVE_GH_GATE_BYPASS_ENV] = "1"
|
|
459
|
+
return env
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _run_git(
|
|
463
|
+
project_root: Path,
|
|
464
|
+
*args: str,
|
|
465
|
+
check: bool = False,
|
|
466
|
+
env: dict[str, str] | None = None,
|
|
467
|
+
) -> subprocess.CompletedProcess:
|
|
468
|
+
return subprocess.run(
|
|
469
|
+
["git", "-C", str(project_root), *args],
|
|
470
|
+
capture_output=True,
|
|
471
|
+
text=True,
|
|
472
|
+
timeout=30,
|
|
473
|
+
check=check,
|
|
474
|
+
env=env,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def check_git_clean(project_root: Path) -> tuple[bool, str]:
|
|
479
|
+
result = _run_git(project_root, "status", "--porcelain")
|
|
480
|
+
if result.returncode != 0:
|
|
481
|
+
return False, f"git status failed: {result.stderr.strip()}"
|
|
482
|
+
output = result.stdout.strip()
|
|
483
|
+
if output:
|
|
484
|
+
return False, output
|
|
485
|
+
return True, ""
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def current_branch(project_root: Path) -> str:
|
|
489
|
+
result = _run_git(project_root, "branch", "--show-current")
|
|
490
|
+
if result.returncode != 0:
|
|
491
|
+
return ""
|
|
492
|
+
return result.stdout.strip()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ---- Step 3 -- vBRIEF lifecycle sync (#734) --------------------------------
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def check_vbrief_lifecycle_sync(
|
|
499
|
+
project_root: Path, repo: str
|
|
500
|
+
) -> tuple[bool, int, str]:
|
|
501
|
+
"""Reconcile vBRIEF references against open GitHub issues (#734).
|
|
502
|
+
|
|
503
|
+
Wraps ``scripts/reconcile_issues.py`` so the release pipeline can
|
|
504
|
+
refuse to cut a release while there are closed-issue vBRIEFs still
|
|
505
|
+
living in non-terminal lifecycle folders -- the v0.21.0 cut
|
|
506
|
+
surfaced 13 stranded vBRIEFs (8 cycle-relevant + 5 historical
|
|
507
|
+
residue) post-publish, the recurrence record this gate prevents.
|
|
508
|
+
|
|
509
|
+
Inverted-lookup direction (#754): the gate queries the state of
|
|
510
|
+
just the vBRIEF-referenced issues via ``fetch_issue_states``
|
|
511
|
+
(batched ``gh api graphql``) instead of fetching every open issue
|
|
512
|
+
in the repo and filtering. Cost scales by
|
|
513
|
+
``O(vBRIEF-referenced-issue-count)`` rather than
|
|
514
|
+
``O(repo-open-issue-count)``, retiring the prior 200-issue
|
|
515
|
+
pagination cap that produced false-positive mismatch floods on
|
|
516
|
+
repos with >200 open issues.
|
|
517
|
+
|
|
518
|
+
Returns ``(ok, mismatch_count, reason)``:
|
|
519
|
+
- ``ok=True, mismatch_count=0`` -- clean (Section (c) is empty).
|
|
520
|
+
- ``ok=False, mismatch_count=N`` -- N closed-issue vBRIEFs are NOT
|
|
521
|
+
in ``completed/`` or ``cancelled/``; operator must run
|
|
522
|
+
``task reconcile:issues -- --apply-lifecycle-fixes`` (or pass
|
|
523
|
+
``--allow-vbrief-drift`` to override).
|
|
524
|
+
- ``ok=False, mismatch_count=-1`` -- configuration error (vbrief
|
|
525
|
+
directory missing, ``gh`` unavailable, etc.).
|
|
526
|
+
|
|
527
|
+
The function delegates to the existing ``reconcile_issues``
|
|
528
|
+
helpers so a single source of truth governs both the standalone
|
|
529
|
+
CLI and the pipeline gate.
|
|
530
|
+
"""
|
|
531
|
+
# Local import to avoid pulling reconcile_issues + its transitive
|
|
532
|
+
# imports at module load time (fast unit-test startup matters in
|
|
533
|
+
# this codebase). The script-relative import path mirrors the
|
|
534
|
+
# convention used by the e2e harness and rollback helpers.
|
|
535
|
+
scripts_dir = Path(__file__).resolve().parent
|
|
536
|
+
if str(scripts_dir) not in sys.path:
|
|
537
|
+
sys.path.insert(0, str(scripts_dir))
|
|
538
|
+
try:
|
|
539
|
+
import reconcile_issues # type: ignore # noqa: PLC0415
|
|
540
|
+
except ImportError as exc:
|
|
541
|
+
return False, -1, f"reconcile_issues import failed: {exc}"
|
|
542
|
+
|
|
543
|
+
vbrief_dir = project_root / "vbrief"
|
|
544
|
+
if not vbrief_dir.is_dir():
|
|
545
|
+
return False, -1, f"vbrief directory not found at {vbrief_dir}"
|
|
546
|
+
|
|
547
|
+
issue_to_vbriefs = reconcile_issues.scan_vbrief_dir(vbrief_dir)
|
|
548
|
+
# #754: inverted lookup -- query just the vBRIEF-referenced subset
|
|
549
|
+
# via batched GraphQL. Bounded by O(vBRIEF-count) regardless of
|
|
550
|
+
# repo open-issue count.
|
|
551
|
+
issue_state_map = reconcile_issues.fetch_issue_states(
|
|
552
|
+
repo, set(issue_to_vbriefs.keys()), cwd=project_root
|
|
553
|
+
)
|
|
554
|
+
if issue_state_map is None:
|
|
555
|
+
return False, -1, "failed to fetch issue states from gh"
|
|
556
|
+
|
|
557
|
+
report = reconcile_issues.reconcile(issue_to_vbriefs, issue_state_map)
|
|
558
|
+
# Section (c) entries that are NOT already terminal -- the
|
|
559
|
+
# apply-mode candidates. Reverse mismatches (issues that reopened
|
|
560
|
+
# after a vBRIEF landed in completed/ or cancelled/) are intentionally NOT
|
|
561
|
+
# counted here per #734 (operator decision; report-only).
|
|
562
|
+
mismatches = [
|
|
563
|
+
rel
|
|
564
|
+
for entry in report.get("no_open_issue", [])
|
|
565
|
+
for rel in entry.get("vbrief_files", [])
|
|
566
|
+
if not reconcile_issues.is_terminal_lifecycle_path(rel)
|
|
567
|
+
]
|
|
568
|
+
count = len(mismatches)
|
|
569
|
+
if count == 0:
|
|
570
|
+
return True, 0, "no mismatches"
|
|
571
|
+
return False, count, (
|
|
572
|
+
f"{count} closed-issue vBRIEF(s) not in completed/ or cancelled/: "
|
|
573
|
+
f"{', '.join(mismatches[:5])}"
|
|
574
|
+
+ (" ..." if count > 5 else "")
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ---- Step 4 -- tag availability pre-flight (#784) --------------------------
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def check_tag_available(
|
|
582
|
+
version: str, repo: str, project_root: Path
|
|
583
|
+
) -> tuple[bool, str]:
|
|
584
|
+
"""Refuse early when v<version> already exists locally, on origin, or as a GitHub release.
|
|
585
|
+
|
|
586
|
+
Read-only check -- safe on every dry-run; no network mutation.
|
|
587
|
+
Three failure surfaces, each producing a distinct actionable reason
|
|
588
|
+
so the operator can target the recovery (the most common cause is a
|
|
589
|
+
typo of the prior release version):
|
|
590
|
+
|
|
591
|
+
1. **Local tag** -- ``git tag -l v<version>`` lists the tag. ``git
|
|
592
|
+
tag`` at the legacy Step 9 would fail; the operator would already
|
|
593
|
+
have an unpushed wrong-version commit + orphaned dist artifact.
|
|
594
|
+
2. **Remote tag on origin** -- ``git ls-remote --tags origin
|
|
595
|
+
refs/tags/v<version>`` returns the ref. ``git push --atomic`` at
|
|
596
|
+
the legacy Step 10 would fail.
|
|
597
|
+
3. **Published GitHub release** -- ``gh release view v<version>``
|
|
598
|
+
exits 0. Tag may have been created via ``gh release create``
|
|
599
|
+
directly without a corresponding ref under ``refs/tags/``.
|
|
600
|
+
|
|
601
|
+
Surfaced 2026-05-01 during the v0.23.0 release attempt where the
|
|
602
|
+
operator typed ``0.22.0`` (the prior release from 12 hours earlier);
|
|
603
|
+
the legacy pipeline ran 8 steps before failing at git tag, requiring
|
|
604
|
+
``git reset --hard`` recovery.
|
|
605
|
+
|
|
606
|
+
``gh`` not on PATH is intentionally NOT a failure: the helper passes
|
|
607
|
+
with a UNVERIFIED caveat in the reason (parallel to the
|
|
608
|
+
``verify_release_draft`` (#724) gh-missing path). Local + remote git
|
|
609
|
+
surfaces still gate the gate, so the most common typo case remains
|
|
610
|
+
caught even on gh-less hosts.
|
|
611
|
+
|
|
612
|
+
Refs #784, #74 (release pipeline parent), #734 (sibling pre-flight
|
|
613
|
+
gate -- vBRIEF lifecycle sync).
|
|
614
|
+
"""
|
|
615
|
+
tag = f"v{version}"
|
|
616
|
+
|
|
617
|
+
# 1. Local tag -- git tag -l <tag> prints the tag name on a hit.
|
|
618
|
+
local = _run_git(project_root, "tag", "-l", tag)
|
|
619
|
+
if local.returncode != 0:
|
|
620
|
+
return False, f"git tag -l failed: {local.stderr.strip()}"
|
|
621
|
+
if local.stdout.strip() == tag:
|
|
622
|
+
return False, (
|
|
623
|
+
f"local tag {tag} already exists; choose a different version "
|
|
624
|
+
f"(operator typo of a prior release is the most likely cause)"
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# 2. Remote tag on origin -- ls-remote prints `<sha>\trefs/tags/<tag>` on a hit.
|
|
628
|
+
# ls-remote can fail for non-conflict reasons (no origin remote configured,
|
|
629
|
+
# network down, auth failure). Treat any non-zero exit as UNVERIFIED rather
|
|
630
|
+
# than a hard FAIL -- mirrors the gh-not-found carve-out below. The local
|
|
631
|
+
# tag check is the primary surface; remote / gh are defense-in-depth, so a
|
|
632
|
+
# "could not check this surface" outcome SHOULD warn-and-continue rather
|
|
633
|
+
# than block the release. (The dirty-tree gate at Step 1 and branch gate
|
|
634
|
+
# at Step 2 will have already caught the more catastrophic
|
|
635
|
+
# not-a-git-repository case before we get here.)
|
|
636
|
+
remote = _run_git(
|
|
637
|
+
project_root, "ls-remote", "--tags", "origin", f"refs/tags/{tag}"
|
|
638
|
+
)
|
|
639
|
+
remote_unverified_note = ""
|
|
640
|
+
if remote.returncode != 0:
|
|
641
|
+
stderr = (remote.stderr or "").strip()
|
|
642
|
+
remote_unverified_note = (
|
|
643
|
+
f" (remote UNVERIFIED -- git ls-remote failed: "
|
|
644
|
+
f"{stderr.splitlines()[0] if stderr else 'no stderr'})"
|
|
645
|
+
)
|
|
646
|
+
elif f"refs/tags/{tag}" in remote.stdout:
|
|
647
|
+
return False, (
|
|
648
|
+
f"remote tag {tag} already exists on origin; "
|
|
649
|
+
f"choose a different version"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# 3. Published GitHub release (defense in depth).
|
|
653
|
+
gh_path = _resolve_gh()
|
|
654
|
+
if gh_path is None:
|
|
655
|
+
return True, (
|
|
656
|
+
f"local clean{remote_unverified_note} (gh CLI not on PATH; "
|
|
657
|
+
f"GitHub release surface UNVERIFIED -- install gh or pass "
|
|
658
|
+
f"--skip-release to suppress this caveat)"
|
|
659
|
+
)
|
|
660
|
+
try:
|
|
661
|
+
gh = subprocess.run(
|
|
662
|
+
[
|
|
663
|
+
gh_path,
|
|
664
|
+
"release",
|
|
665
|
+
"view",
|
|
666
|
+
tag,
|
|
667
|
+
"--repo",
|
|
668
|
+
repo,
|
|
669
|
+
"--json",
|
|
670
|
+
"tagName",
|
|
671
|
+
],
|
|
672
|
+
capture_output=True,
|
|
673
|
+
text=True,
|
|
674
|
+
timeout=30,
|
|
675
|
+
check=False,
|
|
676
|
+
)
|
|
677
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
678
|
+
# gh CLI vanished between the which() probe and the invocation
|
|
679
|
+
# (or hung). Treat as UNVERIFIED rather than a release-exists
|
|
680
|
+
# false positive: the issue body's "gh-CLI not-found != release-
|
|
681
|
+
# exists" carve-out applies here too.
|
|
682
|
+
return True, (
|
|
683
|
+
f"local clean{remote_unverified_note} (gh probe failed: {exc}; "
|
|
684
|
+
f"GitHub release surface UNVERIFIED)"
|
|
685
|
+
)
|
|
686
|
+
if gh.returncode == 0:
|
|
687
|
+
return False, (
|
|
688
|
+
f"GitHub release {tag} already exists on {repo}; "
|
|
689
|
+
f"choose a different version"
|
|
690
|
+
)
|
|
691
|
+
# Non-zero rc on a missing release is the OK path.
|
|
692
|
+
return (
|
|
693
|
+
True,
|
|
694
|
+
f"local clean{remote_unverified_note}; no GitHub release {tag} on {repo}",
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
# ---- Step 5 -- CI ----------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def run_ci(project_root: Path) -> tuple[bool, str]:
|
|
702
|
+
"""Run the local CI entrypoint without requiring go-task (#1659)."""
|
|
703
|
+
import ci_local # noqa: PLC0415
|
|
704
|
+
|
|
705
|
+
code = ci_local.main(["--root", str(project_root)])
|
|
706
|
+
if code != 0:
|
|
707
|
+
return False, f"ci:local failed (exit {code})"
|
|
708
|
+
return True, "ran ci:local"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ---- Step 4 -- CHANGELOG promotion -----------------------------------------
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _split_body_and_links(text: str) -> tuple[str, str]:
|
|
715
|
+
"""Split CHANGELOG content into (body, link-footer).
|
|
716
|
+
|
|
717
|
+
The link footer is the trailing block of `[X.Y.Z]: url` lines. We split
|
|
718
|
+
on the FIRST link line so we can inject a new line at the top of the
|
|
719
|
+
block while preserving comment markers (e.g. ``<!-- ... -->``) that may
|
|
720
|
+
be interleaved with the link list.
|
|
721
|
+
"""
|
|
722
|
+
lines = text.splitlines(keepends=True)
|
|
723
|
+
first_link_idx: int | None = None
|
|
724
|
+
for idx, line in enumerate(lines):
|
|
725
|
+
if line.startswith("[Unreleased]:") or re.match(r"^\[\d+\.\d+\.\d+\]:", line):
|
|
726
|
+
first_link_idx = idx
|
|
727
|
+
break
|
|
728
|
+
if first_link_idx is None:
|
|
729
|
+
return text, ""
|
|
730
|
+
body = "".join(lines[:first_link_idx])
|
|
731
|
+
footer = "".join(lines[first_link_idx:])
|
|
732
|
+
return body, footer
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _extract_previous_version(footer: str) -> str | None:
|
|
736
|
+
"""Return the previous version from the existing ``[Unreleased]:`` link, or None."""
|
|
737
|
+
match = _UNRELEASED_LINK_RE.search(footer)
|
|
738
|
+
if match:
|
|
739
|
+
return match.group("prev")
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def promote_changelog(
|
|
744
|
+
text: str,
|
|
745
|
+
version: str,
|
|
746
|
+
repo: str,
|
|
747
|
+
today: str,
|
|
748
|
+
summary: str | None = None,
|
|
749
|
+
) -> str:
|
|
750
|
+
"""Promote ``[Unreleased]`` to ``[<version>] - <today>`` and refresh the link footer.
|
|
751
|
+
|
|
752
|
+
Raises ValueError when the input lacks an ``[Unreleased]`` heading or
|
|
753
|
+
appears malformed.
|
|
754
|
+
|
|
755
|
+
When ``summary`` is a non-empty string, a one-line Markdown blockquote
|
|
756
|
+
(``> <summary>``) is injected directly after the new
|
|
757
|
+
``## [<version>] - <today>`` heading and before the first sub-section
|
|
758
|
+
so the promoted block reads::
|
|
759
|
+
|
|
760
|
+
## [<version>] - <date>
|
|
761
|
+
|
|
762
|
+
> <summary>
|
|
763
|
+
|
|
764
|
+
### Added
|
|
765
|
+
- ...
|
|
766
|
+
|
|
767
|
+
The blockquote is sandwiched by blank lines for proper Keep-a-Changelog
|
|
768
|
+
rendering. The summary is treated as inline Markdown and preserved
|
|
769
|
+
verbatim (operators may include ``**bold**``, ``[link](url)``, etc.).
|
|
770
|
+
Newlines in the summary cause a ``ValueError`` -- the slot is
|
|
771
|
+
explicitly single-line per the release-narrative-gap scope vBRIEF.
|
|
772
|
+
Empty / ``None`` summary preserves pre-existing behaviour byte-for-byte
|
|
773
|
+
(no blockquote is emitted).
|
|
774
|
+
"""
|
|
775
|
+
if not _UNRELEASED_RE.search(text):
|
|
776
|
+
raise ValueError("CHANGELOG.md does not contain a '## [Unreleased]' heading.")
|
|
777
|
+
|
|
778
|
+
if summary is not None and ("\n" in summary or "\r" in summary):
|
|
779
|
+
raise ValueError(
|
|
780
|
+
"--summary is single-line; got embedded newline. "
|
|
781
|
+
"Author the blockquote on a single line."
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
body, footer = _split_body_and_links(text)
|
|
785
|
+
|
|
786
|
+
# Promote: rename heading + insert fresh empty Unreleased block above.
|
|
787
|
+
promoted_heading = f"## [{version}] - {today}"
|
|
788
|
+
if summary:
|
|
789
|
+
# Sandwich the blockquote with blank lines so Keep-a-Changelog
|
|
790
|
+
# renders it as a real blockquote (a ``>`` line glued to the
|
|
791
|
+
# heading or the first sub-section can break Markdown rendering
|
|
792
|
+
# in some clients). Layout in the substitution result:
|
|
793
|
+
#
|
|
794
|
+
# ## [<v>] - <d>\n <- heading
|
|
795
|
+
# \n <- blank line
|
|
796
|
+
# > <summary>\n <- blockquote line + newline
|
|
797
|
+
# <next char from body, which is "\n### Added...">
|
|
798
|
+
#
|
|
799
|
+
# so the rendered shape is heading / blank / > summary / blank /
|
|
800
|
+
# ### Added. The trailing ``\n`` we append below combines with
|
|
801
|
+
# the single ``\n`` left in body after the regex (the
|
|
802
|
+
# ``_UNRELEASED_RE`` ``\s*`` greedy-then-backtrack consumes one
|
|
803
|
+
# of the two ``\n``s following ``## [Unreleased]``) to form the
|
|
804
|
+
# blank line.
|
|
805
|
+
promoted_heading = f"{promoted_heading}\n\n> {summary}\n"
|
|
806
|
+
fresh_block = FRESH_UNRELEASED_BLOCK.rstrip() + "\n\n"
|
|
807
|
+
# P1 (#730 Greptile): use a callable replacement so Python's ``re``
|
|
808
|
+
# module does NOT interpret backslash sequences in the operator's
|
|
809
|
+
# summary as group backreferences (``\1``-``\9``, ``\g<name>``).
|
|
810
|
+
# ``_UNRELEASED_RE`` has no capture groups, so a literal-string
|
|
811
|
+
# replacement containing e.g. ``"\\1"`` would raise an uncaught
|
|
812
|
+
# ``re.error: invalid group reference`` -- ugly traceback that
|
|
813
|
+
# bypasses the ``ValueError`` newline guard. A lambda repl returns
|
|
814
|
+
# the value verbatim and skips all backslash interpretation.
|
|
815
|
+
replacement = fresh_block + promoted_heading
|
|
816
|
+
new_body, count = _UNRELEASED_RE.subn(
|
|
817
|
+
lambda _match: replacement,
|
|
818
|
+
body,
|
|
819
|
+
count=1,
|
|
820
|
+
)
|
|
821
|
+
if count != 1:
|
|
822
|
+
raise ValueError("Failed to locate exactly one '## [Unreleased]' heading.")
|
|
823
|
+
|
|
824
|
+
# Refresh the link footer.
|
|
825
|
+
prev = _extract_previous_version(footer)
|
|
826
|
+
new_unreleased_link = (
|
|
827
|
+
f"[Unreleased]: https://github.com/{repo}/compare/v{version}...HEAD"
|
|
828
|
+
)
|
|
829
|
+
if prev:
|
|
830
|
+
version_link = (
|
|
831
|
+
f"[{version}]: https://github.com/{repo}/compare/v{prev}...v{version}"
|
|
832
|
+
)
|
|
833
|
+
else:
|
|
834
|
+
version_link = (
|
|
835
|
+
f"[{version}]: https://github.com/{repo}/releases/tag/v{version}"
|
|
836
|
+
)
|
|
837
|
+
if footer:
|
|
838
|
+
footer_lines = footer.splitlines(keepends=True)
|
|
839
|
+
# Replace the existing [Unreleased]: line (assumed first link) and
|
|
840
|
+
# prepend the new version-link line immediately after it.
|
|
841
|
+
replaced = False
|
|
842
|
+
new_footer_lines: list[str] = []
|
|
843
|
+
for line in footer_lines:
|
|
844
|
+
if not replaced and line.startswith("[Unreleased]:"):
|
|
845
|
+
new_footer_lines.append(new_unreleased_link + "\n")
|
|
846
|
+
new_footer_lines.append(version_link + "\n")
|
|
847
|
+
replaced = True
|
|
848
|
+
continue
|
|
849
|
+
new_footer_lines.append(line)
|
|
850
|
+
if not replaced:
|
|
851
|
+
# No prior [Unreleased]: line; prepend both lines.
|
|
852
|
+
new_footer_lines = [new_unreleased_link + "\n", version_link + "\n"] + footer_lines
|
|
853
|
+
new_footer = "".join(new_footer_lines)
|
|
854
|
+
else:
|
|
855
|
+
new_footer = new_unreleased_link + "\n" + version_link + "\n"
|
|
856
|
+
|
|
857
|
+
return new_body + new_footer
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _section_for_version(text: str, version: str) -> str:
|
|
861
|
+
"""Extract the body of ``## [<version>] - <date>`` for use as release notes."""
|
|
862
|
+
pattern = re.compile(
|
|
863
|
+
rf"^##\s+\[{re.escape(version)}\][^\n]*\n(?P<body>.*?)(?=^##\s+\[|\Z)",
|
|
864
|
+
re.MULTILINE | re.DOTALL,
|
|
865
|
+
)
|
|
866
|
+
match = pattern.search(text)
|
|
867
|
+
if not match:
|
|
868
|
+
return ""
|
|
869
|
+
return match.group("body").strip()
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _prepend_upgrade_banner(notes: str, repo: str, project_root: Path) -> str:
|
|
873
|
+
"""Lead maintainer-mode GitHub release notes with the upgrade banner (#1413).
|
|
874
|
+
|
|
875
|
+
Pure function: given the assembled release ``notes``, the resolved
|
|
876
|
+
``repo`` slug, and the ``project_root``, return ``banner + "\\n\\n" +
|
|
877
|
+
notes`` when BOTH conditions hold:
|
|
878
|
+
|
|
879
|
+
1. **Maintainer mode** -- ``repo`` is the canonical directive framework
|
|
880
|
+
slug (``DEFAULT_REPO`` == ``deftai/directive``). A consumer-mode
|
|
881
|
+
cut (any other ``owner/repo``) returns ``notes`` unchanged so a
|
|
882
|
+
downstream project that vendors the release pipeline never inherits
|
|
883
|
+
deft's upgrade guidance.
|
|
884
|
+
2. **Template present** -- the editable banner template exists and is
|
|
885
|
+
readable at ``<project_root>/.github/release-notes/upgrade-banner.md``.
|
|
886
|
+
|
|
887
|
+
The banner is GitHub-release-body-only: it is prepended to the notes
|
|
888
|
+
passed to ``create_github_release`` and is NEVER written back into
|
|
889
|
+
CHANGELOG.md. Line endings in the template are normalised to ``\\n``
|
|
890
|
+
and the trailing whitespace is trimmed so the banner joins the notes
|
|
891
|
+
with exactly one blank line regardless of how the template was saved
|
|
892
|
+
(CRLF on a Windows checkout, etc.).
|
|
893
|
+
|
|
894
|
+
Graceful degradation: a missing or unreadable template returns
|
|
895
|
+
``notes`` unchanged and NEVER raises -- a release must not be blocked
|
|
896
|
+
because the optional banner could not be loaded.
|
|
897
|
+
"""
|
|
898
|
+
if repo != DEFAULT_REPO:
|
|
899
|
+
return notes
|
|
900
|
+
banner_path = project_root / _UPGRADE_BANNER_RELPATH
|
|
901
|
+
try:
|
|
902
|
+
banner = banner_path.read_text(encoding="utf-8")
|
|
903
|
+
except OSError:
|
|
904
|
+
# Missing / unreadable template (FileNotFoundError, PermissionError,
|
|
905
|
+
# IsADirectoryError, ...). The banner is best-effort; never block a
|
|
906
|
+
# release on its absence.
|
|
907
|
+
return notes
|
|
908
|
+
banner = banner.replace("\r\n", "\n").strip()
|
|
909
|
+
if not banner:
|
|
910
|
+
return notes
|
|
911
|
+
return f"{banner}\n\n{notes}"
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
# ---- Step 5 -- ROADMAP refresh ---------------------------------------------
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def refresh_roadmap(project_root: Path) -> tuple[bool, str]:
|
|
918
|
+
"""Re-render ROADMAP.md via the Python roadmap renderer.
|
|
919
|
+
|
|
920
|
+
``scripts/roadmap_render.py`` already aggregates ``vbrief/pending/``
|
|
921
|
+
(Active) and ``vbrief/completed/`` (Completed) idempotently, so the
|
|
922
|
+
release script trusts the renderer rather than mutating the file
|
|
923
|
+
directly. vBRIEFs that should appear in ``## Completed`` are expected
|
|
924
|
+
to have been moved via ``task scope:complete`` in advance.
|
|
925
|
+
"""
|
|
926
|
+
import roadmap_render # noqa: PLC0415
|
|
927
|
+
|
|
928
|
+
ok, msg = roadmap_render.render_roadmap(
|
|
929
|
+
str(project_root / "vbrief" / "pending"),
|
|
930
|
+
str(project_root / "ROADMAP.md"),
|
|
931
|
+
completed_dir=str(project_root / "vbrief" / "completed"),
|
|
932
|
+
)
|
|
933
|
+
if not ok:
|
|
934
|
+
return False, f"roadmap:render failed: {msg}"
|
|
935
|
+
return True, "ROADMAP.md re-rendered"
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
# ---- Step 6 -- build dist --------------------------------------------------
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def run_build(project_root: Path, version: str | None = None) -> tuple[bool, str]:
|
|
942
|
+
"""Run ``deft build`` for the release, pinning the artifact version (#723).
|
|
943
|
+
|
|
944
|
+
The Taskfile resolves its ``VERSION`` variable via the inline POSIX
|
|
945
|
+
``sh:`` block in ``Taskfile.yml`` ``vars: VERSION``, which honors
|
|
946
|
+
``DEFT_RELEASE_VERSION`` over the latest annotated git tag (mirrored
|
|
947
|
+
in ``scripts/resolve_version.py`` for Python callers + tests).
|
|
948
|
+
Setting the env var here makes the in-flight release version (e.g.
|
|
949
|
+
``0.21.0``) the canonical source for the artifact filename so
|
|
950
|
+
``dist/deft-{version}.zip`` always matches the requested release
|
|
951
|
+
rather than a stale Taskfile literal or the most-recent tag (which
|
|
952
|
+
lags the in-flight tag during ``task release``).
|
|
953
|
+
|
|
954
|
+
``version`` may be ``None`` for callers that want the resolver
|
|
955
|
+
default (git tag -> dev fallback). When ``version`` is falsy, any
|
|
956
|
+
inherited ``DEFT_RELEASE_VERSION`` value is explicitly stripped from
|
|
957
|
+
the subprocess env -- otherwise a stale value leaked from the parent
|
|
958
|
+
shell (e.g. an interrupted prior ``task release`` run that exported
|
|
959
|
+
the var into the operator's session) would silently re-introduce the
|
|
960
|
+
exact stale-version bug #723 just closed.
|
|
961
|
+
|
|
962
|
+
Contract:
|
|
963
|
+
- ``version`` truthy: subprocess env carries
|
|
964
|
+
``DEFT_RELEASE_VERSION=<version>``.
|
|
965
|
+
- ``version`` falsy / ``None``: subprocess env carries NO
|
|
966
|
+
``DEFT_RELEASE_VERSION`` (any inherited value is removed).
|
|
967
|
+
"""
|
|
968
|
+
if version:
|
|
969
|
+
previous = os.environ.get("DEFT_RELEASE_VERSION")
|
|
970
|
+
os.environ["DEFT_RELEASE_VERSION"] = version
|
|
971
|
+
else:
|
|
972
|
+
previous = os.environ.pop("DEFT_RELEASE_VERSION", None)
|
|
973
|
+
# Strip any inherited value so version=None means "let the resolver
|
|
974
|
+
# decide" (git tag -> dev fallback) and never
|
|
975
|
+
# "use whatever leaked from the parent shell" -- see #723.
|
|
976
|
+
try:
|
|
977
|
+
result = run_framework_command(
|
|
978
|
+
"build",
|
|
979
|
+
project_root=project_root,
|
|
980
|
+
framework_root=project_root,
|
|
981
|
+
)
|
|
982
|
+
finally:
|
|
983
|
+
if previous is None:
|
|
984
|
+
os.environ.pop("DEFT_RELEASE_VERSION", None)
|
|
985
|
+
else:
|
|
986
|
+
os.environ["DEFT_RELEASE_VERSION"] = previous
|
|
987
|
+
if result.code != 0:
|
|
988
|
+
return False, f"build failed (exit {result.code})"
|
|
989
|
+
suffix = f" (DEFT_RELEASE_VERSION={version})" if version else ""
|
|
990
|
+
return True, f"build ran clean{suffix}"
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
# ---- Step 5 -- pyproject.toml [project].version sync (#771) ----------------
|
|
994
|
+
|
|
995
|
+
# Single ``version = "X.Y.Z"`` line under the ``[project]`` section. We do
|
|
996
|
+
# NOT use ``tomllib`` to write because it is read-only in stdlib, and we do
|
|
997
|
+
# NOT bring in a TOML writer dep just to flip one literal -- the regex
|
|
998
|
+
# below targets the canonical Keep-a-pyproject ``[project]`` block shape
|
|
999
|
+
# (the same shape ``uv init`` / PEP 621 examples emit) and rewrites only
|
|
1000
|
+
# the FIRST ``version = "..."`` line that follows the ``[project]`` table
|
|
1001
|
+
# header. Other ``version`` keys (e.g. inside ``[tool.poetry]`` / vendored
|
|
1002
|
+
# tool configs) are left untouched.
|
|
1003
|
+
_PYPROJECT_VERSION_LINE_RE = re.compile(r'version\s*=\s*"[^"]*"')
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def update_pyproject_version(text: str, version: str) -> str:
|
|
1007
|
+
"""Rewrite ``[project].version`` in pyproject.toml content (#771).
|
|
1008
|
+
|
|
1009
|
+
Pure function: takes the full file content + the resolved release
|
|
1010
|
+
version (PEP 440-normalized; the caller is responsible for the
|
|
1011
|
+
normalization, see ``scripts.resolve_version.to_pep440``) and
|
|
1012
|
+
returns the new content. Operates on the FIRST ``version = "..."``
|
|
1013
|
+
line under the ``[project]`` section; sub-tables (e.g.
|
|
1014
|
+
``[tool.poetry]`` ``version``) are intentionally untouched.
|
|
1015
|
+
|
|
1016
|
+
Idempotent: if the line is already at the requested version, the
|
|
1017
|
+
return value equals ``text`` byte-for-byte.
|
|
1018
|
+
|
|
1019
|
+
Raises ``ValueError`` when the input has no ``[project]`` section or
|
|
1020
|
+
the section has no ``version`` key -- the release pipeline treats
|
|
1021
|
+
this as a config error so misconfigured projects do not silently
|
|
1022
|
+
skip the sync.
|
|
1023
|
+
"""
|
|
1024
|
+
if not isinstance(text, str):
|
|
1025
|
+
raise ValueError(f"text must be a string, got {type(text).__name__}")
|
|
1026
|
+
if not isinstance(version, str) or not version.strip():
|
|
1027
|
+
raise ValueError("version must be a non-empty string")
|
|
1028
|
+
|
|
1029
|
+
lines = text.splitlines(keepends=True)
|
|
1030
|
+
in_project_section = False
|
|
1031
|
+
for idx, line in enumerate(lines):
|
|
1032
|
+
stripped = line.strip()
|
|
1033
|
+
# Comment / blank lines do not change section state.
|
|
1034
|
+
if not stripped or stripped.startswith("#"):
|
|
1035
|
+
continue
|
|
1036
|
+
# Detect a TOML table header. Match exactly ``[project]`` (not
|
|
1037
|
+
# ``[project.scripts]`` etc.) -- those subtables can carry their
|
|
1038
|
+
# own ``version`` keys we MUST NOT clobber.
|
|
1039
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
1040
|
+
in_project_section = stripped == "[project]"
|
|
1041
|
+
continue
|
|
1042
|
+
if in_project_section and _PYPROJECT_VERSION_LINE_RE.match(stripped):
|
|
1043
|
+
new_line = _PYPROJECT_VERSION_LINE_RE.sub(
|
|
1044
|
+
f'version = "{version}"', line, count=1
|
|
1045
|
+
)
|
|
1046
|
+
if new_line == line:
|
|
1047
|
+
return text
|
|
1048
|
+
lines[idx] = new_line
|
|
1049
|
+
return "".join(lines)
|
|
1050
|
+
raise ValueError(
|
|
1051
|
+
"pyproject.toml has no [project] section with a version key"
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
# ---- Step 7/8 -- commit + tag + push ---------------------------------------
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
# Files written by the release pipeline (steps 4 + 5) that MUST be committed
|
|
1059
|
+
# before tagging so the annotated tag and GitHub release point at the
|
|
1060
|
+
# CHANGELOG-promoted / ROADMAP-refreshed commit (#74 Greptile P1).
|
|
1061
|
+
#
|
|
1062
|
+
# ``pyproject.toml`` joins the set in #771 because Step 5 now also syncs
|
|
1063
|
+
# ``[project].version`` from the resolved release version (PEP 440
|
|
1064
|
+
# normalized via ``scripts.resolve_version.to_pep440``). The helper
|
|
1065
|
+
# below stages it conditionally on existence so projects without a
|
|
1066
|
+
# pyproject (the synthetic test fixtures) keep working unchanged.
|
|
1067
|
+
#
|
|
1068
|
+
# ``uv.lock`` joins the set in #774 (Greptile P1) because Step 5 now
|
|
1069
|
+
# also runs ``uv lock`` to regenerate the lockfile after the pyproject
|
|
1070
|
+
# version write -- without staging it the released tag would record a
|
|
1071
|
+
# pyproject at the new version and a uv.lock still pinning the old
|
|
1072
|
+
# version, causing every subsequent ``uv lock --check`` (and any
|
|
1073
|
+
# downstream ``uv sync --frozen`` consumer) to fail post-pipeline.
|
|
1074
|
+
_RELEASE_ARTIFACTS = ("CHANGELOG.md", "ROADMAP.md", "pyproject.toml", "uv.lock")
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _release_commit_subject(version: str) -> str:
|
|
1078
|
+
"""Return the canonical subject line for the release commit."""
|
|
1079
|
+
return f"chore(release): v{version} -- promote CHANGELOG + ROADMAP"
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def commit_release_artifacts(
|
|
1083
|
+
project_root: Path, version: str
|
|
1084
|
+
) -> tuple[bool, str]:
|
|
1085
|
+
"""Stage and commit CHANGELOG.md / ROADMAP.md before tagging.
|
|
1086
|
+
|
|
1087
|
+
Without this step the annotated tag would land on the pre-release HEAD
|
|
1088
|
+
commit -- meaning the tagged commit and GitHub release would be anchored
|
|
1089
|
+
to content that predates the CHANGELOG promotion, AND the working tree
|
|
1090
|
+
would remain dirty after the pipeline (#74 Greptile P1).
|
|
1091
|
+
|
|
1092
|
+
Stages only the canonical release artifacts (CHANGELOG.md / ROADMAP.md)
|
|
1093
|
+
so any unrelated changes the operator left in the tree are NOT silently
|
|
1094
|
+
swept into the release commit. If neither file actually changed, the
|
|
1095
|
+
function reports a clean no-op so callers can proceed to tagging without
|
|
1096
|
+
a bogus empty commit.
|
|
1097
|
+
"""
|
|
1098
|
+
paths_to_stage = [
|
|
1099
|
+
path
|
|
1100
|
+
for path in _RELEASE_ARTIFACTS
|
|
1101
|
+
if (project_root / path).is_file()
|
|
1102
|
+
]
|
|
1103
|
+
if not paths_to_stage:
|
|
1104
|
+
return True, "no release artifacts to commit (none exist)"
|
|
1105
|
+
|
|
1106
|
+
add = _run_git(project_root, "add", "--", *paths_to_stage)
|
|
1107
|
+
if add.returncode != 0:
|
|
1108
|
+
return False, f"git add failed: {add.stderr.strip()}"
|
|
1109
|
+
|
|
1110
|
+
# Confirm something is actually staged before committing -- a no-op
|
|
1111
|
+
# `git commit` would otherwise return non-zero with "nothing to commit".
|
|
1112
|
+
diff = _run_git(project_root, "diff", "--cached", "--quiet")
|
|
1113
|
+
if diff.returncode == 0:
|
|
1114
|
+
return True, "release artifacts already up-to-date; no commit needed"
|
|
1115
|
+
|
|
1116
|
+
subject = _release_commit_subject(version)
|
|
1117
|
+
# #867: pass DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1 in subprocess env so the
|
|
1118
|
+
# #747 pre-commit hook recognises the release pipeline as the canonical
|
|
1119
|
+
# authorised commit-on-master path; parent os.environ is left untouched.
|
|
1120
|
+
commit = _run_git(
|
|
1121
|
+
project_root, "commit", "-m", subject, env=_release_subprocess_env()
|
|
1122
|
+
)
|
|
1123
|
+
if commit.returncode != 0:
|
|
1124
|
+
return False, f"git commit failed: {commit.stderr.strip()}"
|
|
1125
|
+
return True, f"committed release artifacts ({subject})"
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def create_tag(project_root: Path, version: str) -> tuple[bool, str]:
|
|
1129
|
+
tag = f"v{version}"
|
|
1130
|
+
# #867: pass DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1 -- defence-in-depth in case
|
|
1131
|
+
# a future tag-side hook is wired into the #747 enforcement surface.
|
|
1132
|
+
result = _run_git(
|
|
1133
|
+
project_root,
|
|
1134
|
+
"tag",
|
|
1135
|
+
"-a",
|
|
1136
|
+
tag,
|
|
1137
|
+
"-m",
|
|
1138
|
+
f"Release {tag}",
|
|
1139
|
+
env=_release_subprocess_env(),
|
|
1140
|
+
)
|
|
1141
|
+
if result.returncode != 0:
|
|
1142
|
+
return False, f"git tag failed: {result.stderr.strip()}"
|
|
1143
|
+
return True, f"created tag {tag}"
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def push_release(
|
|
1147
|
+
project_root: Path, version: str, base_branch: str
|
|
1148
|
+
) -> tuple[bool, str]:
|
|
1149
|
+
"""Push the release commit + the annotated tag to ``origin`` atomically.
|
|
1150
|
+
|
|
1151
|
+
The branch update is published BEFORE the tag (`--atomic`) so the tag
|
|
1152
|
+
always resolves to a publicly-fetchable commit on ``origin/<base>``.
|
|
1153
|
+
Without the branch push the tag would dangle on origin until the next
|
|
1154
|
+
push of the branch, breaking ``gh release create --notes-from-tag`` and
|
|
1155
|
+
`git describe` for downstream consumers.
|
|
1156
|
+
"""
|
|
1157
|
+
tag = f"v{version}"
|
|
1158
|
+
# #867: pass DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1 in subprocess env so the
|
|
1159
|
+
# #747 pre-push hook recognises the release pipeline as the canonical
|
|
1160
|
+
# authorised push-from-master path; parent os.environ is left untouched.
|
|
1161
|
+
result = _run_git(
|
|
1162
|
+
project_root,
|
|
1163
|
+
"push",
|
|
1164
|
+
"--atomic",
|
|
1165
|
+
"origin",
|
|
1166
|
+
base_branch,
|
|
1167
|
+
tag,
|
|
1168
|
+
env=_release_subprocess_env(),
|
|
1169
|
+
)
|
|
1170
|
+
if result.returncode != 0:
|
|
1171
|
+
return False, f"git push failed: {result.stderr.strip()}"
|
|
1172
|
+
return True, f"pushed {base_branch} + {tag} to origin"
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
# Backwards-compatible alias for callers (and tests) that still reference
|
|
1176
|
+
# the original symbol name.
|
|
1177
|
+
def push_tag(project_root: Path, version: str) -> tuple[bool, str]:
|
|
1178
|
+
"""Deprecated alias kept for backwards compatibility.
|
|
1179
|
+
|
|
1180
|
+
Prefer ``push_release`` which atomically pushes the release branch and
|
|
1181
|
+
its annotated tag together (#74 Greptile P1). This shim exists so
|
|
1182
|
+
pre-existing callers that reference ``push_tag`` continue to work; new
|
|
1183
|
+
code MUST call ``push_release`` directly.
|
|
1184
|
+
"""
|
|
1185
|
+
return push_release(project_root, version, DEFAULT_BASE_BRANCH)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
# ---- Step 9 -- gh release create -------------------------------------------
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def create_github_release(
|
|
1192
|
+
project_root: Path,
|
|
1193
|
+
version: str,
|
|
1194
|
+
repo: str,
|
|
1195
|
+
notes: str,
|
|
1196
|
+
*,
|
|
1197
|
+
draft: bool = True,
|
|
1198
|
+
prerelease: bool = False,
|
|
1199
|
+
) -> tuple[bool, str]:
|
|
1200
|
+
"""Create the GitHub release tagged ``v<version>``.
|
|
1201
|
+
|
|
1202
|
+
``draft`` defaults to True (#716 safety hardening): the release is
|
|
1203
|
+
created in draft state so binaries upload via release.yml CI but the
|
|
1204
|
+
artifact is not yet visible to consumers. ``task release:publish --
|
|
1205
|
+
<version>`` flips the draft to public after manual review.
|
|
1206
|
+
|
|
1207
|
+
``prerelease`` defaults to False; when True the release is created
|
|
1208
|
+
with ``--prerelease`` so SemVer pre-release tags (``-rc.N`` / ``-beta.N``
|
|
1209
|
+
/ ``-alpha.N``) are flagged as GitHub pre-releases automatically (#425).
|
|
1210
|
+
Callers derive the boolean from the tag via ``is_prerelease_tag`` so this
|
|
1211
|
+
path agrees with the workflow-side ``prerelease: ${{ contains(...) }}``.
|
|
1212
|
+
|
|
1213
|
+
Notes-file path (#731): when ``notes`` is non-empty we materialise
|
|
1214
|
+
it to a UTF-8 temp file and pass ``--notes-file <path>`` to ``gh``
|
|
1215
|
+
rather than ``--notes "<text>"``. Inlining a multi-KB CHANGELOG
|
|
1216
|
+
section as a single argv element overflows the Windows command-line
|
|
1217
|
+
buffer (~32 KB) and surfaces from CreateProcess as
|
|
1218
|
+
``FileNotFoundError(winerror=206, ERROR_FILENAME_EXCED_RANGE)``. The
|
|
1219
|
+
temp file is cleaned up in a ``try/finally`` regardless of
|
|
1220
|
+
subprocess outcome (success, non-zero exit, FileNotFoundError, any
|
|
1221
|
+
other exception). When ``notes`` is empty we fall through to
|
|
1222
|
+
``--generate-notes`` (gh-side auto-generation from PR titles since
|
|
1223
|
+
the previous tag) so the release body is never blank.
|
|
1224
|
+
"""
|
|
1225
|
+
gh_path = _resolve_gh()
|
|
1226
|
+
if gh_path is None:
|
|
1227
|
+
return False, "gh CLI not found on PATH"
|
|
1228
|
+
tag = f"v{version}"
|
|
1229
|
+
cmd = [
|
|
1230
|
+
gh_path, "release", "create", tag,
|
|
1231
|
+
"--repo", repo,
|
|
1232
|
+
"--title", tag,
|
|
1233
|
+
]
|
|
1234
|
+
if draft:
|
|
1235
|
+
cmd.append("--draft")
|
|
1236
|
+
if prerelease:
|
|
1237
|
+
cmd.append("--prerelease")
|
|
1238
|
+
|
|
1239
|
+
# Materialise notes to a UTF-8 temp file when non-empty so the
|
|
1240
|
+
# gh release create command line stays well under the OS argv cap
|
|
1241
|
+
# (~32 KB on Windows; ARG_MAX 128 KB-2 MB elsewhere). The previous
|
|
1242
|
+
# ``--notes <text>`` shape blew up on the v0.21.0 e2e cut against
|
|
1243
|
+
# deft's own CHANGELOG (#731).
|
|
1244
|
+
notes_file: Path | None = None
|
|
1245
|
+
if notes:
|
|
1246
|
+
# delete=False because we close the handle BEFORE invoking gh:
|
|
1247
|
+
# Windows holds an exclusive lock on a NamedTemporaryFile while
|
|
1248
|
+
# it is open, which would prevent gh from reading the file.
|
|
1249
|
+
# Cleanup happens in the finally block below.
|
|
1250
|
+
#
|
|
1251
|
+
# Greptile P2 (#732 review): assign ``notes_file`` BEFORE the
|
|
1252
|
+
# write so the outer ``finally`` cleanup can still find the
|
|
1253
|
+
# path if ``fh.write(notes)`` raises (e.g. disk-full OSError).
|
|
1254
|
+
# The file already exists on disk at this point (delete=False),
|
|
1255
|
+
# so leaving ``notes_file = None`` would orphan the temp file.
|
|
1256
|
+
with tempfile.NamedTemporaryFile(
|
|
1257
|
+
mode="w",
|
|
1258
|
+
encoding="utf-8",
|
|
1259
|
+
newline="",
|
|
1260
|
+
suffix=".md",
|
|
1261
|
+
delete=False,
|
|
1262
|
+
) as fh:
|
|
1263
|
+
notes_file = Path(fh.name)
|
|
1264
|
+
fh.write(notes)
|
|
1265
|
+
cmd.extend(["--notes-file", str(notes_file)])
|
|
1266
|
+
else:
|
|
1267
|
+
cmd.append("--generate-notes")
|
|
1268
|
+
|
|
1269
|
+
try:
|
|
1270
|
+
try:
|
|
1271
|
+
result = subprocess.run(
|
|
1272
|
+
cmd,
|
|
1273
|
+
cwd=str(project_root),
|
|
1274
|
+
capture_output=True,
|
|
1275
|
+
text=True,
|
|
1276
|
+
timeout=120,
|
|
1277
|
+
check=False,
|
|
1278
|
+
env=os.environ.copy(),
|
|
1279
|
+
)
|
|
1280
|
+
except FileNotFoundError as exc:
|
|
1281
|
+
# Windows error 206 (ERROR_FILENAME_EXCED_RANGE) surfaces as
|
|
1282
|
+
# FileNotFoundError because Python's CreateProcess wrapper
|
|
1283
|
+
# maps it that way. Distinguish the cmd-line-overflow case
|
|
1284
|
+
# from a genuinely missing gh binary so operators see an
|
|
1285
|
+
# accurate diagnostic instead of being mis-pointed at the
|
|
1286
|
+
# #722 PATHEXT shim (#731).
|
|
1287
|
+
if getattr(exc, "winerror", None) == 206:
|
|
1288
|
+
return False, (
|
|
1289
|
+
"gh release create command line exceeded Windows "
|
|
1290
|
+
"limit (winerror 206, ERROR_FILENAME_EXCED_RANGE). "
|
|
1291
|
+
"This should be mitigated by the --notes-file "
|
|
1292
|
+
"switch landed in #731 -- if you still see this "
|
|
1293
|
+
"with notes already in a file, file a follow-up."
|
|
1294
|
+
)
|
|
1295
|
+
return False, "gh CLI not found on PATH"
|
|
1296
|
+
if result.returncode != 0:
|
|
1297
|
+
return False, f"gh release create failed: {result.stderr.strip()}"
|
|
1298
|
+
flags = [
|
|
1299
|
+
label
|
|
1300
|
+
for label, enabled in (("draft", draft), ("prerelease", prerelease))
|
|
1301
|
+
if enabled
|
|
1302
|
+
]
|
|
1303
|
+
suffix = f" ({', '.join(flags)})" if flags else ""
|
|
1304
|
+
return True, f"created GitHub release {tag}{suffix}"
|
|
1305
|
+
finally:
|
|
1306
|
+
if notes_file is not None:
|
|
1307
|
+
# Cleanup is best-effort; an undeleted temp file in the OS
|
|
1308
|
+
# temp dir is a housekeeping issue, not a release-pipeline
|
|
1309
|
+
# failure (ruff SIM105: contextlib.suppress over try/pass).
|
|
1310
|
+
with contextlib.suppress(OSError):
|
|
1311
|
+
notes_file.unlink(missing_ok=True)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
# ---- Step 11 -- post-create verify-isDraft gate (#724) ---------------------
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
VERIFY_DRAFT_MAX_ATTEMPTS = 5
|
|
1318
|
+
VERIFY_DRAFT_INTERVAL_SECONDS = 1.0
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _gh_release_view_is_draft(
|
|
1322
|
+
gh_path: str, version: str, repo: str, project_root: Path
|
|
1323
|
+
) -> tuple[str, str]:
|
|
1324
|
+
"""Return ``(state, detail)`` for a single isDraft probe.
|
|
1325
|
+
|
|
1326
|
+
``state`` is one of:
|
|
1327
|
+
- ``"draft"``: release exists with isDraft=true (verified safe).
|
|
1328
|
+
- ``"public"``: release exists with isDraft=false (defense-in-depth
|
|
1329
|
+
flip required).
|
|
1330
|
+
- ``"not-found"``: gh reported the release does not exist yet.
|
|
1331
|
+
- ``"error"``: gh failed for an unrelated reason; ``detail`` carries
|
|
1332
|
+
the stderr line.
|
|
1333
|
+
"""
|
|
1334
|
+
tag = f"v{version}"
|
|
1335
|
+
cmd = [
|
|
1336
|
+
gh_path, "release", "view", tag,
|
|
1337
|
+
"--repo", repo,
|
|
1338
|
+
"--json", "isDraft",
|
|
1339
|
+
]
|
|
1340
|
+
try:
|
|
1341
|
+
result = subprocess.run(
|
|
1342
|
+
cmd,
|
|
1343
|
+
cwd=str(project_root),
|
|
1344
|
+
capture_output=True,
|
|
1345
|
+
text=True,
|
|
1346
|
+
timeout=30,
|
|
1347
|
+
check=False,
|
|
1348
|
+
env=os.environ.copy(),
|
|
1349
|
+
)
|
|
1350
|
+
except FileNotFoundError:
|
|
1351
|
+
return "error", "gh CLI not found on PATH"
|
|
1352
|
+
except subprocess.TimeoutExpired:
|
|
1353
|
+
return "error", "gh release view timed out"
|
|
1354
|
+
if result.returncode != 0:
|
|
1355
|
+
stderr = (result.stderr or "").strip()
|
|
1356
|
+
# gh exits non-zero with a "release not found" / "not found"
|
|
1357
|
+
# diagnostic when the tag has no release yet -- treat that as
|
|
1358
|
+
# the not-found state so the verify gate can keep polling.
|
|
1359
|
+
if "not found" in stderr.lower() or "release not found" in stderr.lower():
|
|
1360
|
+
return "not-found", stderr
|
|
1361
|
+
return "error", stderr
|
|
1362
|
+
try:
|
|
1363
|
+
payload = json.loads(result.stdout or "{}")
|
|
1364
|
+
except json.JSONDecodeError as exc:
|
|
1365
|
+
return "error", f"unparseable gh JSON: {exc}"
|
|
1366
|
+
is_draft = payload.get("isDraft")
|
|
1367
|
+
if is_draft is True:
|
|
1368
|
+
return "draft", ""
|
|
1369
|
+
if is_draft is False:
|
|
1370
|
+
return "public", ""
|
|
1371
|
+
return "error", f"isDraft missing from gh response: {payload!r}"
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _gh_release_flip_to_draft(
|
|
1375
|
+
gh_path: str, version: str, repo: str, project_root: Path
|
|
1376
|
+
) -> tuple[bool, str]:
|
|
1377
|
+
"""Invoke ``gh release edit v<version> --draft=true``."""
|
|
1378
|
+
tag = f"v{version}"
|
|
1379
|
+
cmd = [
|
|
1380
|
+
gh_path, "release", "edit", tag,
|
|
1381
|
+
"--repo", repo,
|
|
1382
|
+
"--draft=true",
|
|
1383
|
+
]
|
|
1384
|
+
try:
|
|
1385
|
+
result = subprocess.run(
|
|
1386
|
+
cmd,
|
|
1387
|
+
cwd=str(project_root),
|
|
1388
|
+
capture_output=True,
|
|
1389
|
+
text=True,
|
|
1390
|
+
timeout=30,
|
|
1391
|
+
check=False,
|
|
1392
|
+
env=os.environ.copy(),
|
|
1393
|
+
)
|
|
1394
|
+
except FileNotFoundError:
|
|
1395
|
+
return False, "gh CLI not found on PATH"
|
|
1396
|
+
except subprocess.TimeoutExpired:
|
|
1397
|
+
return False, "gh release edit timed out"
|
|
1398
|
+
if result.returncode != 0:
|
|
1399
|
+
return False, f"gh release edit failed: {(result.stderr or '').strip()}"
|
|
1400
|
+
return True, f"flipped {tag} to draft"
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def verify_release_draft(
|
|
1404
|
+
project_root: Path,
|
|
1405
|
+
version: str,
|
|
1406
|
+
repo: str,
|
|
1407
|
+
*,
|
|
1408
|
+
max_attempts: int = VERIFY_DRAFT_MAX_ATTEMPTS,
|
|
1409
|
+
interval: float = VERIFY_DRAFT_INTERVAL_SECONDS,
|
|
1410
|
+
sleep: Callable[[float], None] | None = None,
|
|
1411
|
+
) -> tuple[bool, str]:
|
|
1412
|
+
"""Verify the freshly-created release actually landed in draft state (#724).
|
|
1413
|
+
|
|
1414
|
+
Polls ``gh release view v<version> --json isDraft`` up to
|
|
1415
|
+
``max_attempts`` times with ``interval`` seconds between attempts (5s
|
|
1416
|
+
total budget by default). Three terminal states:
|
|
1417
|
+
|
|
1418
|
+
- ``"draft"``: release exists with ``isDraft=true``. Returns
|
|
1419
|
+
``(True, "verified draft")`` -- the happy path.
|
|
1420
|
+
- ``"public"``: release exists with ``isDraft=false``. Immediately
|
|
1421
|
+
invokes ``gh release edit --draft=true`` and emits a ``WARNING``
|
|
1422
|
+
line citing #724. Returns ``(True, "flipped to draft (...)")`` on
|
|
1423
|
+
successful flip; ``(False, ...)`` only if the flip itself fails.
|
|
1424
|
+
- ``"not-found"`` after every poll: the release record has not
|
|
1425
|
+
propagated yet (release.yml CI may still be processing). Returns
|
|
1426
|
+
``(True, "not found within budget; release.yml may still be
|
|
1427
|
+
processing")`` -- emits a WARN line but does not fail the pipeline,
|
|
1428
|
+
since the create call itself exited 0.
|
|
1429
|
+
|
|
1430
|
+
The auto-flip is defense-in-depth: it covers the case where the
|
|
1431
|
+
create command exited 0 but the release somehow landed as public
|
|
1432
|
+
(e.g. operator-error variant of #724 where an alternate code path
|
|
1433
|
+
sent the release without ``--draft``). It is a no-op on the happy
|
|
1434
|
+
path and never fires when the create call itself failed.
|
|
1435
|
+
"""
|
|
1436
|
+
if max_attempts <= 0:
|
|
1437
|
+
return True, "verify gate disabled (max_attempts <= 0)"
|
|
1438
|
+
sleep_fn = sleep if sleep is not None else time.sleep
|
|
1439
|
+
gh_path = _resolve_gh()
|
|
1440
|
+
if gh_path is None:
|
|
1441
|
+
# Surface a non-fatal warning -- the verify gate is best-effort
|
|
1442
|
+
# defense-in-depth and the create call already exited 0.
|
|
1443
|
+
print(
|
|
1444
|
+
"WARNING: cannot verify draft state (gh CLI not found on PATH); "
|
|
1445
|
+
"defense-in-depth gate skipped (see #724)",
|
|
1446
|
+
file=sys.stderr,
|
|
1447
|
+
)
|
|
1448
|
+
return True, "gh CLI not found on PATH; verify gate skipped"
|
|
1449
|
+
last_state = ""
|
|
1450
|
+
last_detail = ""
|
|
1451
|
+
for attempt in range(1, max_attempts + 1):
|
|
1452
|
+
state, detail = _gh_release_view_is_draft(
|
|
1453
|
+
gh_path, version, repo, project_root
|
|
1454
|
+
)
|
|
1455
|
+
last_state, last_detail = state, detail
|
|
1456
|
+
if state == "draft":
|
|
1457
|
+
return True, f"verified draft on attempt {attempt}/{max_attempts}"
|
|
1458
|
+
if state == "public":
|
|
1459
|
+
print(
|
|
1460
|
+
f"WARNING: release v{version} landed as public; "
|
|
1461
|
+
f"flipping to draft (defense-in-depth, see #724)",
|
|
1462
|
+
file=sys.stderr,
|
|
1463
|
+
)
|
|
1464
|
+
ok, reason = _gh_release_flip_to_draft(
|
|
1465
|
+
gh_path, version, repo, project_root
|
|
1466
|
+
)
|
|
1467
|
+
if ok:
|
|
1468
|
+
return True, f"flipped to draft ({reason})"
|
|
1469
|
+
return False, reason
|
|
1470
|
+
# not-found / error -- keep polling; sleep between attempts only
|
|
1471
|
+
# while we still have budget. ``sleep_fn`` is typed as
|
|
1472
|
+
# ``Callable[[float], None]`` so callers (production: ``time.sleep``;
|
|
1473
|
+
# tests: 1-arg stubs like ``lambda _s: None`` or
|
|
1474
|
+
# ``lambda s: sleeps.append(s)``) all accept the interval argument.
|
|
1475
|
+
if attempt < max_attempts:
|
|
1476
|
+
sleep_fn(interval)
|
|
1477
|
+
if last_state == "not-found":
|
|
1478
|
+
print(
|
|
1479
|
+
f"WARNING: release v{version} not found within "
|
|
1480
|
+
f"{max_attempts}*{interval}s budget; release.yml CI may still "
|
|
1481
|
+
f"be processing (see #724)",
|
|
1482
|
+
file=sys.stderr,
|
|
1483
|
+
)
|
|
1484
|
+
return True, "not found within budget; verify gate inconclusive"
|
|
1485
|
+
print(
|
|
1486
|
+
f"WARNING: verify gate could not confirm draft state for v{version}: "
|
|
1487
|
+
f"last state {last_state!r}; detail: {last_detail} (see #724)",
|
|
1488
|
+
file=sys.stderr,
|
|
1489
|
+
)
|
|
1490
|
+
return True, f"inconclusive ({last_state}); verify gate skipped"
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
# ---- Step 5 -- uv.lock regeneration (#774) ---------------------------------
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def run_uv_lock(project_root: Path) -> tuple[bool, str]:
|
|
1497
|
+
"""Regenerate ``uv.lock`` after the pyproject ``[project].version`` sync.
|
|
1498
|
+
|
|
1499
|
+
The release pipeline rewrites ``[project].version`` in pyproject.toml
|
|
1500
|
+
in Step 5 (#771). Without a matching ``uv lock`` invocation, the
|
|
1501
|
+
lockfile would still record the OLD version while pyproject records
|
|
1502
|
+
the NEW one -- producing a release commit + annotated tag where
|
|
1503
|
+
``uv lock --check`` (and any ``uv sync --frozen`` consumer) fails
|
|
1504
|
+
post-pipeline. Greptile P1 from #774 surfaced this gap.
|
|
1505
|
+
|
|
1506
|
+
Contract:
|
|
1507
|
+
- No pyproject.toml present -- clean skip (no lockfile to keep in
|
|
1508
|
+
sync with a missing root metadata file).
|
|
1509
|
+
- ``uv`` binary not on PATH -- clean skip with a non-fatal warning;
|
|
1510
|
+
the pipeline cannot regenerate a lockfile without the tool, but
|
|
1511
|
+
the pyproject sync itself already landed and a downstream
|
|
1512
|
+
operator can run ``uv lock`` manually before pushing the tag.
|
|
1513
|
+
- ``uv lock`` non-zero exit -- terminal failure; the operator must
|
|
1514
|
+
resolve the lock conflict before the release can ship.
|
|
1515
|
+
- Happy path -- returns ``(True, "uv.lock regenerated")``; the
|
|
1516
|
+
commit step then stages uv.lock alongside the other release
|
|
1517
|
+
artifacts (#774 _RELEASE_ARTIFACTS).
|
|
1518
|
+
"""
|
|
1519
|
+
if not (project_root / "pyproject.toml").is_file():
|
|
1520
|
+
return True, "no pyproject.toml; skipping uv lock"
|
|
1521
|
+
uv_path = shutil.which("uv")
|
|
1522
|
+
if uv_path is None:
|
|
1523
|
+
# Best-effort: surface a warning but do not fail. The pyproject
|
|
1524
|
+
# sync already succeeded; an operator running the release on a
|
|
1525
|
+
# host without uv can regenerate the lockfile manually.
|
|
1526
|
+
print(
|
|
1527
|
+
"WARNING: uv binary not on PATH; skipping uv.lock regeneration "
|
|
1528
|
+
"(see #774). Run `uv lock` manually before pushing the release tag.",
|
|
1529
|
+
file=sys.stderr,
|
|
1530
|
+
)
|
|
1531
|
+
return True, "uv binary not on PATH; skipping uv lock"
|
|
1532
|
+
try:
|
|
1533
|
+
result = subprocess.run(
|
|
1534
|
+
[uv_path, "lock"],
|
|
1535
|
+
cwd=str(project_root),
|
|
1536
|
+
capture_output=True,
|
|
1537
|
+
text=True,
|
|
1538
|
+
timeout=300,
|
|
1539
|
+
check=False,
|
|
1540
|
+
)
|
|
1541
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
1542
|
+
return False, f"uv lock failed: {exc}"
|
|
1543
|
+
if result.returncode != 0:
|
|
1544
|
+
stderr = (result.stderr or "").strip()
|
|
1545
|
+
return False, f"uv lock failed (exit {result.returncode}): {stderr}"
|
|
1546
|
+
return True, "uv.lock regenerated"
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
# ---- Step 5 -- pyproject sync helper (#771) --------------------------------
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _sync_pyproject_for_release(
|
|
1553
|
+
pyproject_path: Path,
|
|
1554
|
+
version: str,
|
|
1555
|
+
*,
|
|
1556
|
+
dry_run: bool,
|
|
1557
|
+
) -> tuple[str, str | None]:
|
|
1558
|
+
"""Compute the pyproject ``[project].version`` sync outcome (#771).
|
|
1559
|
+
|
|
1560
|
+
Returns ``(note, new_text)`` where ``note`` is a short operator-
|
|
1561
|
+
readable status string the pipeline embeds in the Step 5 label, and
|
|
1562
|
+
``new_text`` is the rewritten file content to write (``None`` when
|
|
1563
|
+
no write is required -- e.g. dry-run, missing pyproject, or
|
|
1564
|
+
non-publishable version).
|
|
1565
|
+
|
|
1566
|
+
Outcomes:
|
|
1567
|
+
- ``"pyproject [project].version -> 0.21.0"`` -- happy path
|
|
1568
|
+
- ``"pyproject already at 0.21.0"`` -- idempotent no-op
|
|
1569
|
+
- ``"no pyproject.toml; skipping sync"`` -- file absent
|
|
1570
|
+
- ``"non-publishable tag <reason>; skipping pyproject sync"`` --
|
|
1571
|
+
``test.N`` and other ``NonPublishableVersionError`` cases per
|
|
1572
|
+
``scripts.resolve_version`` Phase B
|
|
1573
|
+
- ``"FAIL (...)"`` -- terminal config error; pipeline halts
|
|
1574
|
+
|
|
1575
|
+
The release pipeline catches ``NonPublishableVersionError`` here and
|
|
1576
|
+
treats it as a clean skip rather than a failure: a disposable test
|
|
1577
|
+
tag (``v0.0.0-test.1`` from ``task release:e2e``) MUST never propagate
|
|
1578
|
+
into ``[project].version`` even if the rest of the pipeline runs.
|
|
1579
|
+
Generic ``ValueError`` (malformed ``[project]`` section, missing
|
|
1580
|
+
version key) IS terminal -- the misconfiguration must be fixed before
|
|
1581
|
+
a release can ship.
|
|
1582
|
+
"""
|
|
1583
|
+
if not pyproject_path.is_file():
|
|
1584
|
+
return "no pyproject.toml; skipping sync", None
|
|
1585
|
+
try:
|
|
1586
|
+
pep_version = to_pep440(version)
|
|
1587
|
+
except NonPublishableVersionError as exc:
|
|
1588
|
+
return (
|
|
1589
|
+
f"non-publishable tag ({exc}); skipping pyproject sync",
|
|
1590
|
+
None,
|
|
1591
|
+
)
|
|
1592
|
+
except ValueError as exc:
|
|
1593
|
+
# Malformed input -- the pipeline already validated strict
|
|
1594
|
+
# X.Y.Z via ``_validate_version``, so this branch is
|
|
1595
|
+
# defensive: if to_pep440's contract widens we surface the
|
|
1596
|
+
# parse error rather than silently skip.
|
|
1597
|
+
return f"FAIL (cannot normalize version to PEP 440: {exc})", None
|
|
1598
|
+
|
|
1599
|
+
original = pyproject_path.read_text(encoding="utf-8")
|
|
1600
|
+
try:
|
|
1601
|
+
new_text = update_pyproject_version(original, pep_version)
|
|
1602
|
+
except ValueError as exc:
|
|
1603
|
+
return f"FAIL (pyproject.toml: {exc})", None
|
|
1604
|
+
|
|
1605
|
+
if new_text == original:
|
|
1606
|
+
return f"pyproject already at {pep_version}", None
|
|
1607
|
+
if dry_run:
|
|
1608
|
+
return f"pyproject [project].version -> {pep_version}", None
|
|
1609
|
+
return f"pyproject [project].version -> {pep_version}", new_text
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
# ---- Pipeline orchestration ------------------------------------------------
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
_TOTAL_STEPS = 13
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def _emit(step: int, label: str, status: str, *, file=None) -> None:
|
|
1619
|
+
# Resolve sys.stderr at call time so test capture (pytest's capsys, which
|
|
1620
|
+
# rebinds sys.stderr per-test) sees emitted lines. Binding the default at
|
|
1621
|
+
# function-definition time would freeze the original stderr captured at
|
|
1622
|
+
# module load and bypass capsys.
|
|
1623
|
+
target = file if file is not None else sys.stderr
|
|
1624
|
+
print(f"[{step}/{_TOTAL_STEPS}] {label}... {status}", file=target)
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def run_pipeline(config: ReleaseConfig) -> int:
|
|
1628
|
+
"""Execute the release pipeline; returns the process exit code."""
|
|
1629
|
+
project_root = config.project_root
|
|
1630
|
+
version = config.version
|
|
1631
|
+
today = _today_iso()
|
|
1632
|
+
changelog_path = project_root / "CHANGELOG.md"
|
|
1633
|
+
|
|
1634
|
+
# Step 1: dirty-tree guard.
|
|
1635
|
+
label = "Pre-flight git status"
|
|
1636
|
+
if config.dry_run:
|
|
1637
|
+
_emit(1, label, f"DRYRUN (would run `git status --porcelain` in {project_root})")
|
|
1638
|
+
else:
|
|
1639
|
+
ok, output = check_git_clean(project_root)
|
|
1640
|
+
if ok:
|
|
1641
|
+
_emit(1, label, "OK (tree clean)")
|
|
1642
|
+
elif config.allow_dirty:
|
|
1643
|
+
_emit(1, label, f"WARN (dirty, --allow-dirty set):\n{output}")
|
|
1644
|
+
else:
|
|
1645
|
+
_emit(
|
|
1646
|
+
1,
|
|
1647
|
+
label,
|
|
1648
|
+
"FAIL (working tree is dirty; commit/stash or pass --allow-dirty)",
|
|
1649
|
+
)
|
|
1650
|
+
print(output, file=sys.stderr)
|
|
1651
|
+
return EXIT_VIOLATION
|
|
1652
|
+
|
|
1653
|
+
# Step 2: branch guard.
|
|
1654
|
+
label = f"Pre-flight branch == {config.base_branch}"
|
|
1655
|
+
if config.dry_run:
|
|
1656
|
+
_emit(2, label, f"DRYRUN (would assert current branch == {config.base_branch})")
|
|
1657
|
+
else:
|
|
1658
|
+
branch = current_branch(project_root)
|
|
1659
|
+
if branch == config.base_branch:
|
|
1660
|
+
_emit(2, label, f"OK (on {branch})")
|
|
1661
|
+
else:
|
|
1662
|
+
_emit(
|
|
1663
|
+
2,
|
|
1664
|
+
label,
|
|
1665
|
+
f"FAIL (on {branch!r}; expected {config.base_branch!r})",
|
|
1666
|
+
)
|
|
1667
|
+
return EXIT_VIOLATION
|
|
1668
|
+
|
|
1669
|
+
# Step 3: vBRIEF lifecycle sync (#734).
|
|
1670
|
+
label = "Pre-flight vBRIEF lifecycle sync"
|
|
1671
|
+
if config.allow_vbrief_drift:
|
|
1672
|
+
_emit(3, label, "SKIP (--allow-vbrief-drift)")
|
|
1673
|
+
elif config.dry_run:
|
|
1674
|
+
_emit(
|
|
1675
|
+
3,
|
|
1676
|
+
label,
|
|
1677
|
+
"DRYRUN (would scan vbrief/ + gh open issues for closed-issue mismatches)",
|
|
1678
|
+
)
|
|
1679
|
+
else:
|
|
1680
|
+
ok, mismatch_count, reason = check_vbrief_lifecycle_sync(
|
|
1681
|
+
project_root, config.repo
|
|
1682
|
+
)
|
|
1683
|
+
if ok:
|
|
1684
|
+
_emit(3, label, "OK (no mismatches)")
|
|
1685
|
+
elif mismatch_count == -1:
|
|
1686
|
+
_emit(3, label, f"FAIL ({reason})")
|
|
1687
|
+
return EXIT_CONFIG_ERROR
|
|
1688
|
+
else:
|
|
1689
|
+
_emit(
|
|
1690
|
+
3,
|
|
1691
|
+
label,
|
|
1692
|
+
(
|
|
1693
|
+
f"FAIL ({mismatch_count} mismatches; "
|
|
1694
|
+
"run task reconcile:issues -- --apply-lifecycle-fixes "
|
|
1695
|
+
"to fix, or pass --allow-vbrief-drift to override)"
|
|
1696
|
+
),
|
|
1697
|
+
)
|
|
1698
|
+
print(reason, file=sys.stderr)
|
|
1699
|
+
return EXIT_VIOLATION
|
|
1700
|
+
|
|
1701
|
+
# Step 4: tag availability pre-flight (#784) -- refuse early before any
|
|
1702
|
+
# state mutation when v<version> already exists locally, on origin, or
|
|
1703
|
+
# as a published GitHub release. Read-only; safe on every dry-run.
|
|
1704
|
+
label = "Pre-flight tag availability"
|
|
1705
|
+
if config.dry_run:
|
|
1706
|
+
_emit(
|
|
1707
|
+
4,
|
|
1708
|
+
label,
|
|
1709
|
+
(
|
|
1710
|
+
f"DRYRUN (would verify v{version} tag not present locally / "
|
|
1711
|
+
f"on origin / as GitHub release on {config.repo})"
|
|
1712
|
+
),
|
|
1713
|
+
)
|
|
1714
|
+
else:
|
|
1715
|
+
ok, reason = check_tag_available(version, config.repo, project_root)
|
|
1716
|
+
if ok:
|
|
1717
|
+
_emit(4, label, f"OK ({reason})")
|
|
1718
|
+
else:
|
|
1719
|
+
_emit(4, label, f"FAIL ({reason})")
|
|
1720
|
+
return EXIT_VIOLATION
|
|
1721
|
+
|
|
1722
|
+
# Step 5: CI.
|
|
1723
|
+
label = "Pre-flight CI (task ci:local | fallback task check)"
|
|
1724
|
+
if config.skip_ci:
|
|
1725
|
+
# #720: e2e rehearsal opts out -- CI is covered by the unit-test
|
|
1726
|
+
# suite at every commit on master, not by re-running it inside
|
|
1727
|
+
# the auto-created temp repo.
|
|
1728
|
+
_emit(5, label, "SKIP (--skip-ci)")
|
|
1729
|
+
elif config.dry_run:
|
|
1730
|
+
_emit(5, label, "DRYRUN (would run task ci:local with task check fallback)")
|
|
1731
|
+
else:
|
|
1732
|
+
ok, reason = run_ci(project_root)
|
|
1733
|
+
if ok:
|
|
1734
|
+
_emit(5, label, f"OK ({reason})")
|
|
1735
|
+
else:
|
|
1736
|
+
_emit(5, label, f"FAIL ({reason})")
|
|
1737
|
+
return EXIT_VIOLATION
|
|
1738
|
+
|
|
1739
|
+
# Step 6: CHANGELOG promotion.
|
|
1740
|
+
label = "CHANGELOG promotion"
|
|
1741
|
+
if not changelog_path.is_file():
|
|
1742
|
+
_emit(6, label, f"FAIL (CHANGELOG.md not found at {changelog_path})")
|
|
1743
|
+
return EXIT_CONFIG_ERROR
|
|
1744
|
+
original_changelog = changelog_path.read_text(encoding="utf-8")
|
|
1745
|
+
try:
|
|
1746
|
+
promoted_changelog = promote_changelog(
|
|
1747
|
+
original_changelog,
|
|
1748
|
+
version,
|
|
1749
|
+
config.repo,
|
|
1750
|
+
today,
|
|
1751
|
+
summary=config.summary,
|
|
1752
|
+
)
|
|
1753
|
+
except ValueError as exc:
|
|
1754
|
+
_emit(6, label, f"FAIL ({exc})")
|
|
1755
|
+
return EXIT_CONFIG_ERROR
|
|
1756
|
+
# Surface whether a summary was supplied so operators can validate
|
|
1757
|
+
# the wording during Phase 2 dry-run before any file is written
|
|
1758
|
+
# (release-narrative-gap scope vBRIEF).
|
|
1759
|
+
if config.summary:
|
|
1760
|
+
truncated = config.summary[:60]
|
|
1761
|
+
# P2 (#730 Greptile): variable name ``ellipsis`` shadows the
|
|
1762
|
+
# Python builtin (the type of ``...``). Rename to
|
|
1763
|
+
# ``truncation_suffix`` to avoid the shadow.
|
|
1764
|
+
truncation_suffix = "..." if len(config.summary) > 60 else ""
|
|
1765
|
+
summary_note = f' summary: "{truncated}{truncation_suffix}"'
|
|
1766
|
+
else:
|
|
1767
|
+
summary_note = " no summary"
|
|
1768
|
+
# #771: also sync pyproject.toml [project].version from the resolved
|
|
1769
|
+
# release version (PEP 440 normalized via
|
|
1770
|
+
# ``scripts.resolve_version.to_pep440``). Disposable / test-only tags
|
|
1771
|
+
# (``test.N``) raise ``NonPublishableVersionError`` and the sync is
|
|
1772
|
+
# explicitly skipped so PyPI / consumer-visible metadata is not
|
|
1773
|
+
# polluted with throwaway versions. The pyproject sync is bundled
|
|
1774
|
+
# into the CHANGELOG-promotion step (rather than a new step) so the
|
|
1775
|
+
# operator-readable status string surfaces the pyproject-side
|
|
1776
|
+
# outcome inline. The step number was 5 pre-#784 and is now 6 after
|
|
1777
|
+
# the new tag-availability pre-flight gate (Step 4) bumped
|
|
1778
|
+
# _TOTAL_STEPS 12 -> 13.
|
|
1779
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
1780
|
+
pyproject_note, promoted_pyproject = _sync_pyproject_for_release(
|
|
1781
|
+
pyproject_path, version, dry_run=config.dry_run
|
|
1782
|
+
)
|
|
1783
|
+
if pyproject_note.startswith("FAIL"):
|
|
1784
|
+
_emit(6, label, pyproject_note)
|
|
1785
|
+
return EXIT_CONFIG_ERROR
|
|
1786
|
+
|
|
1787
|
+
if config.dry_run:
|
|
1788
|
+
_emit(
|
|
1789
|
+
6,
|
|
1790
|
+
label,
|
|
1791
|
+
f"DRYRUN (would rewrite {changelog_path.name}: "
|
|
1792
|
+
f"## [Unreleased] -> ## [{version}] - {today}; new compare link added;"
|
|
1793
|
+
f"{summary_note}; {pyproject_note}; "
|
|
1794
|
+
f"would run `uv lock` to refresh uv.lock to {version})",
|
|
1795
|
+
)
|
|
1796
|
+
else:
|
|
1797
|
+
changelog_path.write_text(promoted_changelog, encoding="utf-8")
|
|
1798
|
+
uv_lock_note = "uv.lock unchanged (pyproject not modified)"
|
|
1799
|
+
if promoted_pyproject is not None:
|
|
1800
|
+
pyproject_path.write_text(promoted_pyproject, encoding="utf-8")
|
|
1801
|
+
# #774: pyproject [project].version was rewritten -- regenerate
|
|
1802
|
+
# uv.lock so the lockfile records the same version. Without
|
|
1803
|
+
# this every future ``task release`` produces a release
|
|
1804
|
+
# commit + tag where pyproject and uv.lock disagree and
|
|
1805
|
+
# downstream ``uv lock --check`` fails.
|
|
1806
|
+
uv_ok, uv_lock_note = run_uv_lock(project_root)
|
|
1807
|
+
if not uv_ok:
|
|
1808
|
+
_emit(6, label, f"FAIL ({uv_lock_note})")
|
|
1809
|
+
return EXIT_VIOLATION
|
|
1810
|
+
_emit(
|
|
1811
|
+
6,
|
|
1812
|
+
label,
|
|
1813
|
+
f"OK (## [{version}] - {today};{summary_note}; "
|
|
1814
|
+
f"{pyproject_note}; {uv_lock_note})",
|
|
1815
|
+
)
|
|
1816
|
+
|
|
1817
|
+
# Step 7: ROADMAP refresh.
|
|
1818
|
+
label = "ROADMAP refresh (task roadmap:render)"
|
|
1819
|
+
if config.dry_run:
|
|
1820
|
+
_emit(7, label, "DRYRUN (would run task roadmap:render)")
|
|
1821
|
+
else:
|
|
1822
|
+
ok, reason = refresh_roadmap(project_root)
|
|
1823
|
+
if ok:
|
|
1824
|
+
_emit(7, label, f"OK ({reason})")
|
|
1825
|
+
else:
|
|
1826
|
+
_emit(7, label, f"FAIL ({reason})")
|
|
1827
|
+
return EXIT_VIOLATION
|
|
1828
|
+
|
|
1829
|
+
# Step 8: build dist (#723: pin DEFT_RELEASE_VERSION so the artifact
|
|
1830
|
+
# filename matches the in-flight release version, not the stale
|
|
1831
|
+
# Taskfile literal or the most-recent git tag; #720: --skip-build
|
|
1832
|
+
# opts out for e2e rehearsals where build artefacts are not needed
|
|
1833
|
+
# for the draft-release verification step).
|
|
1834
|
+
label = f"Build dist (task build, DEFT_RELEASE_VERSION={version})"
|
|
1835
|
+
if config.skip_build:
|
|
1836
|
+
_emit(8, label, "SKIP (--skip-build)")
|
|
1837
|
+
elif config.dry_run:
|
|
1838
|
+
_emit(
|
|
1839
|
+
8,
|
|
1840
|
+
label,
|
|
1841
|
+
f"DRYRUN (would run `task build` with DEFT_RELEASE_VERSION={version})",
|
|
1842
|
+
)
|
|
1843
|
+
else:
|
|
1844
|
+
ok, reason = run_build(project_root, version)
|
|
1845
|
+
if ok:
|
|
1846
|
+
_emit(8, label, f"OK ({reason})")
|
|
1847
|
+
else:
|
|
1848
|
+
_emit(8, label, f"FAIL ({reason})")
|
|
1849
|
+
return EXIT_VIOLATION
|
|
1850
|
+
|
|
1851
|
+
# Step 9: commit release artifacts (CHANGELOG + ROADMAP) before tagging
|
|
1852
|
+
# so the annotated tag and GitHub release anchor at the promoted commit
|
|
1853
|
+
# rather than the pre-release HEAD (#74 Greptile P1). Skipped together
|
|
1854
|
+
# with tagging when --skip-tag is set, since a committed-but-untagged
|
|
1855
|
+
# state would still leave the working tree dirty post-pipeline.
|
|
1856
|
+
label = f"Commit release artifacts ({', '.join(_RELEASE_ARTIFACTS)})"
|
|
1857
|
+
if config.skip_tag:
|
|
1858
|
+
_emit(9, label, "SKIP (--skip-tag)")
|
|
1859
|
+
elif config.dry_run:
|
|
1860
|
+
_emit(
|
|
1861
|
+
9,
|
|
1862
|
+
label,
|
|
1863
|
+
f"DRYRUN (would run `git add {' '.join(_RELEASE_ARTIFACTS)}` + "
|
|
1864
|
+
f"`git commit -m '{_release_commit_subject(version)}'`)",
|
|
1865
|
+
)
|
|
1866
|
+
else:
|
|
1867
|
+
ok, reason = commit_release_artifacts(project_root, version)
|
|
1868
|
+
if ok:
|
|
1869
|
+
_emit(9, label, f"OK ({reason})")
|
|
1870
|
+
else:
|
|
1871
|
+
_emit(9, label, f"FAIL ({reason})")
|
|
1872
|
+
return EXIT_VIOLATION
|
|
1873
|
+
|
|
1874
|
+
# Step 10: git tag.
|
|
1875
|
+
label = f"Tag v{version}"
|
|
1876
|
+
if config.skip_tag:
|
|
1877
|
+
_emit(10, label, "SKIP (--skip-tag)")
|
|
1878
|
+
elif config.dry_run:
|
|
1879
|
+
_emit(10, label, f"DRYRUN (would run `git tag -a v{version} -m 'Release v{version}'`)")
|
|
1880
|
+
else:
|
|
1881
|
+
ok, reason = create_tag(project_root, version)
|
|
1882
|
+
if ok:
|
|
1883
|
+
_emit(10, label, f"OK ({reason})")
|
|
1884
|
+
else:
|
|
1885
|
+
_emit(10, label, f"FAIL ({reason})")
|
|
1886
|
+
return EXIT_VIOLATION
|
|
1887
|
+
|
|
1888
|
+
# Step 11: push branch + tag atomically.
|
|
1889
|
+
label = f"Push {config.base_branch} + v{version} to origin (atomic)"
|
|
1890
|
+
if config.skip_tag:
|
|
1891
|
+
_emit(11, label, "SKIP (--skip-tag)")
|
|
1892
|
+
elif config.dry_run:
|
|
1893
|
+
_emit(
|
|
1894
|
+
11,
|
|
1895
|
+
label,
|
|
1896
|
+
f"DRYRUN (would run `git push --atomic origin {config.base_branch} v{version}`)",
|
|
1897
|
+
)
|
|
1898
|
+
else:
|
|
1899
|
+
ok, reason = push_release(project_root, version, config.base_branch)
|
|
1900
|
+
if ok:
|
|
1901
|
+
_emit(11, label, f"OK ({reason})")
|
|
1902
|
+
else:
|
|
1903
|
+
_emit(11, label, f"FAIL ({reason})")
|
|
1904
|
+
return EXIT_VIOLATION
|
|
1905
|
+
|
|
1906
|
+
# Step 12: GitHub release. #425: flag SemVer pre-release tags
|
|
1907
|
+
# (``-rc.N`` / ``-beta.N`` / ``-alpha.N``) as GitHub pre-releases
|
|
1908
|
+
# automatically so RC cuts no longer require a manual
|
|
1909
|
+
# ``gh release edit --prerelease``. The decision mirrors the
|
|
1910
|
+
# workflow-side ``prerelease: ${{ contains(github.ref_name, '-') }}``.
|
|
1911
|
+
prerelease = is_prerelease_tag(version)
|
|
1912
|
+
draft_suffix = " (draft)" if config.draft else " (PUBLIC)"
|
|
1913
|
+
prerelease_suffix = " (prerelease)" if prerelease else ""
|
|
1914
|
+
label = f"GitHub release v{version}{draft_suffix}{prerelease_suffix}"
|
|
1915
|
+
create_succeeded = False
|
|
1916
|
+
if config.skip_release:
|
|
1917
|
+
_emit(12, label, "SKIP (--skip-release)")
|
|
1918
|
+
elif config.dry_run:
|
|
1919
|
+
draft_flag = " --draft" if config.draft else ""
|
|
1920
|
+
prerelease_flag = " --prerelease" if prerelease else ""
|
|
1921
|
+
_emit(
|
|
1922
|
+
12,
|
|
1923
|
+
label,
|
|
1924
|
+
(
|
|
1925
|
+
f"DRYRUN (would run `gh release create v{version} "
|
|
1926
|
+
f"--repo {config.repo}{draft_flag}{prerelease_flag} ...`)"
|
|
1927
|
+
),
|
|
1928
|
+
)
|
|
1929
|
+
else:
|
|
1930
|
+
notes = _section_for_version(promoted_changelog, version)
|
|
1931
|
+
# #1413: lead maintainer-mode (deftai/directive) release notes with
|
|
1932
|
+
# the standard upgrade-guidance banner sourced from
|
|
1933
|
+
# .github/release-notes/upgrade-banner.md. No-op for consumer-mode
|
|
1934
|
+
# repos and when the template is absent (graceful degradation).
|
|
1935
|
+
notes = _prepend_upgrade_banner(notes, config.repo, project_root)
|
|
1936
|
+
ok, reason = create_github_release(
|
|
1937
|
+
project_root,
|
|
1938
|
+
version,
|
|
1939
|
+
config.repo,
|
|
1940
|
+
notes,
|
|
1941
|
+
draft=config.draft,
|
|
1942
|
+
prerelease=prerelease,
|
|
1943
|
+
)
|
|
1944
|
+
if ok:
|
|
1945
|
+
_emit(12, label, f"OK ({reason})")
|
|
1946
|
+
create_succeeded = True
|
|
1947
|
+
else:
|
|
1948
|
+
_emit(12, label, f"FAIL ({reason})")
|
|
1949
|
+
return EXIT_VIOLATION
|
|
1950
|
+
|
|
1951
|
+
# Step 13: post-create verify-isDraft gate (#724). Defense in depth
|
|
1952
|
+
# against the v0.21.0 incident where a manual recovery created a
|
|
1953
|
+
# public release for ~90s before being flipped. Skipped when the
|
|
1954
|
+
# create step itself was skipped, when the operator opted into a
|
|
1955
|
+
# direct-publish via --no-draft, and during dry-run.
|
|
1956
|
+
label = f"Verify draft state of v{version} (#724 defense-in-depth)"
|
|
1957
|
+
if config.skip_release:
|
|
1958
|
+
_emit(13, label, "SKIP (--skip-release)")
|
|
1959
|
+
elif not config.draft:
|
|
1960
|
+
_emit(13, label, "SKIP (--no-draft; intentional public release)")
|
|
1961
|
+
elif config.dry_run:
|
|
1962
|
+
_emit(
|
|
1963
|
+
13,
|
|
1964
|
+
label,
|
|
1965
|
+
(
|
|
1966
|
+
f"DRYRUN (would poll `gh release view v{version} --json isDraft`"
|
|
1967
|
+
f" up to {VERIFY_DRAFT_MAX_ATTEMPTS}x at {VERIFY_DRAFT_INTERVAL_SECONDS}s"
|
|
1968
|
+
" intervals; auto-flip via `gh release edit --draft=true` on isDraft=false)"
|
|
1969
|
+
),
|
|
1970
|
+
)
|
|
1971
|
+
elif not create_succeeded:
|
|
1972
|
+
# Should be unreachable -- the create branch returns
|
|
1973
|
+
# EXIT_VIOLATION on failure -- but guard explicitly for the
|
|
1974
|
+
# benefit of unit-test stubs that bypass the early return.
|
|
1975
|
+
_emit(13, label, "SKIP (release was not created in this run)")
|
|
1976
|
+
else:
|
|
1977
|
+
ok, reason = verify_release_draft(
|
|
1978
|
+
project_root, version, config.repo
|
|
1979
|
+
)
|
|
1980
|
+
if ok:
|
|
1981
|
+
_emit(13, label, f"OK ({reason})")
|
|
1982
|
+
else:
|
|
1983
|
+
_emit(13, label, f"FAIL ({reason})")
|
|
1984
|
+
return EXIT_VIOLATION
|
|
1985
|
+
|
|
1986
|
+
print(
|
|
1987
|
+
f"Release v{version} pipeline complete "
|
|
1988
|
+
f"(dry_run={config.dry_run}, skip_tag={config.skip_tag}, "
|
|
1989
|
+
f"skip_release={config.skip_release}).",
|
|
1990
|
+
file=sys.stderr,
|
|
1991
|
+
)
|
|
1992
|
+
return EXIT_OK
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
# ---- main -------------------------------------------------------------------
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1999
|
+
parser = _build_parser()
|
|
2000
|
+
args = parser.parse_args(argv)
|
|
2001
|
+
|
|
2002
|
+
try:
|
|
2003
|
+
_validate_version(args.version)
|
|
2004
|
+
except ValueError as exc:
|
|
2005
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2006
|
+
return EXIT_CONFIG_ERROR
|
|
2007
|
+
|
|
2008
|
+
project_root = _resolve_project_root(args.project_root)
|
|
2009
|
+
repo = _resolve_repo(args.repo, project_root)
|
|
2010
|
+
|
|
2011
|
+
config = ReleaseConfig(
|
|
2012
|
+
version=args.version,
|
|
2013
|
+
repo=repo,
|
|
2014
|
+
base_branch=args.base_branch,
|
|
2015
|
+
project_root=project_root,
|
|
2016
|
+
dry_run=args.dry_run,
|
|
2017
|
+
skip_tag=args.skip_tag,
|
|
2018
|
+
skip_release=args.skip_release,
|
|
2019
|
+
allow_dirty=args.allow_dirty,
|
|
2020
|
+
draft=args.draft,
|
|
2021
|
+
skip_ci=args.skip_ci,
|
|
2022
|
+
skip_build=args.skip_build,
|
|
2023
|
+
summary=args.summary,
|
|
2024
|
+
allow_vbrief_drift=args.allow_vbrief_drift,
|
|
2025
|
+
)
|
|
2026
|
+
return run_pipeline(config)
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
if __name__ == "__main__":
|
|
2030
|
+
sys.exit(main())
|