arkaos 2.42.0 → 2.44.0
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.
- package/VERSION +1 -1
- package/core/cognition/__pycache__/reorganizer.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/leak_scanner.cpython-313.pyc +0 -0
- package/core/governance/leak_scanner.py +155 -0
- package/core/release/__init__.py +1 -0
- package/core/release/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/release/__pycache__/preflight.cpython-313.pyc +0 -0
- package/core/release/__pycache__/preflight_cli.cpython-313.pyc +0 -0
- package/core/release/preflight.py +284 -0
- package/core/release/preflight_cli.py +74 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.44.0
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Client-name leak scanner (PR22 v2.44.0).
|
|
2
|
+
|
|
3
|
+
Reads the user-local ``~/.arkaos/redaction-clients.json`` and scans
|
|
4
|
+
source files / arbitrary text for word-boundary matches. Wired into
|
|
5
|
+
the release preflight gate as a blocking check.
|
|
6
|
+
|
|
7
|
+
Closes a real process gap: PR20 v2.42.0 shipped 9 client names in a
|
|
8
|
+
production constant; the Quality Gate did not flag it; operator
|
|
9
|
+
caught it manually pre-commit. Automation > checklist.
|
|
10
|
+
|
|
11
|
+
Empty / missing config is a no-op by design — there are no false
|
|
12
|
+
positives in CI clones that lack the operator's local list.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Iterable
|
|
22
|
+
|
|
23
|
+
_DEFAULT_CONFIG_PATH = Path.home() / ".arkaos" / "redaction-clients.json"
|
|
24
|
+
_MAX_FILE_BYTES = 10 * 1024 * 1024 # 10 MB per-file cap
|
|
25
|
+
_SCAN_EXTENSIONS: frozenset[str] = frozenset({
|
|
26
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs",
|
|
27
|
+
".json", ".yaml", ".yml", ".toml", ".md", ".sh", ".txt",
|
|
28
|
+
})
|
|
29
|
+
_EXCLUDE_DIRS: frozenset[str] = frozenset({
|
|
30
|
+
"node_modules", "__pycache__", ".venv", ".git", "dist", "build",
|
|
31
|
+
})
|
|
32
|
+
_LINE_EXCERPT_LIMIT = 200
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class LeakHit:
|
|
37
|
+
"""One client-name occurrence in a scanned file."""
|
|
38
|
+
path: Path
|
|
39
|
+
line_number: int
|
|
40
|
+
line_excerpt: str
|
|
41
|
+
matched_token: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ScanReport:
|
|
46
|
+
"""Result of a scan."""
|
|
47
|
+
config_path: Path
|
|
48
|
+
pattern_count: int
|
|
49
|
+
files_scanned: int
|
|
50
|
+
hits: list[LeakHit]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def clean(self) -> bool:
|
|
54
|
+
return not self.hits
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def scan_paths(
|
|
58
|
+
paths: Iterable[Path],
|
|
59
|
+
*,
|
|
60
|
+
config_path: Path | None = None,
|
|
61
|
+
) -> ScanReport:
|
|
62
|
+
"""Recursively scan the given paths for client-name leaks."""
|
|
63
|
+
cfg = config_path or _DEFAULT_CONFIG_PATH
|
|
64
|
+
patterns = _load_patterns(cfg)
|
|
65
|
+
if not patterns:
|
|
66
|
+
return ScanReport(config_path=cfg, pattern_count=0, files_scanned=0, hits=[])
|
|
67
|
+
regex = _build_regex(patterns)
|
|
68
|
+
hits: list[LeakHit] = []
|
|
69
|
+
files_scanned = 0
|
|
70
|
+
for path in paths:
|
|
71
|
+
for file_path in _iter_scannable_files(Path(path)):
|
|
72
|
+
files_scanned += 1
|
|
73
|
+
hits.extend(_scan_file(file_path, regex))
|
|
74
|
+
return ScanReport(
|
|
75
|
+
config_path=cfg,
|
|
76
|
+
pattern_count=len(patterns),
|
|
77
|
+
files_scanned=files_scanned,
|
|
78
|
+
hits=hits,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def scan_text(
|
|
83
|
+
text: str,
|
|
84
|
+
*,
|
|
85
|
+
config_path: Path | None = None,
|
|
86
|
+
) -> list[str]:
|
|
87
|
+
"""Return the list of matched tokens (lowercased) found in *text*."""
|
|
88
|
+
cfg = config_path or _DEFAULT_CONFIG_PATH
|
|
89
|
+
patterns = _load_patterns(cfg)
|
|
90
|
+
if not patterns:
|
|
91
|
+
return []
|
|
92
|
+
regex = _build_regex(patterns)
|
|
93
|
+
return [m.group(1).lower() for m in regex.finditer(text)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load_patterns(config_path: Path) -> tuple[str, ...]:
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
99
|
+
raw = data.get("clients", [])
|
|
100
|
+
return tuple(
|
|
101
|
+
str(c).strip().lower() for c in raw
|
|
102
|
+
if c and isinstance(c, str)
|
|
103
|
+
)
|
|
104
|
+
except (OSError, json.JSONDecodeError, AttributeError):
|
|
105
|
+
return ()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_regex(patterns: tuple[str, ...]) -> re.Pattern[str]:
|
|
109
|
+
escaped = [re.escape(p) for p in patterns]
|
|
110
|
+
return re.compile(
|
|
111
|
+
r"(?<![a-z0-9])(" + "|".join(escaped) + r")(?![a-z0-9])",
|
|
112
|
+
re.IGNORECASE,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _iter_scannable_files(root: Path):
|
|
117
|
+
if root.is_file():
|
|
118
|
+
if _is_scannable(root):
|
|
119
|
+
yield root
|
|
120
|
+
return
|
|
121
|
+
if not root.is_dir():
|
|
122
|
+
return
|
|
123
|
+
for entry in sorted(root.rglob("*")):
|
|
124
|
+
if any(part in _EXCLUDE_DIRS for part in entry.parts):
|
|
125
|
+
continue
|
|
126
|
+
if entry.is_file() and _is_scannable(entry):
|
|
127
|
+
yield entry
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_scannable(path: Path) -> bool:
|
|
131
|
+
if path.suffix.lower() not in _SCAN_EXTENSIONS:
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
if path.stat().st_size > _MAX_FILE_BYTES:
|
|
135
|
+
return False
|
|
136
|
+
except OSError:
|
|
137
|
+
return False
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _scan_file(path: Path, regex: re.Pattern[str]) -> list[LeakHit]:
|
|
142
|
+
try:
|
|
143
|
+
text = path.read_text(encoding="utf-8")
|
|
144
|
+
except (OSError, UnicodeDecodeError):
|
|
145
|
+
return []
|
|
146
|
+
hits: list[LeakHit] = []
|
|
147
|
+
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
148
|
+
for match in regex.finditer(line):
|
|
149
|
+
hits.append(LeakHit(
|
|
150
|
+
path=path,
|
|
151
|
+
line_number=line_no,
|
|
152
|
+
line_excerpt=line[:_LINE_EXCERPT_LIMIT],
|
|
153
|
+
matched_token=match.group(1).lower(),
|
|
154
|
+
))
|
|
155
|
+
return hits
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Release pipeline tooling — preflight gate, version helpers."""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Release preflight gate (PR21 v2.43.0).
|
|
2
|
+
|
|
3
|
+
Runs BEFORE the irreversible tag/push/publish steps of the release
|
|
4
|
+
pipeline. Catches expired tokens, version misalignment, and dirty
|
|
5
|
+
git state at minute 0 instead of minute 60.
|
|
6
|
+
|
|
7
|
+
Closes a real-world debt: v2.40.0 release took ~1h because the npm
|
|
8
|
+
token had expired silently and the failure was only discovered
|
|
9
|
+
after merge, tag, GH release. A 2-second check up front would have
|
|
10
|
+
turned that into an immediate STOP-and-rotate.
|
|
11
|
+
|
|
12
|
+
All shell-outs use ``subprocess.run(check=False, capture_output=True,
|
|
13
|
+
timeout=10)`` with argv as a list — no ``shell=True``, no string
|
|
14
|
+
interpolation of user input.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
_DEFAULT_REPO_ROOT = Path.cwd()
|
|
26
|
+
_SUBPROCESS_TIMEOUT = 10
|
|
27
|
+
_NPM_PACKAGE_VERSION_RE = re.compile(r'"version"\s*:\s*"([^"]+)"')
|
|
28
|
+
_PYPROJECT_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CheckResult:
|
|
33
|
+
"""Single preflight check outcome."""
|
|
34
|
+
name: str
|
|
35
|
+
passed: bool
|
|
36
|
+
reason: str
|
|
37
|
+
remediation: str | None = None
|
|
38
|
+
severity: str = "blocking"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class PreflightReport:
|
|
43
|
+
"""Aggregated preflight verdict."""
|
|
44
|
+
all_passed: bool
|
|
45
|
+
results: list[CheckResult] = field(default_factory=list)
|
|
46
|
+
blocking_failures: list[CheckResult] = field(default_factory=list)
|
|
47
|
+
warnings: list[CheckResult] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_preflight(
|
|
51
|
+
*,
|
|
52
|
+
repo_root: Path | None = None,
|
|
53
|
+
expected_npm_user: str | None = None,
|
|
54
|
+
) -> PreflightReport:
|
|
55
|
+
"""Run every preflight check and return an aggregated report."""
|
|
56
|
+
root = repo_root or _DEFAULT_REPO_ROOT
|
|
57
|
+
results = [
|
|
58
|
+
check_version_alignment(repo_root=root),
|
|
59
|
+
check_npm_auth(expected_user=expected_npm_user),
|
|
60
|
+
check_npm_publish_capability(),
|
|
61
|
+
check_gh_auth(),
|
|
62
|
+
check_git_remote(),
|
|
63
|
+
check_git_clean(),
|
|
64
|
+
check_no_client_name_leaks(repo_root=root),
|
|
65
|
+
]
|
|
66
|
+
return _aggregate(results)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_no_client_name_leaks(
|
|
70
|
+
*, repo_root: Path | None = None,
|
|
71
|
+
) -> CheckResult:
|
|
72
|
+
"""Scan tracked source files for client identifiers (PR22 v2.44.0).
|
|
73
|
+
|
|
74
|
+
No-op when the operator's ~/.arkaos/redaction-clients.json is absent
|
|
75
|
+
or empty — no false positives in CI clones lacking the local config.
|
|
76
|
+
"""
|
|
77
|
+
from core.governance.leak_scanner import scan_paths
|
|
78
|
+
|
|
79
|
+
root = repo_root or _DEFAULT_REPO_ROOT
|
|
80
|
+
paths = _collect_tracked_paths(root)
|
|
81
|
+
if paths is None:
|
|
82
|
+
return CheckResult(
|
|
83
|
+
name="no-client-name-leaks", passed=True,
|
|
84
|
+
reason="git ls-files unavailable — skipped",
|
|
85
|
+
)
|
|
86
|
+
report = scan_paths(paths)
|
|
87
|
+
return _format_leak_check_result(report)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _collect_tracked_paths(root: Path) -> list[Path] | None:
|
|
91
|
+
tracked = _run(["git", "-C", str(root), "ls-files", "--cached"])
|
|
92
|
+
if tracked is None or tracked.returncode != 0:
|
|
93
|
+
return None
|
|
94
|
+
return [root / line for line in tracked.stdout.splitlines() if line.strip()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _format_leak_check_result(report) -> CheckResult:
|
|
98
|
+
if report.pattern_count == 0:
|
|
99
|
+
return CheckResult(
|
|
100
|
+
name="no-client-name-leaks", passed=True,
|
|
101
|
+
reason="no redaction config — scan skipped (no-op)",
|
|
102
|
+
)
|
|
103
|
+
if report.clean:
|
|
104
|
+
return CheckResult(
|
|
105
|
+
name="no-client-name-leaks", passed=True,
|
|
106
|
+
reason=f"{report.files_scanned} file(s) scanned, no leaks",
|
|
107
|
+
)
|
|
108
|
+
first = report.hits[0]
|
|
109
|
+
# PR22 v2.44.0 introduces this check at WARNING severity to ship the
|
|
110
|
+
# scanner without blocking on pre-existing leaks in test fixtures
|
|
111
|
+
# (test_dreaming.py and siblings) + historical CHANGELOG/ADR
|
|
112
|
+
# references. PR23 cleans those up and flips severity to "blocking".
|
|
113
|
+
return CheckResult(
|
|
114
|
+
name="no-client-name-leaks", passed=False, severity="warning",
|
|
115
|
+
reason=(
|
|
116
|
+
f"{len(report.hits)} leak(s) — first: "
|
|
117
|
+
f"{first.path.name}:{first.line_number} matched `{first.matched_token}`"
|
|
118
|
+
),
|
|
119
|
+
remediation=(
|
|
120
|
+
"move the literal to ~/.arkaos/ or sanitize before commit; "
|
|
121
|
+
"PR22 scope intentionally warning-only — PR23 will scrub "
|
|
122
|
+
"historical leaks and flip this check to blocking"
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _load_repo_versions(
|
|
128
|
+
root: Path,
|
|
129
|
+
) -> tuple[str | None, str | None, str | None] | None:
|
|
130
|
+
"""Read VERSION, package.json, pyproject.toml. None if any unreadable."""
|
|
131
|
+
try:
|
|
132
|
+
v = (root / "VERSION").read_text(encoding="utf-8").strip()
|
|
133
|
+
pkg_match = _NPM_PACKAGE_VERSION_RE.search(
|
|
134
|
+
(root / "package.json").read_text(encoding="utf-8"),
|
|
135
|
+
)
|
|
136
|
+
py_match = _PYPROJECT_VERSION_RE.search(
|
|
137
|
+
(root / "pyproject.toml").read_text(encoding="utf-8"),
|
|
138
|
+
)
|
|
139
|
+
except (OSError, AttributeError):
|
|
140
|
+
return None
|
|
141
|
+
pkg_v = pkg_match.group(1) if pkg_match else None
|
|
142
|
+
py_v = py_match.group(1) if py_match else None
|
|
143
|
+
return v, pkg_v, py_v
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_version_alignment(*, repo_root: Path | None = None) -> CheckResult:
|
|
147
|
+
"""Verify VERSION, package.json, pyproject.toml all carry the same version."""
|
|
148
|
+
root = repo_root or _DEFAULT_REPO_ROOT
|
|
149
|
+
versions = _load_repo_versions(root)
|
|
150
|
+
if versions is None:
|
|
151
|
+
return CheckResult(
|
|
152
|
+
name="version-alignment", passed=False,
|
|
153
|
+
reason="could not read one of VERSION / package.json / pyproject.toml",
|
|
154
|
+
remediation="ensure all three files exist at repo root",
|
|
155
|
+
)
|
|
156
|
+
v, pkg_v, py_v = versions
|
|
157
|
+
if v and pkg_v == v and py_v == v:
|
|
158
|
+
return CheckResult(
|
|
159
|
+
name="version-alignment", passed=True,
|
|
160
|
+
reason=f"all three at {v}",
|
|
161
|
+
)
|
|
162
|
+
return CheckResult(
|
|
163
|
+
name="version-alignment", passed=False,
|
|
164
|
+
reason=f"mismatch: VERSION={v} package.json={pkg_v} pyproject={py_v}",
|
|
165
|
+
remediation="align all three files to the same version, then re-run",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_npm_auth(*, expected_user: str | None = None) -> CheckResult:
|
|
170
|
+
"""Verify `npm whoami` returns a valid user (and matches expected, if set)."""
|
|
171
|
+
out = _run(["npm", "whoami"])
|
|
172
|
+
if out is None:
|
|
173
|
+
return CheckResult(
|
|
174
|
+
name="npm-auth", passed=False,
|
|
175
|
+
reason="npm command not found or timed out",
|
|
176
|
+
remediation="install Node.js / npm and retry",
|
|
177
|
+
)
|
|
178
|
+
if out.returncode != 0:
|
|
179
|
+
return CheckResult(
|
|
180
|
+
name="npm-auth", passed=False,
|
|
181
|
+
reason=f"npm whoami failed: {out.stderr.strip() or 'unknown'}",
|
|
182
|
+
remediation="run `npm login` or rotate token in ~/.npmrc",
|
|
183
|
+
)
|
|
184
|
+
user = out.stdout.strip()
|
|
185
|
+
if expected_user and user != expected_user:
|
|
186
|
+
return CheckResult(
|
|
187
|
+
name="npm-auth", passed=False,
|
|
188
|
+
reason=f"npm authenticated as {user}, expected {expected_user}",
|
|
189
|
+
remediation=f"switch token to {expected_user} or update expected user",
|
|
190
|
+
)
|
|
191
|
+
return CheckResult(
|
|
192
|
+
name="npm-auth", passed=True,
|
|
193
|
+
reason=f"authenticated as {user}",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_npm_publish_capability() -> CheckResult:
|
|
198
|
+
"""Verify `npm pack --dry-run` succeeds — proves write scope on the package."""
|
|
199
|
+
out = _run(["npm", "pack", "--dry-run"])
|
|
200
|
+
if out is None or out.returncode != 0:
|
|
201
|
+
msg = (out.stderr.strip() if out else "command unavailable") or "unknown"
|
|
202
|
+
return CheckResult(
|
|
203
|
+
name="npm-publish-capability", passed=False,
|
|
204
|
+
reason=f"pack dry-run failed: {msg}",
|
|
205
|
+
remediation="check granular token has 'read and write' on this package",
|
|
206
|
+
)
|
|
207
|
+
return CheckResult(
|
|
208
|
+
name="npm-publish-capability", passed=True,
|
|
209
|
+
reason="pack dry-run OK",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def check_gh_auth() -> CheckResult:
|
|
214
|
+
"""Verify `gh auth status` reports a logged-in session."""
|
|
215
|
+
out = _run(["gh", "auth", "status"])
|
|
216
|
+
if out is None:
|
|
217
|
+
return CheckResult(
|
|
218
|
+
name="gh-auth", passed=False,
|
|
219
|
+
reason="gh command not found or timed out",
|
|
220
|
+
remediation="install the GitHub CLI and run `gh auth login`",
|
|
221
|
+
)
|
|
222
|
+
if out.returncode != 0:
|
|
223
|
+
return CheckResult(
|
|
224
|
+
name="gh-auth", passed=False,
|
|
225
|
+
reason="gh not authenticated",
|
|
226
|
+
remediation="run `gh auth login`",
|
|
227
|
+
)
|
|
228
|
+
return CheckResult(name="gh-auth", passed=True, reason="authenticated")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def check_git_remote() -> CheckResult:
|
|
232
|
+
"""Verify `git remote get-url origin` returns a real remote URL."""
|
|
233
|
+
out = _run(["git", "remote", "get-url", "origin"])
|
|
234
|
+
if out is None or out.returncode != 0:
|
|
235
|
+
return CheckResult(
|
|
236
|
+
name="git-remote", passed=False,
|
|
237
|
+
reason="no `origin` remote configured",
|
|
238
|
+
remediation="set up the remote with `git remote add origin <url>`",
|
|
239
|
+
)
|
|
240
|
+
return CheckResult(
|
|
241
|
+
name="git-remote", passed=True,
|
|
242
|
+
reason=out.stdout.strip(),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def check_git_clean() -> CheckResult:
|
|
247
|
+
"""Flag uncommitted changes as a WARNING (not blocking)."""
|
|
248
|
+
out = _run(["git", "status", "--porcelain"])
|
|
249
|
+
if out is None:
|
|
250
|
+
return CheckResult(
|
|
251
|
+
name="git-clean", passed=False, severity="warning",
|
|
252
|
+
reason="git not available",
|
|
253
|
+
)
|
|
254
|
+
dirty = out.stdout.strip()
|
|
255
|
+
if not dirty:
|
|
256
|
+
return CheckResult(name="git-clean", passed=True, reason="working tree clean")
|
|
257
|
+
file_count = len([line for line in dirty.splitlines() if line.strip()])
|
|
258
|
+
return CheckResult(
|
|
259
|
+
name="git-clean", passed=False, severity="warning",
|
|
260
|
+
reason=f"{file_count} uncommitted file(s)",
|
|
261
|
+
remediation="commit, stash, or proceed deliberately",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _run(cmd: list[str]) -> subprocess.CompletedProcess | None:
|
|
266
|
+
"""subprocess.run with our defaults; returns None on missing exe / timeout."""
|
|
267
|
+
try:
|
|
268
|
+
return subprocess.run(
|
|
269
|
+
cmd, capture_output=True, text=True,
|
|
270
|
+
timeout=_SUBPROCESS_TIMEOUT, check=False,
|
|
271
|
+
)
|
|
272
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _aggregate(results: list[CheckResult]) -> PreflightReport:
|
|
277
|
+
blocking = [r for r in results if not r.passed and r.severity == "blocking"]
|
|
278
|
+
warnings = [r for r in results if not r.passed and r.severity == "warning"]
|
|
279
|
+
return PreflightReport(
|
|
280
|
+
all_passed=not blocking,
|
|
281
|
+
results=results,
|
|
282
|
+
blocking_failures=blocking,
|
|
283
|
+
warnings=warnings,
|
|
284
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""CLI entry for the release preflight gate (PR21 v2.43.0).
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m core.release.preflight_cli [--expected-npm-user USER]
|
|
5
|
+
|
|
6
|
+
Exit code: 0 if all blocking checks pass, 1 otherwise. Warnings never
|
|
7
|
+
gate the exit code. Output is markdown so it renders cleanly in both
|
|
8
|
+
terminal and PR comments.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
from core.release.preflight import (
|
|
18
|
+
CheckResult,
|
|
19
|
+
PreflightReport,
|
|
20
|
+
run_preflight,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _glyph(result: CheckResult) -> str:
|
|
25
|
+
if result.passed:
|
|
26
|
+
return "PASS"
|
|
27
|
+
if result.severity == "warning":
|
|
28
|
+
return "WARN"
|
|
29
|
+
return "FAIL"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _render(report: PreflightReport) -> str:
|
|
33
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
34
|
+
lines = [
|
|
35
|
+
f"# Release Preflight — {today}",
|
|
36
|
+
"",
|
|
37
|
+
"| Check | Status | Notes |",
|
|
38
|
+
"|---|---|---|",
|
|
39
|
+
]
|
|
40
|
+
for r in report.results:
|
|
41
|
+
notes = r.reason or ""
|
|
42
|
+
if r.remediation and not r.passed:
|
|
43
|
+
notes = f"{notes} | remediation: {r.remediation}"
|
|
44
|
+
lines.append(f"| {r.name} | {_glyph(r)} | {notes} |")
|
|
45
|
+
lines.append("")
|
|
46
|
+
if report.blocking_failures:
|
|
47
|
+
lines.append(f"**BLOCKED — {len(report.blocking_failures)} failure(s).** "
|
|
48
|
+
"Fix each remediation and rerun before tagging/publishing.")
|
|
49
|
+
elif report.warnings:
|
|
50
|
+
lines.append(f"All blocking checks passed ({len(report.warnings)} "
|
|
51
|
+
"warning(s) noted, non-blocking).")
|
|
52
|
+
else:
|
|
53
|
+
lines.append("All checks green. Safe to proceed to tag → release → publish.")
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv: list[str]) -> int:
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
prog="arkaos-preflight",
|
|
60
|
+
description="Run release preflight checks. Exit 1 on any blocking failure.",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--expected-npm-user", default=None,
|
|
64
|
+
help="Assert that `npm whoami` returns this user (e.g. wizardingcode).",
|
|
65
|
+
)
|
|
66
|
+
args = parser.parse_args(argv[1:])
|
|
67
|
+
|
|
68
|
+
report = run_preflight(expected_npm_user=args.expected_npm_user)
|
|
69
|
+
print(_render(report))
|
|
70
|
+
return 0 if report.all_passed else 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
raise SystemExit(main(sys.argv))
|
package/package.json
CHANGED