@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.
Files changed (44) hide show
  1. package/README.md +33 -9
  2. package/package.json +2 -1
  3. package/plugins/ca-pi/CHANGELOG.md +67 -0
  4. package/plugins/ca-pi/LICENSE +661 -0
  5. package/plugins/ca-pi/SKILLS.md +2 -2
  6. package/plugins/ca-pi/SPRINT.md +47 -0
  7. package/plugins/ca-pi/THIRD_PARTY_NOTICES.md +40 -0
  8. package/plugins/ca-pi/generated/command-catalog.json +2 -2
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  15. package/plugins/ca-pi/helpers/artifacts/release.json +6 -6
  16. package/plugins/ca-pi/hooks/_approvallib.py +58 -22
  17. package/plugins/ca-pi/hooks/_artifactauthoritylib.py +2135 -0
  18. package/plugins/ca-pi/hooks/_artifactlib.py +164 -7
  19. package/plugins/ca-pi/hooks/_artifactpromptlib.py +177 -0
  20. package/plugins/ca-pi/hooks/_host.py +1 -1
  21. package/plugins/ca-pi/hooks/_overrideappendlib.py +5 -1
  22. package/plugins/ca-pi/hooks/_prerequisitelib.py +44 -22
  23. package/plugins/ca-pi/hooks/_reconciliationlib.py +472 -0
  24. package/plugins/ca-pi/hooks/_releaselib.py +345 -111
  25. package/plugins/ca-pi/hooks/_sprintapprovallib.py +302 -0
  26. package/plugins/ca-pi/hooks/artifact-authority-hook.py +127 -0
  27. package/plugins/ca-pi/hooks/artifact-authority.py +102 -0
  28. package/plugins/ca-pi/hooks/artifact-reconcile.py +57 -0
  29. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  30. package/plugins/ca-pi/hooks/prompt-submit.py +20 -3
  31. package/plugins/ca-pi/hooks/releasehash.py +36 -23
  32. package/plugins/ca-pi/includes/artifacts.md +120 -13
  33. package/plugins/ca-pi/includes/sprint-authority.md +69 -0
  34. package/plugins/ca-pi/package.json +1 -1
  35. package/plugins/ca-pi/routines/INDEX.md +7 -4
  36. package/plugins/ca-pi/routines/brainstorming/SKILL.md +97 -29
  37. package/plugins/ca-pi/routines/commit-gate/SKILL.md +7 -1
  38. package/plugins/ca-pi/routines/executing-plans/SKILL.md +32 -15
  39. package/plugins/ca-pi/routines/release/SKILL.md +220 -70
  40. package/plugins/ca-pi/routines/skill-author/SKILL.md +26 -5
  41. package/plugins/ca-pi/routines/writing-plans/SKILL.md +22 -10
  42. package/plugins/ca-pi/skills/ca-commit/SKILL.md +170 -16
  43. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +116 -21
  44. 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(r"Released-at:\s*(\d{4}-\d{2}-\d{2})")
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 base_key is None or initial_key is None:
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
- tm = _RELEASED_AT_RE.search(tag_message)
882
- if not cm or not tm:
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
- return cm.group(1) == tm.group(1)
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 or len(records) != 1:
1029
- return None, "committed changelog path is absent or ambiguous"
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(r"^CHANGELOG:", re.MULTILINE)
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": bool(_CHANGELOG_FOOTER_RE.search(body))}
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(_BREAKING_FOOTER_RE.search(body))
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": bool(_CHANGELOG_FOOTER_RE.search(body)),
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
- verdict["changelog"] = reconciliations[sha].strip() if reconciled else ""
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._-]+", values["target"]) is None:
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}", values["commit_sha"]) is None:
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 --pretty=format:%H%n%s%n%b%n----` output -> the
1648
+ """`git log --format=%H%x00%s%x00%b%x00` output -> the
1609
1649
  `[{sha, subject, body}]` shape `classify_window` consumes.
1610
1650
 
1611
- The separator is the one the release skill already prescribes, so the
1612
- prose and this parser cannot drift apart into two different readings
1613
- of the same command's output.
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 chunk in text.split("\n----"):
1619
- lines = [ln for ln in chunk.split("\n")]
1620
- while lines and not lines[0].strip():
1621
- lines.pop(0)
1622
- if not lines or not lines[0].strip():
1623
- continue
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() or any(char.isspace() for char in value):
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
- return subprocess.run(
2139
- args, capture_output=True, text=True, cwd=project_root,
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(.+)", entries[0])
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%n%s%n%b%n---- -- $PAYLOAD` -> prints the
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
- `name: value` line each (multi-valued
2905
- fields comma-separated), or just NAME's raw
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
- if "--field" in rest:
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
- query_fields = fields + [
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
- policy, initial = (rest[4], rest[5] or None) if len(rest) == 6 else (
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 --pretty=format:%H%n%s%n%b%n---- --
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
- commits = parse_window_log(sys.stdin.read())
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}", published_commit) is None):
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
- """(paths -> content digest, failure). Content, not just the
3566
- porcelain LINE (HIGH, adversarial review run 10).
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
- # `git_executable()`, not a bare "git": Pi-reachable modules
3585
- # must resolve git through the trusted-path seam, because a
3586
- # host that has no `git` on PATH (or a PATH an attacker can
3587
- # prepend to) would otherwise silently run the wrong binary or
3588
- # none at all. Enforced by test_pi_package's
3589
- # `test_shared_python_contains_no_direct_bare_git_subprocess`.
3590
- # `--` plus the same `:/` + `,top`-exclusion pathspec the
3591
- # release skill's own Pre-flight and step-7 clean-tree checks
3592
- # are required to spell (#584 MEDIUM-1): the hooks append to
3593
- # `gate-events.log` on essentially every command, INCLUDING the
3594
- # commands this probe's own caller (`run-pre-tag`) runs, so a
3595
- # mid-window append between the baseline snapshot and a
3596
- # post-command probe put a blameless audit log in the changed
3597
- # set -- and exit 6's remedy tells the operator to permanently
3598
- # delete a release gate for it. `.markers/` is exempted for the
3599
- # same reason the skill exempts it: a per-machine confirmation
3600
- # marker minted by this same run is not a release surface.
3601
- probe = release_tree_status(row, project_root)
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
- return None, (probe.stderr.strip() or "git status failed")
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 line in probe.stdout.splitlines():
3606
- # Porcelain v1: XY then a space then the path. A rename
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
- with open(absolute, "rb") as fh:
3617
- digest = hashlib.sha256(fh.read()).hexdigest()
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 = "<absent-or-unreadable>"
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
- "mutated. Investigate why `git status` failed in this tree "
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
- if (candidate_key is None or floor_key is None
3821
- or initial_key is None or candidate_key < initial_key):
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
- as_bool = lambda s: str(s).lower() == "true"
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
- reconfigure = getattr(sys.stdout, "reconfigure", None)
3995
- if callable(reconfigure):
3996
- reconfigure(newline="\n")
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:]))