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,98 @@
|
|
|
1
|
+
from sarj_standards.libs.release.artifacts import (
|
|
2
|
+
required_artifact_paths,
|
|
3
|
+
verify_built_package,
|
|
4
|
+
verify_package_tarball,
|
|
5
|
+
verify_python_wheel_license,
|
|
6
|
+
)
|
|
7
|
+
from sarj_standards.libs.release.causality import (
|
|
8
|
+
CausalityViolation,
|
|
9
|
+
ReleaseCausalityReport,
|
|
10
|
+
check_release_causality,
|
|
11
|
+
)
|
|
12
|
+
from sarj_standards.libs.release.changes import changed_release_targets, pending_release_targets
|
|
13
|
+
from sarj_standards.libs.release.process import (
|
|
14
|
+
ProcessFailureError,
|
|
15
|
+
ProcessResult,
|
|
16
|
+
ProcessRunner,
|
|
17
|
+
credential_free_environment,
|
|
18
|
+
run_build_process,
|
|
19
|
+
run_process,
|
|
20
|
+
run_process_environment,
|
|
21
|
+
)
|
|
22
|
+
from sarj_standards.libs.release.publish import PublishTarget, publish_target
|
|
23
|
+
from sarj_standards.libs.release.release_age import (
|
|
24
|
+
PackageIdentity,
|
|
25
|
+
PackumentFetcher,
|
|
26
|
+
ReleaseAgeFailure,
|
|
27
|
+
ReleaseAgePolicy,
|
|
28
|
+
ReleaseAgeReport,
|
|
29
|
+
check_lockfile_release_age,
|
|
30
|
+
fetch_npm_packument,
|
|
31
|
+
load_exact_exclusions,
|
|
32
|
+
locked_registry_packages,
|
|
33
|
+
)
|
|
34
|
+
from sarj_standards.libs.release.tags import (
|
|
35
|
+
RELEASE_TARGETS,
|
|
36
|
+
ReleaseTarget,
|
|
37
|
+
ReleaseTargetId,
|
|
38
|
+
TagSyncResult,
|
|
39
|
+
ValidatedReleaseTag,
|
|
40
|
+
create_release_tags,
|
|
41
|
+
missing_remote_release_tags,
|
|
42
|
+
read_manifest_version,
|
|
43
|
+
validate_release_tag,
|
|
44
|
+
verify_remote_release_tags,
|
|
45
|
+
)
|
|
46
|
+
from sarj_standards.libs.release.typescript import (
|
|
47
|
+
PackedArtifact,
|
|
48
|
+
ReleaseMode,
|
|
49
|
+
check_typescript,
|
|
50
|
+
pack_typescript,
|
|
51
|
+
run_typescript_release,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = (
|
|
56
|
+
"RELEASE_TARGETS",
|
|
57
|
+
"CausalityViolation",
|
|
58
|
+
"PackageIdentity",
|
|
59
|
+
"PackedArtifact",
|
|
60
|
+
"PackumentFetcher",
|
|
61
|
+
"ProcessFailureError",
|
|
62
|
+
"ProcessResult",
|
|
63
|
+
"ProcessRunner",
|
|
64
|
+
"PublishTarget",
|
|
65
|
+
"ReleaseAgeFailure",
|
|
66
|
+
"ReleaseAgePolicy",
|
|
67
|
+
"ReleaseAgeReport",
|
|
68
|
+
"ReleaseCausalityReport",
|
|
69
|
+
"ReleaseMode",
|
|
70
|
+
"ReleaseTarget",
|
|
71
|
+
"ReleaseTargetId",
|
|
72
|
+
"TagSyncResult",
|
|
73
|
+
"ValidatedReleaseTag",
|
|
74
|
+
"changed_release_targets",
|
|
75
|
+
"check_lockfile_release_age",
|
|
76
|
+
"check_release_causality",
|
|
77
|
+
"check_typescript",
|
|
78
|
+
"create_release_tags",
|
|
79
|
+
"credential_free_environment",
|
|
80
|
+
"fetch_npm_packument",
|
|
81
|
+
"load_exact_exclusions",
|
|
82
|
+
"locked_registry_packages",
|
|
83
|
+
"missing_remote_release_tags",
|
|
84
|
+
"pack_typescript",
|
|
85
|
+
"pending_release_targets",
|
|
86
|
+
"publish_target",
|
|
87
|
+
"read_manifest_version",
|
|
88
|
+
"required_artifact_paths",
|
|
89
|
+
"run_build_process",
|
|
90
|
+
"run_process",
|
|
91
|
+
"run_process_environment",
|
|
92
|
+
"run_typescript_release",
|
|
93
|
+
"validate_release_tag",
|
|
94
|
+
"verify_built_package",
|
|
95
|
+
"verify_package_tarball",
|
|
96
|
+
"verify_python_wheel_license",
|
|
97
|
+
"verify_remote_release_tags",
|
|
98
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TypeIs
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_object_list(value: object) -> TypeIs[list[object]]:
|
|
7
|
+
return isinstance(value, list)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_object_dict(value: object) -> TypeIs[dict[object, object]]:
|
|
11
|
+
return isinstance(value, dict)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def string_object_dict(value: object, *, label: str) -> dict[str, object]:
|
|
15
|
+
if not is_object_dict(value):
|
|
16
|
+
msg = f"{label} must contain an object or table"
|
|
17
|
+
raise TypeError(msg)
|
|
18
|
+
result: dict[str, object] = {}
|
|
19
|
+
for key, item in value.items():
|
|
20
|
+
if not isinstance(key, str):
|
|
21
|
+
msg = f"{label} contains a non-string key"
|
|
22
|
+
raise TypeError(msg)
|
|
23
|
+
result[key] = item
|
|
24
|
+
return result
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
import tarfile
|
|
6
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
7
|
+
import zipfile
|
|
8
|
+
|
|
9
|
+
from sarj_standards.libs.release._values import is_object_dict, is_object_list, string_object_dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
|
|
17
|
+
_INSTALL_LIFECYCLE_SCRIPTS = frozenset({"preinstall", "install", "postinstall", "prepare"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _InspectedMembers(NamedTuple):
|
|
21
|
+
found: set[str]
|
|
22
|
+
identity_verified: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _json_value(value: object) -> JsonValue:
|
|
26
|
+
match value:
|
|
27
|
+
case None | str() | int() | float() | bool():
|
|
28
|
+
return value
|
|
29
|
+
case _ if is_object_list(value):
|
|
30
|
+
return [_json_value(item) for item in value]
|
|
31
|
+
case _ if is_object_dict(value):
|
|
32
|
+
return {key: _json_value(item) for key, item in string_object_dict(value, label="JSON").items()}
|
|
33
|
+
case _:
|
|
34
|
+
msg = "JSON contains an unsupported value"
|
|
35
|
+
raise TypeError(msg)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def required_artifact_paths(package_json: Mapping[str, object]) -> tuple[str, ...]:
|
|
39
|
+
candidates: list[str] = []
|
|
40
|
+
for field in ("main", "module", "types"):
|
|
41
|
+
candidates.extend(_exported_paths(_json_value(package_json.get(field))))
|
|
42
|
+
candidates.extend(_exported_paths(_json_value(package_json.get("exports"))))
|
|
43
|
+
return tuple(dict.fromkeys(_safe_artifact_path(path) for path in candidates))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _exported_paths(value: JsonValue) -> tuple[str, ...]:
|
|
47
|
+
match value:
|
|
48
|
+
case str():
|
|
49
|
+
return (value,)
|
|
50
|
+
case list():
|
|
51
|
+
return tuple(path for item in value for path in _exported_paths(item))
|
|
52
|
+
case dict():
|
|
53
|
+
return tuple(path for item in value.values() for path in _exported_paths(item))
|
|
54
|
+
case _:
|
|
55
|
+
return ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _safe_artifact_path(value: str) -> str:
|
|
59
|
+
normalized = value.removeprefix("./")
|
|
60
|
+
path = PurePosixPath(normalized)
|
|
61
|
+
if not normalized or path.is_absolute() or ".." in path.parts or path.as_posix() != normalized:
|
|
62
|
+
msg = f"unsafe exported entry point: {value}"
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
return normalized
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_package_json(package_root: Path) -> dict[str, JsonValue]:
|
|
68
|
+
manifest = package_root / "package.json"
|
|
69
|
+
try:
|
|
70
|
+
untyped: object = json.loads(manifest.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
|
|
71
|
+
value = _json_value(untyped)
|
|
72
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
73
|
+
msg = f"could not read {manifest}: {exc}"
|
|
74
|
+
raise ValueError(msg) from exc
|
|
75
|
+
if not isinstance(value, dict):
|
|
76
|
+
msg = f"{manifest} must contain a JSON object"
|
|
77
|
+
raise TypeError(msg)
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def verify_built_package(package_root: Path) -> tuple[str, ...]:
|
|
82
|
+
required = required_artifact_paths(load_package_json(package_root))
|
|
83
|
+
if not required:
|
|
84
|
+
msg = "package.json declares no publishable entry points"
|
|
85
|
+
raise ValueError(msg)
|
|
86
|
+
resolved_root = package_root.resolve()
|
|
87
|
+
for relative in required:
|
|
88
|
+
if PurePosixPath(relative).parts[0] != "dist":
|
|
89
|
+
msg = f"exported entry point must live under dist/: {relative}"
|
|
90
|
+
raise ValueError(msg)
|
|
91
|
+
artifact = package_root / relative
|
|
92
|
+
try:
|
|
93
|
+
artifact.resolve().relative_to(resolved_root)
|
|
94
|
+
except (OSError, ValueError) as exc:
|
|
95
|
+
msg = f"exported entry point escapes the package root: {relative}"
|
|
96
|
+
raise ValueError(msg) from exc
|
|
97
|
+
if artifact.is_symlink() or not artifact.is_file() or artifact.stat().st_size == 0:
|
|
98
|
+
msg = f"exported entry point is missing or empty: {relative}"
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
return required
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def verify_package_tarball(
|
|
104
|
+
tarball: Path,
|
|
105
|
+
required: Sequence[str],
|
|
106
|
+
*,
|
|
107
|
+
expected_name: str | None = None,
|
|
108
|
+
expected_version: str | None = None,
|
|
109
|
+
) -> tuple[str, ...]:
|
|
110
|
+
expected = {"package/LICENSE", *(f"package/{path}" for path in required)}
|
|
111
|
+
try:
|
|
112
|
+
with tarfile.open(tarball, mode="r:gz") as archive:
|
|
113
|
+
found, identity_verified = _inspect_members(
|
|
114
|
+
archive,
|
|
115
|
+
expected,
|
|
116
|
+
expected_name=expected_name,
|
|
117
|
+
expected_version=expected_version,
|
|
118
|
+
)
|
|
119
|
+
except (OSError, tarfile.TarError) as exc:
|
|
120
|
+
msg = f"could not inspect package archive {tarball}: {exc}"
|
|
121
|
+
raise ValueError(msg) from exc
|
|
122
|
+
missing = sorted(expected - found)
|
|
123
|
+
if missing:
|
|
124
|
+
msg = f"packed artifact omits exported entry point: {missing[0].removeprefix('package/')}"
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
if (expected_name is not None or expected_version is not None) and not identity_verified:
|
|
127
|
+
msg = "package archive omits package/package.json identity"
|
|
128
|
+
raise ValueError(msg)
|
|
129
|
+
return tuple(required)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def verify_python_wheel_license(wheel: Path) -> None:
|
|
133
|
+
try:
|
|
134
|
+
with zipfile.ZipFile(wheel) as archive:
|
|
135
|
+
licenses = [name for name in archive.namelist() if PurePosixPath(name).name == "LICENSE"]
|
|
136
|
+
if not licenses or any(not archive.read(name) for name in licenses):
|
|
137
|
+
msg = f"wheel omits a non-empty LICENSE: {wheel.name}"
|
|
138
|
+
raise ValueError(msg)
|
|
139
|
+
except zipfile.BadZipFile as exc:
|
|
140
|
+
msg = f"could not inspect wheel {wheel.name}"
|
|
141
|
+
raise ValueError(msg) from exc
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _inspect_members(
|
|
145
|
+
archive: tarfile.TarFile,
|
|
146
|
+
expected: set[str],
|
|
147
|
+
*,
|
|
148
|
+
expected_name: str | None,
|
|
149
|
+
expected_version: str | None,
|
|
150
|
+
) -> _InspectedMembers:
|
|
151
|
+
found: set[str] = set()
|
|
152
|
+
identity_verified = False
|
|
153
|
+
for member in archive.getmembers():
|
|
154
|
+
path = PurePosixPath(member.name)
|
|
155
|
+
if path.is_absolute() or ".." in path.parts or not path.parts or path.parts[0] != "package":
|
|
156
|
+
msg = f"unsafe path in package archive: {member.name}"
|
|
157
|
+
raise ValueError(msg)
|
|
158
|
+
if member.issym() or member.islnk():
|
|
159
|
+
msg = f"links are forbidden in package archive: {member.name}"
|
|
160
|
+
raise ValueError(msg)
|
|
161
|
+
if member.isfile() and member.name.endswith(".map"):
|
|
162
|
+
msg = f"source maps are forbidden in package archive: {member.name}"
|
|
163
|
+
raise ValueError(msg)
|
|
164
|
+
if member.name == "package/package.json" and member.isfile():
|
|
165
|
+
manifest_file = archive.extractfile(member)
|
|
166
|
+
if manifest_file is None:
|
|
167
|
+
msg = "could not read package/package.json from package archive"
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
try:
|
|
170
|
+
manifest: object = json.load(manifest_file) # pyright: ignore[reportAny]
|
|
171
|
+
except json.JSONDecodeError as exc:
|
|
172
|
+
msg = "package archive contains invalid package/package.json"
|
|
173
|
+
raise ValueError(msg) from exc
|
|
174
|
+
manifest_data = string_object_dict(manifest, label="packed package.json")
|
|
175
|
+
for field, expected_value in (("name", expected_name), ("version", expected_version)):
|
|
176
|
+
if expected_value is not None and manifest_data.get(field) != expected_value:
|
|
177
|
+
msg = f"packed package {field} does not match source manifest"
|
|
178
|
+
raise ValueError(msg)
|
|
179
|
+
identity_verified = True
|
|
180
|
+
scripts = manifest_data.get("scripts")
|
|
181
|
+
if is_object_dict(scripts):
|
|
182
|
+
dangerous = _INSTALL_LIFECYCLE_SCRIPTS.intersection(scripts)
|
|
183
|
+
if dangerous:
|
|
184
|
+
msg = f"install lifecycle script is forbidden in package archive: {min(dangerous)}"
|
|
185
|
+
raise ValueError(msg)
|
|
186
|
+
if member.name in expected:
|
|
187
|
+
if not member.isfile() or member.size == 0:
|
|
188
|
+
msg = f"packed entry point is missing, empty, or not a regular file: {member.name}"
|
|
189
|
+
raise ValueError(msg)
|
|
190
|
+
found.add(member.name)
|
|
191
|
+
return _InspectedMembers(found, identity_verified)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .changes import changed_release_targets
|
|
7
|
+
from .process import ProcessRunner, run_process
|
|
8
|
+
from .tags import RELEASE_ARTIFACT_FILES, RELEASE_ARTIFACT_PREFIXES, RELEASE_TARGETS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_DISPLAY_PATH_LIMIT = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class CausalityViolation:
|
|
16
|
+
target: str
|
|
17
|
+
manifest: Path
|
|
18
|
+
version_field: str
|
|
19
|
+
changed_paths: tuple[str, ...]
|
|
20
|
+
|
|
21
|
+
def render(self) -> str:
|
|
22
|
+
paths = ", ".join(self.changed_paths[:_DISPLAY_PATH_LIMIT])
|
|
23
|
+
suffix = (
|
|
24
|
+
f" (+{len(self.changed_paths) - _DISPLAY_PATH_LIMIT} more)"
|
|
25
|
+
if len(self.changed_paths) > _DISPLAY_PATH_LIMIT
|
|
26
|
+
else ""
|
|
27
|
+
)
|
|
28
|
+
return (
|
|
29
|
+
f"{self.target}: bump {self.version_field} in {self.manifest}; publishable files changed: {paths}{suffix}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ReleaseCausalityReport:
|
|
35
|
+
before: str
|
|
36
|
+
after: str
|
|
37
|
+
changed_targets: tuple[str, ...]
|
|
38
|
+
bumped_targets: tuple[str, ...]
|
|
39
|
+
violations: tuple[CausalityViolation, ...]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def ok(self) -> bool:
|
|
43
|
+
return not self.violations
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_release_causality(
|
|
47
|
+
root: Path,
|
|
48
|
+
*,
|
|
49
|
+
before: str,
|
|
50
|
+
after: str,
|
|
51
|
+
runner: ProcessRunner = run_process,
|
|
52
|
+
) -> ReleaseCausalityReport:
|
|
53
|
+
result = runner(("git", "diff", "--name-only", "-z", before, after, "--"), cwd=root, capture_output=True)
|
|
54
|
+
changed_paths = tuple(sorted(path for path in result.stdout.split("\0") if path))
|
|
55
|
+
bumped = changed_release_targets(root, before=before, after=after, runner=runner)
|
|
56
|
+
by_target = {
|
|
57
|
+
name: tuple(path for path in changed_paths if _belongs_to_artifact(name, path=path)) for name in RELEASE_TARGETS
|
|
58
|
+
}
|
|
59
|
+
changed_targets = tuple(name for name, paths in by_target.items() if paths)
|
|
60
|
+
violations = tuple(
|
|
61
|
+
CausalityViolation(
|
|
62
|
+
name,
|
|
63
|
+
RELEASE_TARGETS[name].manifest,
|
|
64
|
+
'top-level "version"' if RELEASE_TARGETS[name].format == "json" else "[project].version",
|
|
65
|
+
by_target[name],
|
|
66
|
+
)
|
|
67
|
+
for name in changed_targets
|
|
68
|
+
if not bumped[name]
|
|
69
|
+
)
|
|
70
|
+
return ReleaseCausalityReport(
|
|
71
|
+
before,
|
|
72
|
+
after,
|
|
73
|
+
changed_targets,
|
|
74
|
+
tuple(name for name, changed in bumped.items() if changed),
|
|
75
|
+
violations,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _belongs_to_artifact(target: str, *, path: str) -> bool:
|
|
80
|
+
return path in RELEASE_ARTIFACT_FILES[target] or path.startswith(RELEASE_ARTIFACT_PREFIXES[target])
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from sarj_standards.libs.release.process import ProcessRunner, run_process
|
|
7
|
+
from sarj_standards.libs.release.registry import PublicationChecker, publication_exists, target_requirement
|
|
8
|
+
from sarj_standards.libs.release.tags import RELEASE_TARGETS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_ADDED_JSON_VERSION = re.compile(r'(?m)^\+\s*"version"\s*:')
|
|
17
|
+
_ADDED_TOML_VERSION = re.compile(r"(?m)^\+version\s*=")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def changed_release_targets(
|
|
21
|
+
root: Path,
|
|
22
|
+
*,
|
|
23
|
+
before: str,
|
|
24
|
+
after: str,
|
|
25
|
+
runner: ProcessRunner = run_process,
|
|
26
|
+
) -> Mapping[str, bool]:
|
|
27
|
+
changed: dict[str, bool] = {}
|
|
28
|
+
for name, target in RELEASE_TARGETS.items():
|
|
29
|
+
result = runner(
|
|
30
|
+
("git", "diff", "--no-color", before, after, "--", target.manifest.as_posix()),
|
|
31
|
+
cwd=root,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
)
|
|
34
|
+
pattern = _ADDED_JSON_VERSION if target.format == "json" else _ADDED_TOML_VERSION
|
|
35
|
+
changed[name] = pattern.search(result.stdout) is not None
|
|
36
|
+
return changed
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pending_release_targets(
|
|
40
|
+
root: Path,
|
|
41
|
+
*,
|
|
42
|
+
before: str,
|
|
43
|
+
after: str,
|
|
44
|
+
runner: ProcessRunner = run_process,
|
|
45
|
+
checker: PublicationChecker = publication_exists,
|
|
46
|
+
) -> Mapping[str, bool]:
|
|
47
|
+
_ = changed_release_targets(root, before=before, after=after, runner=runner)
|
|
48
|
+
return {name: not checker(target_requirement(root, name)) for name in RELEASE_TARGETS}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import os
|
|
5
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] -- Centralized argv-only process adapter; shell execution is never enabled.
|
|
6
|
+
import tempfile
|
|
7
|
+
from typing import TYPE_CHECKING, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_BLOCKED_CREDENTIAL_NAMES = frozenset(
|
|
16
|
+
{"DOCKER_CONFIG", "GIT_ASKPASS", "NETRC", "NPM_CONFIG_USERCONFIG", "PIP_CONFIG_FILE", "SSH_ASKPASS"}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ProcessResult:
|
|
22
|
+
returncode: int
|
|
23
|
+
stdout: str = ""
|
|
24
|
+
stderr: str = ""
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if type(self.returncode) is not int:
|
|
28
|
+
msg = "process return code must be an integer"
|
|
29
|
+
raise TypeError(msg)
|
|
30
|
+
if type(self.stdout) is not str:
|
|
31
|
+
msg = "process stdout must be text"
|
|
32
|
+
raise TypeError(msg)
|
|
33
|
+
if type(self.stderr) is not str:
|
|
34
|
+
msg = "process stderr must be text"
|
|
35
|
+
raise TypeError(msg)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProcessRunner(Protocol):
|
|
39
|
+
def __call__(
|
|
40
|
+
self,
|
|
41
|
+
argv: tuple[str, ...],
|
|
42
|
+
*,
|
|
43
|
+
cwd: Path,
|
|
44
|
+
capture_output: bool = False,
|
|
45
|
+
) -> ProcessResult: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProcessFailureError(RuntimeError):
|
|
49
|
+
argv: tuple[str, ...]
|
|
50
|
+
returncode: int
|
|
51
|
+
|
|
52
|
+
def __init__(self, argv: tuple[str, ...], returncode: int) -> None:
|
|
53
|
+
self.argv = argv
|
|
54
|
+
self.returncode = returncode
|
|
55
|
+
super().__init__(f"{argv[0]} {argv[1] if len(argv) > 1 else ''} failed with exit code {returncode}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_process(
|
|
59
|
+
argv: tuple[str, ...],
|
|
60
|
+
*,
|
|
61
|
+
cwd: Path,
|
|
62
|
+
capture_output: bool = False,
|
|
63
|
+
) -> ProcessResult:
|
|
64
|
+
return run_process_environment(argv, cwd=cwd, capture_output=capture_output, environment=None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_process_environment(
|
|
68
|
+
argv: tuple[str, ...],
|
|
69
|
+
*,
|
|
70
|
+
cwd: Path,
|
|
71
|
+
capture_output: bool = False,
|
|
72
|
+
environment: Mapping[str, str] | None,
|
|
73
|
+
) -> ProcessResult:
|
|
74
|
+
completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- argv is passed directly and shell remains disabled.
|
|
75
|
+
argv,
|
|
76
|
+
cwd=cwd,
|
|
77
|
+
check=False,
|
|
78
|
+
env=None if environment is None else dict(environment),
|
|
79
|
+
text=True,
|
|
80
|
+
stdout=subprocess.PIPE if capture_output else None,
|
|
81
|
+
stderr=None,
|
|
82
|
+
)
|
|
83
|
+
result = ProcessResult(completed.returncode, completed.stdout or "")
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
raise ProcessFailureError(argv, result.returncode)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def credential_free_environment(environment: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
90
|
+
source = os.environ if environment is None else environment # ruff: ignore[banned-api] -- deliberate child-process boundary
|
|
91
|
+
blocked_fragments = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "API_KEY", "AUTH")
|
|
92
|
+
blocked_prefixes = ("AWS_", "AZURE_", "GOOGLE_", "TWINE_", "UV_PUBLISH_", "ACTIONS_ID_TOKEN_")
|
|
93
|
+
return {
|
|
94
|
+
name: value
|
|
95
|
+
for name, value in source.items()
|
|
96
|
+
if name.upper() not in _BLOCKED_CREDENTIAL_NAMES
|
|
97
|
+
and not name.upper().startswith(blocked_prefixes)
|
|
98
|
+
and not any(fragment in name.upper() for fragment in blocked_fragments)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run_build_process(
|
|
103
|
+
argv: tuple[str, ...],
|
|
104
|
+
*,
|
|
105
|
+
cwd: Path,
|
|
106
|
+
capture_output: bool = False,
|
|
107
|
+
) -> ProcessResult:
|
|
108
|
+
with tempfile.TemporaryDirectory(prefix="sarj-build-home-") as home:
|
|
109
|
+
environment = credential_free_environment()
|
|
110
|
+
environment.update(
|
|
111
|
+
{
|
|
112
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
113
|
+
"HOME": home,
|
|
114
|
+
"USERPROFILE": home,
|
|
115
|
+
"APPDATA": home,
|
|
116
|
+
"LOCALAPPDATA": home,
|
|
117
|
+
"NETRC": os.devnull,
|
|
118
|
+
"NPM_CONFIG_USERCONFIG": os.devnull,
|
|
119
|
+
"PIP_CONFIG_FILE": os.devnull,
|
|
120
|
+
"XDG_CONFIG_HOME": home,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
return run_process_environment(
|
|
124
|
+
argv,
|
|
125
|
+
cwd=cwd,
|
|
126
|
+
capture_output=capture_output,
|
|
127
|
+
environment=environment,
|
|
128
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import TYPE_CHECKING, Literal
|
|
8
|
+
|
|
9
|
+
from sarj_standards.libs.release.artifacts import verify_python_wheel_license
|
|
10
|
+
from sarj_standards.libs.release.process import ProcessRunner, run_build_process, run_process
|
|
11
|
+
from sarj_standards.libs.release.typescript import run_typescript_release
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PublishTarget = Literal["typescript", "bootstrap", "python", "sql", "iac", "standards", "tsconfig", "docs-ui"]
|
|
19
|
+
_EXPECTED_PYTHON_ARTIFACTS = 1
|
|
20
|
+
_PYTHON_TARGETS: Mapping[str, str] = MappingProxyType(
|
|
21
|
+
{
|
|
22
|
+
"bootstrap": "bootstrap",
|
|
23
|
+
"python": "python",
|
|
24
|
+
"sql": "sql",
|
|
25
|
+
"iac": "iac",
|
|
26
|
+
"standards": "standards",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def publish_target(root: Path, target: PublishTarget, *, runner: ProcessRunner = run_process) -> None:
|
|
32
|
+
resolved = root.resolve()
|
|
33
|
+
build_runner = run_build_process if runner is run_process else runner
|
|
34
|
+
if target == "typescript":
|
|
35
|
+
_ = run_typescript_release("publish", resolved / "packages" / "typescript", runner=runner)
|
|
36
|
+
return
|
|
37
|
+
if target in {"tsconfig", "docs-ui"}:
|
|
38
|
+
cwd = resolved / "packages" / target
|
|
39
|
+
with TemporaryDirectory(prefix=f"sarj-{target}-release-") as temporary:
|
|
40
|
+
destination = Path(temporary)
|
|
41
|
+
result = build_runner(
|
|
42
|
+
("npm", "pack", "--json", "--pack-destination", str(destination)),
|
|
43
|
+
cwd=cwd,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
)
|
|
46
|
+
artifact = destination / _npm_pack_filename(result.stdout)
|
|
47
|
+
if not artifact.is_file() or artifact.stat().st_size == 0:
|
|
48
|
+
msg = f"npm pack did not create its reported artifact: {artifact.name}"
|
|
49
|
+
raise ValueError(msg)
|
|
50
|
+
runner(("npm", "publish", str(artifact), "--access", "public", "--ignore-scripts"), cwd=cwd)
|
|
51
|
+
return
|
|
52
|
+
package = _PYTHON_TARGETS.get(target)
|
|
53
|
+
if package is None:
|
|
54
|
+
msg = f"unsupported release target: {target}"
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
cwd = resolved / "packages" / package
|
|
57
|
+
with TemporaryDirectory(prefix=f"sarj-{package}-release-") as temporary:
|
|
58
|
+
destination = Path(temporary)
|
|
59
|
+
build_runner(("uv", "build", "--wheel", "--out-dir", str(destination)), cwd=cwd)
|
|
60
|
+
artifacts = tuple(sorted(destination.glob("*.whl")))
|
|
61
|
+
if len(artifacts) != _EXPECTED_PYTHON_ARTIFACTS or any(
|
|
62
|
+
not artifact.is_file() or artifact.stat().st_size == 0 for artifact in artifacts
|
|
63
|
+
):
|
|
64
|
+
msg = f"uv build did not create exactly one wheel for {target}"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
verify_python_wheel_license(artifacts[0])
|
|
67
|
+
runner(("uv", "publish", *(str(artifact) for artifact in artifacts)), cwd=cwd)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _npm_pack_filename(output: str) -> str:
|
|
71
|
+
decoder = json.JSONDecoder()
|
|
72
|
+
for index in range(len(output) - 1, -1, -1):
|
|
73
|
+
if output[index] != "[":
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
report, _ = decoder.raw_decode(output[index:]) # pyright: ignore[reportAny]
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
continue
|
|
79
|
+
if not isinstance(report, list) or not report or not isinstance(report[0], dict):
|
|
80
|
+
continue
|
|
81
|
+
filename = report[0].get("filename") # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
|
|
82
|
+
if isinstance(filename, str) and Path(filename).name == filename and filename.endswith(".tgz"):
|
|
83
|
+
return filename
|
|
84
|
+
msg = "npm pack returned no safe artifact filename"
|
|
85
|
+
raise ValueError(msg)
|