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,373 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
import tomllib
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import TYPE_CHECKING, Final, Literal
|
|
14
|
+
|
|
15
|
+
from sarj_standards.libs.release._values import is_object_dict, string_object_dict
|
|
16
|
+
from sarj_standards.libs.release.process import ProcessFailureError, ProcessRunner, run_process
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Mapping
|
|
21
|
+
|
|
22
|
+
from sarj_standards.libs.release.registry import PublicationChecker
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ManifestFormat = Literal["json", "toml"]
|
|
26
|
+
_GIT_NO_MATCH = 2
|
|
27
|
+
_GIT_MISSING_REVISION_CODES = frozenset((1, 128))
|
|
28
|
+
_TAGGER_EMAIL = "release-automation@sarj.ai"
|
|
29
|
+
_TAGGER_NAME = "sarj-ai release automation"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReleaseTargetId(StrEnum):
|
|
33
|
+
TYPESCRIPT = "typescript"
|
|
34
|
+
BOOTSTRAP = "bootstrap"
|
|
35
|
+
PYTHON = "python"
|
|
36
|
+
SQL = "sql"
|
|
37
|
+
IAC = "iac"
|
|
38
|
+
STANDARDS = "standards"
|
|
39
|
+
TSCONFIG = "tsconfig"
|
|
40
|
+
DOCS_UI = "docs-ui"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ReleaseTarget:
|
|
45
|
+
manifest: Path
|
|
46
|
+
format: ManifestFormat
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class ValidatedReleaseTag:
|
|
51
|
+
tag: str
|
|
52
|
+
target: str
|
|
53
|
+
version: str
|
|
54
|
+
manifest: Path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class TagSyncResult:
|
|
59
|
+
created: tuple[str, ...]
|
|
60
|
+
existing: tuple[str, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
RELEASE_TARGETS: Final[Mapping[str, ReleaseTarget]] = MappingProxyType(
|
|
64
|
+
{
|
|
65
|
+
ReleaseTargetId.TYPESCRIPT: ReleaseTarget(Path("packages/typescript/package.json"), "json"),
|
|
66
|
+
ReleaseTargetId.BOOTSTRAP: ReleaseTarget(Path("packages/bootstrap/pyproject.toml"), "toml"),
|
|
67
|
+
ReleaseTargetId.PYTHON: ReleaseTarget(Path("packages/python/pyproject.toml"), "toml"),
|
|
68
|
+
ReleaseTargetId.SQL: ReleaseTarget(Path("packages/sql/pyproject.toml"), "toml"),
|
|
69
|
+
ReleaseTargetId.IAC: ReleaseTarget(Path("packages/iac/pyproject.toml"), "toml"),
|
|
70
|
+
ReleaseTargetId.STANDARDS: ReleaseTarget(Path("packages/standards/pyproject.toml"), "toml"),
|
|
71
|
+
ReleaseTargetId.TSCONFIG: ReleaseTarget(Path("packages/tsconfig/package.json"), "json"),
|
|
72
|
+
ReleaseTargetId.DOCS_UI: ReleaseTarget(Path("packages/docs-ui/package.json"), "json"),
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
RELEASE_ARTIFACT_PREFIXES: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
|
|
76
|
+
{
|
|
77
|
+
ReleaseTargetId.BOOTSTRAP: ("packages/bootstrap/src/",),
|
|
78
|
+
ReleaseTargetId.PYTHON: ("packages/python/src/",),
|
|
79
|
+
ReleaseTargetId.SQL: ("packages/sql/src/",),
|
|
80
|
+
ReleaseTargetId.IAC: ("packages/iac/src/",),
|
|
81
|
+
ReleaseTargetId.STANDARDS: ("packages/standards/src/",),
|
|
82
|
+
ReleaseTargetId.TYPESCRIPT: ("packages/typescript/src/",),
|
|
83
|
+
ReleaseTargetId.TSCONFIG: ("packages/tsconfig/base.json", "packages/tsconfig/strict.json"),
|
|
84
|
+
ReleaseTargetId.DOCS_UI: ("packages/docs-ui/src/",),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
RELEASE_ARTIFACT_FILES: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
|
|
88
|
+
{
|
|
89
|
+
name: (target.manifest.as_posix(), str(target.manifest.parent / "LICENSE"))
|
|
90
|
+
for name, target in RELEASE_TARGETS.items()
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
_TAG_PATTERN: Final = re.compile(r"^(?P<target>[a-z][a-z0-9-]*)-v(?P<version>[^\s/]+)$")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_manifest_version(path: Path, manifest_format: ManifestFormat) -> str:
|
|
97
|
+
try:
|
|
98
|
+
data = _read_manifest(path, manifest_format)
|
|
99
|
+
except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as exc:
|
|
100
|
+
msg = f"could not read release manifest {path}: {exc}"
|
|
101
|
+
raise ValueError(msg) from exc
|
|
102
|
+
version = data.get("version")
|
|
103
|
+
if manifest_format == "toml" and not isinstance(version, str):
|
|
104
|
+
project = data.get("project")
|
|
105
|
+
project_data = string_object_dict(project, label="project table") if is_object_dict(project) else {}
|
|
106
|
+
version = project_data.get("version")
|
|
107
|
+
if not isinstance(version, str) or not version:
|
|
108
|
+
msg = f"release manifest {path} has no non-empty package version"
|
|
109
|
+
raise ValueError(msg)
|
|
110
|
+
return version
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_manifest(path: Path, manifest_format: ManifestFormat) -> dict[str, object]:
|
|
114
|
+
if manifest_format == "json":
|
|
115
|
+
untyped: object = json.loads(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
|
|
116
|
+
return string_object_dict(untyped, label="release manifest")
|
|
117
|
+
with path.open("rb") as manifest:
|
|
118
|
+
untyped_toml: object = tomllib.load(manifest)
|
|
119
|
+
return string_object_dict(untyped_toml, label="release manifest")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_release_tag(
|
|
123
|
+
tag: str,
|
|
124
|
+
repo_root: Path,
|
|
125
|
+
*,
|
|
126
|
+
targets: Mapping[str, ReleaseTarget] = RELEASE_TARGETS,
|
|
127
|
+
) -> ValidatedReleaseTag:
|
|
128
|
+
match = _TAG_PATTERN.fullmatch(tag)
|
|
129
|
+
if match is None or match["target"] not in targets:
|
|
130
|
+
msg = f"unrecognized release tag: {tag}"
|
|
131
|
+
raise ValueError(msg)
|
|
132
|
+
target_name = match["target"]
|
|
133
|
+
target = targets[target_name]
|
|
134
|
+
resolved_root = repo_root.resolve()
|
|
135
|
+
manifest = (resolved_root / target.manifest).resolve()
|
|
136
|
+
try:
|
|
137
|
+
manifest.relative_to(resolved_root)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
msg = f"release manifest escapes repository root: {target.manifest}"
|
|
140
|
+
raise ValueError(msg) from exc
|
|
141
|
+
actual = read_manifest_version(manifest, target.format)
|
|
142
|
+
tagged = match["version"]
|
|
143
|
+
if tagged != actual:
|
|
144
|
+
msg = f"{tag} does not match {target.manifest} version {actual}"
|
|
145
|
+
raise ValueError(msg)
|
|
146
|
+
return ValidatedReleaseTag(tag, target_name, actual, target.manifest)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _current_tag(target_name: ReleaseTargetId, root: Path) -> str:
|
|
150
|
+
target = RELEASE_TARGETS.get(target_name)
|
|
151
|
+
if target is None:
|
|
152
|
+
msg = f"unsupported release target: {target_name}"
|
|
153
|
+
raise ValueError(msg)
|
|
154
|
+
version = read_manifest_version(root / target.manifest, target.format)
|
|
155
|
+
return f"{target_name}-v{version}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _remote_tag_exists(root: Path, tag: str, *, runner: ProcessRunner) -> bool:
|
|
159
|
+
try:
|
|
160
|
+
runner(
|
|
161
|
+
("git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"),
|
|
162
|
+
cwd=root,
|
|
163
|
+
capture_output=True,
|
|
164
|
+
)
|
|
165
|
+
except ProcessFailureError as exc:
|
|
166
|
+
if exc.returncode == _GIT_NO_MATCH:
|
|
167
|
+
return False
|
|
168
|
+
raise
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def missing_remote_release_tags(
|
|
173
|
+
root: Path,
|
|
174
|
+
*,
|
|
175
|
+
runner: ProcessRunner = run_process,
|
|
176
|
+
) -> tuple[str, ...]:
|
|
177
|
+
resolved = root.resolve()
|
|
178
|
+
tags = (_current_tag(ReleaseTargetId(target), resolved) for target in RELEASE_TARGETS)
|
|
179
|
+
return tuple(tag for tag in tags if not _remote_tag_exists(resolved, tag, runner=runner))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def verify_remote_release_tags(
|
|
183
|
+
root: Path,
|
|
184
|
+
*,
|
|
185
|
+
commit: str,
|
|
186
|
+
runner: ProcessRunner = run_process,
|
|
187
|
+
) -> tuple[str, ...]:
|
|
188
|
+
resolved = root.resolve()
|
|
189
|
+
if not commit or commit.startswith("-"):
|
|
190
|
+
msg = "release tag verification requires an explicit publishing commit"
|
|
191
|
+
raise ValueError(msg)
|
|
192
|
+
resolved_commit = (
|
|
193
|
+
runner(
|
|
194
|
+
("git", "rev-parse", "--verify", f"{commit}^{{commit}}"),
|
|
195
|
+
cwd=resolved,
|
|
196
|
+
capture_output=True,
|
|
197
|
+
).stdout.strip()
|
|
198
|
+
or commit
|
|
199
|
+
)
|
|
200
|
+
missing: list[str] = []
|
|
201
|
+
for target_text in RELEASE_TARGETS:
|
|
202
|
+
target_name = ReleaseTargetId(target_text)
|
|
203
|
+
tag = _current_tag(target_name, resolved)
|
|
204
|
+
if not _remote_tag_exists(resolved, tag, runner=runner):
|
|
205
|
+
missing.append(tag)
|
|
206
|
+
continue
|
|
207
|
+
_require_remote_tag_commit(resolved, tag, target_name, resolved_commit, runner=runner)
|
|
208
|
+
return tuple(missing)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def create_release_tags(
|
|
212
|
+
root: Path,
|
|
213
|
+
targets: tuple[str, ...],
|
|
214
|
+
*,
|
|
215
|
+
commit: str,
|
|
216
|
+
runner: ProcessRunner = run_process,
|
|
217
|
+
publication_checker: PublicationChecker | None = None,
|
|
218
|
+
attempts: int = 1,
|
|
219
|
+
delay: timedelta = timedelta(0),
|
|
220
|
+
sleeper: Callable[[float], object] = time.sleep,
|
|
221
|
+
) -> TagSyncResult:
|
|
222
|
+
from sarj_standards.libs.release.registry import ( # ruff: ignore[import-outside-top-level] -- avoid the tags/registry import cycle
|
|
223
|
+
publication_exists,
|
|
224
|
+
require_publication,
|
|
225
|
+
target_requirement,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
resolved = root.resolve()
|
|
229
|
+
if not commit or commit.startswith("-"):
|
|
230
|
+
msg = "release tag creation requires an explicit publishing commit"
|
|
231
|
+
raise ValueError(msg)
|
|
232
|
+
if attempts < 1:
|
|
233
|
+
msg = "publication attempts must be at least one"
|
|
234
|
+
raise ValueError(msg)
|
|
235
|
+
delay_seconds = delay.total_seconds()
|
|
236
|
+
if not math.isfinite(delay_seconds) or delay_seconds < 0:
|
|
237
|
+
msg = "publication retry delay must be finite and non-negative"
|
|
238
|
+
raise ValueError(msg)
|
|
239
|
+
resolved_commit = (
|
|
240
|
+
runner(
|
|
241
|
+
("git", "rev-parse", "--verify", f"{commit}^{{commit}}"),
|
|
242
|
+
cwd=resolved,
|
|
243
|
+
capture_output=True,
|
|
244
|
+
).stdout.strip()
|
|
245
|
+
or commit
|
|
246
|
+
)
|
|
247
|
+
checker = publication_exists if publication_checker is None else publication_checker
|
|
248
|
+
created: list[str] = []
|
|
249
|
+
existing: list[str] = []
|
|
250
|
+
for target_text in dict.fromkeys(targets):
|
|
251
|
+
try:
|
|
252
|
+
target = ReleaseTargetId(target_text)
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
msg = f"unsupported release target: {target_text}"
|
|
255
|
+
raise ValueError(msg) from exc
|
|
256
|
+
tag = _current_tag(target, resolved)
|
|
257
|
+
if _remote_tag_exists(resolved, tag, runner=runner):
|
|
258
|
+
_require_remote_tag_commit(resolved, tag, target, resolved_commit, runner=runner)
|
|
259
|
+
existing.append(tag)
|
|
260
|
+
continue
|
|
261
|
+
requirement = target_requirement(resolved, target)
|
|
262
|
+
for attempt in range(attempts):
|
|
263
|
+
try:
|
|
264
|
+
require_publication(requirement, checker=checker)
|
|
265
|
+
break
|
|
266
|
+
except OSError, ValueError:
|
|
267
|
+
if attempt + 1 == attempts:
|
|
268
|
+
raise
|
|
269
|
+
_ = sleeper(delay_seconds)
|
|
270
|
+
if not _require_local_tag_commit(resolved, tag, resolved_commit, runner=runner):
|
|
271
|
+
version = tag.removeprefix(f"{target}-v")
|
|
272
|
+
runner(
|
|
273
|
+
(
|
|
274
|
+
"git",
|
|
275
|
+
"-c",
|
|
276
|
+
f"user.name={_TAGGER_NAME}",
|
|
277
|
+
"-c",
|
|
278
|
+
f"user.email={_TAGGER_EMAIL}",
|
|
279
|
+
"tag",
|
|
280
|
+
"-a",
|
|
281
|
+
tag,
|
|
282
|
+
commit,
|
|
283
|
+
"-m",
|
|
284
|
+
f"{target} {version}",
|
|
285
|
+
),
|
|
286
|
+
cwd=resolved,
|
|
287
|
+
)
|
|
288
|
+
runner(("git", "push", "origin", f"refs/tags/{tag}"), cwd=resolved)
|
|
289
|
+
created.append(tag)
|
|
290
|
+
return TagSyncResult(tuple(created), tuple(existing))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _require_remote_tag_commit(
|
|
294
|
+
root: Path,
|
|
295
|
+
tag: str,
|
|
296
|
+
target_name: ReleaseTargetId,
|
|
297
|
+
commit: str,
|
|
298
|
+
*,
|
|
299
|
+
runner: ProcessRunner,
|
|
300
|
+
) -> None:
|
|
301
|
+
result = runner(
|
|
302
|
+
(
|
|
303
|
+
"git",
|
|
304
|
+
"ls-remote",
|
|
305
|
+
"--exit-code",
|
|
306
|
+
"--tags",
|
|
307
|
+
"origin",
|
|
308
|
+
f"refs/tags/{tag}",
|
|
309
|
+
f"refs/tags/{tag}^{{}}",
|
|
310
|
+
),
|
|
311
|
+
cwd=root,
|
|
312
|
+
capture_output=True,
|
|
313
|
+
)
|
|
314
|
+
references = dict(line.split("\t", 1)[::-1] for line in result.stdout.splitlines() if "\t" in line)
|
|
315
|
+
actual = references.get(f"refs/tags/{tag}^{{}}", references.get(f"refs/tags/{tag}"))
|
|
316
|
+
if actual is None:
|
|
317
|
+
msg = f"existing remote tag {tag} has no resolvable object"
|
|
318
|
+
raise ValueError(msg)
|
|
319
|
+
try:
|
|
320
|
+
actual_commit = runner(
|
|
321
|
+
("git", "rev-parse", "--verify", f"{actual}^{{commit}}"),
|
|
322
|
+
cwd=root,
|
|
323
|
+
capture_output=True,
|
|
324
|
+
).stdout.strip()
|
|
325
|
+
except ProcessFailureError as exc:
|
|
326
|
+
msg = f"existing remote tag {tag} does not resolve to a commit"
|
|
327
|
+
raise ValueError(msg) from exc
|
|
328
|
+
if not actual_commit:
|
|
329
|
+
msg = f"existing remote tag {tag} does not resolve to a commit"
|
|
330
|
+
raise ValueError(msg)
|
|
331
|
+
actual = actual_commit
|
|
332
|
+
if actual == commit:
|
|
333
|
+
return
|
|
334
|
+
try:
|
|
335
|
+
runner(
|
|
336
|
+
("git", "merge-base", "--is-ancestor", actual, commit),
|
|
337
|
+
cwd=root,
|
|
338
|
+
capture_output=True,
|
|
339
|
+
)
|
|
340
|
+
except ProcessFailureError as exc:
|
|
341
|
+
msg = f"existing remote tag {tag} points to {actual}, which is not an ancestor of publishing commit {commit}"
|
|
342
|
+
raise ValueError(msg) from exc
|
|
343
|
+
target_paths = (*RELEASE_ARTIFACT_FILES[target_name], *RELEASE_ARTIFACT_PREFIXES[target_name])
|
|
344
|
+
try:
|
|
345
|
+
runner(
|
|
346
|
+
("git", "diff", "--quiet", actual or tag, commit, "--", *target_paths),
|
|
347
|
+
cwd=root,
|
|
348
|
+
capture_output=True,
|
|
349
|
+
)
|
|
350
|
+
except ProcessFailureError:
|
|
351
|
+
pass
|
|
352
|
+
else:
|
|
353
|
+
return
|
|
354
|
+
msg = f"existing remote tag {tag} points to {actual or 'an unknown object'}, not publishing commit {commit}"
|
|
355
|
+
raise ValueError(msg)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _require_local_tag_commit(root: Path, tag: str, commit: str, *, runner: ProcessRunner) -> bool:
|
|
359
|
+
try:
|
|
360
|
+
result = runner(
|
|
361
|
+
("git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"),
|
|
362
|
+
cwd=root,
|
|
363
|
+
capture_output=True,
|
|
364
|
+
)
|
|
365
|
+
except ProcessFailureError as exc:
|
|
366
|
+
if exc.returncode in _GIT_MISSING_REVISION_CODES:
|
|
367
|
+
return False
|
|
368
|
+
raise
|
|
369
|
+
actual = result.stdout.strip()
|
|
370
|
+
if actual == commit:
|
|
371
|
+
return True
|
|
372
|
+
msg = f"existing local tag {tag} points to {actual or 'an unknown object'}, not publishing commit {commit}"
|
|
373
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
from typing import TYPE_CHECKING, Literal
|
|
8
|
+
|
|
9
|
+
from sarj_standards.libs.release._values import is_object_dict, is_object_list, string_object_dict
|
|
10
|
+
from sarj_standards.libs.release.artifacts import (
|
|
11
|
+
load_package_json,
|
|
12
|
+
required_artifact_paths,
|
|
13
|
+
verify_package_tarball,
|
|
14
|
+
)
|
|
15
|
+
from sarj_standards.libs.release.process import (
|
|
16
|
+
ProcessResult,
|
|
17
|
+
ProcessRunner,
|
|
18
|
+
run_build_process,
|
|
19
|
+
run_process,
|
|
20
|
+
run_process_environment,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ReleaseMode = Literal["check", "pack", "publish"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class PackedArtifact:
|
|
33
|
+
path: Path
|
|
34
|
+
included_files: tuple[str, ...]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pack_typescript(
|
|
38
|
+
package_root: Path,
|
|
39
|
+
destination: Path,
|
|
40
|
+
*,
|
|
41
|
+
runner: ProcessRunner = run_build_process,
|
|
42
|
+
) -> PackedArtifact:
|
|
43
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
result = runner(
|
|
45
|
+
(
|
|
46
|
+
"npm",
|
|
47
|
+
"pack",
|
|
48
|
+
"--json",
|
|
49
|
+
"--ignore-scripts",
|
|
50
|
+
"--pack-destination",
|
|
51
|
+
str(destination.resolve()),
|
|
52
|
+
),
|
|
53
|
+
cwd=package_root,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
)
|
|
56
|
+
report = _npm_pack_report(result.stdout)
|
|
57
|
+
filename = report.get("filename")
|
|
58
|
+
if not isinstance(filename, str) or Path(filename).name != filename:
|
|
59
|
+
msg = "npm pack returned an invalid artifact manifest"
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
included = _included_paths(report.get("files"))
|
|
62
|
+
package_manifest = load_package_json(package_root)
|
|
63
|
+
required = required_artifact_paths(package_manifest)
|
|
64
|
+
if not required:
|
|
65
|
+
msg = "package.json declares no publishable entry points"
|
|
66
|
+
raise ValueError(msg)
|
|
67
|
+
missing_report = next((path for path in required if path not in included), None)
|
|
68
|
+
if missing_report is not None:
|
|
69
|
+
msg = f"npm pack report omits exported entry point: {missing_report}"
|
|
70
|
+
raise ValueError(msg)
|
|
71
|
+
tarball = destination.resolve() / filename
|
|
72
|
+
name = package_manifest.get("name")
|
|
73
|
+
version = package_manifest.get("version")
|
|
74
|
+
if not isinstance(name, str) or not isinstance(version, str):
|
|
75
|
+
msg = "package.json must declare string name and version fields"
|
|
76
|
+
raise TypeError(msg)
|
|
77
|
+
verify_package_tarball(tarball, required, expected_name=name, expected_version=version)
|
|
78
|
+
return PackedArtifact(tarball, included)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _npm_pack_report(output: str) -> dict[str, object]:
|
|
82
|
+
decoder = json.JSONDecoder()
|
|
83
|
+
reports: list[dict[str, object]] = []
|
|
84
|
+
for index, character in enumerate(output):
|
|
85
|
+
if character not in "[{":
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
candidate: object
|
|
89
|
+
candidate, _ = decoder.raw_decode(output[index:]) # pyright: ignore[reportAny]
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
continue
|
|
92
|
+
report = _as_pack_report(candidate)
|
|
93
|
+
if report is not None:
|
|
94
|
+
reports.append(report)
|
|
95
|
+
if not reports:
|
|
96
|
+
msg = "npm pack returned no artifact manifest"
|
|
97
|
+
raise ValueError(msg)
|
|
98
|
+
return reports[-1]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _as_pack_report(value: object) -> dict[str, object] | None:
|
|
102
|
+
if is_object_list(value):
|
|
103
|
+
if not value or not is_object_dict(value[0]):
|
|
104
|
+
return None
|
|
105
|
+
return _valid_pack_report(value[0])
|
|
106
|
+
if not is_object_dict(value):
|
|
107
|
+
return None
|
|
108
|
+
reports = tuple(
|
|
109
|
+
report
|
|
110
|
+
for item in string_object_dict(value, label="npm pack reports").values()
|
|
111
|
+
if (report := _valid_pack_report(item)) is not None
|
|
112
|
+
)
|
|
113
|
+
return reports[0] if len(reports) == 1 else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _valid_pack_report(value: object) -> dict[str, object] | None:
|
|
117
|
+
if not is_object_dict(value):
|
|
118
|
+
return None
|
|
119
|
+
report = string_object_dict(value, label="npm pack report")
|
|
120
|
+
return report if "filename" in report and "files" in report else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _included_paths(value: object) -> tuple[str, ...]:
|
|
124
|
+
if not is_object_list(value):
|
|
125
|
+
msg = "npm pack returned an invalid files manifest"
|
|
126
|
+
raise TypeError(msg)
|
|
127
|
+
included: list[str] = []
|
|
128
|
+
for item in value:
|
|
129
|
+
if not is_object_dict(item):
|
|
130
|
+
continue
|
|
131
|
+
path = string_object_dict(item, label="npm pack file").get("path")
|
|
132
|
+
if isinstance(path, str):
|
|
133
|
+
included.append(path)
|
|
134
|
+
return tuple(included)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_typescript(package_root: Path, *, runner: ProcessRunner = run_build_process) -> None:
|
|
138
|
+
for argv in (
|
|
139
|
+
("npm", "ci", "--no-audit", "--no-fund"),
|
|
140
|
+
("npm", "run", "lint"),
|
|
141
|
+
("npm", "run", "typecheck"),
|
|
142
|
+
("npm", "test"),
|
|
143
|
+
):
|
|
144
|
+
runner(argv, cwd=package_root)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_typescript_release(
|
|
148
|
+
mode: ReleaseMode,
|
|
149
|
+
package_root: Path,
|
|
150
|
+
*,
|
|
151
|
+
destination: Path | None = None,
|
|
152
|
+
runner: ProcessRunner = run_process,
|
|
153
|
+
environment: Mapping[str, str] | None = None,
|
|
154
|
+
) -> PackedArtifact | None:
|
|
155
|
+
if mode not in {"check", "pack", "publish"}:
|
|
156
|
+
msg = f"unsupported TypeScript release mode: {mode}"
|
|
157
|
+
raise ValueError(msg)
|
|
158
|
+
build_runner = run_build_process if runner is run_process else runner
|
|
159
|
+
if mode == "pack":
|
|
160
|
+
if destination is None:
|
|
161
|
+
msg = "pack mode requires a destination directory"
|
|
162
|
+
raise ValueError(msg)
|
|
163
|
+
return pack_typescript(package_root, destination, runner=build_runner)
|
|
164
|
+
if destination is not None:
|
|
165
|
+
msg = f"{mode} mode does not accept a destination directory"
|
|
166
|
+
raise ValueError(msg)
|
|
167
|
+
check_typescript(package_root, runner=build_runner)
|
|
168
|
+
with TemporaryDirectory(prefix="sarj-typescript-release-") as temporary:
|
|
169
|
+
artifact = pack_typescript(package_root, Path(temporary), runner=build_runner)
|
|
170
|
+
if mode == "publish":
|
|
171
|
+
publish_runner = runner
|
|
172
|
+
if runner is run_process and environment is not None:
|
|
173
|
+
|
|
174
|
+
def publish_runner(
|
|
175
|
+
argv: tuple[str, ...],
|
|
176
|
+
*,
|
|
177
|
+
cwd: Path,
|
|
178
|
+
capture_output: bool = False,
|
|
179
|
+
) -> ProcessResult:
|
|
180
|
+
return run_process_environment(
|
|
181
|
+
argv,
|
|
182
|
+
cwd=cwd,
|
|
183
|
+
capture_output=capture_output,
|
|
184
|
+
environment=environment,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
publish_runner(
|
|
188
|
+
("npm", "publish", str(artifact.path), "--access", "public", "--ignore-scripts"),
|
|
189
|
+
cwd=package_root,
|
|
190
|
+
)
|
|
191
|
+
return None
|
|
File without changes
|