@arbiterforge/ca-pi 0.10.12 → 0.14.6
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/README.md +6 -5
- package/package.json +2 -1
- package/plugins/ca-pi/CHANGELOG.md +165 -0
- package/plugins/ca-pi/SKILLS.md +1 -1
- package/plugins/ca-pi/SPRINT.md +40 -10
- package/plugins/ca-pi/agents/backend-author.md +1 -1
- package/plugins/ca-pi/agents/frontend-author.md +1 -1
- package/plugins/ca-pi/generated/command-catalog.json +1 -1
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
- package/plugins/ca-pi/hooks/_approvallib.py +355 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
- package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
- package/plugins/ca-pi/hooks/_hooklib.py +37 -13
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +31 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
- package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
- package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
- package/plugins/ca-pi/hooks/_releaselib.py +933 -85
- package/plugins/ca-pi/hooks/_standuplib.py +38 -10
- package/plugins/ca-pi/hooks/doctor.py +103 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +33 -10
- package/plugins/ca-pi/hooks/releasehash.py +46 -32
- package/plugins/ca-pi/hooks/session-start.py +5 -4
- package/plugins/ca-pi/includes/artifacts.md +166 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
- package/plugins/ca-pi/includes/farm.md +27 -4
- package/plugins/ca-pi/includes/routing-table.md +2 -2
- package/plugins/ca-pi/includes/verification-boundary.md +27 -0
- package/plugins/ca-pi/package.json +18 -0
- package/plugins/ca-pi/routines/INDEX.md +4 -4
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
- package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
- package/plugins/ca-pi/routines/release/SKILL.md +150 -44
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
- package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
- package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +36 -8
|
@@ -37,9 +37,18 @@
|
|
|
37
37
|
# semver_greater(current, base) -> bool
|
|
38
38
|
# apply_bump(base, word) -> str | None
|
|
39
39
|
# last_tag_select(tags, prefix) -> str
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
40
|
+
# numeric_sequence_key(value, initial_version) -> tuple | None
|
|
41
|
+
# version_greater(current, base, policy, initial_version) -> bool
|
|
42
|
+
# derive_version(base, word, policy, initial_version) -> str | None
|
|
43
|
+
# last_tag_select_for_policy(tags, prefix, policy, initial_version) -> str
|
|
44
|
+
# notes_heading_matches(notes_text, tag, policy, initial_version) -> bool
|
|
45
|
+
# release_dates_consistent(changelog_section, tag_message, policy,
|
|
46
|
+
# initial_version) -> bool
|
|
47
|
+
# changelog_section(changelog_text, version, policy, initial_version)
|
|
48
|
+
# -> str | None
|
|
49
|
+
# render_release_assets(templates, version, tag) -> list[str] | None
|
|
50
|
+
# verify_release_asset_inventory(directory, asset_names)
|
|
51
|
+
# -> list[str] | None
|
|
43
52
|
# classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
|
|
44
53
|
# manifest_version, release_is_nondraft) -> str
|
|
45
54
|
# select_release_target_by_name(pairs, targets) -> str name-keyed
|
|
@@ -52,11 +61,13 @@
|
|
|
52
61
|
# -> list[str] paths filtered to payload, minus
|
|
53
62
|
# any payload-exclude entry (A-3.4)
|
|
54
63
|
# provenance_trigger_paths(rows) -> list[str] every manifest/changelog/
|
|
55
|
-
# generated_manifest/artifacts path a
|
|
56
|
-
# references, sorted and de-duplicated
|
|
64
|
+
# generated_manifest/artifacts/reconciliation path a
|
|
65
|
+
# declared row references, sorted and de-duplicated
|
|
66
|
+
# (A-5.6)
|
|
57
67
|
# _manifest_version(path) -> str | None
|
|
58
68
|
# classify_commit(subject, body) -> dict
|
|
59
|
-
# classify_window(commits) -> dict
|
|
69
|
+
# classify_window(commits, reconciliations=None, published_shas=None) -> dict
|
|
70
|
+
# parse_changelog_reconciliations(text, target) -> dict[str, str]
|
|
60
71
|
# parse_window_log(text) -> list[dict]
|
|
61
72
|
# first_release_baseline(adoption_log_text) -> str
|
|
62
73
|
# peel_tag(ls_remote_text, tag) -> str
|
|
@@ -92,6 +103,8 @@
|
|
|
92
103
|
# MultipleBlocksError — more than one delimiter block in the file
|
|
93
104
|
# ValueTooLongError — a declared value exceeds VALUE_MAX_CHARS
|
|
94
105
|
# (A-2.4); a sibling declared-file error, exits 4
|
|
106
|
+
# ChangelogReconciliationError — a declared reconciliation ledger is
|
|
107
|
+
# missing, unreadable, unsafe, or malformed
|
|
95
108
|
# DelimiterInValueError — a value contains the literal closing delimiter,
|
|
96
109
|
# which would otherwise truncate the block under a
|
|
97
110
|
# naive non-greedy match
|
|
@@ -124,7 +137,9 @@
|
|
|
124
137
|
from __future__ import annotations
|
|
125
138
|
|
|
126
139
|
import hashlib
|
|
140
|
+
import json
|
|
127
141
|
import os
|
|
142
|
+
import posixpath
|
|
128
143
|
import re
|
|
129
144
|
import shlex
|
|
130
145
|
import shutil
|
|
@@ -251,6 +266,15 @@ class ValueTooLongError(ReleaseTargetsError):
|
|
|
251
266
|
genuinely-absent state that triggers the Back-fill lane."""
|
|
252
267
|
|
|
253
268
|
|
|
269
|
+
class ChangelogReconciliationError(ReleaseTargetsError):
|
|
270
|
+
"""A declared changelog-reconciliation ledger is malformed.
|
|
271
|
+
|
|
272
|
+
Reconciliations are release policy, not best-effort input. A malformed
|
|
273
|
+
record must therefore fail closed like the release-target declaration
|
|
274
|
+
that points at it.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
|
|
254
278
|
class DelimiterInValueError(ReleaseTargetsError):
|
|
255
279
|
"""A declared value contains the literal closing-delimiter text, which
|
|
256
280
|
would otherwise silently truncate the block under a naive non-greedy
|
|
@@ -291,6 +315,92 @@ _PLAIN_SEMVER_CAPTURE_PATTERN = (
|
|
|
291
315
|
r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")\." +
|
|
292
316
|
r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")")
|
|
293
317
|
|
|
318
|
+
_RELEASE_ASSET_TEMPLATE_RE = re.compile(
|
|
319
|
+
r"^(?:[A-Za-z0-9._+-]|\{version\}|\{tag\})+$")
|
|
320
|
+
_RELEASE_ASSET_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
|
|
321
|
+
_WINDOWS_RESERVED_ASSET_BASENAMES = frozenset(
|
|
322
|
+
{"con", "prn", "aux", "nul"}
|
|
323
|
+
| {f"com{index}" for index in range(1, 10)}
|
|
324
|
+
| {f"lpt{index}" for index in range(1, 10)})
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _safe_release_asset_name(value):
|
|
328
|
+
"""True only for one flat, shell-inert release filename."""
|
|
329
|
+
if (not isinstance(value, str)
|
|
330
|
+
or _RELEASE_ASSET_NAME_RE.fullmatch(value) is None
|
|
331
|
+
or value.endswith(".")):
|
|
332
|
+
return False
|
|
333
|
+
basename = value.split(".", 1)[0].casefold()
|
|
334
|
+
return basename not in _WINDOWS_RESERVED_ASSET_BASENAMES
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def render_release_assets(templates, version, tag):
|
|
338
|
+
"""Render a declared asset-template sequence, or refuse with ``None``.
|
|
339
|
+
|
|
340
|
+
The grammar is intentionally smaller than ``str.format``: only the two
|
|
341
|
+
exact placeholders are recognized, all literal characters are from the
|
|
342
|
+
flat filename allowlist, and the rendered names must remain unique.
|
|
343
|
+
"""
|
|
344
|
+
if (not isinstance(templates, (list, tuple)) or not templates
|
|
345
|
+
or not isinstance(version, str) or not isinstance(tag, str)):
|
|
346
|
+
return None
|
|
347
|
+
rendered = []
|
|
348
|
+
seen = set()
|
|
349
|
+
for template in templates:
|
|
350
|
+
if (not isinstance(template, str)
|
|
351
|
+
or _RELEASE_ASSET_TEMPLATE_RE.fullmatch(template) is None):
|
|
352
|
+
return None
|
|
353
|
+
name = template.replace("{version}", version).replace("{tag}", tag)
|
|
354
|
+
identity = name.casefold()
|
|
355
|
+
if not _safe_release_asset_name(name) or identity in seen:
|
|
356
|
+
return None
|
|
357
|
+
seen.add(identity)
|
|
358
|
+
rendered.append(name)
|
|
359
|
+
return rendered
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def verify_release_asset_inventory(directory, asset_names):
|
|
363
|
+
"""Return declared-order absolute paths iff ``directory`` is exact.
|
|
364
|
+
|
|
365
|
+
The directory itself may not be a symlink. Every entry must be one of the
|
|
366
|
+
declared safe names and a non-symlink, non-empty regular file; missing or
|
|
367
|
+
additional entries fail closed with ``None``.
|
|
368
|
+
"""
|
|
369
|
+
if (not isinstance(directory, (str, os.PathLike))
|
|
370
|
+
or not isinstance(asset_names, (list, tuple)) or not asset_names):
|
|
371
|
+
return None
|
|
372
|
+
names = list(asset_names)
|
|
373
|
+
identities = [name.casefold() for name in names
|
|
374
|
+
if isinstance(name, str)]
|
|
375
|
+
if (any(not _safe_release_asset_name(name) for name in names)
|
|
376
|
+
or len(identities) != len(names)
|
|
377
|
+
or len(set(identities)) != len(identities)):
|
|
378
|
+
return None
|
|
379
|
+
root = os.path.abspath(os.fspath(directory))
|
|
380
|
+
try:
|
|
381
|
+
if os.path.islink(root) or not os.path.isdir(root):
|
|
382
|
+
return None
|
|
383
|
+
entries = list(os.scandir(root))
|
|
384
|
+
except OSError:
|
|
385
|
+
return None
|
|
386
|
+
entry_names = [entry.name for entry in entries]
|
|
387
|
+
entry_identities = [name.casefold() for name in entry_names]
|
|
388
|
+
if (len(set(entry_identities)) != len(entry_identities)
|
|
389
|
+
or set(entry_identities) != set(identities)
|
|
390
|
+
or set(entry_names) != set(names)):
|
|
391
|
+
return None
|
|
392
|
+
by_name = {entry.name: entry for entry in entries}
|
|
393
|
+
try:
|
|
394
|
+
for name in names:
|
|
395
|
+
entry = by_name[name]
|
|
396
|
+
if (entry.is_symlink()
|
|
397
|
+
or not entry.is_file(follow_symlinks=False)
|
|
398
|
+
or entry.stat(follow_symlinks=False).st_size <= 0):
|
|
399
|
+
return None
|
|
400
|
+
except OSError:
|
|
401
|
+
return None
|
|
402
|
+
return [os.path.join(root, name) for name in names]
|
|
403
|
+
|
|
294
404
|
|
|
295
405
|
def _release_re(prefix):
|
|
296
406
|
"""The anchored `<prefix>MAJOR.MINOR.PATCH` matcher for one release series."""
|
|
@@ -313,6 +423,10 @@ _HEADING_RE = re.compile(
|
|
|
313
423
|
r"^##[ \t]+(?:\[(Unreleased|" + _PLAIN_SEMVER_PATTERN + r")\]|"
|
|
314
424
|
r"v(" + _PLAIN_SEMVER_PATTERN + r"))(?:[ \t]+[^\r\n]*)?[ \t]*$",
|
|
315
425
|
re.MULTILINE)
|
|
426
|
+
_DECLARED_HEADING_RE = re.compile(
|
|
427
|
+
r"^##[ \t]+(?:\[(Unreleased|([^\]\r\n]+))\]|v([^ \t\r\n]+))"
|
|
428
|
+
r"(?:[ \t]+[^\r\n]*)?[ \t]*$",
|
|
429
|
+
re.MULTILINE)
|
|
316
430
|
_LEGACY_DATE_H2_RE = re.compile(
|
|
317
431
|
r"^##[ \t]+\[[0-9]{4}-[0-9]{2}-[0-9]{2}\]"
|
|
318
432
|
r"(?:[ \t]+[^\r\n]*)?[ \t]*$")
|
|
@@ -326,12 +440,41 @@ _SECTION_INVALID = "invalid"
|
|
|
326
440
|
_SECTION_DUPLICATE = "duplicate"
|
|
327
441
|
|
|
328
442
|
|
|
329
|
-
def
|
|
443
|
+
def _version_matches_policy(value, policy="semver", initial_version=None):
|
|
444
|
+
"""True when `value` has the exact shape declared by `policy`."""
|
|
445
|
+
if policy == "semver" and initial_version is None:
|
|
446
|
+
return (isinstance(value, str)
|
|
447
|
+
and _BARE_RELEASE_VERSION_RE.fullmatch(value) is not None)
|
|
448
|
+
if policy == "numeric-sequence":
|
|
449
|
+
return numeric_sequence_key(value, initial_version) is not None
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _policy_declaration_valid(policy="semver", initial_version=None):
|
|
454
|
+
"""True for one complete, supported version-policy declaration."""
|
|
455
|
+
if policy == "semver":
|
|
456
|
+
return initial_version is None
|
|
457
|
+
return (policy == "numeric-sequence"
|
|
458
|
+
and numeric_sequence_key(
|
|
459
|
+
initial_version, initial_version) is not None)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _heading_version(line, policy="semver", initial_version=None):
|
|
330
463
|
"""Bare version / `Unreleased` for one exact heading line, else None."""
|
|
331
|
-
|
|
464
|
+
if policy == "semver" and initial_version is None:
|
|
465
|
+
match = _HEADING_RE.fullmatch(line)
|
|
466
|
+
if match is None:
|
|
467
|
+
return None
|
|
468
|
+
return match.group(1) or match.group(2)
|
|
469
|
+
|
|
470
|
+
match = _DECLARED_HEADING_RE.fullmatch(line)
|
|
332
471
|
if match is None:
|
|
333
472
|
return None
|
|
334
|
-
|
|
473
|
+
if match.group(1) == "Unreleased":
|
|
474
|
+
return "Unreleased"
|
|
475
|
+
version = match.group(2) or match.group(3)
|
|
476
|
+
return version if _version_matches_policy(
|
|
477
|
+
version, policy, initial_version) else None
|
|
335
478
|
|
|
336
479
|
|
|
337
480
|
def _looks_like_changelog_heading(line):
|
|
@@ -345,11 +488,12 @@ def _looks_like_changelog_heading(line):
|
|
|
345
488
|
return body.startswith("[") or body.startswith("v")
|
|
346
489
|
|
|
347
490
|
|
|
348
|
-
def _changelog_section_result(changelog_text, version
|
|
491
|
+
def _changelog_section_result(changelog_text, version, policy="semver",
|
|
492
|
+
initial_version=None):
|
|
349
493
|
"""Return `(section, status)` without raising on malformed changelogs."""
|
|
350
494
|
if (not isinstance(changelog_text, str)
|
|
351
495
|
or not isinstance(version, str)
|
|
352
|
-
or
|
|
496
|
+
or not _version_matches_policy(version, policy, initial_version)):
|
|
353
497
|
return None, _SECTION_INVALID
|
|
354
498
|
|
|
355
499
|
boundaries = list(_H2_RE.finditer(changelog_text))
|
|
@@ -359,7 +503,7 @@ def _changelog_section_result(changelog_text, version):
|
|
|
359
503
|
|
|
360
504
|
for index, boundary in enumerate(boundaries):
|
|
361
505
|
line = boundary.group(0)
|
|
362
|
-
heading_version = _heading_version(line)
|
|
506
|
+
heading_version = _heading_version(line, policy, initial_version)
|
|
363
507
|
if heading_version is None:
|
|
364
508
|
if _looks_like_changelog_heading(line):
|
|
365
509
|
return None, _SECTION_INVALID
|
|
@@ -450,7 +594,7 @@ def semver_greater(current, base):
|
|
|
450
594
|
return current_pre > base_pre
|
|
451
595
|
|
|
452
596
|
|
|
453
|
-
def _bare_version(tag):
|
|
597
|
+
def _bare_version(tag, policy="semver", initial_version=None):
|
|
454
598
|
"""`v2.6.0` / `[2.6.0]` / `2.6.0` / `myapp-v0.1.31` -> the bare SemVer.
|
|
455
599
|
|
|
456
600
|
Lets the heading match compare a tag against a bracket-style changelog
|
|
@@ -465,6 +609,19 @@ def _bare_version(tag):
|
|
|
465
609
|
if not isinstance(tag, str):
|
|
466
610
|
return tag
|
|
467
611
|
text = tag.strip().strip("[]")
|
|
612
|
+
if policy != "semver" or initial_version is not None:
|
|
613
|
+
initial = _numeric_sequence_components(initial_version)
|
|
614
|
+
if policy != "numeric-sequence" or initial is None:
|
|
615
|
+
return None
|
|
616
|
+
component = r"(?:0|[1-9][0-9]*)"
|
|
617
|
+
pattern = r"(?<![0-9.])(" + r"\.".join(
|
|
618
|
+
[component] * len(initial)) + r")$"
|
|
619
|
+
match = re.search(pattern, text)
|
|
620
|
+
if match is None:
|
|
621
|
+
return None
|
|
622
|
+
candidate = match.group(1)
|
|
623
|
+
return candidate if numeric_sequence_key(
|
|
624
|
+
candidate, initial_version) is not None else None
|
|
468
625
|
match = re.search(
|
|
469
626
|
r"(?<![0-9])(" + _PLAIN_SEMVER_PATTERN + r".*)$", text)
|
|
470
627
|
return match.group(1) if match else text.lstrip("v")
|
|
@@ -512,36 +669,109 @@ def last_tag_select(tags, prefix):
|
|
|
512
669
|
NONE_SENTINEL = "<none>"
|
|
513
670
|
|
|
514
671
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
672
|
+
_NUMERIC_SEQUENCE_COMPONENT_RE = re.compile(r"^(?:0|[1-9][0-9]*)$")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _numeric_sequence_components(value):
|
|
676
|
+
"""Canonical dotted numeric components, or None for malformed input."""
|
|
677
|
+
if not isinstance(value, str):
|
|
678
|
+
return None
|
|
679
|
+
parts = value.split(".")
|
|
680
|
+
if len(parts) < 2 or any(
|
|
681
|
+
_NUMERIC_SEQUENCE_COMPONENT_RE.fullmatch(part) is None
|
|
682
|
+
for part in parts):
|
|
683
|
+
return None
|
|
684
|
+
try:
|
|
685
|
+
return tuple(int(part) for part in parts)
|
|
686
|
+
except (ValueError, TypeError):
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def numeric_sequence_key(value, initial_version):
|
|
691
|
+
"""Sortable fixed-shape numeric identity, or None when incompatible.
|
|
692
|
+
|
|
693
|
+
`initial_version` declares the component count for the series. Both the
|
|
694
|
+
initial identity and candidate use canonical decimal spelling: no signs,
|
|
695
|
+
empty components, leading zeroes, or non-ASCII digits.
|
|
696
|
+
"""
|
|
697
|
+
initial = _numeric_sequence_components(initial_version)
|
|
698
|
+
candidate = _numeric_sequence_components(value)
|
|
699
|
+
if initial is None or candidate is None or len(candidate) != len(initial):
|
|
700
|
+
return None
|
|
701
|
+
return candidate
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def version_greater(current, base, policy="semver", initial_version=None):
|
|
705
|
+
"""True iff `current` strictly advances `base` under `policy`."""
|
|
706
|
+
if policy == "semver" and initial_version is None:
|
|
707
|
+
return semver_greater(current, base)
|
|
708
|
+
if policy != "numeric-sequence":
|
|
709
|
+
return False
|
|
710
|
+
current_key = numeric_sequence_key(current, initial_version)
|
|
711
|
+
base_key = numeric_sequence_key(base, initial_version)
|
|
712
|
+
initial_key = numeric_sequence_key(initial_version, initial_version)
|
|
713
|
+
if current_key is None or base_key is None or initial_key is None:
|
|
714
|
+
return False
|
|
715
|
+
return current_key >= initial_key and current_key > base_key
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def last_tag_select_for_policy(tags, prefix, policy="semver", initial_version=None):
|
|
719
|
+
"""Highest tag in one series under a declared version policy."""
|
|
720
|
+
if policy == "semver" and initial_version is None:
|
|
721
|
+
return last_tag_select(tags, prefix)
|
|
722
|
+
if (policy != "numeric-sequence" or not isinstance(tags, (list, tuple))
|
|
723
|
+
or not isinstance(prefix, str) or not prefix
|
|
724
|
+
or numeric_sequence_key(initial_version, initial_version) is None):
|
|
725
|
+
return NONE_SENTINEL
|
|
726
|
+
|
|
727
|
+
best = None
|
|
728
|
+
for tag in tags:
|
|
729
|
+
if not isinstance(tag, str) or not tag.startswith(prefix):
|
|
730
|
+
continue
|
|
731
|
+
key = numeric_sequence_key(tag[len(prefix):], initial_version)
|
|
732
|
+
if key is None:
|
|
733
|
+
continue
|
|
734
|
+
if best is None or key > best[0]:
|
|
735
|
+
best = (key, tag)
|
|
736
|
+
return best[1] if best else NONE_SENTINEL
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def notes_heading_matches(notes_text, tag, policy="semver", initial_version=None):
|
|
740
|
+
"""True iff the FIRST changelog heading names `tag` under `policy`.
|
|
741
|
+
|
|
742
|
+
Both `## vVERSION` and Keep-a-Changelog `## [VERSION]` spellings are
|
|
743
|
+
accepted. A stale notes-file returns False, so a release lane cannot
|
|
744
|
+
publish the wrong changelog section under the right tag. Missing heading,
|
|
745
|
+
malformed policy input, or non-string input -> False.
|
|
746
|
+
"""
|
|
522
747
|
if not isinstance(notes_text, str) or not isinstance(tag, str):
|
|
523
748
|
return False
|
|
524
749
|
headings = list(_H2_RE.finditer(notes_text))
|
|
525
750
|
if not headings:
|
|
526
751
|
return False
|
|
527
|
-
version = _bare_version(tag)
|
|
528
|
-
first_version = _heading_version(
|
|
752
|
+
version = _bare_version(tag, policy, initial_version)
|
|
753
|
+
first_version = _heading_version(
|
|
754
|
+
headings[0].group(0), policy, initial_version)
|
|
529
755
|
if first_version != version or first_version == "Unreleased":
|
|
530
756
|
return False
|
|
531
|
-
_section, status = _changelog_section_result(
|
|
757
|
+
_section, status = _changelog_section_result(
|
|
758
|
+
notes_text, version, policy, initial_version)
|
|
532
759
|
return status == _SECTION_OK
|
|
533
760
|
|
|
534
761
|
|
|
535
|
-
def release_dates_consistent(changelog_section, tag_message
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
762
|
+
def release_dates_consistent(changelog_section, tag_message, policy="semver",
|
|
763
|
+
initial_version=None):
|
|
764
|
+
"""True iff the changelog and `Released-at` dates agree under `policy`.
|
|
765
|
+
|
|
766
|
+
Guards against the date being hand-typed inconsistently across surfaces.
|
|
767
|
+
Either date absent, malformed policy input, or non-string input -> False.
|
|
768
|
+
"""
|
|
540
769
|
if not isinstance(changelog_section, str) or not isinstance(tag_message, str):
|
|
541
770
|
return False
|
|
542
|
-
heading =
|
|
771
|
+
heading = _H2_RE.match(changelog_section)
|
|
543
772
|
if (heading is None or heading.start() != 0
|
|
544
|
-
or _heading_version(
|
|
773
|
+
or _heading_version(
|
|
774
|
+
heading.group(0), policy, initial_version) in (None, "Unreleased")):
|
|
545
775
|
return False
|
|
546
776
|
cm = _CHANGELOG_DATE_RE.search(heading.group(0))
|
|
547
777
|
tm = _RELEASED_AT_RE.search(tag_message)
|
|
@@ -550,12 +780,14 @@ def release_dates_consistent(changelog_section, tag_message):
|
|
|
550
780
|
return cm.group(1) == tm.group(1)
|
|
551
781
|
|
|
552
782
|
|
|
553
|
-
def changelog_section(changelog_text, version
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
783
|
+
def changelog_section(changelog_text, version, policy="semver",
|
|
784
|
+
initial_version=None):
|
|
785
|
+
"""Extract the declared-policy `VERSION` section verbatim.
|
|
786
|
+
|
|
787
|
+
The section starts at `## [VERSION] ...` (or `## vVERSION ...`) and ends
|
|
788
|
+
before the next `##` heading, or at end of text. Returns it with exactly
|
|
789
|
+
one trailing newline, or `None` when no valid heading names `version`
|
|
790
|
+
exactly. Non-string or malformed policy input -> `None`.
|
|
559
791
|
|
|
560
792
|
This is the mechanical replacement for hand-copying a changelog section:
|
|
561
793
|
Phase 3's `resume_publish` path needs the SAME text Phase 1 composed,
|
|
@@ -564,7 +796,8 @@ def changelog_section(changelog_text, version):
|
|
|
564
796
|
COMMITTED `$CHANGELOG` -- which Phase 1 step 7 commits before any tag
|
|
565
797
|
exists -- is reading the one permanent home of that text, not
|
|
566
798
|
re-deriving or hand-writing new notes (blind exercise run 19, HIGH-2)."""
|
|
567
|
-
section, status = _changelog_section_result(
|
|
799
|
+
section, status = _changelog_section_result(
|
|
800
|
+
changelog_text, version, policy, initial_version)
|
|
568
801
|
return section if status == _SECTION_OK else None
|
|
569
802
|
|
|
570
803
|
|
|
@@ -646,20 +879,28 @@ def _committed_changelog_text(repo_root, revision, changelog_path):
|
|
|
646
879
|
if os.path.normcase(supplied_root) != os.path.normcase(resolved_root):
|
|
647
880
|
return None, "supplied path is not the repository root"
|
|
648
881
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
882
|
+
# Hosted qualification runs before the tag exists and therefore binds the
|
|
883
|
+
# changelog blob by the exact candidate commit object. Recovery/read-back
|
|
884
|
+
# uses the immutable tag ref. Admit only those two explicit shapes: never
|
|
885
|
+
# let an arbitrary revision expression cross this trust boundary.
|
|
886
|
+
exact_object_id = re.fullmatch(r"[0-9a-fA-F]{40,64}", revision) is not None
|
|
887
|
+
if exact_object_id:
|
|
888
|
+
revision_ref = revision
|
|
889
|
+
else:
|
|
890
|
+
revision_ref = f"refs/tags/{revision}"
|
|
891
|
+
try:
|
|
892
|
+
tag_probe = subprocess.run(
|
|
893
|
+
[git, "check-ref-format", revision_ref],
|
|
894
|
+
capture_output=True, timeout=30, env=git_environment)
|
|
895
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
896
|
+
return None, f"cannot validate release tag: {exc}"
|
|
897
|
+
if tag_probe.returncode != 0:
|
|
898
|
+
return None, "release tag name is invalid"
|
|
658
899
|
|
|
659
900
|
try:
|
|
660
901
|
commit_probe = subprocess.run(
|
|
661
902
|
[git, "-C", actual_root, "rev-parse", "--verify",
|
|
662
|
-
f"{
|
|
903
|
+
f"{revision_ref}^{{commit}}"],
|
|
663
904
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
664
905
|
timeout=30, env=git_environment)
|
|
665
906
|
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
@@ -667,6 +908,11 @@ def _committed_changelog_text(repo_root, revision, changelog_path):
|
|
|
667
908
|
if commit_probe.returncode != 0:
|
|
668
909
|
return None, "cannot resolve committed revision"
|
|
669
910
|
commit = commit_probe.stdout.strip()
|
|
911
|
+
if exact_object_id and revision.lower() != commit.lower():
|
|
912
|
+
# Reject abbreviated SHA-256 object names and raw annotated-tag object
|
|
913
|
+
# IDs. Hosted qualification promises the exact commit object itself,
|
|
914
|
+
# not merely a spelling Git can peel or prefix-expand to that commit.
|
|
915
|
+
return None, "candidate object is not the exact full commit id"
|
|
670
916
|
|
|
671
917
|
try:
|
|
672
918
|
entry_probe = subprocess.run(
|
|
@@ -1041,7 +1287,8 @@ def provenance_trigger_paths(rows):
|
|
|
1041
1287
|
for row in rows:
|
|
1042
1288
|
if not isinstance(row, dict):
|
|
1043
1289
|
continue
|
|
1044
|
-
for key in ("manifest", "changelog", "artifacts", "generated_manifest"
|
|
1290
|
+
for key in ("manifest", "changelog", "artifacts", "generated_manifest",
|
|
1291
|
+
"changelog_reconciliations"):
|
|
1045
1292
|
value = row.get(key)
|
|
1046
1293
|
for item in (value if isinstance(value, list) else [value]):
|
|
1047
1294
|
if isinstance(item, str) and item.strip():
|
|
@@ -1072,9 +1319,12 @@ def _manifest_version(path):
|
|
|
1072
1319
|
return None
|
|
1073
1320
|
with open(path, "rb") as fh:
|
|
1074
1321
|
data = tomllib.load(fh)
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1322
|
+
if os.path.basename(lower) == "cargo.toml":
|
|
1323
|
+
value = data.get("package", {}).get("version")
|
|
1324
|
+
else:
|
|
1325
|
+
value = data.get("project", {}).get("version")
|
|
1326
|
+
if value is None:
|
|
1327
|
+
value = data.get("tool", {}).get("poetry", {}).get("version")
|
|
1078
1328
|
else:
|
|
1079
1329
|
return None
|
|
1080
1330
|
except (OSError, ValueError, AttributeError, TypeError):
|
|
@@ -1123,7 +1373,7 @@ def classify_commit(subject, body=""):
|
|
|
1123
1373
|
}
|
|
1124
1374
|
|
|
1125
1375
|
|
|
1126
|
-
def classify_window(commits):
|
|
1376
|
+
def classify_window(commits, reconciliations=None, published_shas=None):
|
|
1127
1377
|
"""`[{sha, subject, body}, ...]` -> the whole window's verdict:
|
|
1128
1378
|
`{bump, commits: [...], missing_footer: [...]}`.
|
|
1129
1379
|
|
|
@@ -1136,9 +1386,13 @@ def classify_window(commits):
|
|
|
1136
1386
|
A breaking commit bumps major regardless of type, so a `chore!:` is
|
|
1137
1387
|
reported as bumping and IS subject to the footer rule -- a hand-rolled
|
|
1138
1388
|
type-list check misses that, because `chore` is not in the bumping
|
|
1139
|
-
list.
|
|
1389
|
+
list. ``reconciliations`` may supply exact-SHA changelog text only for
|
|
1390
|
+
SHAs independently present in ``published_shas``. It never changes the
|
|
1391
|
+
commit classification or bump.
|
|
1140
1392
|
"""
|
|
1141
1393
|
rows = []
|
|
1394
|
+
reconciliations = reconciliations if isinstance(reconciliations, dict) else {}
|
|
1395
|
+
published_shas = published_shas if isinstance(published_shas, (set, frozenset)) else set()
|
|
1142
1396
|
if not isinstance(commits, (list, tuple)):
|
|
1143
1397
|
commits = []
|
|
1144
1398
|
for entry in commits:
|
|
@@ -1147,16 +1401,97 @@ def classify_window(commits):
|
|
|
1147
1401
|
verdict = classify_commit(entry.get("subject", ""), entry.get("body", ""))
|
|
1148
1402
|
verdict["sha"] = str(entry.get("sha", ""))
|
|
1149
1403
|
verdict["subject"] = str(entry.get("subject", ""))
|
|
1404
|
+
sha = verdict["sha"]
|
|
1405
|
+
reconciled = (
|
|
1406
|
+
re.fullmatch(r"[0-9a-f]{40}", sha) is not None
|
|
1407
|
+
and not verdict["has_changelog_footer"]
|
|
1408
|
+
and sha in published_shas
|
|
1409
|
+
and isinstance(reconciliations.get(sha), str)
|
|
1410
|
+
and bool(reconciliations[sha].strip())
|
|
1411
|
+
and "\n" not in reconciliations[sha]
|
|
1412
|
+
and "\r" not in reconciliations[sha]
|
|
1413
|
+
)
|
|
1414
|
+
verdict["footer_reconciled"] = reconciled
|
|
1415
|
+
verdict["changelog"] = reconciliations[sha].strip() if reconciled else ""
|
|
1150
1416
|
rows.append(verdict)
|
|
1151
1417
|
bump = "none"
|
|
1152
1418
|
for row in rows:
|
|
1153
1419
|
if _BUMP_RANK[row["bump"]] > _BUMP_RANK[bump]:
|
|
1154
1420
|
bump = row["bump"]
|
|
1155
1421
|
missing = [r for r in rows
|
|
1156
|
-
if r["bump"] != "none"
|
|
1422
|
+
if (r["bump"] != "none"
|
|
1423
|
+
and not r["has_changelog_footer"]
|
|
1424
|
+
and not r["footer_reconciled"])]
|
|
1157
1425
|
return {"bump": bump, "commits": rows, "missing_footer": missing}
|
|
1158
1426
|
|
|
1159
1427
|
|
|
1428
|
+
def parse_changelog_reconciliations(text, target):
|
|
1429
|
+
"""Parse a strict SHA-bound changelog-reconciliation ledger.
|
|
1430
|
+
|
|
1431
|
+
Returns only the entries for ``target`` as ``{full_sha: changelog}``, but
|
|
1432
|
+
validates every entry first so an unrelated malformed row cannot be
|
|
1433
|
+
hidden by target filtering. The ledger is deliberately narrow: it can
|
|
1434
|
+
supply one single-line changelog entry for an exact published commit; it
|
|
1435
|
+
cannot change commit classification, version policy, or release scope.
|
|
1436
|
+
"""
|
|
1437
|
+
def unique_object(pairs):
|
|
1438
|
+
result = {}
|
|
1439
|
+
for key, value in pairs:
|
|
1440
|
+
if key in result:
|
|
1441
|
+
raise ChangelogReconciliationError(
|
|
1442
|
+
f"duplicate JSON member {key!r}")
|
|
1443
|
+
result[key] = value
|
|
1444
|
+
return result
|
|
1445
|
+
|
|
1446
|
+
try:
|
|
1447
|
+
data = json.loads(text, object_pairs_hook=unique_object)
|
|
1448
|
+
except (TypeError, ValueError) as exc:
|
|
1449
|
+
raise ChangelogReconciliationError(
|
|
1450
|
+
f"invalid JSON: {exc}") from None
|
|
1451
|
+
if not isinstance(data, dict) or set(data) != {"schema_version", "entries"}:
|
|
1452
|
+
raise ChangelogReconciliationError(
|
|
1453
|
+
"top level must contain exactly schema_version and entries")
|
|
1454
|
+
if type(data["schema_version"]) is not int or data["schema_version"] != 1:
|
|
1455
|
+
raise ChangelogReconciliationError("schema_version must be integer 1")
|
|
1456
|
+
if not isinstance(data["entries"], list):
|
|
1457
|
+
raise ChangelogReconciliationError("entries must be a list")
|
|
1458
|
+
if len(data["entries"]) > 1024:
|
|
1459
|
+
raise ChangelogReconciliationError("entries exceeds the 1024-record limit")
|
|
1460
|
+
|
|
1461
|
+
required = {"target", "commit_sha", "changelog", "reason", "authorization"}
|
|
1462
|
+
seen = set()
|
|
1463
|
+
selected = {}
|
|
1464
|
+
for index, entry in enumerate(data["entries"]):
|
|
1465
|
+
if not isinstance(entry, dict) or set(entry) != required:
|
|
1466
|
+
raise ChangelogReconciliationError(
|
|
1467
|
+
f"entry {index} must contain exactly {', '.join(sorted(required))}")
|
|
1468
|
+
values = {}
|
|
1469
|
+
for key in required:
|
|
1470
|
+
value = entry[key]
|
|
1471
|
+
if (not isinstance(value, str) or value != value.strip()
|
|
1472
|
+
or not value or len(value) > 4096
|
|
1473
|
+
or "\r" in value or "\n" in value):
|
|
1474
|
+
raise ChangelogReconciliationError(
|
|
1475
|
+
f"entry {index} field {key!r} must be a non-empty "
|
|
1476
|
+
"single-line string of at most 4096 characters")
|
|
1477
|
+
values[key] = value
|
|
1478
|
+
if re.fullmatch(r"[A-Za-z0-9._-]+", values["target"]) is None:
|
|
1479
|
+
raise ChangelogReconciliationError(
|
|
1480
|
+
f"entry {index} target is not a valid release target name")
|
|
1481
|
+
if re.fullmatch(r"[0-9a-f]{40}", values["commit_sha"]) is None:
|
|
1482
|
+
raise ChangelogReconciliationError(
|
|
1483
|
+
f"entry {index} commit_sha must be a full lowercase Git SHA")
|
|
1484
|
+
identity = (values["target"], values["commit_sha"])
|
|
1485
|
+
if identity in seen:
|
|
1486
|
+
raise ChangelogReconciliationError(
|
|
1487
|
+
f"duplicate reconciliation for {values['target']} "
|
|
1488
|
+
f"{values['commit_sha']}")
|
|
1489
|
+
seen.add(identity)
|
|
1490
|
+
if values["target"] == target:
|
|
1491
|
+
selected[values["commit_sha"]] = values["changelog"]
|
|
1492
|
+
return selected
|
|
1493
|
+
|
|
1494
|
+
|
|
1160
1495
|
def parse_window_log(text):
|
|
1161
1496
|
"""`git log --pretty=format:%H%n%s%n%b%n----` output -> the
|
|
1162
1497
|
`[{sha, subject, body}]` shape `classify_window` consumes.
|
|
@@ -1287,6 +1622,30 @@ def apply_bump(base, word):
|
|
|
1287
1622
|
return f"{major}.{minor}.{patch + 1}"
|
|
1288
1623
|
|
|
1289
1624
|
|
|
1625
|
+
def derive_version(base, word, policy="semver", initial_version=None):
|
|
1626
|
+
"""Derive one release identity under `policy`, or None on invalid input.
|
|
1627
|
+
|
|
1628
|
+
Existing SemVer arithmetic delegates unchanged to `apply_bump`. A numeric
|
|
1629
|
+
sequence emits its declared initial identity for an empty series and then
|
|
1630
|
+
increments only the final fixed-shape component for every bumping
|
|
1631
|
+
Conventional Commit classification. `none` is refused for both policies.
|
|
1632
|
+
"""
|
|
1633
|
+
if policy == "semver" and initial_version is None:
|
|
1634
|
+
return apply_bump(base, word)
|
|
1635
|
+
if policy != "numeric-sequence" or word not in _BUMP_WORDS:
|
|
1636
|
+
return None
|
|
1637
|
+
initial_key = numeric_sequence_key(initial_version, initial_version)
|
|
1638
|
+
if initial_key is None:
|
|
1639
|
+
return None
|
|
1640
|
+
if base == NONE_SENTINEL:
|
|
1641
|
+
return initial_version
|
|
1642
|
+
base_key = numeric_sequence_key(base, initial_version)
|
|
1643
|
+
if base_key is None or base_key < initial_key:
|
|
1644
|
+
return None
|
|
1645
|
+
next_key = base_key[:-1] + (base_key[-1] + 1,)
|
|
1646
|
+
return ".".join(str(part) for part in next_key)
|
|
1647
|
+
|
|
1648
|
+
|
|
1290
1649
|
# Exit codes a POSIX shell uses to report that a command's INTERPRETER OR
|
|
1291
1650
|
# PROGRAM ITSELF could not be located or executed, as distinct from the
|
|
1292
1651
|
# program running and reporting a failure: 127 is POSIX "command not
|
|
@@ -1399,6 +1758,7 @@ _HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]+)\]$")
|
|
|
1399
1758
|
# List keys repeat by design and preserve declaration order.
|
|
1400
1759
|
_LIST_KEYS = frozenset({
|
|
1401
1760
|
"manifest", "artifacts", "pre-tag", "payload-exclude", "generated-manifest",
|
|
1761
|
+
"release-asset",
|
|
1402
1762
|
})
|
|
1403
1763
|
_BOOLEAN_KEYS = frozenset({"latest-eligible"})
|
|
1404
1764
|
_REQUIRED_KEYS = ("prefix", "changelog", "payload")
|
|
@@ -1460,6 +1820,11 @@ _KEY_FIELD = {
|
|
|
1460
1820
|
# (`codeArbiter`). Optional; the skill falls back to `$TARGET` itself
|
|
1461
1821
|
# when a row declares none.
|
|
1462
1822
|
"display-name": "display_name",
|
|
1823
|
+
"version-policy": "version_policy",
|
|
1824
|
+
"initial-version": "initial_version",
|
|
1825
|
+
"release-build": "release_build",
|
|
1826
|
+
"release-asset": "release_assets",
|
|
1827
|
+
"changelog-reconciliations": "changelog_reconciliations",
|
|
1463
1828
|
}
|
|
1464
1829
|
|
|
1465
1830
|
|
|
@@ -1479,6 +1844,11 @@ def _new_row(name):
|
|
|
1479
1844
|
"generated_manifest": [],
|
|
1480
1845
|
"generate": None,
|
|
1481
1846
|
"display_name": None,
|
|
1847
|
+
"version_policy": "semver",
|
|
1848
|
+
"initial_version": None,
|
|
1849
|
+
"release_build": None,
|
|
1850
|
+
"release_assets": [],
|
|
1851
|
+
"changelog_reconciliations": None,
|
|
1482
1852
|
}
|
|
1483
1853
|
|
|
1484
1854
|
|
|
@@ -1496,6 +1866,154 @@ def _finish_row(row):
|
|
|
1496
1866
|
f"target {row['target']!r} is missing required key(s): "
|
|
1497
1867
|
+ ", ".join(missing)
|
|
1498
1868
|
)
|
|
1869
|
+
policy = row["version_policy"]
|
|
1870
|
+
initial_version = row["initial_version"]
|
|
1871
|
+
if policy not in ("semver", "numeric-sequence"):
|
|
1872
|
+
raise MalformedBlockError(
|
|
1873
|
+
f"target {row['target']!r} declares unknown version-policy "
|
|
1874
|
+
f"{policy!r}")
|
|
1875
|
+
if policy == "semver" and initial_version is not None:
|
|
1876
|
+
raise MalformedBlockError(
|
|
1877
|
+
f"target {row['target']!r} declares initial-version for the "
|
|
1878
|
+
"semver policy")
|
|
1879
|
+
if (policy == "numeric-sequence"
|
|
1880
|
+
and numeric_sequence_key(initial_version, initial_version) is None):
|
|
1881
|
+
raise MalformedBlockError(
|
|
1882
|
+
f"target {row['target']!r} numeric-sequence policy requires an "
|
|
1883
|
+
"initial-version of two or more canonical dotted numeric components")
|
|
1884
|
+
build_declared = row["release_build"] is not None
|
|
1885
|
+
assets_declared = bool(row["release_assets"])
|
|
1886
|
+
if build_declared != assets_declared:
|
|
1887
|
+
raise MalformedBlockError(
|
|
1888
|
+
f"target {row['target']!r} must declare release-build and one "
|
|
1889
|
+
"or more release-asset values together")
|
|
1890
|
+
if build_declared:
|
|
1891
|
+
if not row["release_build"].strip():
|
|
1892
|
+
raise MalformedBlockError(
|
|
1893
|
+
f"target {row['target']!r} declares a blank release-build")
|
|
1894
|
+
representative_version = (
|
|
1895
|
+
initial_version if policy == "numeric-sequence" else "1.2.3")
|
|
1896
|
+
representative_tag = row["prefix"] + representative_version
|
|
1897
|
+
if render_release_assets(
|
|
1898
|
+
row["release_assets"], representative_version,
|
|
1899
|
+
representative_tag) is None:
|
|
1900
|
+
raise MalformedBlockError(
|
|
1901
|
+
f"target {row['target']!r} declares an unsafe, malformed, "
|
|
1902
|
+
"or duplicate release-asset template")
|
|
1903
|
+
reconciliations = row["changelog_reconciliations"]
|
|
1904
|
+
if (reconciliations is not None
|
|
1905
|
+
and _normalised_release_path(reconciliations) in (None, ".")):
|
|
1906
|
+
raise MalformedBlockError(
|
|
1907
|
+
f"target {row['target']!r} changelog-reconciliations must name "
|
|
1908
|
+
"one repository-relative file")
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
_GOVERNANCE_SCRATCH_PATHS = (
|
|
1912
|
+
".codearbiter/gate-events.log",
|
|
1913
|
+
".codearbiter/.markers",
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def _normalised_release_path(value):
|
|
1918
|
+
"""Return a conservative repository-relative path, or None on ambiguity.
|
|
1919
|
+
|
|
1920
|
+
A path that cannot be compared safely must prevent a scratch exclusion;
|
|
1921
|
+
uncertainty may make a clean-tree gate noisier, never blind it.
|
|
1922
|
+
"""
|
|
1923
|
+
if not isinstance(value, str):
|
|
1924
|
+
return None
|
|
1925
|
+
if value != value.strip() or any(char.isspace() for char in value):
|
|
1926
|
+
return None
|
|
1927
|
+
raw = value.replace("\\", "/")
|
|
1928
|
+
if (not raw or raw.startswith(('/', ':'))
|
|
1929
|
+
or any(char in raw for char in "*?[]")
|
|
1930
|
+
or re.match(r"^[A-Za-z]:", raw)):
|
|
1931
|
+
return None
|
|
1932
|
+
components = raw.split("/")
|
|
1933
|
+
if (raw != "."
|
|
1934
|
+
and any(component.endswith((".", " "))
|
|
1935
|
+
or re.search(r"~\d", component)
|
|
1936
|
+
for component in components)):
|
|
1937
|
+
return None
|
|
1938
|
+
normalised = posixpath.normpath(raw)
|
|
1939
|
+
if normalised == ".." or normalised.startswith("../"):
|
|
1940
|
+
return None
|
|
1941
|
+
while normalised.startswith("./"):
|
|
1942
|
+
normalised = normalised[2:]
|
|
1943
|
+
return (normalised or ".").casefold()
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def _release_paths_overlap(left, right):
|
|
1947
|
+
if left is None or right is None:
|
|
1948
|
+
return True
|
|
1949
|
+
if left == "." or right == ".":
|
|
1950
|
+
return True
|
|
1951
|
+
return (left == right
|
|
1952
|
+
or left.startswith(right.rstrip("/") + "/")
|
|
1953
|
+
or right.startswith(left.rstrip("/") + "/"))
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _release_path_contains(container, path):
|
|
1957
|
+
if container is None or path is None:
|
|
1958
|
+
return False
|
|
1959
|
+
return container == "." or path == container or path.startswith(
|
|
1960
|
+
container.rstrip("/") + "/")
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def governance_scratch_exclusions(row):
|
|
1964
|
+
"""Return scratch paths safe to exclude for this declared target.
|
|
1965
|
+
|
|
1966
|
+
Governance-owned scratch is normally outside a target, but an operator
|
|
1967
|
+
may deliberately declare it (or the whole repository) as a payload,
|
|
1968
|
+
manifest, changelog, generated manifest, provenance manifest, artifact,
|
|
1969
|
+
or release asset. Such a declaration wins: the path remains visible to
|
|
1970
|
+
clean-tree and pre-tag mutation gates.
|
|
1971
|
+
"""
|
|
1972
|
+
surfaces = [row.get("changelog"), row.get("provenance_manifest"),
|
|
1973
|
+
row.get("changelog_reconciliations")]
|
|
1974
|
+
for field in ("manifest", "generated_manifest", "artifacts",
|
|
1975
|
+
"release_assets"):
|
|
1976
|
+
surfaces.extend(row.get(field) or [])
|
|
1977
|
+
|
|
1978
|
+
normalised_surfaces = [
|
|
1979
|
+
_normalised_release_path(value)
|
|
1980
|
+
for value in surfaces
|
|
1981
|
+
if value is not None and (not isinstance(value, str) or value.strip())
|
|
1982
|
+
]
|
|
1983
|
+
|
|
1984
|
+
payload = _normalised_release_path(row.get("payload"))
|
|
1985
|
+
payload_excludes = [
|
|
1986
|
+
_normalised_release_path(value)
|
|
1987
|
+
for value in (row.get("payload_exclude") or [])
|
|
1988
|
+
]
|
|
1989
|
+
|
|
1990
|
+
def overlaps(scratch):
|
|
1991
|
+
scratch_path = _normalised_release_path(scratch)
|
|
1992
|
+
for surface in normalised_surfaces:
|
|
1993
|
+
if _release_paths_overlap(surface, scratch_path):
|
|
1994
|
+
return True
|
|
1995
|
+
if payload is None:
|
|
1996
|
+
return True
|
|
1997
|
+
if _release_paths_overlap(payload, scratch_path):
|
|
1998
|
+
if not any(_release_path_contains(exclusion, scratch_path)
|
|
1999
|
+
for exclusion in payload_excludes):
|
|
2000
|
+
return True
|
|
2001
|
+
return False
|
|
2002
|
+
|
|
2003
|
+
return [path for path in _GOVERNANCE_SCRATCH_PATHS if not overlaps(path)]
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def release_tree_status(row, project_root):
|
|
2007
|
+
"""Run the target-aware repository-wide clean-tree probe."""
|
|
2008
|
+
scratch_pathspecs = [
|
|
2009
|
+
f":(exclude,top){path}"
|
|
2010
|
+
for path in governance_scratch_exclusions(row)
|
|
2011
|
+
]
|
|
2012
|
+
return subprocess.run(
|
|
2013
|
+
[git_executable(), "status", "--porcelain", "--", ":/",
|
|
2014
|
+
*scratch_pathspecs],
|
|
2015
|
+
capture_output=True, text=True, cwd=project_root,
|
|
2016
|
+
env=_sanitized_git_environment())
|
|
1499
2017
|
|
|
1500
2018
|
|
|
1501
2019
|
def parse_release_targets(text):
|
|
@@ -1504,7 +2022,8 @@ def parse_release_targets(text):
|
|
|
1504
2022
|
carrying: target, prefix, manifest (list), changelog, payload,
|
|
1505
2023
|
payload_exclude (list), rebuild, artifacts (list), provenance_manifest,
|
|
1506
2024
|
pre_tag (list), latest_eligible (bool), generated_manifest (list),
|
|
1507
|
-
generate, display_name
|
|
2025
|
+
generate, display_name, version_policy, initial_version, release_build,
|
|
2026
|
+
release_assets.
|
|
1508
2027
|
|
|
1509
2028
|
Pure — no file I/O — so it is testable with synthetic input; `load_targets`
|
|
1510
2029
|
is the one function that touches the filesystem.
|
|
@@ -1786,7 +2305,7 @@ def detect_candidate_target(manifest_candidates, changelog_candidates,
|
|
|
1786
2305
|
(`scan_backfill_candidates` is the one filesystem reader, kept separate
|
|
1787
2306
|
per this module's read-isolation convention). Returns a row dict shaped
|
|
1788
2307
|
like one `load_targets` entry (`target`, `prefix`, `manifest`,
|
|
1789
|
-
`changelog`, `payload`, `latest_eligible`) ONLY when exactly one manifest
|
|
2308
|
+
`changelog`, `payload`, `payload_exclude`, `latest_eligible`) ONLY when exactly one manifest
|
|
1790
2309
|
candidate and exactly one changelog candidate were found. Raises
|
|
1791
2310
|
`BackfillAmbiguousError` for every other case — zero or multiple of
|
|
1792
2311
|
either — naming which side was ambiguous and what was found, so a caller
|
|
@@ -1838,6 +2357,10 @@ def detect_candidate_target(manifest_candidates, changelog_candidates,
|
|
|
1838
2357
|
"manifest": [manifest_candidates[0]],
|
|
1839
2358
|
"changelog": changelog_candidates[0],
|
|
1840
2359
|
"payload": ".",
|
|
2360
|
+
# Back-fill is the release-only adoption lane. Its own hooks create
|
|
2361
|
+
# governance scratch under .codearbiter/, so a root payload without
|
|
2362
|
+
# this exclusion can never become clean after ordinary hook use.
|
|
2363
|
+
"payload_exclude": [".codearbiter/"],
|
|
1841
2364
|
"latest_eligible": True,
|
|
1842
2365
|
}
|
|
1843
2366
|
|
|
@@ -1863,6 +2386,8 @@ def format_release_targets_block(row):
|
|
|
1863
2386
|
lines.append(f"manifest: {manifest}")
|
|
1864
2387
|
lines.append(f"changelog: {row['changelog']}")
|
|
1865
2388
|
lines.append(f"payload: {row['payload']}")
|
|
2389
|
+
for excluded in row.get("payload_exclude", []):
|
|
2390
|
+
lines.append(f"payload-exclude: {excluded}")
|
|
1866
2391
|
if "latest_eligible" in row:
|
|
1867
2392
|
lines.append(f"latest-eligible: {'true' if row['latest_eligible'] else 'false'}")
|
|
1868
2393
|
lines.append("<!-- /release-targets -->")
|
|
@@ -1920,6 +2445,87 @@ def default_backfill_root():
|
|
|
1920
2445
|
return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
1921
2446
|
|
|
1922
2447
|
|
|
2448
|
+
def _load_declared_changelog_reconciliations(
|
|
2449
|
+
row, target, project_root, published_commit):
|
|
2450
|
+
"""Load one target's ledger from the fetched default-branch commit."""
|
|
2451
|
+
declared = row.get("changelog_reconciliations")
|
|
2452
|
+
if declared is None:
|
|
2453
|
+
return {}
|
|
2454
|
+
tree_path = _normalize_git_tree_path(declared)
|
|
2455
|
+
if tree_path is None or _normalised_release_path(declared) == ".":
|
|
2456
|
+
raise ChangelogReconciliationError(
|
|
2457
|
+
"changelog-reconciliations must name one repository-relative file")
|
|
2458
|
+
root = os.path.realpath(os.path.abspath(project_root))
|
|
2459
|
+
try:
|
|
2460
|
+
git = git_executable()
|
|
2461
|
+
environment = _sanitized_git_environment()
|
|
2462
|
+
root_probe = subprocess.run(
|
|
2463
|
+
[git, "rev-parse", "--show-toplevel"], cwd=root,
|
|
2464
|
+
capture_output=True, text=True, timeout=30, env=environment)
|
|
2465
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
2466
|
+
raise ChangelogReconciliationError(
|
|
2467
|
+
f"could not verify repository root: {exc}") from None
|
|
2468
|
+
if (root_probe.returncode != 0
|
|
2469
|
+
or os.path.normcase(os.path.realpath(os.path.abspath(
|
|
2470
|
+
root_probe.stdout.strip()))) != os.path.normcase(root)):
|
|
2471
|
+
raise ChangelogReconciliationError(
|
|
2472
|
+
"could not bind changelog-reconciliations to the repository root")
|
|
2473
|
+
|
|
2474
|
+
try:
|
|
2475
|
+
entry_probe = subprocess.run(
|
|
2476
|
+
[git, "ls-tree", "-z", published_commit, "--", tree_path], cwd=root,
|
|
2477
|
+
capture_output=True, timeout=30, env=environment)
|
|
2478
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
2479
|
+
raise ChangelogReconciliationError(
|
|
2480
|
+
f"could not inspect committed ledger: {exc}") from None
|
|
2481
|
+
entries = [entry for entry in entry_probe.stdout.split(b"\0") if entry]
|
|
2482
|
+
if entry_probe.returncode != 0 or len(entries) != 1:
|
|
2483
|
+
raise ChangelogReconciliationError(
|
|
2484
|
+
"changelog-reconciliations must be one regular file committed on "
|
|
2485
|
+
"the fetched default branch")
|
|
2486
|
+
match = re.fullmatch(
|
|
2487
|
+
rb"(100644|100755) blob ([0-9a-f]{40})\t(.+)", entries[0])
|
|
2488
|
+
if match is None:
|
|
2489
|
+
raise ChangelogReconciliationError(
|
|
2490
|
+
"changelog-reconciliations must be a committed regular blob")
|
|
2491
|
+
try:
|
|
2492
|
+
found_path = match.group(3).decode("utf-8")
|
|
2493
|
+
except UnicodeError as exc:
|
|
2494
|
+
raise ChangelogReconciliationError(
|
|
2495
|
+
f"committed ledger path is not UTF-8: {exc}") from None
|
|
2496
|
+
if found_path != tree_path:
|
|
2497
|
+
raise ChangelogReconciliationError(
|
|
2498
|
+
"committed ledger path does not match the declaration exactly")
|
|
2499
|
+
object_sha = match.group(2).decode("ascii")
|
|
2500
|
+
try:
|
|
2501
|
+
size_probe = subprocess.run(
|
|
2502
|
+
[git, "cat-file", "-s", object_sha], cwd=root,
|
|
2503
|
+
capture_output=True, text=True, timeout=30, env=environment)
|
|
2504
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
2505
|
+
raise ChangelogReconciliationError(
|
|
2506
|
+
f"could not inspect committed ledger size: {exc}") from None
|
|
2507
|
+
if size_probe.returncode != 0 or not size_probe.stdout.strip().isdigit():
|
|
2508
|
+
raise ChangelogReconciliationError(
|
|
2509
|
+
"could not verify committed ledger size")
|
|
2510
|
+
if int(size_probe.stdout.strip()) > 1024 * 1024:
|
|
2511
|
+
raise ChangelogReconciliationError("ledger exceeds the 1 MiB limit")
|
|
2512
|
+
try:
|
|
2513
|
+
blob_probe = subprocess.run(
|
|
2514
|
+
[git, "cat-file", "blob", object_sha], cwd=root,
|
|
2515
|
+
capture_output=True, timeout=30, env=environment)
|
|
2516
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
2517
|
+
raise ChangelogReconciliationError(
|
|
2518
|
+
f"could not read committed ledger: {exc}") from None
|
|
2519
|
+
if blob_probe.returncode != 0:
|
|
2520
|
+
raise ChangelogReconciliationError("could not read committed ledger")
|
|
2521
|
+
try:
|
|
2522
|
+
text = blob_probe.stdout.decode("utf-8")
|
|
2523
|
+
except UnicodeError as exc:
|
|
2524
|
+
raise ChangelogReconciliationError(
|
|
2525
|
+
f"committed ledger is not UTF-8: {exc}") from None
|
|
2526
|
+
return parse_changelog_reconciliations(text, target)
|
|
2527
|
+
|
|
2528
|
+
|
|
1923
2529
|
def _resolve_target_row(target, targets_file):
|
|
1924
2530
|
"""The declared row named `target` in `targets_file`, or `None` if no
|
|
1925
2531
|
row of that name is declared. Raises `ReleaseTargetsError` (any
|
|
@@ -1999,13 +2605,19 @@ def main(argv):
|
|
|
1999
2605
|
equality guard: `classify` short-circuits on
|
|
2000
2606
|
a fresh publish and never reaches its own
|
|
2001
2607
|
version comparison (run 12).
|
|
2002
|
-
classify-window
|
|
2608
|
+
classify-window [<target> <default-branch>]
|
|
2609
|
+
stdin = `git log $WINDOW --pretty=format:
|
|
2003
2610
|
%H%n%s%n%b%n---- -- $PAYLOAD` -> prints the
|
|
2004
|
-
derived bump, then
|
|
2611
|
+
derived bump, then accepted `[RECONCILED]`
|
|
2612
|
+
rows and one
|
|
2005
2613
|
`[NEEDS-TRIAGE] <sha> <subject>` line per
|
|
2006
2614
|
BUMPING commit missing a CHANGELOG: footer.
|
|
2007
2615
|
exit 0 clean - 1 a footer is missing (the
|
|
2008
2616
|
step-3 BLOCK) - 2 the window is non-bumping
|
|
2617
|
+
- 4 a declared reconciliation ledger or
|
|
2618
|
+
exact default-branch proof failed closed.
|
|
2619
|
+
The no-argument form remains the strict
|
|
2620
|
+
legacy classifier and accepts no ledger.
|
|
2009
2621
|
(the step-2 STOP). Classifies and reports;
|
|
2010
2622
|
the skill keeps the decision.
|
|
2011
2623
|
adoption-commit stdin = `git log --diff-filter=A --format=%H
|
|
@@ -2114,14 +2726,24 @@ def main(argv):
|
|
|
2114
2726
|
window subtraction plain `git log --
|
|
2115
2727
|
<path>` cannot express. Same declared-file
|
|
2116
2728
|
resolution as `show-row`.
|
|
2729
|
+
clean-tree-status <target> runs repository-wide `git status
|
|
2730
|
+
--porcelain` while excluding governance
|
|
2731
|
+
scratch only when it does not overlap any
|
|
2732
|
+
surface declared by the target. Prints the
|
|
2733
|
+
porcelain result; exit 0 on a successful
|
|
2734
|
+
probe even when dirty, 2 for bad invocation
|
|
2735
|
+
or unknown target, 3/4 for declared-file
|
|
2736
|
+
states, and git's non-zero status on probe
|
|
2737
|
+
failure.
|
|
2117
2738
|
|
|
2118
2739
|
`--targets-file PATH` overrides `default_targets_path()` for `tag-prefix`
|
|
2119
|
-
and `list-targets` only; `show-row
|
|
2120
|
-
the declared file through
|
|
2121
|
-
|
|
2122
|
-
`list-targets`, `show-row`,
|
|
2123
|
-
|
|
2124
|
-
|
|
2740
|
+
and `list-targets` only; `show-row`, `payload-pathspec`, and
|
|
2741
|
+
`clean-tree-status` always resolve the declared file through
|
|
2742
|
+
`default_targets_path()` with no override, and every remaining subcommand
|
|
2743
|
+
needs no declared file at all. `tag-prefix`, `list-targets`, `show-row`,
|
|
2744
|
+
`payload-pathspec`, and `clean-tree-status` all exit 3 when the declared
|
|
2745
|
+
file is genuinely absent and 4 for every other declared-file error
|
|
2746
|
+
(HIGH-1, `_targets_error_exit_code`); every other subcommand
|
|
2125
2747
|
prints a value/label and exits 0, or writes a short cause to stderr and
|
|
2126
2748
|
exits non-zero — never a bare traceback, so a caller shelling out to
|
|
2127
2749
|
this file gets a diagnosable failure either way. Returns a process exit
|
|
@@ -2129,8 +2751,10 @@ def main(argv):
|
|
|
2129
2751
|
if not argv:
|
|
2130
2752
|
sys.stderr.write(
|
|
2131
2753
|
"usage: _releaselib.py {tag-prefix|list-targets|show-row|"
|
|
2132
|
-
"payload-pathspec|last-tag|notes-match|
|
|
2133
|
-
"dates-match|semver-greater|
|
|
2754
|
+
"payload-pathspec|clean-tree-status|last-tag|last-tag-for-policy|notes-match|"
|
|
2755
|
+
"changelog-section|dates-match|semver-greater|version-greater|"
|
|
2756
|
+
"apply-bump|derive-version|render-release-assets|"
|
|
2757
|
+
"verify-release-assets|classify|peel-tag|"
|
|
2134
2758
|
"run-pre-tag|adoption-commit|classify-window|check-manifests|"
|
|
2135
2759
|
"backfill-detect} ...\n")
|
|
2136
2760
|
return 2
|
|
@@ -2213,7 +2837,10 @@ def main(argv):
|
|
|
2213
2837
|
payload = row.get("payload") or "."
|
|
2214
2838
|
parts = [payload] + [f":(exclude){p}"
|
|
2215
2839
|
for p in (row.get("payload_exclude") or [])]
|
|
2216
|
-
|
|
2840
|
+
# One record per line preserves a pathspec containing spaces.
|
|
2841
|
+
# The release skill loads these records into quoted positional
|
|
2842
|
+
# parameters; shell words cannot represent this boundary safely.
|
|
2843
|
+
print("\n".join(parts))
|
|
2217
2844
|
return 0
|
|
2218
2845
|
|
|
2219
2846
|
# Emitted as SHELL-QUOTED `NAME='value'` pairs, and named for the
|
|
@@ -2250,7 +2877,14 @@ def main(argv):
|
|
|
2250
2877
|
("PRE_TAG", "pre_tag"),
|
|
2251
2878
|
("PROVENANCE_MANIFEST", "provenance_manifest"),
|
|
2252
2879
|
("LATEST_ELIGIBLE", "latest_eligible"),
|
|
2880
|
+
("CHANGELOG_RECONCILIATIONS", "changelog_reconciliations"),
|
|
2253
2881
|
("DISPLAY_NAME", "display_name")]
|
|
2882
|
+
query_fields = fields + [
|
|
2883
|
+
("VERSION_POLICY", "version_policy"),
|
|
2884
|
+
("INITIAL_VERSION", "initial_version"),
|
|
2885
|
+
("RELEASE_BUILD", "release_build"),
|
|
2886
|
+
("RELEASE_ASSETS", "release_assets"),
|
|
2887
|
+
]
|
|
2254
2888
|
|
|
2255
2889
|
def _flatten(value):
|
|
2256
2890
|
if isinstance(value, (list, tuple)):
|
|
@@ -2261,11 +2895,11 @@ def main(argv):
|
|
|
2261
2895
|
|
|
2262
2896
|
if field is not None:
|
|
2263
2897
|
wanted = field.strip().lower().replace("-", "_")
|
|
2264
|
-
by_key = {key: name for name, key in
|
|
2898
|
+
by_key = {key: name for name, key in query_fields}
|
|
2265
2899
|
if wanted not in by_key:
|
|
2266
2900
|
sys.stderr.write(
|
|
2267
2901
|
f"unknown field {field!r}; declared fields are: "
|
|
2268
|
-
+ ", ".join(key for _n, key in
|
|
2902
|
+
+ ", ".join(key for _n, key in query_fields) + "\n")
|
|
2269
2903
|
return 2
|
|
2270
2904
|
print(_flatten(row.get(wanted)))
|
|
2271
2905
|
return 0
|
|
@@ -2307,15 +2941,35 @@ def main(argv):
|
|
|
2307
2941
|
print(last_tag_select(sys.stdin.read().split(), rest[0]))
|
|
2308
2942
|
return 0
|
|
2309
2943
|
|
|
2310
|
-
if cmd == "
|
|
2944
|
+
if cmd == "last-tag-for-policy" and len(rest) == 3:
|
|
2945
|
+
prefix, policy, initial_version = rest
|
|
2946
|
+
initial = initial_version or None
|
|
2947
|
+
if (policy not in ("semver", "numeric-sequence")
|
|
2948
|
+
or (policy == "semver" and initial is not None)
|
|
2949
|
+
or (policy == "numeric-sequence"
|
|
2950
|
+
and numeric_sequence_key(initial, initial) is None)):
|
|
2951
|
+
sys.stderr.write("last-tag-for-policy: invalid policy declaration\n")
|
|
2952
|
+
return 2
|
|
2953
|
+
print(last_tag_select_for_policy(
|
|
2954
|
+
sys.stdin.read().split(), prefix, policy, initial))
|
|
2955
|
+
return 0
|
|
2956
|
+
|
|
2957
|
+
if cmd == "notes-match" and len(rest) in (2, 4):
|
|
2958
|
+
tag, notes_path = rest[:2]
|
|
2959
|
+
policy, initial = (rest[2], rest[3] or None) if len(rest) == 4 else (
|
|
2960
|
+
"semver", None)
|
|
2961
|
+
if not _policy_declaration_valid(policy, initial):
|
|
2962
|
+
sys.stderr.write("notes-match: invalid policy declaration\n")
|
|
2963
|
+
return 2
|
|
2311
2964
|
try:
|
|
2312
|
-
with open(
|
|
2965
|
+
with open(notes_path, encoding="utf-8") as fh:
|
|
2313
2966
|
notes_text = fh.read()
|
|
2314
2967
|
except OSError:
|
|
2315
2968
|
notes_text = ""
|
|
2316
|
-
return 0 if notes_heading_matches(
|
|
2969
|
+
return 0 if notes_heading_matches(
|
|
2970
|
+
notes_text, tag, policy, initial) else 1
|
|
2317
2971
|
|
|
2318
|
-
if cmd == "changelog-section" and len(rest)
|
|
2972
|
+
if cmd == "changelog-section" and len(rest) in (4, 6):
|
|
2319
2973
|
# Mechanical reconstruction of Phase 1's composed section, for
|
|
2320
2974
|
# `resume_publish` -- see `changelog_section`'s docstring. Exit 0
|
|
2321
2975
|
# with the section on stdout - 1 the changelog has no heading for
|
|
@@ -2325,7 +2979,13 @@ def main(argv):
|
|
|
2325
2979
|
# `revision` is resolved to a commit hash before the blob read, so a
|
|
2326
2980
|
# current HEAD move or dirty/deleted working file cannot change the
|
|
2327
2981
|
# notes selected for an already-composed tag.
|
|
2328
|
-
repo_root, revision, changelog_path, version = rest
|
|
2982
|
+
repo_root, revision, changelog_path, version = rest[:4]
|
|
2983
|
+
policy, initial = (rest[4], rest[5] or None) if len(rest) == 6 else (
|
|
2984
|
+
"semver", None)
|
|
2985
|
+
if not _policy_declaration_valid(policy, initial):
|
|
2986
|
+
sys.stderr.write(
|
|
2987
|
+
"changelog-section: invalid policy declaration\n")
|
|
2988
|
+
return 2
|
|
2329
2989
|
changelog_text, source_error = _committed_changelog_text(
|
|
2330
2990
|
repo_root, revision, changelog_path)
|
|
2331
2991
|
if source_error is not None:
|
|
@@ -2333,7 +2993,8 @@ def main(argv):
|
|
|
2333
2993
|
f"changelog-section: cannot read committed "
|
|
2334
2994
|
f"{changelog_path!r}: {source_error}\n")
|
|
2335
2995
|
return 3
|
|
2336
|
-
section, status = _changelog_section_result(
|
|
2996
|
+
section, status = _changelog_section_result(
|
|
2997
|
+
changelog_text, version, policy, initial)
|
|
2337
2998
|
if status == _SECTION_ABSENT:
|
|
2338
2999
|
sys.stderr.write(
|
|
2339
3000
|
f"changelog-section: no '## [{version}]' heading in "
|
|
@@ -2416,7 +3077,7 @@ def main(argv):
|
|
|
2416
3077
|
return 2
|
|
2417
3078
|
return 1 if mismatched else 0
|
|
2418
3079
|
|
|
2419
|
-
if cmd == "classify-window" and
|
|
3080
|
+
if cmd == "classify-window" and len(rest) in (0, 2):
|
|
2420
3081
|
# stdin = `git log $WINDOW --pretty=format:%H%n%s%n%b%n---- --
|
|
2421
3082
|
# $PAYLOAD`. Prints the derived bump on the first line, then one
|
|
2422
3083
|
# `[NEEDS-TRIAGE] <short-sha> <subject>` line per BUMPING commit
|
|
@@ -2431,8 +3092,105 @@ def main(argv):
|
|
|
2431
3092
|
# skill keeps the BLOCK. A helper returning proceed/stop would put
|
|
2432
3093
|
# a governance decision inside a library, which is the wrong side
|
|
2433
3094
|
# of ADR-0010's cooperative-agent line.
|
|
2434
|
-
|
|
3095
|
+
commits = parse_window_log(sys.stdin.read())
|
|
3096
|
+
reconciliations = {}
|
|
3097
|
+
published_shas = set()
|
|
3098
|
+
if rest:
|
|
3099
|
+
target, default_branch = rest
|
|
3100
|
+
targets_file = default_targets_path()
|
|
3101
|
+
project_root = os.path.dirname(os.path.dirname(targets_file))
|
|
3102
|
+
try:
|
|
3103
|
+
row = _resolve_target_row(target, targets_file)
|
|
3104
|
+
if row is None:
|
|
3105
|
+
sys.stderr.write(f"unknown release target: {target}\n")
|
|
3106
|
+
return 2
|
|
3107
|
+
except ReleaseTargetsError as exc:
|
|
3108
|
+
sys.stderr.write(
|
|
3109
|
+
f"{type(exc).__name__}: could not load changelog "
|
|
3110
|
+
f"reconciliations: {exc}\n")
|
|
3111
|
+
return _targets_error_exit_code(exc)
|
|
3112
|
+
|
|
3113
|
+
if row.get("changelog_reconciliations") is not None:
|
|
3114
|
+
try:
|
|
3115
|
+
branch_probe = subprocess.run(
|
|
3116
|
+
[git_executable(), "check-ref-format", "--branch",
|
|
3117
|
+
default_branch], cwd=project_root, capture_output=True,
|
|
3118
|
+
text=True, timeout=30,
|
|
3119
|
+
env=_sanitized_git_environment())
|
|
3120
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
3121
|
+
sys.stderr.write(
|
|
3122
|
+
f"classify-window: could not validate default branch: "
|
|
3123
|
+
f"{exc}\n")
|
|
3124
|
+
return 4
|
|
3125
|
+
if branch_probe.returncode != 0:
|
|
3126
|
+
sys.stderr.write(
|
|
3127
|
+
f"classify-window: invalid default branch "
|
|
3128
|
+
f"{default_branch!r}\n")
|
|
3129
|
+
return 4
|
|
3130
|
+
published_ref = f"refs/remotes/origin/{default_branch}"
|
|
3131
|
+
try:
|
|
3132
|
+
published_probe = subprocess.run(
|
|
3133
|
+
[git_executable(), "rev-parse", "--verify",
|
|
3134
|
+
f"{published_ref}^{{commit}}"], cwd=project_root,
|
|
3135
|
+
capture_output=True, text=True, timeout=30,
|
|
3136
|
+
env=_sanitized_git_environment())
|
|
3137
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
3138
|
+
sys.stderr.write(
|
|
3139
|
+
f"classify-window: could not resolve published default "
|
|
3140
|
+
f"branch: {exc}\n")
|
|
3141
|
+
return 4
|
|
3142
|
+
published_commit = published_probe.stdout.strip()
|
|
3143
|
+
if (published_probe.returncode != 0
|
|
3144
|
+
or re.fullmatch(
|
|
3145
|
+
r"[0-9a-f]{40}", published_commit) is None):
|
|
3146
|
+
sys.stderr.write(
|
|
3147
|
+
f"classify-window: could not resolve exact published "
|
|
3148
|
+
f"ref {published_ref!r}\n")
|
|
3149
|
+
return 4
|
|
3150
|
+
try:
|
|
3151
|
+
reconciliations = _load_declared_changelog_reconciliations(
|
|
3152
|
+
row, target, project_root, published_commit)
|
|
3153
|
+
except ReleaseTargetsError as exc:
|
|
3154
|
+
sys.stderr.write(
|
|
3155
|
+
f"{type(exc).__name__}: could not load changelog "
|
|
3156
|
+
f"reconciliations: {exc}\n")
|
|
3157
|
+
return _targets_error_exit_code(exc)
|
|
3158
|
+
|
|
3159
|
+
window_shas = {
|
|
3160
|
+
entry.get("sha") for entry in commits
|
|
3161
|
+
if isinstance(entry, dict)
|
|
3162
|
+
}
|
|
3163
|
+
for sha in sorted(set(reconciliations).intersection(window_shas)):
|
|
3164
|
+
try:
|
|
3165
|
+
ancestor = subprocess.run(
|
|
3166
|
+
[git_executable(), "merge-base", "--is-ancestor",
|
|
3167
|
+
sha, published_commit],
|
|
3168
|
+
cwd=project_root, capture_output=True, text=True,
|
|
3169
|
+
timeout=30, env=_sanitized_git_environment())
|
|
3170
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
3171
|
+
sys.stderr.write(
|
|
3172
|
+
f"classify-window: could not verify published "
|
|
3173
|
+
f"commit {sha}: {exc}\n")
|
|
3174
|
+
return 4
|
|
3175
|
+
if ancestor.returncode == 0:
|
|
3176
|
+
published_shas.add(sha)
|
|
3177
|
+
elif ancestor.returncode != 1:
|
|
3178
|
+
sys.stderr.write(
|
|
3179
|
+
f"classify-window: could not verify {sha} against "
|
|
3180
|
+
f"published ref {published_ref!r}: "
|
|
3181
|
+
f"{ancestor.stderr.strip()}\n")
|
|
3182
|
+
return 4
|
|
3183
|
+
|
|
3184
|
+
window = classify_window(
|
|
3185
|
+
commits,
|
|
3186
|
+
reconciliations=reconciliations,
|
|
3187
|
+
published_shas=published_shas)
|
|
2435
3188
|
print(window["bump"])
|
|
3189
|
+
for row in window["commits"]:
|
|
3190
|
+
if row["footer_reconciled"]:
|
|
3191
|
+
print(
|
|
3192
|
+
f"[RECONCILED] {row['sha'][:7]} {row['subject']} :: "
|
|
3193
|
+
f"{row['changelog']}")
|
|
2436
3194
|
for row in window["missing_footer"]:
|
|
2437
3195
|
print(f"[NEEDS-TRIAGE] {row['sha'][:7]} {row['subject']}")
|
|
2438
3196
|
if window["missing_footer"]:
|
|
@@ -2454,6 +3212,24 @@ def main(argv):
|
|
|
2454
3212
|
print(baseline)
|
|
2455
3213
|
return 0
|
|
2456
3214
|
|
|
3215
|
+
if cmd == "clean-tree-status" and len(rest) == 1:
|
|
3216
|
+
target = rest[0]
|
|
3217
|
+
try:
|
|
3218
|
+
rows = load_targets(default_targets_path())
|
|
3219
|
+
except ReleaseTargetsError as exc:
|
|
3220
|
+
sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
|
|
3221
|
+
return _targets_error_exit_code(exc)
|
|
3222
|
+
row = next((item for item in rows if item["target"] == target), None)
|
|
3223
|
+
if row is None:
|
|
3224
|
+
sys.stderr.write(f"unknown release target: {target}\n")
|
|
3225
|
+
return 2
|
|
3226
|
+
project_root = os.path.dirname(
|
|
3227
|
+
os.path.dirname(default_targets_path())) or "."
|
|
3228
|
+
probe = release_tree_status(row, project_root)
|
|
3229
|
+
sys.stdout.write(probe.stdout)
|
|
3230
|
+
sys.stderr.write(probe.stderr)
|
|
3231
|
+
return probe.returncode
|
|
3232
|
+
|
|
2457
3233
|
if cmd == "run-pre-tag" and len(rest) == 1:
|
|
2458
3234
|
# A-2.1/2.2/2.3 (DECISION-0034). Runs the row's declared `pre-tag`
|
|
2459
3235
|
# commands IN DECLARED ORDER, stops at the first non-zero exit, and
|
|
@@ -2587,11 +3363,7 @@ def main(argv):
|
|
|
2587
3363
|
# delete a release gate for it. `.markers/` is exempted for the
|
|
2588
3364
|
# same reason the skill exempts it: a per-machine confirmation
|
|
2589
3365
|
# marker minted by this same run is not a release surface.
|
|
2590
|
-
probe =
|
|
2591
|
-
[git_executable(), "status", "--porcelain", "--", ":/",
|
|
2592
|
-
":(exclude,top).codearbiter/gate-events.log",
|
|
2593
|
-
":(exclude,top).codearbiter/.markers/"],
|
|
2594
|
-
capture_output=True, text=True, cwd=project_root)
|
|
3366
|
+
probe = release_tree_status(row, project_root)
|
|
2595
3367
|
if probe.returncode != 0:
|
|
2596
3368
|
return None, (probe.stderr.strip() or "git status failed")
|
|
2597
3369
|
state = {}
|
|
@@ -2799,6 +3571,27 @@ def main(argv):
|
|
|
2799
3571
|
return 2
|
|
2800
3572
|
return 0 if semver_greater(rest[0], rest[1]) else 1
|
|
2801
3573
|
|
|
3574
|
+
if cmd == "version-greater" and len(rest) == 4:
|
|
3575
|
+
candidate, floor, policy, initial_version = rest
|
|
3576
|
+
initial = initial_version or None
|
|
3577
|
+
if policy == "semver" and initial is None:
|
|
3578
|
+
if semver_key(candidate) is None or semver_key(floor) is None:
|
|
3579
|
+
sys.stderr.write("version-greater: invalid semver comparison\n")
|
|
3580
|
+
return 2
|
|
3581
|
+
elif policy == "numeric-sequence":
|
|
3582
|
+
candidate_key = numeric_sequence_key(candidate, initial)
|
|
3583
|
+
floor_key = numeric_sequence_key(floor, initial)
|
|
3584
|
+
initial_key = numeric_sequence_key(initial, initial)
|
|
3585
|
+
if (candidate_key is None or floor_key is None
|
|
3586
|
+
or initial_key is None or candidate_key < initial_key):
|
|
3587
|
+
sys.stderr.write(
|
|
3588
|
+
"version-greater: invalid numeric-sequence comparison\n")
|
|
3589
|
+
return 2
|
|
3590
|
+
else:
|
|
3591
|
+
sys.stderr.write("version-greater: invalid policy declaration\n")
|
|
3592
|
+
return 2
|
|
3593
|
+
return 0 if version_greater(candidate, floor, policy, initial) else 1
|
|
3594
|
+
|
|
2802
3595
|
if cmd == "apply-bump" and len(rest) == 2:
|
|
2803
3596
|
# #585 MEDIUM-3: the one number that mattered was the only step
|
|
2804
3597
|
# left to the eye. Step 4 had the operator "apply the step-2 bump
|
|
@@ -2826,7 +3619,46 @@ def main(argv):
|
|
|
2826
3619
|
print(result)
|
|
2827
3620
|
return 0
|
|
2828
3621
|
|
|
2829
|
-
if cmd == "
|
|
3622
|
+
if cmd == "derive-version" and len(rest) == 4:
|
|
3623
|
+
base, word, policy, initial_version = rest
|
|
3624
|
+
result = derive_version(base, word, policy, initial_version or None)
|
|
3625
|
+
if result is None:
|
|
3626
|
+
sys.stderr.write(
|
|
3627
|
+
f"derive-version: cannot derive from {base!r} under policy "
|
|
3628
|
+
f"{policy!r} with initial-version {initial_version!r}\n")
|
|
3629
|
+
return 2
|
|
3630
|
+
print(result)
|
|
3631
|
+
return 0
|
|
3632
|
+
|
|
3633
|
+
if cmd == "render-release-assets" and len(rest) >= 3:
|
|
3634
|
+
version, tag = rest[:2]
|
|
3635
|
+
rendered = render_release_assets(rest[2:], version, tag)
|
|
3636
|
+
if rendered is None:
|
|
3637
|
+
sys.stderr.write(
|
|
3638
|
+
"render-release-assets: invalid or duplicate asset template\n")
|
|
3639
|
+
return 2
|
|
3640
|
+
for name in rendered:
|
|
3641
|
+
print(name)
|
|
3642
|
+
return 0
|
|
3643
|
+
|
|
3644
|
+
if cmd == "verify-release-assets" and len(rest) >= 2:
|
|
3645
|
+
directory, names = rest[0], rest[1:]
|
|
3646
|
+
if (any(not _safe_release_asset_name(name) for name in names)
|
|
3647
|
+
or len(set(names)) != len(names)):
|
|
3648
|
+
sys.stderr.write(
|
|
3649
|
+
"verify-release-assets: invalid or duplicate asset name\n")
|
|
3650
|
+
return 2
|
|
3651
|
+
verified = verify_release_asset_inventory(directory, names)
|
|
3652
|
+
if verified is None:
|
|
3653
|
+
sys.stderr.write(
|
|
3654
|
+
"verify-release-assets: local inventory does not exactly "
|
|
3655
|
+
"match the declared non-empty regular files\n")
|
|
3656
|
+
return 1
|
|
3657
|
+
for path in verified:
|
|
3658
|
+
print(path)
|
|
3659
|
+
return 0
|
|
3660
|
+
|
|
3661
|
+
if cmd == "dates-match" and len(rest) in (2, 4):
|
|
2830
3662
|
# MEDIUM (adversarial review 2026-07-31, run 4): Phase 1 step 5 and
|
|
2831
3663
|
# Phase 2 step 1 both name `release_dates_consistent`, and Phase 2
|
|
2832
3664
|
# says it "must pass" -- but this CLI exposed no way to run it, so
|
|
@@ -2837,14 +3669,21 @@ def main(argv):
|
|
|
2837
3669
|
# unreadable-file-is-empty-text behaviour (an unreadable file has
|
|
2838
3670
|
# no date, so the comparison is False, so the exit code is 1 --
|
|
2839
3671
|
# never a traceback).
|
|
3672
|
+
paths = rest[:2]
|
|
3673
|
+
policy, initial = (rest[2], rest[3] or None) if len(rest) == 4 else (
|
|
3674
|
+
"semver", None)
|
|
3675
|
+
if not _policy_declaration_valid(policy, initial):
|
|
3676
|
+
sys.stderr.write("dates-match: invalid policy declaration\n")
|
|
3677
|
+
return 2
|
|
2840
3678
|
texts = []
|
|
2841
|
-
for path in
|
|
3679
|
+
for path in paths:
|
|
2842
3680
|
try:
|
|
2843
3681
|
with open(path, encoding="utf-8") as fh:
|
|
2844
3682
|
texts.append(fh.read())
|
|
2845
3683
|
except OSError:
|
|
2846
3684
|
texts.append("")
|
|
2847
|
-
return 0 if release_dates_consistent(
|
|
3685
|
+
return 0 if release_dates_consistent(
|
|
3686
|
+
texts[0], texts[1], policy, initial) else 1
|
|
2848
3687
|
|
|
2849
3688
|
if cmd == "classify" and len(rest) == 6:
|
|
2850
3689
|
as_bool = lambda s: str(s).lower() == "true"
|
|
@@ -2884,4 +3723,13 @@ def main(argv):
|
|
|
2884
3723
|
|
|
2885
3724
|
|
|
2886
3725
|
if __name__ == "__main__":
|
|
3726
|
+
# This CLI is consumed as a line-record protocol by POSIX shells on every
|
|
3727
|
+
# supported host. Native Windows text stdout otherwise translates each
|
|
3728
|
+
# ``\n`` to ``\r\n``; Git Bash's ``read -r`` removes the newline but keeps
|
|
3729
|
+
# the carriage return, corrupting pathspecs, target names, and asset paths.
|
|
3730
|
+
# Force the wire format to LF once at the executable boundary. Imported
|
|
3731
|
+
# library calls retain their caller-owned stream configuration.
|
|
3732
|
+
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
|
3733
|
+
if callable(reconfigure):
|
|
3734
|
+
reconfigure(newline="\n")
|
|
2887
3735
|
sys.exit(main(sys.argv[1:]))
|