@arbiterforge/ca-pi 0.14.10 → 0.14.16
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 +33 -9
- package/package.json +2 -1
- package/plugins/ca-pi/CHANGELOG.md +67 -0
- package/plugins/ca-pi/LICENSE +661 -0
- package/plugins/ca-pi/SKILLS.md +2 -2
- package/plugins/ca-pi/SPRINT.md +47 -0
- package/plugins/ca-pi/THIRD_PARTY_NOTICES.md +40 -0
- package/plugins/ca-pi/generated/command-catalog.json +2 -2
- 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 +6 -6
- package/plugins/ca-pi/hooks/_approvallib.py +58 -22
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +2135 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +164 -7
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +177 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_overrideappendlib.py +5 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +44 -22
- package/plugins/ca-pi/hooks/_reconciliationlib.py +472 -0
- package/plugins/ca-pi/hooks/_releaselib.py +345 -111
- package/plugins/ca-pi/hooks/_sprintapprovallib.py +302 -0
- package/plugins/ca-pi/hooks/artifact-authority-hook.py +127 -0
- package/plugins/ca-pi/hooks/artifact-authority.py +102 -0
- package/plugins/ca-pi/hooks/artifact-reconcile.py +57 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +20 -3
- package/plugins/ca-pi/hooks/releasehash.py +36 -23
- package/plugins/ca-pi/includes/artifacts.md +120 -13
- package/plugins/ca-pi/includes/sprint-authority.md +69 -0
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/INDEX.md +7 -4
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +97 -29
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +7 -1
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +32 -15
- package/plugins/ca-pi/routines/release/SKILL.md +220 -70
- package/plugins/ca-pi/routines/skill-author/SKILL.md +26 -5
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +22 -10
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +170 -16
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +116 -21
- package/plugins/ca-pi/skills/ca-release/SKILL.md +6 -4
|
@@ -137,8 +137,10 @@
|
|
|
137
137
|
from __future__ import annotations
|
|
138
138
|
|
|
139
139
|
import hashlib
|
|
140
|
+
import datetime
|
|
140
141
|
import json
|
|
141
142
|
import os
|
|
143
|
+
import stat
|
|
142
144
|
import posixpath
|
|
143
145
|
import re
|
|
144
146
|
import shlex
|
|
@@ -431,7 +433,8 @@ _LEGACY_DATE_H2_RE = re.compile(
|
|
|
431
433
|
r"^##[ \t]+\[[0-9]{4}-[0-9]{2}-[0-9]{2}\]"
|
|
432
434
|
r"(?:[ \t]+[^\r\n]*)?[ \t]*$")
|
|
433
435
|
_CHANGELOG_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})[ \t]*$")
|
|
434
|
-
_RELEASED_AT_RE = re.compile(
|
|
436
|
+
_RELEASED_AT_RE = re.compile(
|
|
437
|
+
r"^Released-at:[ \t]*(\d{4}-\d{2}-\d{2})[ \t]*$", re.MULTILINE)
|
|
435
438
|
_BARE_RELEASE_VERSION_RE = re.compile(r"^" + _PLAIN_SEMVER_PATTERN + r"$")
|
|
436
439
|
|
|
437
440
|
_SECTION_OK = "ok"
|
|
@@ -708,9 +711,13 @@ def version_greater(current, base, policy="semver", initial_version=None):
|
|
|
708
711
|
if policy != "numeric-sequence":
|
|
709
712
|
return False
|
|
710
713
|
current_key = numeric_sequence_key(current, initial_version)
|
|
711
|
-
base_key = numeric_sequence_key(base, initial_version)
|
|
712
714
|
initial_key = numeric_sequence_key(initial_version, initial_version)
|
|
713
|
-
if current_key is None or
|
|
715
|
+
if current_key is None or initial_key is None:
|
|
716
|
+
return False
|
|
717
|
+
if base == NONE_SENTINEL:
|
|
718
|
+
return current_key >= initial_key
|
|
719
|
+
base_key = numeric_sequence_key(base, initial_version)
|
|
720
|
+
if base_key is None:
|
|
714
721
|
return False
|
|
715
722
|
return current_key >= initial_key and current_key > base_key
|
|
716
723
|
|
|
@@ -878,10 +885,19 @@ def release_dates_consistent(changelog_section, tag_message, policy="semver",
|
|
|
878
885
|
heading.group(0), policy, initial_version) in (None, "Unreleased")):
|
|
879
886
|
return False
|
|
880
887
|
cm = _CHANGELOG_DATE_RE.search(heading.group(0))
|
|
881
|
-
|
|
882
|
-
|
|
888
|
+
released_at = list(_RELEASED_AT_RE.finditer(tag_message))
|
|
889
|
+
trimmed_message = tag_message.rstrip("\r\n")
|
|
890
|
+
if (not cm or len(released_at) != 1
|
|
891
|
+
or released_at[0].end() != len(trimmed_message)):
|
|
883
892
|
return False
|
|
884
|
-
|
|
893
|
+
changelog_date = cm.group(1)
|
|
894
|
+
tag_date = released_at[0].group(1)
|
|
895
|
+
try:
|
|
896
|
+
datetime.date.fromisoformat(changelog_date)
|
|
897
|
+
datetime.date.fromisoformat(tag_date)
|
|
898
|
+
except ValueError:
|
|
899
|
+
return False
|
|
900
|
+
return changelog_date == tag_date
|
|
885
901
|
|
|
886
902
|
|
|
887
903
|
def changelog_section(changelog_text, version, policy="semver",
|
|
@@ -1025,8 +1041,12 @@ def _committed_changelog_text(repo_root, revision, changelog_path):
|
|
|
1025
1041
|
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
|
1026
1042
|
return None, f"cannot resolve committed changelog: {exc}"
|
|
1027
1043
|
records = [record for record in entry_probe.stdout.split(b"\0") if record]
|
|
1028
|
-
if entry_probe.returncode != 0
|
|
1029
|
-
return None, "committed changelog
|
|
1044
|
+
if entry_probe.returncode != 0:
|
|
1045
|
+
return None, "committed changelog tree inspection failed"
|
|
1046
|
+
if not records:
|
|
1047
|
+
return None, "committed changelog path is absent"
|
|
1048
|
+
if len(records) != 1:
|
|
1049
|
+
return None, "committed changelog path is ambiguous"
|
|
1030
1050
|
try:
|
|
1031
1051
|
metadata, encoded_path = records[0].split(b"\t", 1)
|
|
1032
1052
|
mode, object_type, oid = metadata.split(b" ", 2)
|
|
@@ -1235,7 +1255,8 @@ _CC_SUBJECT_RE = re.compile(r"^(?P<type>[a-zA-Z]+)(?:\((?P<scope>[^)]*)\))?(?P<b
|
|
|
1235
1255
|
# a FOOTER -- at the start of its own line, never mid-sentence, so prose
|
|
1236
1256
|
# that merely discusses a breaking change does not silently bump a major.
|
|
1237
1257
|
_BREAKING_FOOTER_RE = re.compile(r"^BREAKING[ -]CHANGE:", re.MULTILINE)
|
|
1238
|
-
_CHANGELOG_FOOTER_RE = re.compile(
|
|
1258
|
+
_CHANGELOG_FOOTER_RE = re.compile(
|
|
1259
|
+
r"^CHANGELOG:[ \t]*(.*?)[ \t]*$", re.MULTILINE)
|
|
1239
1260
|
|
|
1240
1261
|
# Which types bump, and to what. `refactor` bumps patch and IS harvested
|
|
1241
1262
|
# (see the changelog-grouping rule); `docs`/`chore`/`test`/`ci` bump
|
|
@@ -1245,6 +1266,14 @@ _BUMPING_TYPES = {"feat": "minor", "fix": "patch", "perf": "patch",
|
|
|
1245
1266
|
_BUMP_RANK = {"none": 0, "patch": 1, "minor": 2, "major": 3}
|
|
1246
1267
|
|
|
1247
1268
|
|
|
1269
|
+
def _terminal_footer_text(body):
|
|
1270
|
+
"""Return only the final commit-message paragraph eligible as footers."""
|
|
1271
|
+
if not isinstance(body, str):
|
|
1272
|
+
return ""
|
|
1273
|
+
paragraphs = re.split(r"(?:\r?\n)[ \t]*(?:\r?\n)+", body.rstrip())
|
|
1274
|
+
return paragraphs[-1] if paragraphs else ""
|
|
1275
|
+
|
|
1276
|
+
|
|
1248
1277
|
def row_assertions(row):
|
|
1249
1278
|
"""Which release-lane steps a row's DECLARED FIELDS turn on (A-3.1..3.5).
|
|
1250
1279
|
|
|
@@ -1458,12 +1487,19 @@ def classify_commit(subject, body=""):
|
|
|
1458
1487
|
subject = ""
|
|
1459
1488
|
if not isinstance(body, str):
|
|
1460
1489
|
body = ""
|
|
1490
|
+
footer_text = _terminal_footer_text(body)
|
|
1491
|
+
changelog_matches = list(_CHANGELOG_FOOTER_RE.finditer(footer_text))
|
|
1492
|
+
changelog = (changelog_matches[0].group(1).strip()
|
|
1493
|
+
if len(changelog_matches) == 1 else "")
|
|
1494
|
+
has_changelog_footer = bool(changelog)
|
|
1461
1495
|
match = _CC_SUBJECT_RE.match(subject)
|
|
1462
1496
|
if match is None:
|
|
1463
1497
|
return {"type": "", "scope": "", "breaking": False, "bump": "none",
|
|
1464
|
-
"has_changelog_footer":
|
|
1498
|
+
"has_changelog_footer": has_changelog_footer,
|
|
1499
|
+
"changelog": changelog}
|
|
1465
1500
|
ctype = match.group("type").lower()
|
|
1466
|
-
breaking = bool(match.group("bang")) or bool(
|
|
1501
|
+
breaking = bool(match.group("bang")) or bool(
|
|
1502
|
+
_BREAKING_FOOTER_RE.search(footer_text))
|
|
1467
1503
|
if breaking:
|
|
1468
1504
|
bump = "major"
|
|
1469
1505
|
else:
|
|
@@ -1473,7 +1509,8 @@ def classify_commit(subject, body=""):
|
|
|
1473
1509
|
"scope": match.group("scope") or "",
|
|
1474
1510
|
"breaking": breaking,
|
|
1475
1511
|
"bump": bump,
|
|
1476
|
-
"has_changelog_footer":
|
|
1512
|
+
"has_changelog_footer": has_changelog_footer,
|
|
1513
|
+
"changelog": changelog,
|
|
1477
1514
|
}
|
|
1478
1515
|
|
|
1479
1516
|
|
|
@@ -1515,7 +1552,7 @@ def classify_window(commits, reconciliations=None, published_shas=None):
|
|
|
1515
1552
|
verdict["subject"] = str(entry.get("subject", ""))
|
|
1516
1553
|
sha = verdict["sha"]
|
|
1517
1554
|
reconciled = (
|
|
1518
|
-
re.fullmatch(r"[0-9a-f]{40}", sha) is not None
|
|
1555
|
+
re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", sha) is not None
|
|
1519
1556
|
and not verdict["has_changelog_footer"]
|
|
1520
1557
|
and sha in published_shas
|
|
1521
1558
|
and isinstance(reconciliations.get(sha), str)
|
|
@@ -1524,7 +1561,8 @@ def classify_window(commits, reconciliations=None, published_shas=None):
|
|
|
1524
1561
|
and "\r" not in reconciliations[sha]
|
|
1525
1562
|
)
|
|
1526
1563
|
verdict["footer_reconciled"] = reconciled
|
|
1527
|
-
|
|
1564
|
+
if reconciled:
|
|
1565
|
+
verdict["changelog"] = reconciliations[sha].strip()
|
|
1528
1566
|
rows.append(verdict)
|
|
1529
1567
|
bump = "none"
|
|
1530
1568
|
for row in rows:
|
|
@@ -1587,10 +1625,12 @@ def parse_changelog_reconciliations(text, target):
|
|
|
1587
1625
|
f"entry {index} field {key!r} must be a non-empty "
|
|
1588
1626
|
"single-line string of at most 4096 characters")
|
|
1589
1627
|
values[key] = value
|
|
1590
|
-
if re.fullmatch(r"[A-Za-z0-9._-]
|
|
1628
|
+
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*",
|
|
1629
|
+
values["target"]) is None:
|
|
1591
1630
|
raise ChangelogReconciliationError(
|
|
1592
1631
|
f"entry {index} target is not a valid release target name")
|
|
1593
|
-
if re.fullmatch(r"[0-9a-f]{40}
|
|
1632
|
+
if re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})",
|
|
1633
|
+
values["commit_sha"]) is None:
|
|
1594
1634
|
raise ChangelogReconciliationError(
|
|
1595
1635
|
f"entry {index} commit_sha must be a full lowercase Git SHA")
|
|
1596
1636
|
identity = (values["target"], values["commit_sha"])
|
|
@@ -1605,25 +1645,28 @@ def parse_changelog_reconciliations(text, target):
|
|
|
1605
1645
|
|
|
1606
1646
|
|
|
1607
1647
|
def parse_window_log(text):
|
|
1608
|
-
"""`git log --
|
|
1648
|
+
"""`git log --format=%H%x00%s%x00%b%x00` output -> the
|
|
1609
1649
|
`[{sha, subject, body}]` shape `classify_window` consumes.
|
|
1610
1650
|
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1651
|
+
Git commit messages cannot contain NUL, so the three-field framing cannot
|
|
1652
|
+
be forged by a subject or body line. A printable separator is unsafe here:
|
|
1653
|
+
a body can contain any such line and silently turn a breaking footer into
|
|
1654
|
+
a phantom record.
|
|
1614
1655
|
"""
|
|
1615
1656
|
if not isinstance(text, str):
|
|
1616
|
-
return
|
|
1657
|
+
return None
|
|
1658
|
+
fields = text.split("\0")
|
|
1659
|
+
if fields and not fields[-1].strip():
|
|
1660
|
+
fields.pop()
|
|
1661
|
+
if not fields or len(fields) % 3:
|
|
1662
|
+
return None
|
|
1617
1663
|
entries = []
|
|
1618
|
-
for
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
sha = lines[0].strip()
|
|
1625
|
-
subject = lines[1] if len(lines) > 1 else ""
|
|
1626
|
-
body = "\n".join(lines[2:])
|
|
1664
|
+
for index in range(0, len(fields), 3):
|
|
1665
|
+
sha = fields[index].strip()
|
|
1666
|
+
if re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", sha) is None:
|
|
1667
|
+
return None
|
|
1668
|
+
subject = fields[index + 1]
|
|
1669
|
+
body = fields[index + 2]
|
|
1627
1670
|
entries.append({"sha": sha, "subject": subject, "body": body})
|
|
1628
1671
|
return entries
|
|
1629
1672
|
|
|
@@ -1863,7 +1906,7 @@ def _resolve_posix_shell():
|
|
|
1863
1906
|
|
|
1864
1907
|
_OPEN_RE = re.compile(r"<!--\s*release-targets\s*-->")
|
|
1865
1908
|
_CLOSE_RE = re.compile(r"<!--\s*/release-targets\s*-->")
|
|
1866
|
-
_HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]
|
|
1909
|
+
_HEADER_RE = re.compile(r"^\[([A-Za-z0-9][A-Za-z0-9._-]*)\]$")
|
|
1867
1910
|
|
|
1868
1911
|
# A key not in _LIST_KEYS is scalar: exactly one value per target block, a
|
|
1869
1912
|
# second occurrence of the same key within one block is a DuplicateKeyError.
|
|
@@ -1978,6 +2021,22 @@ def _finish_row(row):
|
|
|
1978
2021
|
f"target {row['target']!r} is missing required key(s): "
|
|
1979
2022
|
+ ", ".join(missing)
|
|
1980
2023
|
)
|
|
2024
|
+
prefix = row["prefix"]
|
|
2025
|
+
forbidden = " ~^:?*[\\"
|
|
2026
|
+
unsafe_prefix = (
|
|
2027
|
+
prefix.startswith(("-", "/", ".", "refs/"))
|
|
2028
|
+
or prefix.endswith(("/", "."))
|
|
2029
|
+
or ".." in prefix
|
|
2030
|
+
or "@{" in prefix
|
|
2031
|
+
or "//" in prefix
|
|
2032
|
+
or any(ord(char) < 32 or ord(char) == 127 or char in forbidden
|
|
2033
|
+
for char in prefix)
|
|
2034
|
+
or any(not part or part.startswith(".") or part.endswith(".lock")
|
|
2035
|
+
for part in prefix.split("/"))
|
|
2036
|
+
)
|
|
2037
|
+
if unsafe_prefix:
|
|
2038
|
+
raise MalformedBlockError(
|
|
2039
|
+
f"target {row['target']!r} prefix is not a safe Git tag prefix")
|
|
1981
2040
|
policy = row["version_policy"]
|
|
1982
2041
|
initial_version = row["initial_version"]
|
|
1983
2042
|
if policy not in ("semver", "numeric-sequence"):
|
|
@@ -1993,6 +2052,23 @@ def _finish_row(row):
|
|
|
1993
2052
|
raise MalformedBlockError(
|
|
1994
2053
|
f"target {row['target']!r} numeric-sequence policy requires an "
|
|
1995
2054
|
"initial-version of two or more canonical dotted numeric components")
|
|
2055
|
+
writable_surfaces = [row["changelog"], row["provenance_manifest"]]
|
|
2056
|
+
for field in ("manifest", "generated_manifest", "artifacts"):
|
|
2057
|
+
writable_surfaces.extend(row[field])
|
|
2058
|
+
# Literal spaces are valid file names; overlap checks below retain their
|
|
2059
|
+
# conservative default so uncertain paths cannot hide governance scratch.
|
|
2060
|
+
if any(_normalised_release_path(path, allow_internal_spaces=True) in (None, ".")
|
|
2061
|
+
for path in writable_surfaces if path is not None):
|
|
2062
|
+
raise MalformedBlockError(
|
|
2063
|
+
f"target {row['target']!r} declares a writable release surface "
|
|
2064
|
+
"outside the repository or at its root")
|
|
2065
|
+
payload_surfaces = [row["payload"], *row["payload_exclude"]]
|
|
2066
|
+
if any(not isinstance(path, str)
|
|
2067
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in path)
|
|
2068
|
+
for path in payload_surfaces):
|
|
2069
|
+
raise MalformedBlockError(
|
|
2070
|
+
f"target {row['target']!r} declares a payload path containing "
|
|
2071
|
+
"a control byte")
|
|
1996
2072
|
build_declared = row["release_build"] is not None
|
|
1997
2073
|
assets_declared = bool(row["release_assets"])
|
|
1998
2074
|
if build_declared != assets_declared:
|
|
@@ -2026,7 +2102,7 @@ _GOVERNANCE_SCRATCH_PATHS = (
|
|
|
2026
2102
|
)
|
|
2027
2103
|
|
|
2028
2104
|
|
|
2029
|
-
def _normalised_release_path(value):
|
|
2105
|
+
def _normalised_release_path(value, *, allow_internal_spaces=False):
|
|
2030
2106
|
"""Return a conservative repository-relative path, or None on ambiguity.
|
|
2031
2107
|
|
|
2032
2108
|
A path that cannot be compared safely must prevent a scratch exclusion;
|
|
@@ -2034,7 +2110,10 @@ def _normalised_release_path(value):
|
|
|
2034
2110
|
"""
|
|
2035
2111
|
if not isinstance(value, str):
|
|
2036
2112
|
return None
|
|
2037
|
-
if value != value.strip()
|
|
2113
|
+
if (value != value.strip()
|
|
2114
|
+
or any((char.isspace() and (not allow_internal_spaces or char != " "))
|
|
2115
|
+
or ord(char) < 32 or ord(char) == 127
|
|
2116
|
+
for char in value)):
|
|
2038
2117
|
return None
|
|
2039
2118
|
raw = value.replace("\\", "/")
|
|
2040
2119
|
if (not raw or raw.startswith(('/', ':'))
|
|
@@ -2115,7 +2194,7 @@ def governance_scratch_exclusions(row):
|
|
|
2115
2194
|
return [path for path in _GOVERNANCE_SCRATCH_PATHS if not overlaps(path)]
|
|
2116
2195
|
|
|
2117
2196
|
|
|
2118
|
-
def release_tree_status(row, project_root):
|
|
2197
|
+
def release_tree_status(row, project_root, nul=False):
|
|
2119
2198
|
"""Run the target-aware repository-wide clean-tree probe.
|
|
2120
2199
|
|
|
2121
2200
|
Bounded with `timeout=30`, matching every other internal Git-probe call
|
|
@@ -2132,16 +2211,50 @@ def release_tree_status(row, project_root):
|
|
|
2132
2211
|
f":(exclude,top){path}"
|
|
2133
2212
|
for path in governance_scratch_exclusions(row)
|
|
2134
2213
|
]
|
|
2135
|
-
args = [git_executable(), "status", "--porcelain", "
|
|
2214
|
+
args = [git_executable(), "status", "--porcelain", *(["-z"] if nul else []),
|
|
2215
|
+
"--", ":/",
|
|
2136
2216
|
*scratch_pathspecs]
|
|
2137
2217
|
try:
|
|
2138
|
-
|
|
2139
|
-
args, capture_output=True, text=
|
|
2218
|
+
status_probe = subprocess.run(
|
|
2219
|
+
args, capture_output=True, text=not nul, cwd=project_root,
|
|
2140
2220
|
timeout=30, env=_sanitized_git_environment())
|
|
2221
|
+
if status_probe.returncode != 0:
|
|
2222
|
+
return status_probe
|
|
2223
|
+
flags_probe = subprocess.run(
|
|
2224
|
+
[git_executable(), "ls-files", "-v", "-z", "--", ":/",
|
|
2225
|
+
*scratch_pathspecs],
|
|
2226
|
+
capture_output=True, cwd=project_root, timeout=30,
|
|
2227
|
+
env=_sanitized_git_environment())
|
|
2228
|
+
if flags_probe.returncode != 0:
|
|
2229
|
+
return subprocess.CompletedProcess(
|
|
2230
|
+
args, flags_probe.returncode,
|
|
2231
|
+
b"" if nul else "",
|
|
2232
|
+
(flags_probe.stderr if nul else
|
|
2233
|
+
flags_probe.stderr.decode("utf-8", errors="replace")))
|
|
2234
|
+
hidden = []
|
|
2235
|
+
for record in flags_probe.stdout.split(b"\0"):
|
|
2236
|
+
if not record:
|
|
2237
|
+
continue
|
|
2238
|
+
marker = record[:1]
|
|
2239
|
+
if marker == b"S" or marker.islower():
|
|
2240
|
+
try:
|
|
2241
|
+
rel = record[2:].decode("utf-8")
|
|
2242
|
+
except UnicodeDecodeError:
|
|
2243
|
+
rel = "<non-UTF-8-path>"
|
|
2244
|
+
hidden.append(f"!! hidden-index-state {rel}")
|
|
2245
|
+
if hidden:
|
|
2246
|
+
if nul:
|
|
2247
|
+
extra = b"\0".join(
|
|
2248
|
+
line.encode("utf-8") for line in hidden) + b"\0"
|
|
2249
|
+
status_probe.stdout += extra
|
|
2250
|
+
else:
|
|
2251
|
+
status_probe.stdout += "".join(line + "\n" for line in hidden)
|
|
2252
|
+
return status_probe
|
|
2141
2253
|
except subprocess.TimeoutExpired:
|
|
2142
2254
|
return subprocess.CompletedProcess(
|
|
2143
|
-
args, 1, "",
|
|
2144
|
-
"git status probe timed out after 30 seconds"
|
|
2255
|
+
args, 1, b"" if nul else "",
|
|
2256
|
+
(b"git status probe timed out after 30 seconds" if nul else
|
|
2257
|
+
"git status probe timed out after 30 seconds"))
|
|
2145
2258
|
|
|
2146
2259
|
|
|
2147
2260
|
def parse_release_targets(text):
|
|
@@ -2279,6 +2392,11 @@ def parse_release_targets(text):
|
|
|
2279
2392
|
"build invocation")
|
|
2280
2393
|
|
|
2281
2394
|
if key in _LIST_KEYS:
|
|
2395
|
+
if key != "pre-tag" and "," in value:
|
|
2396
|
+
raise MalformedBlockError(
|
|
2397
|
+
f"list-valued key {key!r} in target "
|
|
2398
|
+
f"{row['target']!r} cannot contain a literal comma; "
|
|
2399
|
+
"comma is the declared show-row field separator")
|
|
2282
2400
|
row[field].append(value)
|
|
2283
2401
|
continue
|
|
2284
2402
|
|
|
@@ -2303,6 +2421,23 @@ def parse_release_targets(text):
|
|
|
2303
2421
|
_finish_row(row)
|
|
2304
2422
|
rows.append(row)
|
|
2305
2423
|
|
|
2424
|
+
for index, declared in enumerate(rows):
|
|
2425
|
+
prefix = declared["prefix"]
|
|
2426
|
+
for other in rows[:index]:
|
|
2427
|
+
other_prefix = other["prefix"]
|
|
2428
|
+
if (prefix.startswith(other_prefix)
|
|
2429
|
+
or other_prefix.startswith(prefix)):
|
|
2430
|
+
raise MalformedBlockError(
|
|
2431
|
+
f"targets {other['target']!r} and {declared['target']!r} "
|
|
2432
|
+
f"declare overlapping tag prefixes {other_prefix!r} "
|
|
2433
|
+
f"and {prefix!r}")
|
|
2434
|
+
latest_targets = [declared["target"] for declared in rows
|
|
2435
|
+
if declared["latest_eligible"]]
|
|
2436
|
+
if len(latest_targets) > 1:
|
|
2437
|
+
raise MalformedBlockError(
|
|
2438
|
+
"latest-eligible may be true for at most one release target; "
|
|
2439
|
+
"declared by " + ", ".join(repr(name) for name in latest_targets))
|
|
2440
|
+
|
|
2306
2441
|
return rows
|
|
2307
2442
|
|
|
2308
2443
|
|
|
@@ -2345,6 +2480,9 @@ def load_targets(path):
|
|
|
2345
2480
|
except OSError as exc:
|
|
2346
2481
|
raise UnreadableTargetsFileError(
|
|
2347
2482
|
f"could not read release-targets file {path!r}: {exc}") from exc
|
|
2483
|
+
except UnicodeError as exc:
|
|
2484
|
+
raise UnreadableTargetsFileError(
|
|
2485
|
+
f"release-targets file {path!r} is not UTF-8: {exc}") from exc
|
|
2348
2486
|
try:
|
|
2349
2487
|
return parse_release_targets(text)
|
|
2350
2488
|
except AbsentBlockError as exc:
|
|
@@ -2625,7 +2763,8 @@ def _load_declared_changelog_reconciliations(
|
|
|
2625
2763
|
"changelog-reconciliations must be one regular file committed on "
|
|
2626
2764
|
"the fetched default branch")
|
|
2627
2765
|
match = re.fullmatch(
|
|
2628
|
-
rb"(100644|100755) blob ([0-9a-f]{40})\t(.+)",
|
|
2766
|
+
rb"(100644|100755) blob ([0-9a-f]{40}|[0-9a-f]{64})\t(.+)",
|
|
2767
|
+
entries[0])
|
|
2629
2768
|
if match is None:
|
|
2630
2769
|
raise ChangelogReconciliationError(
|
|
2631
2770
|
"changelog-reconciliations must be a committed regular blob")
|
|
@@ -2787,7 +2926,7 @@ def main(argv):
|
|
|
2787
2926
|
version comparison (run 12).
|
|
2788
2927
|
classify-window [<target> <default-branch>]
|
|
2789
2928
|
stdin = `git log $WINDOW --pretty=format:
|
|
2790
|
-
%H%
|
|
2929
|
+
%H%x00%s%x00%b%x00 -- $PAYLOAD` -> prints the
|
|
2791
2930
|
derived bump, then accepted `[RECONCILED]`
|
|
2792
2931
|
rows and one
|
|
2793
2932
|
`[NEEDS-TRIAGE] <sha> <subject>` line per
|
|
@@ -2901,11 +3040,20 @@ def main(argv):
|
|
|
2901
3040
|
`classify-window` ancestry proof owns that.
|
|
2902
3041
|
show-row <target> [--field NAME]
|
|
2903
3042
|
prints every field the row declares, one
|
|
2904
|
-
`
|
|
2905
|
-
fields comma-separated), or
|
|
3043
|
+
shell-quoted `NAME=value` line each
|
|
3044
|
+
(multi-valued fields comma-separated), or
|
|
3045
|
+
just NAME's raw
|
|
2906
3046
|
value with `--field`. Reads via
|
|
2907
3047
|
`default_targets_path()` only, no
|
|
2908
3048
|
`--targets-file` override.
|
|
3049
|
+
list-field <target> <field> prints one exact item per line for a
|
|
3050
|
+
declared multi-valued field. The accepted
|
|
3051
|
+
field names are manifest,
|
|
3052
|
+
generated-manifest, artifacts, pre-tag,
|
|
3053
|
+
payload-exclude, and release-assets. This
|
|
3054
|
+
is the argument-preserving interface for
|
|
3055
|
+
values such as shell commands containing
|
|
3056
|
+
commas.
|
|
2909
3057
|
payload-pathspec <target> prints the row's `payload`, minus its
|
|
2910
3058
|
`payload-exclude` entries, as verbatim git
|
|
2911
3059
|
pathspec arguments (`:(exclude)` forms
|
|
@@ -2924,11 +3072,11 @@ def main(argv):
|
|
|
2924
3072
|
failure.
|
|
2925
3073
|
|
|
2926
3074
|
`--targets-file PATH` overrides `default_targets_path()` for `tag-prefix`
|
|
2927
|
-
and `list-targets` only; `show-row`, `payload-pathspec`, and
|
|
3075
|
+
and `list-targets` only; `show-row`, `list-field`, `payload-pathspec`, and
|
|
2928
3076
|
`clean-tree-status` always resolve the declared file through
|
|
2929
3077
|
`default_targets_path()` with no override, and every remaining subcommand
|
|
2930
3078
|
needs no declared file at all. `tag-prefix`, `list-targets`, `show-row`,
|
|
2931
|
-
`payload-pathspec`, and `clean-tree-status` all exit 3 when the declared
|
|
3079
|
+
`list-field`, `payload-pathspec`, and `clean-tree-status` all exit 3 when the declared
|
|
2932
3080
|
file is genuinely absent and 4 for every other declared-file error
|
|
2933
3081
|
(HIGH-1, `_targets_error_exit_code`); every other subcommand
|
|
2934
3082
|
prints a value/label and exits 0, or writes a short cause to stderr and
|
|
@@ -2937,7 +3085,7 @@ def main(argv):
|
|
|
2937
3085
|
code."""
|
|
2938
3086
|
if not argv:
|
|
2939
3087
|
sys.stderr.write(
|
|
2940
|
-
"usage: _releaselib.py {tag-prefix|list-targets|show-row|"
|
|
3088
|
+
"usage: _releaselib.py {tag-prefix|list-targets|show-row|list-field|"
|
|
2941
3089
|
"payload-pathspec|clean-tree-status|last-tag|last-tag-for-policy|"
|
|
2942
3090
|
"verify-tag-ancestor|notes-match|"
|
|
2943
3091
|
"changelog-section|dates-match|semver-greater|version-greater|"
|
|
@@ -2980,7 +3128,7 @@ def main(argv):
|
|
|
2980
3128
|
print(row["prefix"])
|
|
2981
3129
|
return 0
|
|
2982
3130
|
|
|
2983
|
-
if cmd in ("show-row", "payload-pathspec"):
|
|
3131
|
+
if cmd in ("show-row", "payload-pathspec", "list-field"):
|
|
2984
3132
|
# Blind-exercise HIGH (run 14). The lane's own rule is that the
|
|
2985
3133
|
# declared file must be read "through the same tested grammar", not
|
|
2986
3134
|
# "by-eye scan of the delimiter block" -- but only `prefix` and the
|
|
@@ -2998,7 +3146,15 @@ def main(argv):
|
|
|
2998
3146
|
# window for a row that declares one. This prints the pathspec
|
|
2999
3147
|
# arguments to pass verbatim, `:(exclude)` forms included.
|
|
3000
3148
|
field = None
|
|
3001
|
-
|
|
3149
|
+
list_field = None
|
|
3150
|
+
if cmd == "list-field":
|
|
3151
|
+
if len(rest) != 2:
|
|
3152
|
+
sys.stderr.write(
|
|
3153
|
+
"list-field requires exactly <target> <field>\n")
|
|
3154
|
+
return 2
|
|
3155
|
+
list_field = rest[1]
|
|
3156
|
+
rest = rest[:1]
|
|
3157
|
+
elif "--field" in rest:
|
|
3002
3158
|
idx = rest.index("--field")
|
|
3003
3159
|
if idx + 1 >= len(rest):
|
|
3004
3160
|
sys.stderr.write("--field requires a value\n")
|
|
@@ -3031,6 +3187,24 @@ def main(argv):
|
|
|
3031
3187
|
print("\n".join(parts))
|
|
3032
3188
|
return 0
|
|
3033
3189
|
|
|
3190
|
+
if cmd == "list-field":
|
|
3191
|
+
list_fields = {
|
|
3192
|
+
"manifest": "manifest",
|
|
3193
|
+
"generated-manifest": "generated_manifest",
|
|
3194
|
+
"artifacts": "artifacts",
|
|
3195
|
+
"pre-tag": "pre_tag",
|
|
3196
|
+
"payload-exclude": "payload_exclude",
|
|
3197
|
+
"release-assets": "release_assets",
|
|
3198
|
+
}
|
|
3199
|
+
row_field = list_fields.get(list_field)
|
|
3200
|
+
if row_field is None:
|
|
3201
|
+
sys.stderr.write(
|
|
3202
|
+
f"unknown list-valued release field: {list_field}\n")
|
|
3203
|
+
return 2
|
|
3204
|
+
for item in row.get(row_field, []):
|
|
3205
|
+
print(str(item))
|
|
3206
|
+
return 0
|
|
3207
|
+
|
|
3034
3208
|
# Emitted as SHELL-QUOTED `NAME='value'` pairs, and named for the
|
|
3035
3209
|
# variables the release skill actually spells (`TAG_PREFIX`, not
|
|
3036
3210
|
# `PREFIX`). Both halves are load-bearing, and blind exercise run 15
|
|
@@ -3067,12 +3241,13 @@ def main(argv):
|
|
|
3067
3241
|
("LATEST_ELIGIBLE", "latest_eligible"),
|
|
3068
3242
|
("CHANGELOG_RECONCILIATIONS", "changelog_reconciliations"),
|
|
3069
3243
|
("DISPLAY_NAME", "display_name")]
|
|
3070
|
-
|
|
3244
|
+
fields += [
|
|
3071
3245
|
("VERSION_POLICY", "version_policy"),
|
|
3072
3246
|
("INITIAL_VERSION", "initial_version"),
|
|
3073
3247
|
("RELEASE_BUILD", "release_build"),
|
|
3074
3248
|
("RELEASE_ASSETS", "release_assets"),
|
|
3075
3249
|
]
|
|
3250
|
+
query_fields = fields
|
|
3076
3251
|
|
|
3077
3252
|
def _flatten(value):
|
|
3078
3253
|
if isinstance(value, (list, tuple)):
|
|
@@ -3201,10 +3376,13 @@ def main(argv):
|
|
|
3201
3376
|
notes_text = fh.read()
|
|
3202
3377
|
except OSError:
|
|
3203
3378
|
notes_text = ""
|
|
3379
|
+
except UnicodeError:
|
|
3380
|
+
sys.stderr.write("notes-match: notes file is not UTF-8\n")
|
|
3381
|
+
return 4
|
|
3204
3382
|
return 0 if notes_heading_matches(
|
|
3205
3383
|
notes_text, tag, policy, initial) else 1
|
|
3206
3384
|
|
|
3207
|
-
if cmd == "changelog-section" and len(rest) in (4, 6):
|
|
3385
|
+
if cmd == "changelog-section" and len(rest) in (4, 6, 7):
|
|
3208
3386
|
# Mechanical reconstruction of Phase 1's composed section, for
|
|
3209
3387
|
# `resume_publish` -- see `changelog_section`'s docstring. Exit 0
|
|
3210
3388
|
# with the section on stdout - 1 the changelog has no heading for
|
|
@@ -3215,7 +3393,11 @@ def main(argv):
|
|
|
3215
3393
|
# current HEAD move or dirty/deleted working file cannot change the
|
|
3216
3394
|
# notes selected for an already-composed tag.
|
|
3217
3395
|
repo_root, revision, changelog_path, version = rest[:4]
|
|
3218
|
-
|
|
3396
|
+
allow_absent_path = len(rest) == 7 and rest[6] == "--allow-absent-path"
|
|
3397
|
+
if len(rest) == 7 and not allow_absent_path:
|
|
3398
|
+
sys.stderr.write("changelog-section: invalid optional flag\n")
|
|
3399
|
+
return 2
|
|
3400
|
+
policy, initial = (rest[4], rest[5] or None) if len(rest) >= 6 else (
|
|
3219
3401
|
"semver", None)
|
|
3220
3402
|
if not _policy_declaration_valid(policy, initial):
|
|
3221
3403
|
sys.stderr.write(
|
|
@@ -3224,6 +3406,12 @@ def main(argv):
|
|
|
3224
3406
|
changelog_text, source_error = _committed_changelog_text(
|
|
3225
3407
|
repo_root, revision, changelog_path)
|
|
3226
3408
|
if source_error is not None:
|
|
3409
|
+
if (allow_absent_path
|
|
3410
|
+
and source_error == "committed changelog path is absent"):
|
|
3411
|
+
sys.stderr.write(
|
|
3412
|
+
f"changelog-section: no committed {changelog_path!r} "
|
|
3413
|
+
"exists at the exact revision\n")
|
|
3414
|
+
return 1
|
|
3227
3415
|
sys.stderr.write(
|
|
3228
3416
|
f"changelog-section: cannot read committed "
|
|
3229
3417
|
f"{changelog_path!r}: {source_error}\n")
|
|
@@ -3313,7 +3501,7 @@ def main(argv):
|
|
|
3313
3501
|
return 1 if mismatched else 0
|
|
3314
3502
|
|
|
3315
3503
|
if cmd == "classify-window" and len(rest) in (0, 2):
|
|
3316
|
-
# stdin = `git log $WINDOW --
|
|
3504
|
+
# stdin = `git log $WINDOW --format=%H%x00%s%x00%b%x00 --
|
|
3317
3505
|
# $PAYLOAD`. Prints the derived bump on the first line, then one
|
|
3318
3506
|
# `[NEEDS-TRIAGE] <short-sha> <subject>` line per BUMPING commit
|
|
3319
3507
|
# with no CHANGELOG: footer -- the exact report shape Phase 1
|
|
@@ -3327,7 +3515,17 @@ def main(argv):
|
|
|
3327
3515
|
# skill keeps the BLOCK. A helper returning proceed/stop would put
|
|
3328
3516
|
# a governance decision inside a library, which is the wrong side
|
|
3329
3517
|
# of ADR-0010's cooperative-agent line.
|
|
3330
|
-
|
|
3518
|
+
try:
|
|
3519
|
+
window_text = sys.stdin.read()
|
|
3520
|
+
except UnicodeDecodeError:
|
|
3521
|
+
sys.stderr.write(
|
|
3522
|
+
"classify-window: git log input is not UTF-8\n")
|
|
3523
|
+
return 4
|
|
3524
|
+
commits = parse_window_log(window_text)
|
|
3525
|
+
if commits is None:
|
|
3526
|
+
sys.stderr.write(
|
|
3527
|
+
"classify-window: malformed NUL-framed git log input\n")
|
|
3528
|
+
return 4
|
|
3331
3529
|
reconciliations = {}
|
|
3332
3530
|
published_shas = set()
|
|
3333
3531
|
if rest:
|
|
@@ -3377,7 +3575,8 @@ def main(argv):
|
|
|
3377
3575
|
published_commit = published_probe.stdout.strip()
|
|
3378
3576
|
if (published_probe.returncode != 0
|
|
3379
3577
|
or re.fullmatch(
|
|
3380
|
-
r"[0-9a-f]{40}",
|
|
3578
|
+
r"(?:[0-9a-f]{40}|[0-9a-f]{64})",
|
|
3579
|
+
published_commit) is None):
|
|
3381
3580
|
sys.stderr.write(
|
|
3382
3581
|
f"classify-window: could not resolve exact published "
|
|
3383
3582
|
f"ref {published_ref!r}\n")
|
|
@@ -3421,6 +3620,17 @@ def main(argv):
|
|
|
3421
3620
|
reconciliations=reconciliations,
|
|
3422
3621
|
published_shas=published_shas)
|
|
3423
3622
|
print(window["bump"])
|
|
3623
|
+
for row in window["commits"]:
|
|
3624
|
+
print("[CLASSIFIED] " + json.dumps({
|
|
3625
|
+
"sha": row["sha"],
|
|
3626
|
+
"subject": row["subject"],
|
|
3627
|
+
"type": row["type"],
|
|
3628
|
+
"scope": row["scope"],
|
|
3629
|
+
"breaking": row["breaking"],
|
|
3630
|
+
"bump": row["bump"],
|
|
3631
|
+
"changelog": row["changelog"],
|
|
3632
|
+
"footer_reconciled": row["footer_reconciled"],
|
|
3633
|
+
}, sort_keys=True, separators=(",", ":")))
|
|
3424
3634
|
for row in window["commits"]:
|
|
3425
3635
|
if row["footer_reconciled"]:
|
|
3426
3636
|
print(
|
|
@@ -3562,62 +3772,73 @@ def main(argv):
|
|
|
3562
3772
|
return 9
|
|
3563
3773
|
|
|
3564
3774
|
def _tree_state():
|
|
3565
|
-
"""
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
A porcelain-line set alone cannot see a command that mutates a
|
|
3569
|
-
file which was ALREADY modified: the line is byte-identical
|
|
3570
|
-
(` M CHANGELOG.md` before and after), so the change falls out
|
|
3571
|
-
of the set difference and the command exits 0. That blind spot
|
|
3572
|
-
covered exactly `$CHANGELOG` and `$MANIFEST` -- the two files
|
|
3573
|
-
Phase 1 touches immediately before this step, and the two an
|
|
3574
|
-
injected line would actually damage, since both ship: one into
|
|
3575
|
-
the tag message and the Release notes, the other as the
|
|
3576
|
-
version the tag claims. Demonstrated with a changed sha256 and
|
|
3577
|
-
an `INJECTED` line surviving to exit 0.
|
|
3578
|
-
|
|
3579
|
-
Digesting every path git reports as changed closes it. Paths
|
|
3580
|
-
git lists but that do not exist (a deletion, or a rename's old
|
|
3581
|
-
side) get a sentinel rather than being skipped, so a delete is
|
|
3582
|
-
a state change like any other.
|
|
3775
|
+
"""Snapshot tracked/untracked paths and index state without
|
|
3776
|
+
following links. Hash content so edits to already-dirty files
|
|
3777
|
+
and skip-worktree paths remain visible.
|
|
3583
3778
|
"""
|
|
3584
|
-
#
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3779
|
+
# Exclude only local governance scratch, not release payloads.
|
|
3780
|
+
scratch_pathspecs = [
|
|
3781
|
+
f":(exclude,top){path}"
|
|
3782
|
+
for path in governance_scratch_exclusions(row)
|
|
3783
|
+
]
|
|
3784
|
+
git = git_executable()
|
|
3785
|
+
env = _sanitized_git_environment()
|
|
3786
|
+
try:
|
|
3787
|
+
probe = subprocess.run(
|
|
3788
|
+
[git, "ls-files", "-z", "--cached",
|
|
3789
|
+
"--others", "--exclude-standard", "--", ":/",
|
|
3790
|
+
*scratch_pathspecs],
|
|
3791
|
+
cwd=project_root, capture_output=True, timeout=30,
|
|
3792
|
+
env=env)
|
|
3793
|
+
index_probe = subprocess.run(
|
|
3794
|
+
[git, "ls-files", "-v", "--stage", "-z", "--", ":/",
|
|
3795
|
+
*scratch_pathspecs],
|
|
3796
|
+
cwd=project_root, capture_output=True, timeout=30,
|
|
3797
|
+
env=env)
|
|
3798
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
3799
|
+
return None, f"git file inventory failed: {exc}"
|
|
3602
3800
|
if probe.returncode != 0:
|
|
3603
|
-
|
|
3801
|
+
stderr = probe.stderr.decode("utf-8", errors="replace").strip()
|
|
3802
|
+
return None, (stderr or "git file inventory failed")
|
|
3803
|
+
if index_probe.returncode != 0:
|
|
3804
|
+
stderr = index_probe.stderr.decode("utf-8", errors="replace").strip()
|
|
3805
|
+
return None, (stderr or "git index inspection failed")
|
|
3806
|
+
index_state = {}
|
|
3807
|
+
for record in index_probe.stdout.split(b"\0"):
|
|
3808
|
+
if not record:
|
|
3809
|
+
continue
|
|
3810
|
+
header, separator, raw_rel = record.partition(b"\t")
|
|
3811
|
+
if not separator:
|
|
3812
|
+
return None, "git index inspection returned a malformed record"
|
|
3813
|
+
try:
|
|
3814
|
+
rel = raw_rel.decode("utf-8")
|
|
3815
|
+
except UnicodeDecodeError:
|
|
3816
|
+
return None, "git index inspection returned a non-UTF-8 path"
|
|
3817
|
+
index_state.setdefault(rel, []).append(header)
|
|
3604
3818
|
state = {}
|
|
3605
|
-
for
|
|
3606
|
-
|
|
3607
|
-
# carries `old -> new`; take the destination, which is the
|
|
3608
|
-
# path that exists on disk.
|
|
3609
|
-
rel = line[3:].strip().strip('"')
|
|
3610
|
-
if " -> " in rel:
|
|
3611
|
-
rel = rel.split(" -> ", 1)[1].strip().strip('"')
|
|
3612
|
-
if not rel:
|
|
3819
|
+
for raw_rel in probe.stdout.split(b"\0"):
|
|
3820
|
+
if not raw_rel:
|
|
3613
3821
|
continue
|
|
3822
|
+
try:
|
|
3823
|
+
rel = raw_rel.decode("utf-8")
|
|
3824
|
+
except UnicodeDecodeError:
|
|
3825
|
+
return None, "git file inventory returned a non-UTF-8 path"
|
|
3614
3826
|
absolute = os.path.join(project_root, *rel.split("/"))
|
|
3615
3827
|
try:
|
|
3616
|
-
|
|
3617
|
-
|
|
3828
|
+
mode = os.lstat(absolute).st_mode
|
|
3829
|
+
if stat.S_ISLNK(mode):
|
|
3830
|
+
digest = ("link", os.readlink(absolute))
|
|
3831
|
+
elif stat.S_ISREG(mode):
|
|
3832
|
+
hasher = hashlib.sha256()
|
|
3833
|
+
with open(absolute, "rb") as fh:
|
|
3834
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
3835
|
+
hasher.update(chunk)
|
|
3836
|
+
digest = ("file", hasher.hexdigest())
|
|
3837
|
+
else:
|
|
3838
|
+
digest = ("other", stat.S_IFMT(mode))
|
|
3618
3839
|
except OSError:
|
|
3619
|
-
digest = "
|
|
3620
|
-
state[rel] = digest
|
|
3840
|
+
digest = ("absent-or-unreadable",)
|
|
3841
|
+
state[rel] = (digest, tuple(index_state.get(rel, ())))
|
|
3621
3842
|
return state, None
|
|
3622
3843
|
|
|
3623
3844
|
# The assertion is "this command changed NOTHING NEW", not "the
|
|
@@ -3647,7 +3868,7 @@ def main(argv):
|
|
|
3647
3868
|
f"run-pre-tag: the tree-state PROBE itself failed: {failure}\n"
|
|
3648
3869
|
" This is not a verdict about any declared command -- no "
|
|
3649
3870
|
"command has run yet, so nothing has been checked or "
|
|
3650
|
-
|
|
3871
|
+
"mutated. Investigate why Git could not inventory this tree "
|
|
3651
3872
|
"(not a git repository, no readable .git, etc.) before "
|
|
3652
3873
|
"re-running.\n")
|
|
3653
3874
|
return 8
|
|
@@ -3815,10 +4036,12 @@ def main(argv):
|
|
|
3815
4036
|
return 2
|
|
3816
4037
|
elif policy == "numeric-sequence":
|
|
3817
4038
|
candidate_key = numeric_sequence_key(candidate, initial)
|
|
3818
|
-
floor_key = numeric_sequence_key(floor, initial)
|
|
3819
4039
|
initial_key = numeric_sequence_key(initial, initial)
|
|
3820
|
-
|
|
3821
|
-
|
|
4040
|
+
floor_key = (None if floor == NONE_SENTINEL
|
|
4041
|
+
else numeric_sequence_key(floor, initial))
|
|
4042
|
+
if (candidate_key is None or initial_key is None
|
|
4043
|
+
or (floor != NONE_SENTINEL and floor_key is None)
|
|
4044
|
+
or candidate_key < initial_key):
|
|
3822
4045
|
sys.stderr.write(
|
|
3823
4046
|
"version-greater: invalid numeric-sequence comparison\n")
|
|
3824
4047
|
return 2
|
|
@@ -3917,11 +4140,21 @@ def main(argv):
|
|
|
3917
4140
|
texts.append(fh.read())
|
|
3918
4141
|
except OSError:
|
|
3919
4142
|
texts.append("")
|
|
4143
|
+
except UnicodeError:
|
|
4144
|
+
sys.stderr.write(
|
|
4145
|
+
f"dates-match: input file {path!r} is not UTF-8\n")
|
|
4146
|
+
return 4
|
|
3920
4147
|
return 0 if release_dates_consistent(
|
|
3921
4148
|
texts[0], texts[1], policy, initial) else 1
|
|
3922
4149
|
|
|
3923
4150
|
if cmd == "classify" and len(rest) == 6:
|
|
3924
|
-
|
|
4151
|
+
if rest[0] not in ("true", "false") or rest[5] not in (
|
|
4152
|
+
"true", "false"):
|
|
4153
|
+
sys.stderr.write(
|
|
4154
|
+
"classify: boolean evidence must be exact lowercase "
|
|
4155
|
+
"true or false\n")
|
|
4156
|
+
return 2
|
|
4157
|
+
as_bool = lambda s: s == "true"
|
|
3925
4158
|
print(classify_publish_state(
|
|
3926
4159
|
tag_exists=as_bool(rest[0]), tag_sha=rest[1], head_sha=rest[2],
|
|
3927
4160
|
tag_version=rest[3], manifest_version=rest[4],
|
|
@@ -3991,7 +4224,8 @@ if __name__ == "__main__":
|
|
|
3991
4224
|
# the carriage return, corrupting pathspecs, target names, and asset paths.
|
|
3992
4225
|
# Force the wire format to LF once at the executable boundary. Imported
|
|
3993
4226
|
# library calls retain their caller-owned stream configuration.
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
reconfigure
|
|
4227
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
4228
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
4229
|
+
if callable(reconfigure):
|
|
4230
|
+
reconfigure(encoding="utf-8", errors="strict", newline="\n")
|
|
3997
4231
|
sys.exit(main(sys.argv[1:]))
|