@arbiterforge/ca-pi 0.8.1 → 0.10.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/README.md +29 -90
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +77 -0
- package/plugins/ca-pi/COMMANDS.md +141 -64
- package/plugins/ca-pi/SKILLS.md +137 -28
- package/plugins/ca-pi/agents/INDEX.md +3 -2
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
- package/plugins/ca-pi/agents/finding-triage.md +31 -14
- package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
- package/plugins/ca-pi/arbiter.md +12 -3
- package/plugins/ca-pi/extensions/codearbiter.js +86 -1
- package/plugins/ca-pi/generated/command-catalog.json +386 -186
- package/plugins/ca-pi/generated/roles.json +9 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +18 -11
- package/plugins/ca-pi/hooks/_gitexec.py +23 -0
- package/plugins/ca-pi/hooks/_githooks.py +50 -23
- package/plugins/ca-pi/hooks/_hooklib.py +94 -7
- package/plugins/ca-pi/hooks/_host.py +9 -1
- package/plugins/ca-pi/hooks/_modelib.py +173 -55
- package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
- package/plugins/ca-pi/hooks/_releaselib.py +278 -48
- package/plugins/ca-pi/hooks/_updatelib.py +230 -50
- package/plugins/ca-pi/hooks/doctor.py +56 -8
- package/plugins/ca-pi/hooks/git-enforce.py +10 -3
- package/plugins/ca-pi/hooks/hostapi.py +220 -22
- package/plugins/ca-pi/hooks/session-start.py +8 -6
- package/plugins/ca-pi/hooks/statusline.py +1 -1
- package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
- package/plugins/ca-pi/includes/command-compatibility.md +16 -0
- package/plugins/ca-pi/includes/routing-table.md +13 -5
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +54 -2
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
- package/plugins/ca-pi/routines/release/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
- package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
- package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
# last_tag_select(tags, prefix) -> str
|
|
40
40
|
# notes_heading_matches(notes_text, tag) -> bool
|
|
41
41
|
# release_dates_consistent(changelog_section, tag_message) -> bool
|
|
42
|
+
# changelog_section(changelog_text, version) -> str | None
|
|
42
43
|
# classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
|
|
43
44
|
# manifest_version, release_is_nondraft) -> str
|
|
44
45
|
# select_release_target_by_name(pairs, targets) -> str name-keyed
|
|
@@ -280,27 +281,111 @@ class MissingRequiredKeyError(ReleaseTargetsError):
|
|
|
280
281
|
# bug-fix cluster does not take on. If the anchor is ever relaxed, a second
|
|
281
282
|
# check will need to be RE-ADDED deliberately, not un-deleted from history.
|
|
282
283
|
_RELEASE_RE_CACHE = {}
|
|
284
|
+
_SEMVER_NUMERIC_IDENTIFIER = r"(?:0|[1-9][0-9]*)"
|
|
285
|
+
_PLAIN_SEMVER_PATTERN = (
|
|
286
|
+
_SEMVER_NUMERIC_IDENTIFIER + r"\." +
|
|
287
|
+
_SEMVER_NUMERIC_IDENTIFIER + r"\." +
|
|
288
|
+
_SEMVER_NUMERIC_IDENTIFIER)
|
|
289
|
+
_PLAIN_SEMVER_CAPTURE_PATTERN = (
|
|
290
|
+
r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")\." +
|
|
291
|
+
r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")\." +
|
|
292
|
+
r"(" + _SEMVER_NUMERIC_IDENTIFIER + r")")
|
|
283
293
|
|
|
284
294
|
|
|
285
295
|
def _release_re(prefix):
|
|
286
296
|
"""The anchored `<prefix>MAJOR.MINOR.PATCH` matcher for one release series."""
|
|
287
297
|
rx = _RELEASE_RE_CACHE.get(prefix)
|
|
288
298
|
if rx is None:
|
|
289
|
-
rx = re.compile(
|
|
299
|
+
rx = re.compile(
|
|
300
|
+
r"^" + re.escape(prefix) + _PLAIN_SEMVER_CAPTURE_PATTERN + r"$")
|
|
290
301
|
_RELEASE_RE_CACHE[prefix] = rx
|
|
291
302
|
return rx
|
|
292
303
|
|
|
293
304
|
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
# `
|
|
297
|
-
#
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
r"
|
|
305
|
+
# H2 is the structural section boundary. A release heading is the narrower,
|
|
306
|
+
# exact grammar: either `## [X.Y.Z] ...`, `## vX.Y.Z ...`, or the one special
|
|
307
|
+
# `## [Unreleased]` marker. Tabs/spaces are admitted within ONE line; `\s` is
|
|
308
|
+
# deliberately absent because it also consumes newlines and used to turn
|
|
309
|
+
# `##\n[1.2.3]` into a release heading. The suffix, when present, must begin
|
|
310
|
+
# with horizontal whitespace, so `[1.2.3]garbage` cannot prefix-match.
|
|
311
|
+
_H2_RE = re.compile(r"^##(?:[ \t]+[^\r\n]*)?[ \t]*$", re.MULTILINE)
|
|
312
|
+
_HEADING_RE = re.compile(
|
|
313
|
+
r"^##[ \t]+(?:\[(Unreleased|" + _PLAIN_SEMVER_PATTERN + r")\]|"
|
|
314
|
+
r"v(" + _PLAIN_SEMVER_PATTERN + r"))(?:[ \t]+[^\r\n]*)?[ \t]*$",
|
|
315
|
+
re.MULTILINE)
|
|
316
|
+
_LEGACY_DATE_H2_RE = re.compile(
|
|
317
|
+
r"^##[ \t]+\[[0-9]{4}-[0-9]{2}-[0-9]{2}\]"
|
|
318
|
+
r"(?:[ \t]+[^\r\n]*)?[ \t]*$")
|
|
319
|
+
_CHANGELOG_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})[ \t]*$")
|
|
303
320
|
_RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})")
|
|
321
|
+
_BARE_RELEASE_VERSION_RE = re.compile(r"^" + _PLAIN_SEMVER_PATTERN + r"$")
|
|
322
|
+
|
|
323
|
+
_SECTION_OK = "ok"
|
|
324
|
+
_SECTION_ABSENT = "absent"
|
|
325
|
+
_SECTION_INVALID = "invalid"
|
|
326
|
+
_SECTION_DUPLICATE = "duplicate"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _heading_version(line):
|
|
330
|
+
"""Bare version / `Unreleased` for one exact heading line, else None."""
|
|
331
|
+
match = _HEADING_RE.fullmatch(line)
|
|
332
|
+
if match is None:
|
|
333
|
+
return None
|
|
334
|
+
return match.group(1) or match.group(2)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _looks_like_changelog_heading(line):
|
|
338
|
+
"""True when an H2 claims changelog-heading syntax but is malformed."""
|
|
339
|
+
# This repository's pre-SemVer history used exact bracketed ISO dates,
|
|
340
|
+
# sometimes more than once for one day. They remain ordinary structural
|
|
341
|
+
# H2 boundaries, not release versions and not malformed SemVer claims.
|
|
342
|
+
if _LEGACY_DATE_H2_RE.fullmatch(line) is not None:
|
|
343
|
+
return False
|
|
344
|
+
body = line[2:].lstrip(" \t")
|
|
345
|
+
return body.startswith("[") or body.startswith("v")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _changelog_section_result(changelog_text, version):
|
|
349
|
+
"""Return `(section, status)` without raising on malformed changelogs."""
|
|
350
|
+
if (not isinstance(changelog_text, str)
|
|
351
|
+
or not isinstance(version, str)
|
|
352
|
+
or _BARE_RELEASE_VERSION_RE.fullmatch(version) is None):
|
|
353
|
+
return None, _SECTION_INVALID
|
|
354
|
+
|
|
355
|
+
boundaries = list(_H2_RE.finditer(changelog_text))
|
|
356
|
+
target_indexes = []
|
|
357
|
+
released_seen = False
|
|
358
|
+
unreleased_seen = False
|
|
359
|
+
|
|
360
|
+
for index, boundary in enumerate(boundaries):
|
|
361
|
+
line = boundary.group(0)
|
|
362
|
+
heading_version = _heading_version(line)
|
|
363
|
+
if heading_version is None:
|
|
364
|
+
if _looks_like_changelog_heading(line):
|
|
365
|
+
return None, _SECTION_INVALID
|
|
366
|
+
continue
|
|
367
|
+
if heading_version == "Unreleased":
|
|
368
|
+
# Repository-derived Keep-a-Changelog compatibility: exactly one
|
|
369
|
+
# Unreleased marker is valid only before every released section.
|
|
370
|
+
if unreleased_seen or released_seen:
|
|
371
|
+
return None, _SECTION_INVALID
|
|
372
|
+
unreleased_seen = True
|
|
373
|
+
continue
|
|
374
|
+
released_seen = True
|
|
375
|
+
if heading_version == version:
|
|
376
|
+
target_indexes.append(index)
|
|
377
|
+
|
|
378
|
+
if not target_indexes:
|
|
379
|
+
return None, _SECTION_ABSENT
|
|
380
|
+
if len(target_indexes) != 1:
|
|
381
|
+
return None, _SECTION_DUPLICATE
|
|
382
|
+
|
|
383
|
+
index = target_indexes[0]
|
|
384
|
+
start = boundaries[index].start()
|
|
385
|
+
end = (boundaries[index + 1].start()
|
|
386
|
+
if index + 1 < len(boundaries) else len(changelog_text))
|
|
387
|
+
section = changelog_text[start:end].rstrip("\r\n") + "\n"
|
|
388
|
+
return section, _SECTION_OK
|
|
304
389
|
|
|
305
390
|
# Full SemVer, including the pre-release and build-metadata tails a release
|
|
306
391
|
# tag never carries but a version MANIFEST can. The anchored `_release_re`
|
|
@@ -313,8 +398,8 @@ _RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})")
|
|
|
313
398
|
VALUE_MAX_CHARS = 1024
|
|
314
399
|
|
|
315
400
|
SEMVER = re.compile(
|
|
316
|
-
r"^(0|[1-9]
|
|
317
|
-
r"(?:-((?:0|[1-9]
|
|
401
|
+
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
|
|
402
|
+
r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?"
|
|
318
403
|
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
|
319
404
|
)
|
|
320
405
|
|
|
@@ -380,7 +465,8 @@ def _bare_version(tag):
|
|
|
380
465
|
if not isinstance(tag, str):
|
|
381
466
|
return tag
|
|
382
467
|
text = tag.strip().strip("[]")
|
|
383
|
-
match = re.search(
|
|
468
|
+
match = re.search(
|
|
469
|
+
r"(?<![0-9])(" + _PLAIN_SEMVER_PATTERN + r".*)$", text)
|
|
384
470
|
return match.group(1) if match else text.lstrip("v")
|
|
385
471
|
|
|
386
472
|
|
|
@@ -435,10 +521,15 @@ def notes_heading_matches(notes_text, tag):
|
|
|
435
521
|
False."""
|
|
436
522
|
if not isinstance(notes_text, str) or not isinstance(tag, str):
|
|
437
523
|
return False
|
|
438
|
-
|
|
439
|
-
if not
|
|
524
|
+
headings = list(_H2_RE.finditer(notes_text))
|
|
525
|
+
if not headings:
|
|
526
|
+
return False
|
|
527
|
+
version = _bare_version(tag)
|
|
528
|
+
first_version = _heading_version(headings[0].group(0))
|
|
529
|
+
if first_version != version or first_version == "Unreleased":
|
|
440
530
|
return False
|
|
441
|
-
|
|
531
|
+
_section, status = _changelog_section_result(notes_text, version)
|
|
532
|
+
return status == _SECTION_OK
|
|
442
533
|
|
|
443
534
|
|
|
444
535
|
def release_dates_consistent(changelog_section, tag_message):
|
|
@@ -448,7 +539,11 @@ def release_dates_consistent(changelog_section, tag_message):
|
|
|
448
539
|
across surfaces. Either date absent, or non-string input -> False."""
|
|
449
540
|
if not isinstance(changelog_section, str) or not isinstance(tag_message, str):
|
|
450
541
|
return False
|
|
451
|
-
|
|
542
|
+
heading = _HEADING_RE.match(changelog_section)
|
|
543
|
+
if (heading is None or heading.start() != 0
|
|
544
|
+
or _heading_version(heading.group(0)) == "Unreleased"):
|
|
545
|
+
return False
|
|
546
|
+
cm = _CHANGELOG_DATE_RE.search(heading.group(0))
|
|
452
547
|
tm = _RELEASED_AT_RE.search(tag_message)
|
|
453
548
|
if not cm or not tm:
|
|
454
549
|
return False
|
|
@@ -469,16 +564,142 @@ def changelog_section(changelog_text, version):
|
|
|
469
564
|
COMMITTED `$CHANGELOG` -- which Phase 1 step 7 commits before any tag
|
|
470
565
|
exists -- is reading the one permanent home of that text, not
|
|
471
566
|
re-deriving or hand-writing new notes (blind exercise run 19, HIGH-2)."""
|
|
472
|
-
|
|
567
|
+
section, status = _changelog_section_result(changelog_text, version)
|
|
568
|
+
return section if status == _SECTION_OK else None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _normalize_git_tree_path(path):
|
|
572
|
+
"""Normalize portable separators; reject paths outside a Git tree."""
|
|
573
|
+
if not isinstance(path, str) or not path or "\0" in path:
|
|
473
574
|
return None
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
575
|
+
normalized = path.replace("\\", "/")
|
|
576
|
+
if (normalized.startswith("/")
|
|
577
|
+
or re.match(r"^[A-Za-z]:", normalized)
|
|
578
|
+
or ":" in normalized):
|
|
579
|
+
return None
|
|
580
|
+
parts = normalized.split("/")
|
|
581
|
+
if any(part in ("", "..") for part in parts):
|
|
582
|
+
return None
|
|
583
|
+
parts = [part for part in parts if part != "."]
|
|
584
|
+
return "/".join(parts) if parts else None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
_GIT_REPOSITORY_ENV = {
|
|
588
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
589
|
+
"GIT_CEILING_DIRECTORIES",
|
|
590
|
+
"GIT_COMMON_DIR",
|
|
591
|
+
"GIT_DIR",
|
|
592
|
+
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
|
|
593
|
+
"GIT_GRAFT_FILE",
|
|
594
|
+
"GIT_INDEX_FILE",
|
|
595
|
+
"GIT_NAMESPACE",
|
|
596
|
+
"GIT_OBJECT_DIRECTORY",
|
|
597
|
+
"GIT_PREFIX",
|
|
598
|
+
"GIT_QUARANTINE_PATH",
|
|
599
|
+
"GIT_REPLACE_REF_BASE",
|
|
600
|
+
"GIT_SHALLOW_FILE",
|
|
601
|
+
"GIT_WORK_TREE",
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _sanitized_git_environment():
|
|
606
|
+
"""Return a Git environment that cannot redirect repository/object reads."""
|
|
607
|
+
environment = os.environ.copy()
|
|
608
|
+
for name in _GIT_REPOSITORY_ENV:
|
|
609
|
+
environment.pop(name, None)
|
|
610
|
+
# Command-scoped config injected through GIT_CONFIG_COUNT can redefine
|
|
611
|
+
# repository paths without using the better-known GIT_DIR variables.
|
|
612
|
+
environment.pop("GIT_CONFIG_COUNT", None)
|
|
613
|
+
for name in tuple(environment):
|
|
614
|
+
if re.fullmatch(r"GIT_CONFIG_(?:KEY|VALUE)_\d+", name):
|
|
615
|
+
environment.pop(name, None)
|
|
616
|
+
# Replacement refs rewrite the commit/tree/blob Git presents for a known
|
|
617
|
+
# object id. Release notes must reflect the tag's stored objects exactly.
|
|
618
|
+
environment["GIT_NO_REPLACE_OBJECTS"] = "1"
|
|
619
|
+
return environment
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _committed_changelog_text(repo_root, revision, changelog_path):
|
|
623
|
+
"""Return `(text, error)` from one exact regular-file blob at revision."""
|
|
624
|
+
if (not isinstance(repo_root, str) or not repo_root
|
|
625
|
+
or not isinstance(revision, str) or not revision
|
|
626
|
+
or revision.startswith("-") or "\0" in revision):
|
|
627
|
+
return None, "invalid repository root or revision"
|
|
628
|
+
tree_path = _normalize_git_tree_path(changelog_path)
|
|
629
|
+
if tree_path is None:
|
|
630
|
+
return None, "changelog path is not a repository-relative Git path"
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
git = git_executable()
|
|
634
|
+
git_environment = _sanitized_git_environment()
|
|
635
|
+
root_probe = subprocess.run(
|
|
636
|
+
[git, "-C", repo_root, "rev-parse", "--show-toplevel"],
|
|
637
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
638
|
+
timeout=30, env=git_environment)
|
|
639
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
640
|
+
return None, f"cannot resolve repository root: {exc}"
|
|
641
|
+
if root_probe.returncode != 0:
|
|
642
|
+
return None, "cannot resolve repository root"
|
|
643
|
+
actual_root = root_probe.stdout.strip()
|
|
644
|
+
supplied_root = os.path.realpath(os.path.abspath(repo_root))
|
|
645
|
+
resolved_root = os.path.realpath(os.path.abspath(actual_root))
|
|
646
|
+
if os.path.normcase(supplied_root) != os.path.normcase(resolved_root):
|
|
647
|
+
return None, "supplied path is not the repository root"
|
|
648
|
+
|
|
649
|
+
tag_ref = f"refs/tags/{revision}"
|
|
650
|
+
try:
|
|
651
|
+
tag_probe = subprocess.run(
|
|
652
|
+
[git, "check-ref-format", tag_ref],
|
|
653
|
+
capture_output=True, timeout=30, env=git_environment)
|
|
654
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
655
|
+
return None, f"cannot validate release tag: {exc}"
|
|
656
|
+
if tag_probe.returncode != 0:
|
|
657
|
+
return None, "release tag name is invalid"
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
commit_probe = subprocess.run(
|
|
661
|
+
[git, "-C", actual_root, "rev-parse", "--verify",
|
|
662
|
+
f"{tag_ref}^{{commit}}"],
|
|
663
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
664
|
+
timeout=30, env=git_environment)
|
|
665
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
666
|
+
return None, f"cannot resolve committed revision: {exc}"
|
|
667
|
+
if commit_probe.returncode != 0:
|
|
668
|
+
return None, "cannot resolve committed revision"
|
|
669
|
+
commit = commit_probe.stdout.strip()
|
|
670
|
+
|
|
671
|
+
try:
|
|
672
|
+
entry_probe = subprocess.run(
|
|
673
|
+
[git, "-C", actual_root, "ls-tree", "-z", commit, "--", tree_path],
|
|
674
|
+
capture_output=True, timeout=30, env=git_environment)
|
|
675
|
+
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
676
|
+
return None, f"cannot resolve committed changelog: {exc}"
|
|
677
|
+
records = [record for record in entry_probe.stdout.split(b"\0") if record]
|
|
678
|
+
if entry_probe.returncode != 0 or len(records) != 1:
|
|
679
|
+
return None, "committed changelog path is absent or ambiguous"
|
|
680
|
+
try:
|
|
681
|
+
metadata, encoded_path = records[0].split(b"\t", 1)
|
|
682
|
+
mode, object_type, oid = metadata.split(b" ", 2)
|
|
683
|
+
found_path = encoded_path.decode("utf-8")
|
|
684
|
+
except (ValueError, UnicodeDecodeError):
|
|
685
|
+
return None, "committed changelog tree entry is malformed"
|
|
686
|
+
if (found_path != tree_path or object_type != b"blob"
|
|
687
|
+
or mode not in (b"100644", b"100755")):
|
|
688
|
+
return None, "committed changelog is not an exact regular-file path"
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
blob = subprocess.run(
|
|
692
|
+
[git, "-C", actual_root, "cat-file", "blob", oid.decode("ascii")],
|
|
693
|
+
capture_output=True, timeout=30, env=git_environment)
|
|
694
|
+
except (OSError, subprocess.TimeoutExpired, ValueError, UnicodeDecodeError) as exc:
|
|
695
|
+
return None, f"cannot read committed changelog: {exc}"
|
|
696
|
+
if blob.returncode != 0:
|
|
697
|
+
return None, "cannot read committed changelog"
|
|
698
|
+
try:
|
|
699
|
+
text = blob.stdout.decode("utf-8")
|
|
700
|
+
except UnicodeDecodeError:
|
|
701
|
+
return None, "committed changelog is not UTF-8"
|
|
702
|
+
return text.replace("\r\n", "\n").replace("\r", "\n"), None
|
|
482
703
|
|
|
483
704
|
|
|
484
705
|
def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
|
|
@@ -1026,7 +1247,8 @@ def peel_tag(ls_remote_text, tag):
|
|
|
1026
1247
|
return peeled or direct
|
|
1027
1248
|
|
|
1028
1249
|
|
|
1029
|
-
_PLAIN_SEMVER_RE = re.compile(
|
|
1250
|
+
_PLAIN_SEMVER_RE = re.compile(
|
|
1251
|
+
r"^" + _PLAIN_SEMVER_CAPTURE_PATTERN + r"$")
|
|
1030
1252
|
_BUMP_WORDS = ("major", "minor", "patch")
|
|
1031
1253
|
|
|
1032
1254
|
|
|
@@ -1752,15 +1974,17 @@ def main(argv):
|
|
|
1752
1974
|
notes-match <tag> <notes_file>
|
|
1753
1975
|
exit 0 iff the notes file's first heading
|
|
1754
1976
|
names the same version as `tag`.
|
|
1755
|
-
changelog-section <
|
|
1977
|
+
changelog-section <repo_root> <revision> <changelog_path> <version>
|
|
1756
1978
|
prints the `## [<version>] ...` section of
|
|
1757
|
-
|
|
1758
|
-
|
|
1979
|
+
the exact regular-file blob at
|
|
1980
|
+
`<revision>:<changelog_path>`, bound to the
|
|
1981
|
+
supplied repository root, from its heading
|
|
1982
|
+
up to the next H2 heading or EOF.
|
|
1759
1983
|
exit 0 with the section on stdout - 1 no
|
|
1760
|
-
heading names `<version>` - 3
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1984
|
+
heading names `<version>` - 3 source/root/
|
|
1985
|
+
path/revision cannot be read safely - 4 the
|
|
1986
|
+
changelog is malformed or ambiguous. The
|
|
1987
|
+
sanctioned way for
|
|
1764
1988
|
Phase 3's `resume_publish` path to
|
|
1765
1989
|
reconstruct release notes when Phase 1's
|
|
1766
1990
|
own scratch file did not survive to a
|
|
@@ -2091,29 +2315,35 @@ def main(argv):
|
|
|
2091
2315
|
notes_text = ""
|
|
2092
2316
|
return 0 if notes_heading_matches(notes_text, rest[0]) else 1
|
|
2093
2317
|
|
|
2094
|
-
if cmd == "changelog-section" and len(rest) ==
|
|
2318
|
+
if cmd == "changelog-section" and len(rest) == 4:
|
|
2095
2319
|
# Mechanical reconstruction of Phase 1's composed section, for
|
|
2096
2320
|
# `resume_publish` -- see `changelog_section`'s docstring. Exit 0
|
|
2097
2321
|
# with the section on stdout - 1 the changelog has no heading for
|
|
2098
2322
|
# `<version>` (drift between $CHANGELOG and the tag, not a
|
|
2099
|
-
# bad-invocation) - 3 the
|
|
2100
|
-
#
|
|
2101
|
-
#
|
|
2102
|
-
#
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2323
|
+
# bad-invocation) - 3 the repository/revision/path/blob cannot be
|
|
2324
|
+
# read safely - 4 a malformed/duplicate/invalidly-ordered changelog.
|
|
2325
|
+
# `revision` is resolved to a commit hash before the blob read, so a
|
|
2326
|
+
# current HEAD move or dirty/deleted working file cannot change the
|
|
2327
|
+
# notes selected for an already-composed tag.
|
|
2328
|
+
repo_root, revision, changelog_path, version = rest
|
|
2329
|
+
changelog_text, source_error = _committed_changelog_text(
|
|
2330
|
+
repo_root, revision, changelog_path)
|
|
2331
|
+
if source_error is not None:
|
|
2108
2332
|
sys.stderr.write(
|
|
2109
|
-
f"changelog-section: cannot read
|
|
2333
|
+
f"changelog-section: cannot read committed "
|
|
2334
|
+
f"{changelog_path!r}: {source_error}\n")
|
|
2110
2335
|
return 3
|
|
2111
|
-
section =
|
|
2112
|
-
if
|
|
2336
|
+
section, status = _changelog_section_result(changelog_text, version)
|
|
2337
|
+
if status == _SECTION_ABSENT:
|
|
2113
2338
|
sys.stderr.write(
|
|
2114
2339
|
f"changelog-section: no '## [{version}]' heading in "
|
|
2115
|
-
f"{changelog_path!r}\n")
|
|
2340
|
+
f"committed {changelog_path!r}\n")
|
|
2116
2341
|
return 1
|
|
2342
|
+
if status != _SECTION_OK:
|
|
2343
|
+
sys.stderr.write(
|
|
2344
|
+
f"changelog-section: committed {changelog_path!r} is "
|
|
2345
|
+
f"{status}; refusing ambiguous release notes\n")
|
|
2346
|
+
return 4
|
|
2117
2347
|
# `sys.stdout.write` is text-mode: on Windows with no
|
|
2118
2348
|
# PYTHONIOENCODING/PYTHONUTF8 set it encodes using the ambient
|
|
2119
2349
|
# console codepage (cp1252, not UTF-8) AND translates `\n` to
|