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.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- 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())
|