@event4u/agent-config 1.9.1 → 1.13.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/.agent-src/commands/agent-handoff.md +15 -0
- package/.agent-src/commands/chat-history-clear.md +98 -0
- package/.agent-src/commands/chat-history-resume.md +178 -0
- package/.agent-src/commands/chat-history.md +102 -0
- package/.agent-src/commands/compress.md +9 -9
- package/.agent-src/commands/copilot-agents-init.md +1 -1
- package/.agent-src/commands/fix-portability.md +2 -2
- package/.agent-src/commands/fix-pr-bot-comments.md +1 -1
- package/.agent-src/commands/fix-pr-developer-comments.md +1 -1
- package/.agent-src/commands/fix-references.md +2 -2
- package/.agent-src/commands/mode.md +5 -5
- package/.agent-src/commands/onboard.md +171 -0
- package/.agent-src/commands/roadmap-create.md +7 -2
- package/.agent-src/commands/roadmap-execute.md +2 -2
- package/.agent-src/commands/set-cost-profile.md +101 -0
- package/.agent-src/commands/sync-agent-settings.md +122 -0
- package/.agent-src/commands/sync-gitignore.md +104 -0
- package/.agent-src/commands/tests-execute.md +6 -6
- package/.agent-src/commands/upstream-contribute.md +5 -4
- package/.agent-src/contexts/augment-infrastructure.md +2 -2
- package/.agent-src/contexts/override-system.md +1 -1
- package/.agent-src/contexts/subagent-configuration.md +3 -3
- package/.agent-src/guidelines/agent-infra/layered-settings.md +48 -5
- package/.agent-src/rules/ask-when-uncertain.md +56 -3
- package/.agent-src/rules/augment-portability.md +52 -1
- package/.agent-src/rules/augment-source-of-truth.md +10 -10
- package/.agent-src/rules/chat-history.md +171 -0
- package/.agent-src/rules/docker-commands.md +5 -7
- package/.agent-src/rules/docs-sync.md +13 -9
- package/.agent-src/rules/improve-before-implement.md +2 -0
- package/.agent-src/rules/onboarding-gate.md +94 -0
- package/.agent-src/rules/package-ci-checks.md +6 -5
- package/.agent-src/rules/roadmap-progress-sync.md +24 -13
- package/.agent-src/rules/size-enforcement.md +1 -1
- package/.agent-src/rules/skill-quality.md +1 -1
- package/.agent-src/rules/think-before-action.md +1 -0
- package/.agent-src/rules/user-interaction.md +53 -7
- package/.agent-src/scripts/update_roadmap_progress.py +57 -10
- package/.agent-src/skills/check-refs/SKILL.md +1 -1
- package/.agent-src/skills/command-routing/SKILL.md +1 -1
- package/.agent-src/skills/command-writing/SKILL.md +4 -3
- package/.agent-src/skills/file-editor/SKILL.md +2 -2
- package/.agent-src/skills/guideline-writing/SKILL.md +4 -3
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +2 -2
- package/.agent-src/skills/lint-skills/SKILL.md +1 -1
- package/.agent-src/skills/roadmap-management/SKILL.md +13 -10
- package/.agent-src/skills/rtk-output-filtering/SKILL.md +20 -30
- package/.agent-src/skills/rule-writing/SKILL.md +5 -5
- package/.agent-src/skills/terragrunt/SKILL.md +0 -8
- package/.agent-src/skills/upstream-contribute/SKILL.md +5 -4
- package/.agent-src/templates/agent-settings.md +86 -34
- package/.agent-src/templates/github-workflows/roadmap-progress-check.yml +63 -0
- package/.agent-src/templates/hooks/pre-commit-roadmap-progress +60 -0
- package/.agent-src/templates/scripts/memory_lookup.py +382 -21
- package/.agent-src/templates/scripts/memory_status.py +110 -9
- package/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +320 -0
- package/CONTRIBUTING.md +89 -40
- package/README.md +24 -3
- package/composer.json +5 -1
- package/config/agent-settings.template.yml +45 -6
- package/config/gitignore-block.txt +24 -0
- package/config/profiles/balanced.ini +5 -0
- package/config/profiles/full.ini +5 -0
- package/config/profiles/minimal.ini +5 -0
- package/docs/customization.md +30 -4
- package/docs/getting-started.md +53 -3
- package/docs/mcp.md +15 -4
- package/package.json +21 -2
- package/scripts/agent-config +230 -0
- package/scripts/chat_history.py +519 -0
- package/scripts/check_portability.py +151 -1
- package/scripts/install.py +55 -3
- package/scripts/install.sh +50 -21
- package/scripts/mcp_render.py +30 -16
- package/scripts/memory_lookup.py +143 -7
- package/scripts/memory_status.py +76 -14
- package/scripts/postinstall.sh +16 -0
- package/scripts/release.py +588 -0
- package/scripts/sync_agent_settings.py +211 -0
- package/scripts/sync_gitignore.py +226 -0
- package/templates/agent-config-wrapper.sh +47 -0
- package/.agent-src/commands/config-agent-settings.md +0 -126
- package/.agent-src/skills/eloquent/evals/last-run.json +0 -99
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""End-to-end release automation for `event4u/agent-config`.
|
|
3
|
+
|
|
4
|
+
Invoked via `task release`. The bump level (major/minor/patch) is
|
|
5
|
+
auto-detected from Conventional Commits since the last tag; pass
|
|
6
|
+
`--as {major,minor,patch}` to force, or `--version X.Y.Z` to pin.
|
|
7
|
+
|
|
8
|
+
Pipeline:
|
|
9
|
+
1. Preflight — on main, clean tree, origin in sync, gh available,
|
|
10
|
+
target tag doesn't exist yet.
|
|
11
|
+
2. Plan — compute new version, parse Conventional Commits
|
|
12
|
+
since the last tag, render CHANGELOG section.
|
|
13
|
+
3. Confirm — show preview, ask once (skippable with --yes).
|
|
14
|
+
4. Branch + bump — create `release/X.Y.Z`, update package.json,
|
|
15
|
+
.claude-plugin/marketplace.json, CHANGELOG.md.
|
|
16
|
+
5. Commit + push — `release: X.Y.Z`, push branch, open PR.
|
|
17
|
+
6. Wait for CI — `gh pr checks --watch` (skippable with --no-wait).
|
|
18
|
+
7. Merge — `gh pr merge --merge --delete-branch`.
|
|
19
|
+
8. Tag main — fast-forward main, tag the merge commit,
|
|
20
|
+
push the tag (this triggers publish-npm.yml).
|
|
21
|
+
9. GitHub Release — `gh release create X.Y.Z --notes <changelog>`.
|
|
22
|
+
|
|
23
|
+
Idempotency is intentionally limited: this script mutates git state, so
|
|
24
|
+
re-running after a partial failure needs a clean tree. Each step prints
|
|
25
|
+
what it's about to do before doing it, so a crash leaves a recoverable
|
|
26
|
+
trail.
|
|
27
|
+
|
|
28
|
+
Stdlib-only (Python 3.10+). No third-party runtime dependencies.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import time
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from datetime import date as _date
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
44
|
+
PACKAGE_JSON = REPO_ROOT / "package.json"
|
|
45
|
+
MARKETPLACE_JSON = REPO_ROOT / ".claude-plugin" / "marketplace.json"
|
|
46
|
+
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
|
|
47
|
+
MAIN_BRANCH = "main"
|
|
48
|
+
REMOTE = "origin"
|
|
49
|
+
REPO_SLUG = "event4u-app/agent-config"
|
|
50
|
+
|
|
51
|
+
# Conventional Commit types and how they map into CHANGELOG sections.
|
|
52
|
+
# Order in this tuple determines order in the rendered entry.
|
|
53
|
+
SECTIONS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|
54
|
+
("Features", "minor", ("feat",)),
|
|
55
|
+
("Bug Fixes", "patch", ("fix",)),
|
|
56
|
+
("Performance", "patch", ("perf",)),
|
|
57
|
+
("Reverts", "patch", ("revert",)),
|
|
58
|
+
("Documentation", None, ("docs",)),
|
|
59
|
+
("Refactoring", None, ("refactor",)),
|
|
60
|
+
("Tests", None, ("test",)),
|
|
61
|
+
("Build", None, ("build",)),
|
|
62
|
+
("CI", None, ("ci",)),
|
|
63
|
+
("Chores", None, ("chore",)),
|
|
64
|
+
)
|
|
65
|
+
BREAKING_RE = re.compile(r"^([a-z]+)(\([^)]+\))?!:")
|
|
66
|
+
CONVENTIONAL_RE = re.compile(
|
|
67
|
+
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<bang>!)?: (?P<subject>.+)$"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─── dataclasses ──────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Commit:
|
|
76
|
+
sha: str
|
|
77
|
+
type: str
|
|
78
|
+
scope: str | None
|
|
79
|
+
subject: str
|
|
80
|
+
breaking: bool
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Plan:
|
|
85
|
+
current: str
|
|
86
|
+
target: str
|
|
87
|
+
bump: str # "major" | "minor" | "patch"
|
|
88
|
+
commits: list[Commit]
|
|
89
|
+
last_tag: str | None
|
|
90
|
+
changelog_body: str # rendered body (without the heading)
|
|
91
|
+
changelog_entry: str # full entry including heading, for CHANGELOG.md
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ─── utilities ────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def die(msg: str, code: int = 2) -> None:
|
|
98
|
+
print(f"error: {msg}", file=sys.stderr)
|
|
99
|
+
sys.exit(code)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run(
|
|
103
|
+
*args: str,
|
|
104
|
+
check: bool = True,
|
|
105
|
+
capture: bool = False,
|
|
106
|
+
cwd: Path | None = None,
|
|
107
|
+
) -> subprocess.CompletedProcess[str]:
|
|
108
|
+
"""Thin subprocess wrapper with sane defaults.
|
|
109
|
+
|
|
110
|
+
When ``check`` and ``capture`` are both True and the command fails,
|
|
111
|
+
Python's default behaviour swallows stderr — callers only see a
|
|
112
|
+
``CalledProcessError`` with no hint of what went wrong. We catch that
|
|
113
|
+
path and die with the actual stderr so release preflight failures are
|
|
114
|
+
diagnosable without re-running with a debugger.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
return subprocess.run(
|
|
118
|
+
list(args),
|
|
119
|
+
check=check,
|
|
120
|
+
cwd=cwd or REPO_ROOT,
|
|
121
|
+
text=True,
|
|
122
|
+
capture_output=capture,
|
|
123
|
+
)
|
|
124
|
+
except subprocess.CalledProcessError as err:
|
|
125
|
+
if capture:
|
|
126
|
+
cmd = " ".join(args)
|
|
127
|
+
out = (err.stderr or err.stdout or "").strip()
|
|
128
|
+
die(f"command failed ({err.returncode}): {cmd}\n{out}")
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def git(*args: str, capture: bool = False) -> str:
|
|
133
|
+
r = run("git", *args, capture=capture)
|
|
134
|
+
return r.stdout.strip() if capture else ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def gh(*args: str, capture: bool = False, check: bool = True) -> str:
|
|
138
|
+
r = run("gh", *args, capture=capture, check=check)
|
|
139
|
+
return r.stdout.strip() if capture else ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def watch_pr_checks() -> None:
|
|
143
|
+
"""Watch PR checks and tolerate the 'no checks' case.
|
|
144
|
+
|
|
145
|
+
``gh pr checks --watch`` exits 1 both on real failures and when no
|
|
146
|
+
checks are reported at all (no workflow triggered, no required
|
|
147
|
+
checks configured in branch protection). The latter must not block
|
|
148
|
+
the release — we warn and continue. Real failures still die.
|
|
149
|
+
|
|
150
|
+
A short grace period gives GitHub time to register workflow runs
|
|
151
|
+
on a freshly-pushed branch.
|
|
152
|
+
"""
|
|
153
|
+
time.sleep(5)
|
|
154
|
+
proc = subprocess.run(
|
|
155
|
+
["gh", "pr", "checks", "--watch"],
|
|
156
|
+
cwd=REPO_ROOT,
|
|
157
|
+
text=True,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
)
|
|
160
|
+
output = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
161
|
+
if proc.returncode == 0:
|
|
162
|
+
if output:
|
|
163
|
+
print(output)
|
|
164
|
+
return
|
|
165
|
+
if "no checks reported" in output.lower():
|
|
166
|
+
print(f"⚠️ {output}")
|
|
167
|
+
print(
|
|
168
|
+
" Continuing without check validation — configure required "
|
|
169
|
+
"checks in branch protection to enforce this gate."
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
if output:
|
|
173
|
+
print(output, file=sys.stderr)
|
|
174
|
+
die(f"PR checks failed (exit {proc.returncode})")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def have(bin: str) -> bool:
|
|
178
|
+
return (
|
|
179
|
+
subprocess.run(
|
|
180
|
+
["which", bin], capture_output=True, text=True
|
|
181
|
+
).returncode
|
|
182
|
+
== 0
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ─── version math ─────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def parse_version(s: str) -> tuple[int, int, int]:
|
|
193
|
+
m = SEMVER_RE.match(s.strip())
|
|
194
|
+
if not m:
|
|
195
|
+
die(f"not a bare semver (X.Y.Z): {s!r}")
|
|
196
|
+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def bump_version(current: str, kind: str) -> str:
|
|
200
|
+
major, minor, patch = parse_version(current)
|
|
201
|
+
if kind == "major":
|
|
202
|
+
return f"{major + 1}.0.0"
|
|
203
|
+
if kind == "minor":
|
|
204
|
+
return f"{major}.{minor + 1}.0"
|
|
205
|
+
if kind == "patch":
|
|
206
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
207
|
+
die(f"unknown bump kind: {kind}")
|
|
208
|
+
return "" # unreachable
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ─── commit parsing + changelog rendering ────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def commits_since(tag: str | None) -> list[Commit]:
|
|
215
|
+
"""Return non-merge commits after `tag` (or all of history if tag is None)."""
|
|
216
|
+
rev = f"{tag}..HEAD" if tag else "HEAD"
|
|
217
|
+
raw = git("log", rev, "--no-merges", "--format=%H%x1f%s", capture=True)
|
|
218
|
+
out: list[Commit] = []
|
|
219
|
+
for line in raw.splitlines():
|
|
220
|
+
if "\x1f" not in line:
|
|
221
|
+
continue
|
|
222
|
+
sha, subject = line.split("\x1f", 1)
|
|
223
|
+
m = CONVENTIONAL_RE.match(subject)
|
|
224
|
+
if not m:
|
|
225
|
+
out.append(Commit(sha, "other", None, subject, False))
|
|
226
|
+
continue
|
|
227
|
+
breaking = bool(m.group("bang")) or "BREAKING CHANGE" in subject
|
|
228
|
+
out.append(
|
|
229
|
+
Commit(
|
|
230
|
+
sha=sha,
|
|
231
|
+
type=m.group("type"),
|
|
232
|
+
scope=m.group("scope"),
|
|
233
|
+
subject=m.group("subject"),
|
|
234
|
+
breaking=breaking,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def infer_bump(commits: list[Commit]) -> str:
|
|
241
|
+
"""Derive the semver bump from commit types (for preview only)."""
|
|
242
|
+
if any(c.breaking for c in commits):
|
|
243
|
+
return "major"
|
|
244
|
+
for _label, level, types in SECTIONS:
|
|
245
|
+
if level == "minor" and any(c.type in types for c in commits):
|
|
246
|
+
return "minor"
|
|
247
|
+
return "patch"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def latest_tag() -> str | None:
|
|
251
|
+
r = run(
|
|
252
|
+
"git", "describe", "--tags", "--abbrev=0", "--match", "[0-9]*.[0-9]*.[0-9]*",
|
|
253
|
+
check=False, capture=True,
|
|
254
|
+
)
|
|
255
|
+
tag = r.stdout.strip()
|
|
256
|
+
return tag or None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def render_changelog_entry(
|
|
260
|
+
version: str,
|
|
261
|
+
prev: str | None,
|
|
262
|
+
commits: list[Commit],
|
|
263
|
+
today: str,
|
|
264
|
+
) -> tuple[str, str]:
|
|
265
|
+
"""Return (heading-aware full entry, body-only for GitHub Release notes)."""
|
|
266
|
+
if prev:
|
|
267
|
+
heading = (
|
|
268
|
+
f"## [{version}](https://github.com/{REPO_SLUG}/compare/"
|
|
269
|
+
f"{prev}...{version}) ({today})"
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
heading = f"## {version} ({today})"
|
|
273
|
+
|
|
274
|
+
# Group by section; commits of unknown type drop into "Other".
|
|
275
|
+
grouped: dict[str, list[Commit]] = {label: [] for label, _, _ in SECTIONS}
|
|
276
|
+
grouped["BREAKING CHANGES"] = []
|
|
277
|
+
other: list[Commit] = []
|
|
278
|
+
for c in commits:
|
|
279
|
+
if c.breaking:
|
|
280
|
+
grouped["BREAKING CHANGES"].append(c)
|
|
281
|
+
continue
|
|
282
|
+
placed = False
|
|
283
|
+
for label, _level, types in SECTIONS:
|
|
284
|
+
if c.type in types:
|
|
285
|
+
grouped[label].append(c)
|
|
286
|
+
placed = True
|
|
287
|
+
break
|
|
288
|
+
if not placed:
|
|
289
|
+
other.append(c)
|
|
290
|
+
|
|
291
|
+
body_lines: list[str] = []
|
|
292
|
+
ordered_labels = ["BREAKING CHANGES"] + [label for label, _, _ in SECTIONS]
|
|
293
|
+
for label in ordered_labels:
|
|
294
|
+
bucket = grouped.get(label) or []
|
|
295
|
+
if not bucket:
|
|
296
|
+
continue
|
|
297
|
+
body_lines.append("")
|
|
298
|
+
body_lines.append(f"### {label}")
|
|
299
|
+
body_lines.append("")
|
|
300
|
+
for c in bucket:
|
|
301
|
+
body_lines.append(_changelog_line(c))
|
|
302
|
+
if other:
|
|
303
|
+
body_lines.append("")
|
|
304
|
+
body_lines.append("### Other")
|
|
305
|
+
body_lines.append("")
|
|
306
|
+
for c in other:
|
|
307
|
+
body_lines.append(_changelog_line(c))
|
|
308
|
+
|
|
309
|
+
body = "\n".join(body_lines).lstrip("\n")
|
|
310
|
+
full = heading + "\n\n" + body + "\n"
|
|
311
|
+
return full, body
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _changelog_line(c: Commit) -> str:
|
|
315
|
+
scope = f"**{c.scope}:** " if c.scope else ""
|
|
316
|
+
short = c.sha[:7]
|
|
317
|
+
link = f"https://github.com/{REPO_SLUG}/commit/{c.sha}"
|
|
318
|
+
return f"* {scope}{c.subject} ([{short}]({link}))"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def prepend_changelog(path: Path, entry: str) -> None:
|
|
322
|
+
"""Insert `entry` directly above the most recent `## [` heading."""
|
|
323
|
+
text = path.read_text(encoding="utf-8")
|
|
324
|
+
marker_re = re.compile(r"^## \[?\d+\.\d+\.\d+", re.MULTILINE)
|
|
325
|
+
m = marker_re.search(text)
|
|
326
|
+
if not m:
|
|
327
|
+
# No prior release heading — append after the introduction.
|
|
328
|
+
path.write_text(text.rstrip() + "\n\n" + entry, encoding="utf-8")
|
|
329
|
+
return
|
|
330
|
+
before = text[: m.start()]
|
|
331
|
+
after = text[m.start() :]
|
|
332
|
+
path.write_text(before + entry + "\n" + after, encoding="utf-8")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ─── file mutations ───────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def set_package_version(path: Path, version: str) -> None:
|
|
340
|
+
"""Update the top-level `version` field; preserve 4-space indentation."""
|
|
341
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
342
|
+
data["version"] = version
|
|
343
|
+
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def set_marketplace_version(path: Path, version: str) -> None:
|
|
347
|
+
"""Update `metadata.version`; preserve 2-space indentation + UTF-8."""
|
|
348
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
349
|
+
data.setdefault("metadata", {})["version"] = version
|
|
350
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ─── preflight ────────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def preflight(target: str) -> None:
|
|
357
|
+
"""Fail fast on conditions that would break the release mid-flight."""
|
|
358
|
+
for b in ("git", "gh"):
|
|
359
|
+
if not have(b):
|
|
360
|
+
die(f"{b!r} not found on PATH")
|
|
361
|
+
|
|
362
|
+
# Probe the active token directly via an authenticated API call. `gh auth
|
|
363
|
+
# status` returns non-zero if *any* account in the keyring is broken, even
|
|
364
|
+
# when the active one is fine — so we'd rather ask "does the token the
|
|
365
|
+
# release will actually use work?" than parse multi-account status output.
|
|
366
|
+
r = run("gh", "api", "user", "--jq", ".login", check=False, capture=True)
|
|
367
|
+
if r.returncode != 0:
|
|
368
|
+
die("gh is not authenticated; run `gh auth login` first")
|
|
369
|
+
|
|
370
|
+
branch = git("rev-parse", "--abbrev-ref", "HEAD", capture=True)
|
|
371
|
+
if branch != MAIN_BRANCH:
|
|
372
|
+
die(f"release must run from {MAIN_BRANCH!r}, currently on {branch!r}")
|
|
373
|
+
|
|
374
|
+
porcelain = git("status", "--porcelain", capture=True)
|
|
375
|
+
if porcelain:
|
|
376
|
+
die("working tree is not clean; commit or stash first")
|
|
377
|
+
|
|
378
|
+
# --force lets the remote's tag positions win over stale local tags.
|
|
379
|
+
# The release consumes the remote view as source of truth, and we're
|
|
380
|
+
# about to create a new tag anyway — local drift (e.g. from renamed
|
|
381
|
+
# release-please tags) should not block the fetch.
|
|
382
|
+
run("git", "fetch", REMOTE, "--tags", "--prune", "--force", capture=True)
|
|
383
|
+
local = git("rev-parse", "HEAD", capture=True)
|
|
384
|
+
remote = git("rev-parse", f"{REMOTE}/{MAIN_BRANCH}", capture=True)
|
|
385
|
+
if local != remote:
|
|
386
|
+
die(
|
|
387
|
+
f"local {MAIN_BRANCH} is not in sync with {REMOTE}/{MAIN_BRANCH}; "
|
|
388
|
+
"pull or push first"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
tags = git("tag", "-l", target, capture=True).splitlines()
|
|
392
|
+
if target in tags:
|
|
393
|
+
die(f"tag {target!r} already exists; nothing to release")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ─── plan ─────────────────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def print_preview(plan: Plan) -> None:
|
|
400
|
+
print()
|
|
401
|
+
print("═" * 72)
|
|
402
|
+
print(f" Release preview — {plan.current} → {plan.target} ({plan.bump})")
|
|
403
|
+
print("═" * 72)
|
|
404
|
+
print()
|
|
405
|
+
print(f"Previous tag: {plan.last_tag or '(none)'}")
|
|
406
|
+
print(f"New tag: {plan.target}")
|
|
407
|
+
print(f"Commits: {len(plan.commits)} since {plan.last_tag or 'start'}")
|
|
408
|
+
detected = infer_bump(plan.commits) if plan.commits else "patch"
|
|
409
|
+
if detected != plan.bump:
|
|
410
|
+
print(
|
|
411
|
+
f"NOTE: commits suggest a {detected!r} bump, "
|
|
412
|
+
f"you picked {plan.bump!r}"
|
|
413
|
+
)
|
|
414
|
+
print()
|
|
415
|
+
print("Files to change:")
|
|
416
|
+
print(f" · {PACKAGE_JSON.relative_to(REPO_ROOT)}")
|
|
417
|
+
print(f" · {MARKETPLACE_JSON.relative_to(REPO_ROOT)}")
|
|
418
|
+
print(f" · {CHANGELOG.relative_to(REPO_ROOT)}")
|
|
419
|
+
print()
|
|
420
|
+
print("Changelog section:")
|
|
421
|
+
print("─" * 72)
|
|
422
|
+
print(plan.changelog_entry.rstrip())
|
|
423
|
+
print("─" * 72)
|
|
424
|
+
print()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def confirm(prompt: str) -> bool:
|
|
428
|
+
ans = input(f"{prompt} [y/N] ").strip().lower()
|
|
429
|
+
return ans in {"y", "yes"}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ─── orchestration ────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _step(n: int, total: int, msg: str) -> None:
|
|
436
|
+
print(f"[{n}/{total}] {msg}")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def execute(plan: Plan, *, wait_for_checks: bool, dry_run: bool) -> None:
|
|
440
|
+
branch = f"release/{plan.target}"
|
|
441
|
+
total = 9
|
|
442
|
+
|
|
443
|
+
if dry_run:
|
|
444
|
+
print("(dry-run) no git/gh mutations will be performed.")
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
_step(1, total, f"Create branch {branch}")
|
|
448
|
+
run("git", "checkout", "-b", branch)
|
|
449
|
+
|
|
450
|
+
_step(2, total, "Bump package.json + marketplace.json, prepend CHANGELOG")
|
|
451
|
+
set_package_version(PACKAGE_JSON, plan.target)
|
|
452
|
+
set_marketplace_version(MARKETPLACE_JSON, plan.target)
|
|
453
|
+
prepend_changelog(CHANGELOG, plan.changelog_entry)
|
|
454
|
+
|
|
455
|
+
_step(3, total, f"Commit `release: {plan.target}`")
|
|
456
|
+
run("git", "add", str(PACKAGE_JSON), str(MARKETPLACE_JSON), str(CHANGELOG))
|
|
457
|
+
run("git", "commit", "-m", f"release: {plan.target}")
|
|
458
|
+
|
|
459
|
+
_step(4, total, f"Push {branch} to {REMOTE}")
|
|
460
|
+
run("git", "push", "-u", REMOTE, branch)
|
|
461
|
+
|
|
462
|
+
_step(5, total, "Open pull request")
|
|
463
|
+
pr_body = (
|
|
464
|
+
f"Release {plan.target}.\n\n"
|
|
465
|
+
f"{plan.changelog_body}\n\n"
|
|
466
|
+
"Created by `scripts/release.py`."
|
|
467
|
+
)
|
|
468
|
+
run(
|
|
469
|
+
"gh", "pr", "create",
|
|
470
|
+
"--base", MAIN_BRANCH,
|
|
471
|
+
"--head", branch,
|
|
472
|
+
"--title", f"release: {plan.target}",
|
|
473
|
+
"--body", pr_body,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if wait_for_checks:
|
|
477
|
+
_step(6, total, "Wait for PR checks")
|
|
478
|
+
watch_pr_checks()
|
|
479
|
+
else:
|
|
480
|
+
_step(6, total, "Skip waiting for checks (--no-wait)")
|
|
481
|
+
|
|
482
|
+
_step(7, total, "Merge pull request (merge commit) and delete branch")
|
|
483
|
+
run("gh", "pr", "merge", "--merge", "--delete-branch")
|
|
484
|
+
|
|
485
|
+
_step(8, total, f"Fast-forward {MAIN_BRANCH}, tag merge commit, push tag")
|
|
486
|
+
run("git", "checkout", MAIN_BRANCH)
|
|
487
|
+
run("git", "pull", "--ff-only", REMOTE, MAIN_BRANCH)
|
|
488
|
+
run("git", "tag", plan.target)
|
|
489
|
+
run("git", "push", REMOTE, plan.target)
|
|
490
|
+
|
|
491
|
+
_step(9, total, "Create GitHub Release (triggers publish-npm on the tag)")
|
|
492
|
+
notes = plan.changelog_body or f"Release {plan.target}"
|
|
493
|
+
run(
|
|
494
|
+
"gh", "release", "create", plan.target,
|
|
495
|
+
"--title", plan.target,
|
|
496
|
+
"--notes", notes,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
print()
|
|
500
|
+
print(f"✅ Released {plan.target}")
|
|
501
|
+
print(f" https://github.com/{REPO_SLUG}/releases/tag/{plan.target}")
|
|
502
|
+
print(" npm publish runs asynchronously via publish-npm.yml on the tag.")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ─── entrypoint ───────────────────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
509
|
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
510
|
+
p.add_argument(
|
|
511
|
+
"--as",
|
|
512
|
+
dest="bump_override",
|
|
513
|
+
choices=("major", "minor", "patch"),
|
|
514
|
+
default=None,
|
|
515
|
+
help=(
|
|
516
|
+
"Force a specific bump level. Default is auto-detect from "
|
|
517
|
+
"Conventional Commits since the last tag."
|
|
518
|
+
),
|
|
519
|
+
)
|
|
520
|
+
p.add_argument(
|
|
521
|
+
"--version",
|
|
522
|
+
dest="explicit",
|
|
523
|
+
default=None,
|
|
524
|
+
help="Use an explicit X.Y.Z instead of the auto-detected bump.",
|
|
525
|
+
)
|
|
526
|
+
p.add_argument(
|
|
527
|
+
"--yes", "-y", action="store_true",
|
|
528
|
+
help="Skip the confirmation prompt.",
|
|
529
|
+
)
|
|
530
|
+
p.add_argument(
|
|
531
|
+
"--dry-run", action="store_true",
|
|
532
|
+
help="Print the plan and exit without touching git/gh.",
|
|
533
|
+
)
|
|
534
|
+
p.add_argument(
|
|
535
|
+
"--no-wait", action="store_true",
|
|
536
|
+
help="Merge immediately without waiting for PR checks to pass.",
|
|
537
|
+
)
|
|
538
|
+
return p.parse_args(argv)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def resolve_bump(override: str | None, commits: list[Commit]) -> str:
|
|
542
|
+
"""Override wins; otherwise auto-detect from commits (or 'patch' if empty)."""
|
|
543
|
+
if override:
|
|
544
|
+
return override
|
|
545
|
+
return infer_bump(commits)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def main(argv: list[str] | None = None) -> int:
|
|
549
|
+
args = _parse_args(list(sys.argv[1:] if argv is None else argv))
|
|
550
|
+
|
|
551
|
+
current = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))["version"]
|
|
552
|
+
parse_version(current)
|
|
553
|
+
|
|
554
|
+
prev = latest_tag()
|
|
555
|
+
commits = commits_since(prev)
|
|
556
|
+
bump = resolve_bump(args.bump_override, commits)
|
|
557
|
+
target = args.explicit or bump_version(current, bump)
|
|
558
|
+
parse_version(target)
|
|
559
|
+
|
|
560
|
+
if not args.dry_run:
|
|
561
|
+
preflight(target)
|
|
562
|
+
|
|
563
|
+
today = _date.today().isoformat()
|
|
564
|
+
full, body = render_changelog_entry(target, prev, commits, today)
|
|
565
|
+
plan = Plan(
|
|
566
|
+
current=current,
|
|
567
|
+
target=target,
|
|
568
|
+
bump=bump,
|
|
569
|
+
commits=commits,
|
|
570
|
+
last_tag=prev,
|
|
571
|
+
changelog_body=body,
|
|
572
|
+
changelog_entry=full,
|
|
573
|
+
)
|
|
574
|
+
print_preview(plan)
|
|
575
|
+
|
|
576
|
+
if args.dry_run:
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
if not args.yes and not confirm(f"Proceed with release {plan.target}?"):
|
|
580
|
+
print("aborted.")
|
|
581
|
+
return 1
|
|
582
|
+
|
|
583
|
+
execute(plan, wait_for_checks=not args.no_wait, dry_run=False)
|
|
584
|
+
return 0
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
if __name__ == "__main__":
|
|
588
|
+
raise SystemExit(main())
|