code-standards 7.0.0__py3-none-any.whl

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 (99) hide show
  1. code_standards-7.0.0.dist-info/METADATA +53 -0
  2. code_standards-7.0.0.dist-info/RECORD +99 -0
  3. code_standards-7.0.0.dist-info/WHEEL +4 -0
  4. code_standards-7.0.0.dist-info/entry_points.txt +3 -0
  5. code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
  6. sarj_standards/__init__.py +30 -0
  7. sarj_standards/__main__.py +5 -0
  8. sarj_standards/_meta.py +22 -0
  9. sarj_standards/api.py +890 -0
  10. sarj_standards/cli/__init__.py +0 -0
  11. sarj_standards/cli/main.py +2466 -0
  12. sarj_standards/configs/cli-reference.v1.json +1 -0
  13. sarj_standards/configs/doctor.config.json +22 -0
  14. sarj_standards/configs/eslint.application.mjs +1366 -0
  15. sarj_standards/configs/eslint.peers.json +44 -0
  16. sarj_standards/configs/eslint.strict.mjs +1060 -0
  17. sarj_standards/configs/markdownlint.strict.yaml +12 -0
  18. sarj_standards/configs/pyright.strict.json +96 -0
  19. sarj_standards/configs/ruff.application.toml +363 -0
  20. sarj_standards/configs/ruff.strict.toml +338 -0
  21. sarj_standards/configs/rule-inventory.v1.json +1 -0
  22. sarj_standards/configs/rule-ledger.json +846 -0
  23. sarj_standards/configs/rule-warning-levels.v1.json +1 -0
  24. sarj_standards/configs/taplo.strict.toml +14 -0
  25. sarj_standards/configs/yamllint.strict.yaml +25 -0
  26. sarj_standards/libs/__init__.py +0 -0
  27. sarj_standards/libs/adoption/__init__.py +0 -0
  28. sarj_standards/libs/adoption/configs.py +36 -0
  29. sarj_standards/libs/adoption/doctor.py +1346 -0
  30. sarj_standards/libs/adoption/exclusions.py +66 -0
  31. sarj_standards/libs/adoption/hooks.py +423 -0
  32. sarj_standards/libs/adoption/launcher.py +240 -0
  33. sarj_standards/libs/adoption/lifecycle.py +493 -0
  34. sarj_standards/libs/adoption/manifest.py +550 -0
  35. sarj_standards/libs/adoption/packagemanager.py +285 -0
  36. sarj_standards/libs/adoption/retired_suppressions.py +371 -0
  37. sarj_standards/libs/adoption/scaffold.py +1660 -0
  38. sarj_standards/libs/adoption/service.py +441 -0
  39. sarj_standards/libs/adoption/transaction.py +274 -0
  40. sarj_standards/libs/adoption/upgrade.py +516 -0
  41. sarj_standards/libs/adoption/uvtool.py +62 -0
  42. sarj_standards/libs/catalogs/__init__.py +9 -0
  43. sarj_standards/libs/catalogs/slack_automations.py +627 -0
  44. sarj_standards/libs/corpus/__init__.py +25 -0
  45. sarj_standards/libs/corpus/manifest.py +211 -0
  46. sarj_standards/libs/corpus/snapshot.py +222 -0
  47. sarj_standards/libs/diagnostics/__init__.py +65 -0
  48. sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
  49. sarj_standards/libs/diagnostics/baseline.py +131 -0
  50. sarj_standards/libs/diagnostics/models.py +574 -0
  51. sarj_standards/libs/diagnostics/serialize.py +290 -0
  52. sarj_standards/libs/diagnostics/source.py +172 -0
  53. sarj_standards/libs/filesystem.py +11 -0
  54. sarj_standards/libs/linting/__init__.py +0 -0
  55. sarj_standards/libs/linting/analysis.py +422 -0
  56. sarj_standards/libs/linting/external.py +1454 -0
  57. sarj_standards/libs/linting/library_policy.py +688 -0
  58. sarj_standards/libs/linting/policy.py +152 -0
  59. sarj_standards/libs/linting/runner.py +442 -0
  60. sarj_standards/libs/linting/textlint.py +1605 -0
  61. sarj_standards/libs/release/__init__.py +98 -0
  62. sarj_standards/libs/release/_values.py +24 -0
  63. sarj_standards/libs/release/artifacts.py +191 -0
  64. sarj_standards/libs/release/causality.py +80 -0
  65. sarj_standards/libs/release/changes.py +48 -0
  66. sarj_standards/libs/release/process.py +128 -0
  67. sarj_standards/libs/release/publish.py +85 -0
  68. sarj_standards/libs/release/registry.py +271 -0
  69. sarj_standards/libs/release/release_age.py +218 -0
  70. sarj_standards/libs/release/rollout.py +1163 -0
  71. sarj_standards/libs/release/tags.py +373 -0
  72. sarj_standards/libs/release/typescript.py +191 -0
  73. sarj_standards/libs/repository/__init__.py +0 -0
  74. sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
  75. sarj_standards/libs/repository/comment_corpus.py +536 -0
  76. sarj_standards/libs/repository/config_generation.py +146 -0
  77. sarj_standards/libs/repository/docs.py +347 -0
  78. sarj_standards/libs/repository/hooks.py +118 -0
  79. sarj_standards/libs/repository/ledger.py +99 -0
  80. sarj_standards/libs/repository/repository.py +744 -0
  81. sarj_standards/libs/repository/rule_authoring.py +246 -0
  82. sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
  83. sarj_standards/libs/repository/rule_changes.py +318 -0
  84. sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
  85. sarj_standards/libs/repository/rule_lifecycle.py +167 -0
  86. sarj_standards/libs/repository/rule_maintenance.py +225 -0
  87. sarj_standards/libs/rules/__init__.py +74 -0
  88. sarj_standards/libs/rules/catalog.py +145 -0
  89. sarj_standards/libs/rules/contracts.py +382 -0
  90. sarj_standards/libs/rules/corpus_runner.py +365 -0
  91. sarj_standards/libs/rules/evaluation.py +177 -0
  92. sarj_standards/libs/setup/__init__.py +4 -0
  93. sarj_standards/libs/setup/repository.py +40 -0
  94. sarj_standards/py.typed +0 -0
  95. sarj_standards/schemas/__init__.py +4 -0
  96. sarj_standards/schemas/_paths.py +7 -0
  97. sarj_standards/schemas/rule-catalog.v1.json +1 -0
  98. sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
  99. sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
@@ -0,0 +1,1163 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ from dataclasses import dataclass
6
+ from datetime import timedelta
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import stat
12
+ import subprocess # ruff: ignore[suspicious-subprocess-import] -- centralized argv-only adapter; shell is never enabled
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ import tomllib
17
+ from types import MappingProxyType
18
+ from typing import TYPE_CHECKING, NamedTuple, Protocol, TypeGuard
19
+
20
+ import yaml
21
+
22
+ from sarj_standards.libs.adoption import doctor as adoption_doctor
23
+ from sarj_standards.libs.adoption import launcher
24
+ from sarj_standards.libs.adoption import manifest as adoption_manifest
25
+ from sarj_standards.libs.adoption import packagemanager as adoption_packagemanager
26
+ from sarj_standards.libs.adoption import scaffold as adoption_scaffold
27
+ from sarj_standards.libs.adoption import uvtool as adoption_uvtool
28
+
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Callable, Mapping, Sequence
32
+
33
+ DEFAULT_REGISTRY = Path(".sarj-standards-rollout.toml")
34
+ SOURCE_REPOSITORY = "https://github.com/sarj-ai/code-standards.git"
35
+ VERSION_RE = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:[a-zA-Z0-9.-]+)?\Z")
36
+ BOT_COMMIT_PREFIX = "chore(standards): adopt "
37
+ MANIFEST = ".sarj-standards.toml"
38
+ LS_REMOTE_FIELDS = 2
39
+ COMMIT_WITH_PARENT_FIELDS = 2
40
+ PORCELAIN_RECORD_MINIMUM = 4
41
+ MANAGED_TRAILER = "Standards-Rollout: managed/v1"
42
+ PR_MARKER_PREFIX = "<!-- sarj-standards-rollout:managed/v1"
43
+ REPOSITORY_VERSION_PIN = re.compile(r"^(STANDARDS_VERSION[ \t]*:?=[ \t]*)\S+[ \t]*$", re.MULTILINE)
44
+ PYRIGHT_COMMAND = re.compile(r"(?m)^(?P<indent>[ \t]*)cd python && uv run pyright[ \t]*$")
45
+ VERIFICATION_FAILED_MARKER = "<!-- sarj-standards-rollout:verification-failed -->"
46
+ RETIRED_ESLINT_SELECTORS = ("@sarj/prefer-single-sentence-comment", "@sarj/prefer-string-literal-union")
47
+ SOURCE_SUFFIXES = frozenset({".py", ".pyi", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".sql"})
48
+ MANAGED_WORKFLOW_PATHS = frozenset({".github/workflows/standards.yml", ".github/workflows/ci.yml"})
49
+ MAX_VERIFICATION_ATTEMPTS = 2
50
+ MISE_CONFIG_PATHS = (Path(".mise.toml"), Path("mise.toml"), Path(".tool-versions"), Path(".mise/config.toml"))
51
+ COREPACK_MANAGERS = frozenset({"pnpm", "yarn"})
52
+ WORKFLOW_TOOL_ACTIONS = MappingProxyType({"hashicorp/setup-terraform": ("terraform", "terraform_version")})
53
+ MANAGED_DELETIONS = frozenset({launcher.RETIRED_REPOSITORY_LAUNCHER.as_posix()})
54
+ RELEASE_VISIBILITY_ATTEMPTS = 7
55
+ RELEASE_VISIBILITY_DELAY = timedelta(seconds=10)
56
+ SECONDARY_JAVASCRIPT_ROOT_EXCLUSIONS = frozenset(
57
+ {
58
+ ".cache",
59
+ ".git",
60
+ ".next",
61
+ ".nox",
62
+ ".pnpm",
63
+ ".tox",
64
+ ".turbo",
65
+ ".venv",
66
+ ".yarn",
67
+ "build",
68
+ "dist",
69
+ "example",
70
+ "examples",
71
+ "fixture",
72
+ "fixtures",
73
+ "node_modules",
74
+ "test",
75
+ "testdata",
76
+ "tests",
77
+ "vendor",
78
+ "venv",
79
+ }
80
+ )
81
+
82
+
83
+ class RolloutError(RuntimeError):
84
+ pass
85
+
86
+
87
+ def is_object(value: object) -> TypeGuard[dict[str, object]]:
88
+ return isinstance(value, dict)
89
+
90
+
91
+ def is_array(value: object) -> TypeGuard[list[object]]:
92
+ return isinstance(value, list)
93
+
94
+
95
+ def required_text(table: Mapping[str, object], key: str) -> str:
96
+ value = table.get(key)
97
+ if not isinstance(value, str) or not value:
98
+ msg = f"rollout registry field {key!r} must be a non-empty string"
99
+ raise RolloutError(msg)
100
+ return value
101
+
102
+
103
+ def optional_bool(table: Mapping[str, object], key: str) -> bool:
104
+ value = table.get(key, False)
105
+ if not isinstance(value, bool):
106
+ msg = f"rollout registry field {key!r} must be a boolean"
107
+ raise RolloutError(msg)
108
+ return value
109
+
110
+
111
+ class RolloutArgs(argparse.Namespace):
112
+ registry: Path = DEFAULT_REGISTRY
113
+ command: str = ""
114
+ version: str | None = None
115
+ dry_run: bool = False
116
+
117
+
118
+ class CommandRunner(Protocol):
119
+ def run(
120
+ self,
121
+ command: Sequence[str],
122
+ *,
123
+ cwd: Path | None = None,
124
+ check: bool = True,
125
+ env: Mapping[str, str] | None = None,
126
+ ) -> subprocess.CompletedProcess[str]: ...
127
+
128
+
129
+ class SubprocessRunner:
130
+ @staticmethod
131
+ def run(
132
+ command: Sequence[str],
133
+ *,
134
+ cwd: Path | None = None,
135
+ check: bool = True,
136
+ env: Mapping[str, str] | None = None,
137
+ ) -> subprocess.CompletedProcess[str]:
138
+ return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- explicit argv; shell remains disabled
139
+ list(command), cwd=cwd, check=check, text=True, capture_output=True, env=env
140
+ )
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class Consumer:
145
+ name: str
146
+ repository: str
147
+ branch: str
148
+ verify: tuple[str, ...]
149
+ requires_approval: bool = False
150
+ auto_merge: bool = False
151
+ channel: str = "stable"
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class Outcome:
156
+ consumer: Consumer
157
+ state: str
158
+ url: str = ""
159
+ detail: str = ""
160
+
161
+ def as_dict(self) -> dict[str, object]:
162
+ return {
163
+ "name": self.consumer.name,
164
+ "repository": self.consumer.repository,
165
+ "branch": self.consumer.branch,
166
+ "state": self.state,
167
+ "url": self.url or None,
168
+ "detail": self.detail or None,
169
+ }
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class Plan:
174
+ source_sha: str
175
+ outcomes: tuple[Outcome, ...]
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class BranchPreparation:
180
+ branch: str
181
+ previous_sha: str | None
182
+
183
+
184
+ class ProvisionedTools(NamedTuple):
185
+ environment: dict[str, str]
186
+ command_prefix: tuple[str, ...]
187
+
188
+
189
+ def load_registry(path: Path) -> tuple[Consumer, ...]: # ruff: ignore[too-many-locals] -- schema fields stay explicit
190
+ with path.open("rb") as stream:
191
+ parsed: object = tomllib.load(stream)
192
+ if not is_object(parsed):
193
+ msg = f"rollout registry must be a TOML table: {path}"
194
+ raise RolloutError(msg)
195
+ raw = parsed
196
+ if raw.get("schema") != 1:
197
+ msg = f"unsupported registry schema in {path}"
198
+ raise RolloutError(msg)
199
+ entries_value = raw.get("consumer")
200
+ if not is_array(entries_value) or not entries_value:
201
+ msg = "the rollout registry must contain at least one consumer"
202
+ raise RolloutError(msg)
203
+ consumers: list[Consumer] = []
204
+ for entry_value in entries_value:
205
+ if not is_object(entry_value) or set(entry_value) - {
206
+ "name",
207
+ "repository",
208
+ "branch",
209
+ "verify",
210
+ "requires_approval",
211
+ "auto_merge",
212
+ "channel",
213
+ }:
214
+ msg = f"invalid registry entry keys: {entry_value!r}"
215
+ raise RolloutError(msg)
216
+ entry = entry_value
217
+ name = required_text(entry, "name")
218
+ repository = required_text(entry, "repository")
219
+ branch = required_text(entry, "branch")
220
+ verify_value = entry.get("verify")
221
+ requires_approval = optional_bool(entry, "requires_approval")
222
+ auto_merge = optional_bool(entry, "auto_merge")
223
+ channel_value = entry.get("channel", "stable")
224
+ if not isinstance(channel_value, str) or re.fullmatch(r"[a-z0-9][a-z0-9-]*", channel_value) is None:
225
+ msg = f"invalid rollout channel: {channel_value!r}"
226
+ raise RolloutError(msg)
227
+ if not is_array(verify_value) or not verify_value:
228
+ msg = f"invalid registry verification command: {entry!r}"
229
+ raise RolloutError(msg)
230
+ if not all(isinstance(item, str) and item for item in verify_value):
231
+ msg = f"invalid registry values: {entry!r}"
232
+ raise RolloutError(msg)
233
+ verify = tuple(item for item in verify_value if isinstance(item, str))
234
+ consumer = Consumer(
235
+ name=name,
236
+ repository=repository,
237
+ branch=branch,
238
+ verify=verify,
239
+ requires_approval=requires_approval,
240
+ auto_merge=auto_merge,
241
+ channel=channel_value,
242
+ )
243
+ consumers.append(consumer)
244
+ identities = tuple((item.repository, item.branch) for item in consumers)
245
+ if len(set(identities)) != len(consumers):
246
+ msg = "registry consumers must have unique repository and branch identities"
247
+ raise RolloutError(msg)
248
+ if sum(item.auto_merge for item in consumers) > 1:
249
+ msg = "at most one consumer may enable rollout auto-merge"
250
+ raise RolloutError(msg)
251
+ return tuple(consumers)
252
+
253
+
254
+ def validate_version(version: str) -> str:
255
+ if not VERSION_RE.fullmatch(version):
256
+ msg = f"invalid immutable version: {version!r}"
257
+ raise RolloutError(msg)
258
+ return version
259
+
260
+
261
+ def rollout_branch(version: str) -> str:
262
+ validate_version(version)
263
+ return "standards-rollout/current"
264
+
265
+
266
+ def pr_marker(consumer: Consumer, version: str) -> str:
267
+ validate_version(version)
268
+ return f"{PR_MARKER_PREFIX} repository={consumer.repository} base={consumer.branch} channel={consumer.channel} -->"
269
+
270
+
271
+ def desired_marker(version: str) -> str:
272
+ return f"<!-- sarj-standards-rollout:desired={validate_version(version)} -->"
273
+
274
+
275
+ def stdout(result: subprocess.CompletedProcess[str]) -> str:
276
+ return (result.stdout or "").strip()
277
+
278
+
279
+ def json_result(result: subprocess.CompletedProcess[str]) -> object:
280
+ rendered = stdout(result)
281
+ try:
282
+ parsed: object = json.loads(rendered or "null") # pyright: ignore[reportAny]
283
+ except json.JSONDecodeError as exc:
284
+ msg = f"command returned invalid JSON: {rendered[:200]}"
285
+ raise RolloutError(msg) from exc
286
+ else:
287
+ return parsed
288
+
289
+
290
+ def verify_release(
291
+ version: str,
292
+ runner: CommandRunner,
293
+ *,
294
+ sleep: Callable[[float], None] = time.sleep,
295
+ ) -> str:
296
+ version = validate_version(version)
297
+ package: subprocess.CompletedProcess[str] | None = None
298
+ for attempt in range(RELEASE_VISIBILITY_ATTEMPTS):
299
+ package = runner.run(
300
+ (
301
+ "uvx",
302
+ "--isolated",
303
+ "--python",
304
+ "3.14",
305
+ "--refresh-package",
306
+ "code-standards",
307
+ "--from",
308
+ f"code-standards=={version}",
309
+ "code-standards",
310
+ "--version",
311
+ ),
312
+ check=False,
313
+ )
314
+ match = re.fullmatch(r"code-standards\s+([0-9a-zA-Z.-]+)", stdout(package))
315
+ if package.returncode == 0 and match is not None and match.group(1) == version:
316
+ break
317
+ if attempt + 1 < RELEASE_VISIBILITY_ATTEMPTS:
318
+ sleep(RELEASE_VISIBILITY_DELAY.total_seconds())
319
+ else:
320
+ msg = f"PyPI artifact did not report version {version}"
321
+ raise RolloutError(msg)
322
+ tag = f"refs/tags/standards-v{version}"
323
+ peeled = tag + "^{}"
324
+ remote = runner.run(("git", "ls-remote", SOURCE_REPOSITORY, tag, peeled))
325
+ refs = {
326
+ fields[1]: fields[0] for line in stdout(remote).splitlines() if len(fields := line.split()) == LS_REMOTE_FIELDS
327
+ }
328
+ sha = refs.get(peeled)
329
+ if len(refs) != LS_REMOTE_FIELDS or sha is None or not re.fullmatch(r"[0-9a-f]{40}", sha):
330
+ msg = f"published tag standards-v{version} is absent or invalid"
331
+ raise RolloutError(msg)
332
+ return sha
333
+
334
+
335
+ def manifest_version(contents: str) -> str | None:
336
+ try:
337
+ parsed = tomllib.loads(contents)
338
+ except tomllib.TOMLDecodeError:
339
+ return None
340
+ value = parsed.get("bundle", parsed.get("version"))
341
+ return value if isinstance(value, str) else None
342
+
343
+
344
+ def base_manifest(consumer: Consumer, runner: CommandRunner) -> str | None:
345
+ result = runner.run(
346
+ (
347
+ "gh",
348
+ "api",
349
+ f"repos/{consumer.repository}/contents/{MANIFEST}",
350
+ "--method",
351
+ "GET",
352
+ "-f",
353
+ f"ref={consumer.branch}",
354
+ ),
355
+ check=False,
356
+ )
357
+ if result.returncode != 0:
358
+ return None
359
+ payload = json_result(result)
360
+ if not is_object(payload):
361
+ return None
362
+ content = payload.get("content")
363
+ if not isinstance(content, str):
364
+ return None
365
+ try:
366
+ return base64.b64decode(content, validate=False).decode()
367
+ except ValueError, UnicodeDecodeError:
368
+ return None
369
+
370
+
371
+ def pull_request(consumer: Consumer, version: str, runner: CommandRunner) -> dict[str, object] | None:
372
+ result = runner.run(
373
+ (
374
+ "gh",
375
+ "pr",
376
+ "list",
377
+ "--repo",
378
+ consumer.repository,
379
+ "--state",
380
+ "open",
381
+ "--head",
382
+ rollout_branch(version),
383
+ "--json",
384
+ "state,mergedAt,url,headRefName,baseRefName,body",
385
+ "--limit",
386
+ "2",
387
+ )
388
+ )
389
+ payload = json_result(result)
390
+ if not is_array(payload) or not payload:
391
+ return None
392
+ if len(payload) != 1:
393
+ msg = f"{consumer.name}: multiple rollout PRs exist for {rollout_branch(version)}"
394
+ raise RolloutError(msg)
395
+ first = payload[0]
396
+ return first if is_object(first) else None
397
+
398
+
399
+ def status_one(consumer: Consumer, version: str, runner: CommandRunner) -> Outcome:
400
+ pull = pull_request(consumer, version, runner)
401
+ if pull is not None:
402
+ identity_is_valid = (
403
+ pull.get("headRefName") == rollout_branch(version)
404
+ and pull.get("baseRefName") == consumer.branch
405
+ and pr_marker(consumer, version) in str(pull.get("body", ""))
406
+ )
407
+ if not identity_is_valid:
408
+ return Outcome(
409
+ consumer,
410
+ "blocked",
411
+ str(pull.get("url", "")),
412
+ "rollout PR ownership marker, head, or base does not match",
413
+ )
414
+ if desired_marker(version) not in str(pull.get("body", "")):
415
+ return Outcome(
416
+ consumer,
417
+ "missing",
418
+ str(pull.get("url", "")),
419
+ "open managed PR targets an older Standards release",
420
+ )
421
+ if VERIFICATION_FAILED_MARKER in str(pull.get("body", "")):
422
+ return Outcome(
423
+ consumer,
424
+ "blocked",
425
+ str(pull.get("url", "")),
426
+ "consumer verification failed; reconcile will retry this managed PR",
427
+ )
428
+ state = "merged" if pull.get("mergedAt") else "pr-open"
429
+ return Outcome(consumer, state, str(pull.get("url", "")))
430
+ adopted = manifest_version(base_manifest(consumer, runner) or "")
431
+ if adopted == version:
432
+ return Outcome(consumer, "already-current")
433
+ return Outcome(consumer, "missing", detail=f"base branch has {adopted or 'no readable manifest'}")
434
+
435
+
436
+ def status(version: str, consumers: Sequence[Consumer], runner: CommandRunner) -> tuple[Outcome, ...]:
437
+ validate_version(version)
438
+ return tuple(status_one(item, version, runner) for item in consumers)
439
+
440
+
441
+ def changed_paths(repo: Path, runner: CommandRunner) -> tuple[str, ...]:
442
+ result = runner.run(("git", "status", "--porcelain=v1", "-z", "--untracked-files=all"), cwd=repo)
443
+ fields = [field for field in (result.stdout or "").split("\0") if field]
444
+ paths: list[str] = []
445
+ index = 0
446
+ while index < len(fields):
447
+ record = fields[index]
448
+ if len(record) < PORCELAIN_RECORD_MINIMUM:
449
+ msg = "git returned an invalid porcelain status record"
450
+ raise RolloutError(msg)
451
+ state, path = record[:2], record[3:]
452
+ if "R" in state or ("D" in state and path not in MANAGED_DELETIONS):
453
+ msg = f"update may not delete or rename files: {path}"
454
+ raise RolloutError(msg)
455
+ paths.append(path)
456
+ index += 2 if "R" in state or "C" in state else 1
457
+ return tuple(paths)
458
+
459
+
460
+ def committed_paths(repo: Path, base: str, runner: CommandRunner) -> tuple[str, ...]:
461
+ comparison = f"origin/{base}...HEAD"
462
+ renamed = stdout(runner.run(("git", "diff", "--name-only", "--diff-filter=R", comparison), cwd=repo))
463
+ deleted = stdout(runner.run(("git", "diff", "--name-only", "--diff-filter=D", comparison), cwd=repo))
464
+ unsafe_deletions = tuple(path for path in deleted.splitlines() if path not in MANAGED_DELETIONS)
465
+ if renamed or unsafe_deletions:
466
+ affected = renamed or ", ".join(unsafe_deletions)
467
+ msg = f"rollout branch may not delete or rename files: {affected}"
468
+ raise RolloutError(msg)
469
+ result = runner.run(("git", "diff", "--name-only", "-z", "--diff-filter=ACMD", comparison), cwd=repo)
470
+ return tuple(path for path in (result.stdout or "").split("\0") if path)
471
+
472
+
473
+ def reject_git_metadata(
474
+ repo: Path,
475
+ paths: Sequence[str],
476
+ runner: CommandRunner,
477
+ *,
478
+ comparison: str = "",
479
+ ) -> None:
480
+ diff_args = (comparison,) if comparison else ()
481
+ summary = stdout(runner.run(("git", "diff", "--summary", *diff_args), cwd=repo))
482
+ if "mode change" in summary or "create mode 120000" in summary:
483
+ msg = "update may not change file modes or create symlinks"
484
+ raise RolloutError(msg)
485
+ numbers = stdout(runner.run(("git", "diff", "--numstat", *diff_args, "--", *paths), cwd=repo))
486
+ if any(line.startswith("-\t-\t") for line in numbers.splitlines()):
487
+ msg = "update may not add or modify binary files"
488
+ raise RolloutError(msg)
489
+ for relative in paths:
490
+ candidate = repo / relative
491
+ tracked = runner.run(("git", "ls-files", "--error-unmatch", "--", relative), cwd=repo, check=False)
492
+ untracked_executable = (
493
+ tracked.returncode != 0
494
+ and candidate.exists()
495
+ and bool(stat.S_IMODE(candidate.stat().st_mode) & stat.S_IXUSR)
496
+ )
497
+ if candidate.is_symlink() or untracked_executable:
498
+ msg = f"update may not create symlinks or executable files: {relative}"
499
+ raise RolloutError(msg)
500
+ if candidate.is_file() and b"\0" in candidate.read_bytes()[:8192]:
501
+ msg = f"update may not add or modify binary files: {relative}"
502
+ raise RolloutError(msg)
503
+
504
+
505
+ def reject_unsafe_diff(
506
+ paths: Sequence[str],
507
+ *,
508
+ allowed_source_paths: frozenset[str] = frozenset(),
509
+ allowed_workflow_paths: frozenset[str] = frozenset(),
510
+ ) -> None:
511
+ if not paths:
512
+ msg = "the update produced no changes but the base manifest is not current"
513
+ raise RolloutError(msg)
514
+ unsafe: list[str] = []
515
+ for rendered in paths:
516
+ path = Path(rendered)
517
+ lowered = rendered.lower()
518
+ workflow_is_unsafe = (
519
+ rendered.startswith(".github/workflows/")
520
+ and rendered not in MANAGED_WORKFLOW_PATHS
521
+ and rendered not in allowed_workflow_paths
522
+ )
523
+ source_is_unsafe = (
524
+ rendered not in allowed_source_paths
525
+ and path.suffix in SOURCE_SUFFIXES
526
+ and any(part in {"src", "app", "apps"} for part in path.parts)
527
+ )
528
+ if workflow_is_unsafe or source_is_unsafe or "baseline" in lowered or "exclusion" in lowered:
529
+ unsafe.append(rendered)
530
+ if unsafe:
531
+ msg = "update touched protected paths: " + ", ".join(unsafe)
532
+ raise RolloutError(msg)
533
+
534
+
535
+ def amend_safe_changes(
536
+ repo: Path,
537
+ runner: CommandRunner,
538
+ *,
539
+ allowed_workflow_paths: frozenset[str],
540
+ ) -> bool:
541
+ paths = changed_paths(repo, runner)
542
+ if not paths:
543
+ return False
544
+ reject_unsafe_diff(paths, allowed_workflow_paths=allowed_workflow_paths)
545
+ reject_git_metadata(repo, paths, runner)
546
+ runner.run(("git", "add", "--", *paths), cwd=repo)
547
+ runner.run(
548
+ ("git", "-c", "core.hooksPath=/dev/null", "commit", "--amend", "--no-edit"),
549
+ cwd=repo,
550
+ )
551
+ return True
552
+
553
+
554
+ def remote_branch_sha(repo: Path, branch: str, runner: CommandRunner) -> str | None:
555
+ result = runner.run(("git", "ls-remote", "--heads", "origin", branch), cwd=repo)
556
+ fields = stdout(result).split()
557
+ return fields[0] if len(fields) == LS_REMOTE_FIELDS else None
558
+
559
+
560
+ def force_with_lease(branch: str, previous_sha: str | None) -> str:
561
+ return f"--force-with-lease=refs/heads/{branch}:{previous_sha or ''}"
562
+
563
+
564
+ def unauthenticated_environment() -> dict[str, str]:
565
+ environment = dict(os.environ) # ruff: ignore[banned-api] — copy before scrubbing auth
566
+ environment.pop("GH_TOKEN", None)
567
+ environment.pop("GITHUB_TOKEN", None)
568
+ inherited_virtual_env = environment.pop("VIRTUAL_ENV", None)
569
+ environment.pop("UV_PROJECT_ENVIRONMENT", None)
570
+ if inherited_virtual_env:
571
+ virtual_bin = Path(inherited_virtual_env) / ("Scripts" if os.name == "nt" else "bin")
572
+ environment["PATH"] = os.pathsep.join(
573
+ entry for entry in environment.get("PATH", "").split(os.pathsep) if Path(entry) != virtual_bin
574
+ )
575
+ for name in tuple(environment):
576
+ if name.startswith("STANDARDS_ROLLOUT_"):
577
+ environment.pop(name)
578
+ return environment
579
+
580
+
581
+ def consumer_verification_environment(environment: Mapping[str, str], base_sha: str) -> dict[str, str]:
582
+ prepared = dict(environment)
583
+ prepared["SARJ_REACT_DOCTOR_BASE"] = base_sha
584
+ return prepared
585
+
586
+
587
+ def declared_workflow_tools(repo: Path) -> tuple[str, ...]:
588
+ requirements: dict[str, str] = {}
589
+ workflow_root = repo / ".github/workflows"
590
+ for path in sorted((*workflow_root.glob("*.yml"), *workflow_root.glob("*.yaml"))):
591
+ try:
592
+ parsed: object = yaml.safe_load(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
593
+ except (OSError, yaml.YAMLError) as exc:
594
+ msg = f"could not read consumer workflow tool declarations from {path}: {exc}"
595
+ raise RolloutError(msg) from exc
596
+ if not is_object(parsed):
597
+ continue
598
+ jobs = parsed.get("jobs")
599
+ if not is_object(jobs):
600
+ continue
601
+ for job_value in jobs.values():
602
+ if not is_object(job_value):
603
+ continue
604
+ steps = job_value.get("steps")
605
+ if not is_array(steps):
606
+ continue
607
+ for step_value in steps:
608
+ if not is_object(step_value):
609
+ continue
610
+ uses = step_value.get("uses")
611
+ if not isinstance(uses, str):
612
+ continue
613
+ action = uses.partition("@")[0]
614
+ declaration = WORKFLOW_TOOL_ACTIONS.get(action)
615
+ if declaration is None:
616
+ continue
617
+ tool, version_key = declaration
618
+ options = step_value.get("with")
619
+ version = options.get(version_key) if is_object(options) else None
620
+ if not isinstance(version, str) or VERSION_RE.fullmatch(version) is None:
621
+ msg = f"{path}: {action} must declare an exact {version_key} for rollout verification"
622
+ raise RolloutError(msg)
623
+ previous = requirements.setdefault(tool, version)
624
+ if previous != version:
625
+ msg = f"consumer workflows declare conflicting {tool} versions: {previous} and {version}"
626
+ raise RolloutError(msg)
627
+ return tuple(f"{tool}@{version}" for tool, version in sorted(requirements.items()))
628
+
629
+
630
+ def provision_consumer_tools(
631
+ repo: Path,
632
+ shim_directory: Path,
633
+ runner: CommandRunner,
634
+ environment: Mapping[str, str],
635
+ ) -> ProvisionedTools:
636
+ prepared = dict(environment)
637
+ mise_prefix: tuple[str, ...] = ()
638
+ has_mise_config = any((repo / relative).is_file() for relative in MISE_CONFIG_PATHS)
639
+ workflow_tools = declared_workflow_tools(repo)
640
+ if has_mise_config or workflow_tools:
641
+ prepared["MISE_YES"] = "1"
642
+ prepared["MISE_TRUSTED_CONFIG_PATHS"] = str(repo.resolve())
643
+ if has_mise_config:
644
+ installed = runner.run(("mise", "install"), cwd=repo, env=prepared, check=False)
645
+ if installed.returncode != 0:
646
+ msg = "could not provision repository-declared mise tools:\n" + verification_detail(installed)
647
+ raise RolloutError(msg)
648
+ if workflow_tools:
649
+ installed = runner.run(("mise", "install", *workflow_tools), cwd=repo, env=prepared, check=False)
650
+ if installed.returncode != 0:
651
+ msg = "could not provision workflow-declared tools:\n" + verification_detail(installed)
652
+ raise RolloutError(msg)
653
+ mise_prefix = ("mise", "exec", *workflow_tools, "--")
654
+
655
+ adopted = adoption_manifest.load(repo)
656
+ python_root = None if adopted is None else repo / adopted.python_dest
657
+ uv_source = adoption_uvtool.version_file(python_root)
658
+ uv_required = None if uv_source is None else adoption_uvtool.required_version(uv_source)
659
+ if uv_required is not None:
660
+ shim_directory.mkdir(parents=True, exist_ok=True)
661
+ install_environment = dict(prepared)
662
+ install_environment["UV_TOOL_DIR"] = str(shim_directory.parent / "uv-tools")
663
+ install_environment["UV_TOOL_BIN_DIR"] = str(shim_directory)
664
+ installed = runner.run(
665
+ ("uv", "--no-config", "tool", "install", "--force", f"uv{uv_required}"),
666
+ cwd=repo,
667
+ env=install_environment,
668
+ check=False,
669
+ )
670
+ if installed.returncode != 0:
671
+ msg = "could not provision repository-declared uv:\n" + verification_detail(installed)
672
+ raise RolloutError(msg)
673
+
674
+ manager = _declared_corepack_manager(repo)
675
+ if manager is not None:
676
+ shim_directory.mkdir(parents=True, exist_ok=True)
677
+ enabled = runner.run(
678
+ (*mise_prefix, "corepack", "enable", "--install-directory", str(shim_directory)),
679
+ cwd=repo,
680
+ env=prepared,
681
+ check=False,
682
+ )
683
+ if enabled.returncode != 0:
684
+ msg = f"could not provision isolated Corepack shims for {manager}:\n" + verification_detail(enabled)
685
+ raise RolloutError(msg)
686
+ if uv_required is not None or manager is not None:
687
+ current_path = prepared.get("PATH", "")
688
+ prepared["PATH"] = f"{shim_directory}{os.pathsep}{current_path}" if current_path else str(shim_directory)
689
+ return ProvisionedTools(prepared, mise_prefix)
690
+
691
+
692
+ def run_consumer_bootstrap(
693
+ repo: Path,
694
+ tool_prefix: tuple[str, ...],
695
+ runner: CommandRunner,
696
+ environment: Mapping[str, str],
697
+ ) -> subprocess.CompletedProcess[str] | None:
698
+ adopted = adoption_manifest.load(repo)
699
+ if adopted is None:
700
+ return None
701
+ python_install = adoption_scaffold.python_ci_install_argv(repo, adopted.python_dest)
702
+ if python_install:
703
+ python_root = repo / adopted.python_dest
704
+ compatible_install = adoption_uvtool.argv(python_root, *python_install[1:])
705
+ result = runner.run((*tool_prefix, *compatible_install), cwd=repo, env=environment, check=False)
706
+ if result.returncode != 0:
707
+ return result
708
+ primary_typescript_root = adoption_packagemanager.workspace_root(
709
+ repo / adopted.typescript_dest,
710
+ repo,
711
+ )
712
+ for javascript_root in secondary_javascript_roots(repo, primary_typescript_root):
713
+ manager = adoption_packagemanager.detect(javascript_root)
714
+ install = adoption_packagemanager.frozen_install_argv(
715
+ manager,
716
+ yarn=adoption_packagemanager.yarn_variant(javascript_root),
717
+ )
718
+ result = runner.run((*tool_prefix, *install), cwd=javascript_root, env=environment, check=False)
719
+ if result.returncode != 0:
720
+ return result
721
+ for command in adopted.ci_bootstrap:
722
+ result = runner.run(
723
+ (*tool_prefix, "bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", command),
724
+ cwd=repo,
725
+ env=environment,
726
+ check=False,
727
+ )
728
+ if result.returncode != 0:
729
+ return result
730
+ return None
731
+
732
+
733
+ def secondary_javascript_roots(repo: Path, primary: Path) -> tuple[Path, ...]:
734
+ repository = repo.resolve()
735
+ primary_root = primary.resolve()
736
+ roots: list[Path] = []
737
+ for current, directories, files in os.walk(repository):
738
+ directories[:] = sorted(
739
+ directory for directory in directories if directory not in SECONDARY_JAVASCRIPT_ROOT_EXCLUSIONS
740
+ )
741
+ root = Path(current)
742
+ if root == primary_root:
743
+ directories.clear()
744
+ continue
745
+ if any(name in files for name, _manager in adoption_packagemanager.LOCKFILES):
746
+ roots.append(root)
747
+ return tuple(sorted(roots, key=lambda path: path.relative_to(repository).as_posix()))
748
+
749
+
750
+ def _declared_corepack_manager(repo: Path) -> str | None:
751
+ for manifest in sorted(repo.glob("**/package.json")):
752
+ if any(part in {"node_modules", ".git"} for part in manifest.parts):
753
+ continue
754
+ try:
755
+ parsed: object = json.loads(manifest.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
756
+ except OSError, json.JSONDecodeError:
757
+ continue
758
+ if not is_object(parsed):
759
+ continue
760
+ declared = parsed.get("packageManager")
761
+ if not isinstance(declared, str):
762
+ continue
763
+ manager = declared.partition("@")[0]
764
+ if manager in COREPACK_MANAGERS:
765
+ return manager
766
+ return None
767
+
768
+
769
+ def synchronize_repository_pin(repo: Path, version: str) -> bool:
770
+ makefile = repo / "Makefile"
771
+ if not makefile.is_file():
772
+ return False
773
+ original = makefile.read_text(encoding="utf-8")
774
+ matches = tuple(REPOSITORY_VERSION_PIN.finditer(original))
775
+ if not matches:
776
+ return False
777
+ if len(matches) != 1:
778
+ msg = "repository Makefile must contain at most one STANDARDS_VERSION pin"
779
+ raise RolloutError(msg)
780
+ updated = REPOSITORY_VERSION_PIN.sub(rf"\g<1>{validate_version(version)}", original)
781
+ if updated == original:
782
+ return False
783
+ makefile.write_text(updated, encoding="utf-8")
784
+ return True
785
+
786
+
787
+ def synchronize_repository_checker(repo: Path) -> bool:
788
+ makefile = repo / "Makefile"
789
+ project = repo / "python/pyproject.toml"
790
+ if not makefile.is_file() or not project.is_file():
791
+ return False
792
+ if "basedpyright" not in project.read_text(encoding="utf-8"):
793
+ return False
794
+ original = makefile.read_text(encoding="utf-8")
795
+ updated, count = PYRIGHT_COMMAND.subn(r"\g<indent>cd python && uv run basedpyright", original)
796
+ if count > 1:
797
+ msg = "repository Makefile contains multiple ambiguous Python typecheck commands"
798
+ raise RolloutError(msg)
799
+ if updated == original:
800
+ return False
801
+ makefile.write_text(updated, encoding="utf-8")
802
+ return True
803
+
804
+
805
+ def remove_retired_eslint_suppressions(repo: Path, runner: CommandRunner) -> frozenset[str]:
806
+ matched = runner.run(
807
+ ("git", "grep", "-lz", "eslint-disable", "--", "*.js", "*.jsx", "*.ts", "*.tsx"),
808
+ cwd=repo,
809
+ check=False,
810
+ )
811
+ if matched.returncode not in {0, 1}:
812
+ msg = "could not enumerate retired ESLint suppressions"
813
+ raise RolloutError(msg)
814
+ changed: set[str] = set()
815
+ for relative in (item for item in (matched.stdout or "").split("\0") if item):
816
+ path = repo / relative
817
+ original = path.read_text(encoding="utf-8")
818
+ lines: list[str] = []
819
+ for line in original.splitlines(keepends=True):
820
+ updated = line
821
+ if "eslint-disable" in updated:
822
+ for selector in RETIRED_ESLINT_SELECTORS:
823
+ updated = updated.replace(f", {selector}", "").replace(f"{selector}, ", "").replace(selector, "")
824
+ lines.append(updated)
825
+ rendered = "".join(lines)
826
+ if rendered != original:
827
+ path.write_text(rendered, encoding="utf-8")
828
+ changed.add(relative)
829
+ return frozenset(changed)
830
+
831
+
832
+ def verification_detail(result: subprocess.CompletedProcess[str]) -> str:
833
+ rendered = "\n".join(value.strip() for value in (result.stdout, result.stderr) if value)
834
+ return rendered[-4000:] or f"verification command exited {result.returncode}"
835
+
836
+
837
+ def process_failure_detail(error: subprocess.CalledProcessError) -> str:
838
+ stdout: object = error.stdout # pyright: ignore[reportAny] - subprocess exception boundary
839
+ stderr: object = error.stderr # pyright: ignore[reportAny] - subprocess exception boundary
840
+ values = [value.strip() for value in (stdout, stderr) if isinstance(value, str) and value]
841
+ return ("\n".join(values)[-4000:] or str(error)).strip()
842
+
843
+
844
+ def prepare_branch(
845
+ repo: Path,
846
+ version: str,
847
+ base_sha: str,
848
+ runner: CommandRunner,
849
+ ) -> BranchPreparation:
850
+ branch = rollout_branch(version)
851
+ previous_sha = remote_branch_sha(repo, branch, runner)
852
+ if previous_sha is None:
853
+ runner.run(("git", "switch", "-c", branch, base_sha), cwd=repo)
854
+ return BranchPreparation(branch, None)
855
+ runner.run(("git", "fetch", "origin", branch), cwd=repo)
856
+ message = stdout(runner.run(("git", "show", "-s", "--format=%B", "FETCH_HEAD"), cwd=repo))
857
+ fetched_commit = stdout(runner.run(("git", "rev-list", "--parents", "-n", "1", "FETCH_HEAD"), cwd=repo)).split()
858
+ valid_commit_shape = (
859
+ len(fetched_commit) == COMMIT_WITH_PARENT_FIELDS
860
+ and fetched_commit[0] == previous_sha
861
+ and all(re.fullmatch(r"[0-9a-f]{40}", sha) is not None for sha in fetched_commit)
862
+ )
863
+ parent_is_base_ancestor = False
864
+ if valid_commit_shape:
865
+ ancestry = runner.run(
866
+ ("git", "merge-base", "--is-ancestor", fetched_commit[1], base_sha),
867
+ cwd=repo,
868
+ check=False,
869
+ )
870
+ parent_is_base_ancestor = ancestry.returncode == 0
871
+ if (
872
+ MANAGED_TRAILER not in message
873
+ or not message.startswith(BOT_COMMIT_PREFIX)
874
+ or not valid_commit_shape
875
+ or not parent_is_base_ancestor
876
+ ):
877
+ msg = f"refusing human-modified rollout branch {branch}"
878
+ raise RolloutError(msg)
879
+ runner.run(("git", "switch", "-C", branch, base_sha), cwd=repo)
880
+ return BranchPreparation(branch, previous_sha)
881
+
882
+
883
+ def apply_one( # ruff: ignore[too-many-locals] - one transaction keeps verification and mutation state bound
884
+ consumer: Consumer,
885
+ version: str,
886
+ runner: CommandRunner,
887
+ *,
888
+ dry_run: bool = False,
889
+ ) -> Outcome:
890
+ existing = status_one(consumer, version, runner)
891
+ retry_verification = existing.state == "blocked" and existing.detail.startswith("consumer verification failed")
892
+ if existing.state == "blocked" and not retry_verification:
893
+ msg = f"{consumer.name}: {existing.detail}: {existing.url}"
894
+ raise RolloutError(msg)
895
+ if existing.state != "missing" and not retry_verification:
896
+ return existing
897
+ if dry_run:
898
+ return Outcome(consumer, "would-create", detail=rollout_branch(version))
899
+ with tempfile.TemporaryDirectory(prefix="standards-rollout-") as temporary:
900
+ repo = Path(temporary) / "repo"
901
+ runner.run(
902
+ (
903
+ "gh",
904
+ "repo",
905
+ "clone",
906
+ consumer.repository,
907
+ str(repo),
908
+ "--",
909
+ "--branch",
910
+ consumer.branch,
911
+ )
912
+ )
913
+ base_sha = stdout(runner.run(("git", "rev-parse", "HEAD"), cwd=repo))
914
+ if re.fullmatch(r"[0-9a-f]{40}", base_sha) is None:
915
+ msg = f"{consumer.name}: cloned base did not resolve to a full commit SHA"
916
+ raise RolloutError(msg)
917
+ preparation = prepare_branch(repo, version, base_sha, runner)
918
+ branch = preparation.branch
919
+ previous_sha = preparation.previous_sha
920
+ tool = (
921
+ "uvx",
922
+ "--isolated",
923
+ "--python",
924
+ "3.14",
925
+ "--from",
926
+ f"code-standards=={version}",
927
+ "code-standards",
928
+ "--root",
929
+ ".",
930
+ )
931
+ unauthenticated, tool_prefix = provision_consumer_tools(
932
+ repo,
933
+ Path(temporary) / "corepack-bin",
934
+ runner,
935
+ unauthenticated_environment(),
936
+ )
937
+ allowed_workflow_paths = frozenset(
938
+ relative
939
+ for update in adoption_doctor.plan_version_pin_updates(repo)
940
+ if (relative := update.path.relative_to(repo).as_posix()).startswith(".github/workflows/")
941
+ )
942
+ failures: list[str] = []
943
+ try:
944
+ runner.run((*tool_prefix, *tool, "update", "--to", version), cwd=repo, env=unauthenticated)
945
+ except subprocess.CalledProcessError as exc:
946
+ msg = f"{consumer.name}: dependency installation failed before a coherent rollout patch was prepared:\n"
947
+ raise RolloutError(msg + process_failure_detail(exc)) from exc
948
+ doctor = runner.run((*tool_prefix, *tool, "doctor"), cwd=repo, env=unauthenticated, check=False)
949
+ if doctor.returncode != 0:
950
+ failures.append("Standards doctor failed:\n" + verification_detail(doctor))
951
+ bootstrap = run_consumer_bootstrap(repo, tool_prefix, runner, unauthenticated)
952
+ worktree_paths = changed_paths(repo, runner)
953
+ reject_unsafe_diff(worktree_paths, allowed_workflow_paths=allowed_workflow_paths)
954
+ reject_git_metadata(repo, worktree_paths, runner)
955
+ runner.run(("git", "add", "--", *worktree_paths), cwd=repo)
956
+ message = f"{BOT_COMMIT_PREFIX}{version}\n\n{MANAGED_TRAILER}"
957
+ runner.run(("git", "-c", "core.hooksPath=/dev/null", "commit", "-m", message), cwd=repo)
958
+ if bootstrap is not None:
959
+ failures.append("consumer bootstrap failed:\n" + verification_detail(bootstrap))
960
+ else:
961
+ verification_failure_detail = ""
962
+ for attempt in range(MAX_VERIFICATION_ATTEMPTS):
963
+ verification = runner.run(
964
+ (*tool_prefix, *consumer.verify),
965
+ cwd=repo,
966
+ env=consumer_verification_environment(unauthenticated, base_sha),
967
+ check=False,
968
+ )
969
+ mutated = amend_safe_changes(repo, runner, allowed_workflow_paths=allowed_workflow_paths)
970
+ if verification.returncode == 0 and not mutated:
971
+ break
972
+ if mutated and attempt + 1 < MAX_VERIFICATION_ATTEMPTS:
973
+ continue
974
+ verification_failure_detail = verification_detail(verification)
975
+ if mutated:
976
+ verification_failure_detail += "\nconsumer verification did not converge after safe auto-fixes"
977
+ break
978
+ if verification_failure_detail:
979
+ failures.append("consumer verification failed:\n" + verification_failure_detail)
980
+ verification_failure = "\n\n".join(failures)[-4000:]
981
+ branch_paths = committed_paths(repo, consumer.branch, runner)
982
+ reject_unsafe_diff(branch_paths, allowed_workflow_paths=allowed_workflow_paths)
983
+ reject_git_metadata(
984
+ repo,
985
+ branch_paths,
986
+ runner,
987
+ comparison=f"origin/{consumer.branch}...HEAD",
988
+ )
989
+ lease = force_with_lease(branch, previous_sha)
990
+ runner.run(("git", "push", lease, "-u", "origin", branch), cwd=repo)
991
+ pull = pull_request(consumer, version, runner)
992
+ body = f"{pr_marker(consumer, version)}\n{desired_marker(version)}\n\n"
993
+ if verification_failure:
994
+ body += (
995
+ f"{VERIFICATION_FAILED_MARKER}\n\n"
996
+ f"Consumer verification is blocked:\n\n```text\n{verification_failure}\n```\n\n"
997
+ )
998
+ body += f"Desired bundle: `code-standards=={version}`.\n\nGenerated by `make rollout`."
999
+ if pull is None:
1000
+ created = runner.run(
1001
+ (
1002
+ "gh",
1003
+ "pr",
1004
+ "create",
1005
+ "--repo",
1006
+ consumer.repository,
1007
+ "--base",
1008
+ consumer.branch,
1009
+ "--head",
1010
+ branch,
1011
+ "--title",
1012
+ BOT_COMMIT_PREFIX + version,
1013
+ "--body",
1014
+ body,
1015
+ )
1016
+ )
1017
+ url = stdout(created)
1018
+ else:
1019
+ url = str(pull.get("url", ""))
1020
+ runner.run(
1021
+ (
1022
+ "gh",
1023
+ "pr",
1024
+ "edit",
1025
+ "--repo",
1026
+ consumer.repository,
1027
+ url,
1028
+ "--title",
1029
+ BOT_COMMIT_PREFIX + version,
1030
+ "--body",
1031
+ body,
1032
+ )
1033
+ )
1034
+ if consumer.auto_merge and not verification_failure:
1035
+ runner.run(("gh", "pr", "merge", "--repo", consumer.repository, "--auto", "--squash", url), check=False)
1036
+ if verification_failure:
1037
+ return Outcome(consumer, "blocked", url, "consumer verification failed; PR opened for remediation")
1038
+ return Outcome(consumer, "pr-open", url)
1039
+
1040
+
1041
+ def plan(version: str, consumers: Sequence[Consumer], runner: CommandRunner) -> Plan:
1042
+ sha = verify_release(version, runner)
1043
+ return Plan(sha, status(version, consumers, runner))
1044
+
1045
+
1046
+ def apply(
1047
+ version: str,
1048
+ consumers: Sequence[Consumer],
1049
+ runner: CommandRunner,
1050
+ *,
1051
+ dry_run: bool = False,
1052
+ ) -> tuple[Outcome, ...]:
1053
+ verify_release(version, runner)
1054
+ outcomes: list[Outcome] = []
1055
+ for consumer in consumers:
1056
+ try:
1057
+ outcomes.append(apply_one(consumer, version, runner, dry_run=dry_run))
1058
+ except subprocess.CalledProcessError as exc:
1059
+ outcomes.append(Outcome(consumer, "error", detail=process_failure_detail(exc)))
1060
+ except (OSError, RolloutError) as exc:
1061
+ outcomes.append(Outcome(consumer, "error", detail=str(exc)))
1062
+ return tuple(outcomes)
1063
+
1064
+
1065
+ def latest_version(runner: CommandRunner) -> str:
1066
+ result = runner.run(
1067
+ (
1068
+ "uvx",
1069
+ "--isolated",
1070
+ "--python",
1071
+ "3.14",
1072
+ "--refresh-package",
1073
+ "code-standards",
1074
+ "--from",
1075
+ "code-standards",
1076
+ "code-standards",
1077
+ "--version",
1078
+ )
1079
+ )
1080
+ match = re.search(r"([0-9]+\.[0-9]+\.[0-9]+(?:[a-zA-Z0-9.-]+)?)", stdout(result))
1081
+ if match is None:
1082
+ msg = "could not determine the latest published Standards version"
1083
+ raise RolloutError(msg)
1084
+ return validate_version(match.group(1))
1085
+
1086
+
1087
+ def print_outcomes(version: str, outcomes: Sequence[Outcome], *, source_sha: str = "") -> None:
1088
+ adopted = sum(item.state in {"merged", "already-current"} for item in outcomes)
1089
+ distributed = sum(item.state in {"pr-open", "merged", "already-current"} for item in outcomes)
1090
+ sys.stdout.write(
1091
+ json.dumps(
1092
+ {
1093
+ "version": version,
1094
+ "sourceSha": source_sha or None,
1095
+ "complete": adopted == len(outcomes),
1096
+ "count": f"{adopted}/{len(outcomes)}",
1097
+ "distributed": distributed == len(outcomes),
1098
+ "distributedCount": f"{distributed}/{len(outcomes)}",
1099
+ "adoptedCount": f"{adopted}/{len(outcomes)}",
1100
+ "consumers": [item.as_dict() for item in outcomes],
1101
+ },
1102
+ indent=2,
1103
+ sort_keys=True,
1104
+ )
1105
+ + "\n"
1106
+ )
1107
+
1108
+
1109
+ def parser() -> argparse.ArgumentParser:
1110
+ result = argparse.ArgumentParser(description=__doc__)
1111
+ result.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY)
1112
+ commands = result.add_subparsers(dest="command", required=True)
1113
+ for name in ("plan", "apply", "status"):
1114
+ command = commands.add_parser(name)
1115
+ command.add_argument("--version", required=True)
1116
+ if name == "apply":
1117
+ command.add_argument("--dry-run", action="store_true")
1118
+ reconcile = commands.add_parser("reconcile")
1119
+ reconcile.add_argument("--version", help="default: latest published version")
1120
+ reconcile.add_argument("--dry-run", action="store_true")
1121
+ return result
1122
+
1123
+
1124
+ def execute(args: RolloutArgs, runner: CommandRunner) -> int:
1125
+ consumers = load_registry(args.registry)
1126
+ version = validate_version(args.version) if args.version else latest_version(runner)
1127
+ if args.command == "plan":
1128
+ rollout_plan = plan(version, consumers, runner)
1129
+ outcomes = rollout_plan.outcomes
1130
+ print_outcomes(version, outcomes, source_sha=rollout_plan.source_sha)
1131
+ elif args.command == "status":
1132
+ outcomes = status(version, consumers, runner)
1133
+ print_outcomes(version, outcomes)
1134
+ return 0 if all(item.state in {"merged", "already-current"} for item in outcomes) else 1
1135
+ else:
1136
+ outcomes = apply(version, consumers, runner, dry_run=args.dry_run)
1137
+ print_outcomes(version, outcomes)
1138
+ if any(item.state in {"blocked", "error"} for item in outcomes):
1139
+ return 1
1140
+ return 0
1141
+
1142
+
1143
+ def main(argv: Sequence[str] | None = None, *, runner: CommandRunner | None = None) -> int:
1144
+ args = RolloutArgs()
1145
+ _ = parser().parse_args(argv, namespace=args)
1146
+ try:
1147
+ return execute(args, runner or SubprocessRunner())
1148
+ except subprocess.CalledProcessError as exc:
1149
+ stdout: object = exc.stdout # pyright: ignore[reportAny] - subprocess exception boundary
1150
+ stderr: object = exc.stderr # pyright: ignore[reportAny] - subprocess exception boundary
1151
+ if isinstance(stdout, str) and stdout:
1152
+ sys.stderr.write(stdout.rstrip() + "\n")
1153
+ if isinstance(stderr, str) and stderr:
1154
+ sys.stderr.write(stderr.rstrip() + "\n")
1155
+ sys.stderr.write(f"standards-rollout: {exc}\n")
1156
+ return 2
1157
+ except (OSError, RolloutError) as exc:
1158
+ sys.stderr.write(f"standards-rollout: {exc}\n")
1159
+ return 2
1160
+
1161
+
1162
+ if __name__ == "__main__":
1163
+ raise SystemExit(main())