@event4u/agent-config 5.8.0 → 5.9.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +17 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_cli/cmd_doctor.py +177 -14
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/check_release_published.py +145 -0
- package/scripts/release.py +54 -31
package/CHANGELOG.md
CHANGED
|
@@ -811,6 +811,23 @@ our recommendation order, not its support status.
|
|
|
811
811
|
> that forces a new era split (`# Era: 5.5.x`, etc.) — see
|
|
812
812
|
> [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
|
|
813
813
|
|
|
814
|
+
## [5.9.0](https://github.com/event4u-app/agent-config/compare/5.8.0...5.9.0) (2026-06-02)
|
|
815
|
+
|
|
816
|
+
### Features
|
|
817
|
+
|
|
818
|
+
* **doctor:** runnable global-only report without a project lockfile ([4c4f20b](https://github.com/event4u-app/agent-config/commit/4c4f20b82eb472ff6b6d84ebac889832ce1329f0))
|
|
819
|
+
* **ci:** release-published drift gate (catch npm lagging main) ([7f4e125](https://github.com/event4u-app/agent-config/commit/7f4e125dd48d0233d33ce8cbb219731a0b501497))
|
|
820
|
+
|
|
821
|
+
### Bug Fixes
|
|
822
|
+
|
|
823
|
+
* **release:** anchor --resume to package.json, auto-delete merged branch ([f2db736](https://github.com/event4u-app/agent-config/commit/f2db736c7259845fe204af1ffd293e7f5341a58d))
|
|
824
|
+
|
|
825
|
+
### Chores
|
|
826
|
+
|
|
827
|
+
* **roadmap:** close + archive doctor-global-only-readiness ([3d3aaf7](https://github.com/event4u-app/agent-config/commit/3d3aaf739464b9e6613175f352b09a153bdbec90))
|
|
828
|
+
|
|
829
|
+
Tests: 5480 (+25 since 5.8.0)
|
|
830
|
+
|
|
814
831
|
## [5.8.0](https://github.com/event4u-app/agent-config/compare/5.7.0...5.8.0) (2026-06-02)
|
|
815
832
|
|
|
816
833
|
### Features
|
|
@@ -10004,7 +10004,7 @@
|
|
|
10004
10004
|
"reason": "scaffold for new SKILL.md authoring"
|
|
10005
10005
|
}
|
|
10006
10006
|
],
|
|
10007
|
-
"generated_at": "2026-06-
|
|
10007
|
+
"generated_at": "2026-06-02T05:57:08Z",
|
|
10008
10008
|
"packs": [
|
|
10009
10009
|
{
|
|
10010
10010
|
"artefact_count": 85,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4b7e6f9a9278cd1129d4cc5d1e5901f0c18b8ef8d741ef2ab7cfc30e567eb0cb discovery-manifest.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.0",
|
|
4
4
|
"description": "Universal AI Agent OS \u2014 audited skills, governance rules, commands, and templates for AI coding tools (Claude Code, Cursor, Windsurf, Copilot).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
Binary file
|
|
@@ -23,8 +23,16 @@ mcp-mode · mcp-beta-readiness · offline-readiness · python-runtime ·
|
|
|
23
23
|
tier-usage-readiness · council-cli · unsupported-combos ·
|
|
24
24
|
wizard-state.
|
|
25
25
|
Each emits a structured ``{id, status, message, remedy}`` record with
|
|
26
|
-
``status`` ∈ ``ok`` / ``warn`` / ``fail``
|
|
27
|
-
``❌``). ``--check <id>`` runs a single check.
|
|
26
|
+
``status`` ∈ ``ok`` / ``warn`` / ``fail`` / ``skipped`` (rendered
|
|
27
|
+
``✅`` / ``⚠️`` / ``❌`` / ``⏭️``). ``--check <id>`` runs a single check.
|
|
28
|
+
|
|
29
|
+
Checks are split by scope (:data:`GLOBAL_CHECK_IDS` vs
|
|
30
|
+
:data:`MANIFEST_REQUIRED_CHECK_IDS`) so an ADR-020 global-only consumer
|
|
31
|
+
(bridge marker present, no ``agents/installed-tools.lock``) gets a
|
|
32
|
+
green-capable report instead of a hard bail: the global checks run, the
|
|
33
|
+
manifest-required checks report ``skipped``, and ``bridge-drift`` returns
|
|
34
|
+
a scope-aware "drift not applicable" verdict. See :func:`main` for the
|
|
35
|
+
no-manifest branch and exit-code contract.
|
|
28
36
|
|
|
29
37
|
Repair affordances: ``--repair wizard-state`` resets a malformed or
|
|
30
38
|
orphaned ``state/wizard-state.json`` under the user-global root (the
|
|
@@ -455,6 +463,41 @@ CHECK_IDS = (
|
|
|
455
463
|
"wizard-state",
|
|
456
464
|
)
|
|
457
465
|
|
|
466
|
+
#: Checks that need only the project root (or no input at all) and run
|
|
467
|
+
#: regardless of whether a project lockfile exists. Under ADR-020
|
|
468
|
+
#: global-only (bridge marker present, no ``installed-tools.lock``) these
|
|
469
|
+
#: still produce a real verdict. ``scope`` lives here because
|
|
470
|
+
#: :func:`_check_scope` reads only ``project_root`` — the roadmap prose
|
|
471
|
+
#: that grouped it with the manifest checks predates this code reality
|
|
472
|
+
#: (AI council, claude-sonnet-4-5 + gpt-4o, design lens, 2026-06-02).
|
|
473
|
+
GLOBAL_CHECK_IDS: frozenset[str] = frozenset({
|
|
474
|
+
"scope",
|
|
475
|
+
"global-binary",
|
|
476
|
+
"mcp-mode",
|
|
477
|
+
"mcp-beta-readiness",
|
|
478
|
+
"offline-readiness",
|
|
479
|
+
"python-runtime",
|
|
480
|
+
"tier-usage-readiness",
|
|
481
|
+
"council-cli",
|
|
482
|
+
"wizard-state",
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
#: Checks that genuinely cannot run without the project manifest. Without
|
|
486
|
+
#: a lockfile they report ``skipped`` rather than a misleading verdict.
|
|
487
|
+
#: ``bridge-drift`` is deliberately **not** here: it is scope-aware —
|
|
488
|
+
#: a manifest-derived drift roll-up when the lockfile exists, and a
|
|
489
|
+
#: "drift not applicable (global-only consumer)" verdict when it does
|
|
490
|
+
#: not (computed in :func:`_check_bridge_drift_no_manifest`, keeping
|
|
491
|
+
#: :func:`_check_bridge_drift` a pure roll-up per the council's SRP point).
|
|
492
|
+
MANIFEST_REQUIRED_CHECK_IDS: frozenset[str] = frozenset({
|
|
493
|
+
"manifest-integrity",
|
|
494
|
+
"lockfile-freshness",
|
|
495
|
+
"unsupported-combos",
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
#: Project-root-relative path of the ADR-020 global-only consumer marker.
|
|
499
|
+
BRIDGE_MARKER_RELATIVE = "agents/.event4u-bridge.yml"
|
|
500
|
+
|
|
458
501
|
#: Repair targets that ``--repair <id>`` accepts. Each id maps to a
|
|
459
502
|
#: function in :func:`_run_repair` that resets the named artefact and
|
|
460
503
|
#: returns an exit code. Additive set: introduce by adding a new id
|
|
@@ -476,7 +519,7 @@ MCP_BETA_GATES: tuple[tuple[str, str], ...] = (
|
|
|
476
519
|
|
|
477
520
|
#: Visible status → glyph map. ``warn`` keeps a trailing space so the
|
|
478
521
|
#: rendered output stays in a single visual column with the other glyphs.
|
|
479
|
-
STATUS_SYMBOLS = {"ok": "✅", "warn": "⚠️ ", "fail": "❌"}
|
|
522
|
+
STATUS_SYMBOLS = {"ok": "✅", "warn": "⚠️ ", "fail": "❌", "skipped": "⏭️ "}
|
|
480
523
|
|
|
481
524
|
#: Minimum Python interpreter the CLI targets. Bumped in lockstep with
|
|
482
525
|
#: ``from __future__ import annotations`` + PEP-604 syntax usage.
|
|
@@ -1159,6 +1202,131 @@ def _run_checks(
|
|
|
1159
1202
|
return out
|
|
1160
1203
|
|
|
1161
1204
|
|
|
1205
|
+
def _skipped_manifest_check(check_id: str) -> dict[str, Any]:
|
|
1206
|
+
"""A ``skipped`` verdict for a manifest-required check with no lockfile.
|
|
1207
|
+
|
|
1208
|
+
Explicit machine-readable record (not omitted, not ``null``) so the
|
|
1209
|
+
``--json`` ``checks`` array keeps a stable shape for a global-only
|
|
1210
|
+
consumer — a council convergence point (2026-06-02).
|
|
1211
|
+
"""
|
|
1212
|
+
return {
|
|
1213
|
+
"id": check_id, "status": "skipped",
|
|
1214
|
+
"message": "requires a project lockfile (agents/installed-tools.lock)",
|
|
1215
|
+
"remedy": "run `agent-config init` to create a project lockfile, "
|
|
1216
|
+
"then re-run this check",
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _check_bridge_drift_no_manifest(bridge_present: bool) -> dict[str, Any]:
|
|
1221
|
+
"""Scope-aware ``bridge-drift`` verdict when no project manifest exists.
|
|
1222
|
+
|
|
1223
|
+
Kept out of :func:`_check_bridge_drift` (which stays a pure
|
|
1224
|
+
manifest-derived roll-up) per the council's single-responsibility
|
|
1225
|
+
point. A global-only consumer has no distributed tools to drift, so
|
|
1226
|
+
a bridge-present repo reports ``ok`` ("not applicable"); a repo with
|
|
1227
|
+
neither lockfile nor bridge marker reports ``skipped``.
|
|
1228
|
+
"""
|
|
1229
|
+
if bridge_present:
|
|
1230
|
+
return {
|
|
1231
|
+
"id": "bridge-drift", "status": "ok",
|
|
1232
|
+
"message": "no project lockfile → distributed-tool drift not "
|
|
1233
|
+
"applicable (global-only consumer)",
|
|
1234
|
+
"remedy": "",
|
|
1235
|
+
}
|
|
1236
|
+
return {
|
|
1237
|
+
"id": "bridge-drift", "status": "skipped",
|
|
1238
|
+
"message": "no project lockfile and no bridge marker → drift check "
|
|
1239
|
+
"not applicable",
|
|
1240
|
+
"remedy": "run `agent-config init` (project install) or "
|
|
1241
|
+
"`agent-config refresh --project` (global-only consumer)",
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _run_checks_no_manifest(
|
|
1246
|
+
project_root: Path,
|
|
1247
|
+
bridge_present: bool,
|
|
1248
|
+
only: str | None = None,
|
|
1249
|
+
) -> list[dict[str, Any]]:
|
|
1250
|
+
"""Run the registry with no project manifest available.
|
|
1251
|
+
|
|
1252
|
+
Mirrors :func:`_run_checks` and preserves :data:`CHECK_IDS` order.
|
|
1253
|
+
Global checks (:data:`GLOBAL_CHECK_IDS`) run unchanged; manifest-required
|
|
1254
|
+
checks (:data:`MANIFEST_REQUIRED_CHECK_IDS`) report ``skipped``;
|
|
1255
|
+
``bridge-drift`` gets the scope-aware no-manifest verdict.
|
|
1256
|
+
"""
|
|
1257
|
+
runners: dict[str, Any] = {
|
|
1258
|
+
"scope": lambda: _check_scope(project_root),
|
|
1259
|
+
"global-binary": lambda: _check_global_binary(project_root),
|
|
1260
|
+
"manifest-integrity": lambda: _skipped_manifest_check("manifest-integrity"),
|
|
1261
|
+
"lockfile-freshness": lambda: _skipped_manifest_check("lockfile-freshness"),
|
|
1262
|
+
"bridge-drift": lambda: _check_bridge_drift_no_manifest(bridge_present),
|
|
1263
|
+
"mcp-mode": lambda: _check_mcp_mode(project_root),
|
|
1264
|
+
"mcp-beta-readiness": lambda: _check_mcp_beta_readiness(project_root),
|
|
1265
|
+
"offline-readiness": lambda: _check_offline_readiness(),
|
|
1266
|
+
"python-runtime": lambda: _check_python_runtime(),
|
|
1267
|
+
"tier-usage-readiness": lambda: _check_tier_usage_readiness(project_root),
|
|
1268
|
+
"council-cli": lambda: _check_council_cli(project_root),
|
|
1269
|
+
"unsupported-combos": lambda: _skipped_manifest_check("unsupported-combos"),
|
|
1270
|
+
"wizard-state": _check_wizard_state,
|
|
1271
|
+
}
|
|
1272
|
+
out: list[dict[str, Any]] = []
|
|
1273
|
+
for cid in CHECK_IDS:
|
|
1274
|
+
if only is not None and cid != only:
|
|
1275
|
+
continue
|
|
1276
|
+
out.append(runners[cid]())
|
|
1277
|
+
return out
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _run_no_manifest(
|
|
1281
|
+
opts: argparse.Namespace,
|
|
1282
|
+
project_root: Path,
|
|
1283
|
+
origin: str,
|
|
1284
|
+
bridge_present: bool,
|
|
1285
|
+
) -> int:
|
|
1286
|
+
"""Handle the no-project-lockfile path without the old hard bail.
|
|
1287
|
+
|
|
1288
|
+
Exit-code contract (council-defined, 2026-06-02):
|
|
1289
|
+
|
|
1290
|
+
* ``0`` — bare report for a recognised global-only consumer
|
|
1291
|
+
(bridge marker present), or a single global ``--check`` that passed.
|
|
1292
|
+
* ``1`` — a runnable health check requested via ``--check`` failed.
|
|
1293
|
+
* ``2`` — a requested ``--check`` cannot run (manifest-required check
|
|
1294
|
+
with no lockfile, or ``bridge-drift`` with neither lockfile nor
|
|
1295
|
+
bridge marker), or a bare report in an **uninitialised** repo
|
|
1296
|
+
(neither lockfile nor bridge marker) — preserves the spirit of the
|
|
1297
|
+
pre-existing "run init" signal while still printing a real report.
|
|
1298
|
+
"""
|
|
1299
|
+
checks = _run_checks_no_manifest(project_root, bridge_present, only=opts.check)
|
|
1300
|
+
fail_check = any(c["status"] == "fail" for c in checks)
|
|
1301
|
+
skipped_requested = opts.check is not None and any(
|
|
1302
|
+
c["id"] == opts.check and c["status"] == "skipped" for c in checks
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
if opts.json:
|
|
1306
|
+
_emit_json(project_root, [], [], [], [], checks=checks, origin=origin)
|
|
1307
|
+
elif opts.check is None:
|
|
1308
|
+
print(f" 📍 project_root: {project_root} (origin: {origin})")
|
|
1309
|
+
if bridge_present:
|
|
1310
|
+
print(" ℹ️ global-only consumer: bridge marker present, no "
|
|
1311
|
+
"project lockfile (expected under ADR-020)")
|
|
1312
|
+
print(" project-manifest checks are skipped — they apply only "
|
|
1313
|
+
"to project-local distributed tools")
|
|
1314
|
+
else:
|
|
1315
|
+
print(" ⚠️ no project lockfile and no bridge marker at "
|
|
1316
|
+
f"{project_root}", file=sys.stderr)
|
|
1317
|
+
print(" run `agent-config init` (project install) or "
|
|
1318
|
+
"`agent-config refresh --project` (global-only consumer)",
|
|
1319
|
+
file=sys.stderr)
|
|
1320
|
+
_emit_checks_text(checks)
|
|
1321
|
+
else:
|
|
1322
|
+
_emit_checks_text(checks)
|
|
1323
|
+
|
|
1324
|
+
if opts.check is not None:
|
|
1325
|
+
if skipped_requested:
|
|
1326
|
+
return 2
|
|
1327
|
+
return 1 if fail_check else 0
|
|
1328
|
+
return 0 if bridge_present else 2
|
|
1329
|
+
|
|
1162
1330
|
|
|
1163
1331
|
def _emit_json(
|
|
1164
1332
|
project_root: Path,
|
|
@@ -1371,17 +1539,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1371
1539
|
manifest_pth = installed_tools.manifest_path(project_root)
|
|
1372
1540
|
manifest = installed_tools.read_manifest(manifest_pth)
|
|
1373
1541
|
if manifest is None:
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
file=sys.stderr,
|
|
1381
|
-
)
|
|
1382
|
-
print(" run `./agent-config init` to create one",
|
|
1383
|
-
file=sys.stderr)
|
|
1384
|
-
return 2
|
|
1542
|
+
# No project lockfile. Under ADR-020 global-only this is a
|
|
1543
|
+
# legitimate state, not an error — run the lockfile-independent
|
|
1544
|
+
# checks and report consumer state instead of a hard bail. The
|
|
1545
|
+
# per-scope split + exit-code contract live in _run_no_manifest.
|
|
1546
|
+
bridge_present = (project_root / BRIDGE_MARKER_RELATIVE).is_file()
|
|
1547
|
+
return _run_no_manifest(opts, project_root, origin, bridge_present)
|
|
1385
1548
|
|
|
1386
1549
|
records, known = _collect_manifest_entries(project_root, manifest)
|
|
1387
1550
|
missing, modified, tag_drift = _classify(records)
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Release-published drift gate.
|
|
4
|
+
|
|
5
|
+
Catches the "release merged to main but never tagged/published" failure
|
|
6
|
+
mode — where ``main``'s ``package.json`` claims a version that has no
|
|
7
|
+
matching git tag, and npm's ``latest`` therefore lags behind main. This
|
|
8
|
+
is the backstop that would have surfaced the 5.8.0-stuck-on-5.7.0 state
|
|
9
|
+
the moment it happened, instead of weeks later.
|
|
10
|
+
|
|
11
|
+
Two independent invariants:
|
|
12
|
+
|
|
13
|
+
1. **Tag invariant** (always checkable, no network): the version in
|
|
14
|
+
``package.json`` MUST have a matching git tag (local or remote).
|
|
15
|
+
2. **npm invariant** (``--check-npm``, network): ``npm view <pkg>
|
|
16
|
+
dist-tags.latest`` MUST equal the ``package.json`` version.
|
|
17
|
+
|
|
18
|
+
Scope guard: this only makes sense on the release trunk. Off ``main``
|
|
19
|
+
(e.g. a feature branch, or a ``release/X.Y.Z`` branch mid-flight where
|
|
20
|
+
the bump legitimately precedes the tag) the gate **no-ops** unless
|
|
21
|
+
``--strict`` is combined with an explicit on-main signal. The daily
|
|
22
|
+
scheduled workflow runs ``--strict --check-npm`` on ``main``; the local
|
|
23
|
+
``task check-release-published`` runs the tag invariant only.
|
|
24
|
+
|
|
25
|
+
Exit codes: 0 = pass / no-op · 1 = drift detected · 3 = internal error.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$")
|
|
38
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
39
|
+
PACKAGE_JSON = REPO_ROOT / "package.json"
|
|
40
|
+
MAIN_BRANCH = "main"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _git(*args: str) -> tuple[int, str]:
|
|
44
|
+
proc = subprocess.run(["git", *args], capture_output=True, text=True, check=False)
|
|
45
|
+
return proc.returncode, (proc.stdout or "").strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _package_version() -> str:
|
|
49
|
+
data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
|
|
50
|
+
return str(data["version"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _package_name() -> str:
|
|
54
|
+
data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
|
|
55
|
+
return str(data["name"])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tag_exists(tag: str) -> bool:
|
|
59
|
+
rc, out = _git("tag", "-l", tag)
|
|
60
|
+
if rc == 0 and tag in out.splitlines():
|
|
61
|
+
return True
|
|
62
|
+
rc, _ = _git("ls-remote", "--exit-code", "--tags", "origin", tag)
|
|
63
|
+
return rc == 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _on_main() -> bool:
|
|
67
|
+
# Local checkout, CI push ref, or CI scheduled ref all map to main.
|
|
68
|
+
ref = os.environ.get("GITHUB_REF", "")
|
|
69
|
+
if ref in ("refs/heads/main", "refs/heads/master"):
|
|
70
|
+
return True
|
|
71
|
+
rc, head = _git("rev-parse", "--abbrev-ref", "HEAD")
|
|
72
|
+
return rc == 0 and head == MAIN_BRANCH
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _npm_latest(pkg: str) -> str | None:
|
|
76
|
+
proc = subprocess.run(
|
|
77
|
+
["npm", "view", pkg, "dist-tags.latest"],
|
|
78
|
+
capture_output=True, text=True, check=False,
|
|
79
|
+
)
|
|
80
|
+
if proc.returncode != 0:
|
|
81
|
+
return None
|
|
82
|
+
return (proc.stdout or "").strip() or None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv: list[str] | None = None) -> int:
|
|
86
|
+
ap = argparse.ArgumentParser(description="Release-published drift gate.")
|
|
87
|
+
ap.add_argument("--strict", action="store_true",
|
|
88
|
+
help="Fail on drift (default: informational, exit 0).")
|
|
89
|
+
ap.add_argument("--check-npm", action="store_true",
|
|
90
|
+
help="Also assert npm dist-tags.latest == package.json version (network).")
|
|
91
|
+
ap.add_argument("--require-main", action="store_true",
|
|
92
|
+
help="Only enforce when on main; no-op elsewhere (default for scheduled).")
|
|
93
|
+
args = ap.parse_args(argv)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
version = _package_version()
|
|
97
|
+
except (OSError, KeyError, json.JSONDecodeError) as exc:
|
|
98
|
+
print(f"❌ cannot read package.json version: {exc}", file=sys.stderr)
|
|
99
|
+
return 3
|
|
100
|
+
if not SEMVER_RE.match(version):
|
|
101
|
+
print(f"❌ package.json version is not semver: {version!r}", file=sys.stderr)
|
|
102
|
+
return 3
|
|
103
|
+
|
|
104
|
+
if args.require_main and not _on_main():
|
|
105
|
+
print(f"ℹ️ not on {MAIN_BRANCH} — release-published gate skipped.")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
problems: list[str] = []
|
|
109
|
+
|
|
110
|
+
if not _tag_exists(version):
|
|
111
|
+
problems.append(
|
|
112
|
+
f"package.json is {version} but no git tag {version} exists "
|
|
113
|
+
f"(local or origin) — the release was bumped/merged but never "
|
|
114
|
+
f"tagged. Complete it: tag the release-merge commit and push "
|
|
115
|
+
f"(triggers publish-npm.yml), e.g. `git tag {version} && git "
|
|
116
|
+
f"push origin {version}`."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if args.check_npm:
|
|
120
|
+
pkg = _package_name()
|
|
121
|
+
latest = _npm_latest(pkg)
|
|
122
|
+
if latest is None:
|
|
123
|
+
print(f"⚠️ could not read npm dist-tags.latest for {pkg} "
|
|
124
|
+
f"(network/registry) — npm invariant not checked.", file=sys.stderr)
|
|
125
|
+
elif latest != version:
|
|
126
|
+
problems.append(
|
|
127
|
+
f"npm {pkg}@latest is {latest} but package.json is {version} "
|
|
128
|
+
f"— the published release lags main. Check publish-npm.yml "
|
|
129
|
+
f"for tag {version}, or re-dispatch it."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if not problems:
|
|
133
|
+
suffix = " + npm" if args.check_npm else ""
|
|
134
|
+
print(f"✅ release-published: {version} is tagged{suffix} and in sync.")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
header = "❌ Release-published drift:" if args.strict else "⚠️ Release-published drift (warn-only):"
|
|
138
|
+
print(header, file=sys.stderr)
|
|
139
|
+
for p in problems:
|
|
140
|
+
print(f" - {p}", file=sys.stderr)
|
|
141
|
+
return 1 if args.strict else 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
raise SystemExit(main())
|
package/scripts/release.py
CHANGED
|
@@ -665,7 +665,7 @@ def execute(
|
|
|
665
665
|
resume: bool = False,
|
|
666
666
|
) -> None:
|
|
667
667
|
branch = f"release/{plan.target}"
|
|
668
|
-
total =
|
|
668
|
+
total = 10
|
|
669
669
|
|
|
670
670
|
if dry_run:
|
|
671
671
|
print("(dry-run) no git/gh mutations will be performed.")
|
|
@@ -842,6 +842,25 @@ def execute(
|
|
|
842
842
|
"--notes", notes,
|
|
843
843
|
)
|
|
844
844
|
|
|
845
|
+
# ─── 10. delete the merged release branch (local + remote) ───────────────
|
|
846
|
+
# Branch hygiene: a merged-but-undeleted release/X.Y.Z is what made
|
|
847
|
+
# `--resume` mis-detect an old version. Delete it now so it can never
|
|
848
|
+
# accumulate. Idempotent — skips whatever is already gone. Never touches
|
|
849
|
+
# `main` or any tag.
|
|
850
|
+
if dry_run:
|
|
851
|
+
_step(10, total, f"Would delete merged branch {branch} (local + remote)")
|
|
852
|
+
else:
|
|
853
|
+
deleted = []
|
|
854
|
+
if _branch_exists_local(branch) and \
|
|
855
|
+
git("rev-parse", "--abbrev-ref", "HEAD", capture=True) != branch:
|
|
856
|
+
run("git", "branch", "-D", branch, check=False)
|
|
857
|
+
deleted.append("local")
|
|
858
|
+
if _branch_exists_remote(branch):
|
|
859
|
+
run("git", "push", REMOTE, "--delete", branch, check=False)
|
|
860
|
+
deleted.append("remote")
|
|
861
|
+
where = " + ".join(deleted) if deleted else "already gone"
|
|
862
|
+
_step(10, total, f"Delete merged branch {branch} ({where})")
|
|
863
|
+
|
|
845
864
|
print()
|
|
846
865
|
print(f"✅ Released {plan.target}")
|
|
847
866
|
print(f" https://github.com/{REPO_SLUG}/releases/tag/{plan.target}")
|
|
@@ -904,43 +923,47 @@ _RELEASE_BRANCH_RE = re.compile(r"^release/(\d+\.\d+\.\d+)$")
|
|
|
904
923
|
|
|
905
924
|
|
|
906
925
|
def _detect_in_flight_target() -> str | None:
|
|
907
|
-
"""Find the in-flight release target
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
is
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
926
|
+
"""Find the in-flight release target — the SOURCE OF TRUTH is package.json.
|
|
927
|
+
|
|
928
|
+
An "in-flight" release is one whose version was already bumped into
|
|
929
|
+
``main``'s ``package.json`` (and possibly merged) but whose tag has not
|
|
930
|
+
yet been pushed — i.e. the publish step never completed. The canonical
|
|
931
|
+
anchor is therefore ``package.json`` version `V` **with no matching tag
|
|
932
|
+
`V`**, NOT the set of ``release/X.Y.Z`` branches.
|
|
933
|
+
|
|
934
|
+
Why not the branch set: merged release branches are frequently left
|
|
935
|
+
undeleted on the remote, so "highest existing release/* branch" can
|
|
936
|
+
resolve to an OLD, already-published version (e.g. picking 5.4.0 while
|
|
937
|
+
5.8.0 is the real in-flight target) and tag a downgrade. The package.json
|
|
938
|
+
version cannot lie that way — it is the version main currently claims to
|
|
939
|
+
be, and an untagged claim is exactly an incomplete release.
|
|
940
|
+
|
|
941
|
+
Resolution order:
|
|
942
|
+
1. If HEAD is on a ``release/X.Y.Z`` branch, that explicit checkout wins.
|
|
943
|
+
2. Else: read ``package.json`` version `V`. If tag `V` does not exist
|
|
944
|
+
(local or remote), `V` is the in-flight target. If it is already
|
|
945
|
+
tagged, the release is complete → return None (regular bump path).
|
|
946
|
+
|
|
947
|
+
Stale ``release/*`` branches are never used for version detection.
|
|
918
948
|
"""
|
|
919
949
|
head = git("rev-parse", "--abbrev-ref", "HEAD", capture=True)
|
|
920
950
|
m = _RELEASE_BRANCH_RE.match(head)
|
|
921
951
|
if m:
|
|
922
952
|
return m.group(1)
|
|
923
953
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
f"refs/remotes/{REMOTE}/release/", capture=True,
|
|
933
|
-
)
|
|
934
|
-
for line in remote_raw.splitlines():
|
|
935
|
-
bare = line.strip().removeprefix(f"{REMOTE}/")
|
|
936
|
-
if (m := _RELEASE_BRANCH_RE.match(bare)):
|
|
937
|
-
candidates.append(m.group(1))
|
|
954
|
+
try:
|
|
955
|
+
version = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))["version"]
|
|
956
|
+
except (OSError, KeyError, json.JSONDecodeError):
|
|
957
|
+
return None
|
|
958
|
+
try:
|
|
959
|
+
parse_version(version)
|
|
960
|
+
except Exception:
|
|
961
|
+
return None
|
|
938
962
|
|
|
939
|
-
|
|
963
|
+
# An already-tagged version is a completed release, not in-flight.
|
|
964
|
+
if _tag_exists_local(version) or _tag_exists_remote(version):
|
|
940
965
|
return None
|
|
941
|
-
|
|
942
|
-
candidates.sort(key=parse_version)
|
|
943
|
-
return candidates[-1]
|
|
966
|
+
return version
|
|
944
967
|
|
|
945
968
|
|
|
946
969
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -961,7 +984,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
961
984
|
target = args.explicit
|
|
962
985
|
elif in_flight:
|
|
963
986
|
target = in_flight
|
|
964
|
-
print(f"(resume)
|
|
987
|
+
print(f"(resume) in-flight target {in_flight} (package.json version with no tag yet)")
|
|
965
988
|
else:
|
|
966
989
|
target = bump_version(current, bump)
|
|
967
990
|
parse_version(target)
|