@caddis/cli 0.4.51 → 0.4.53
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/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +86 -2
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +2 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Check a merge on prod, item by item, from the repo's profile `.caddis/prod-check.toml`.
|
|
2
|
+
|
|
3
|
+
Items (design section 10): (1) a push run exists for the merge SHA and is not cancelled; (2) the required jobs
|
|
4
|
+
succeeded (with --docs-only: the deploy job was skipped); (3) the version endpoint SHA equals the merge SHA or contains
|
|
5
|
+
it; (4) the health endpoint JSON has every value `true`, not only HTTP 200; (5) optional: named background jobs were
|
|
6
|
+
attempted after the deploy with zero failures. A repo with no profile gets items 1 and 2 only.
|
|
7
|
+
|
|
8
|
+
Prints one PASS or FAIL line per item and a RESULT line. Exit 0 only when every item passes; 1 when one fails; 3 when
|
|
9
|
+
the profile cannot be read; 4 when the SHA is ambiguous or not found. Read-only: it never writes to GitHub or prod.
|
|
10
|
+
|
|
11
|
+
Usage: caddis_prod_check.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run] <sha>
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import tomllib
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Callable, NamedTuple
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import caddis_exit
|
|
30
|
+
except ModuleNotFoundError as exc:
|
|
31
|
+
if exc.name != "caddis_exit":
|
|
32
|
+
raise
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
34
|
+
import caddis_exit
|
|
35
|
+
|
|
36
|
+
FULL_SHA = re.compile(r"[0-9a-f]{40}")
|
|
37
|
+
SHORT_SHA = re.compile(r"[0-9a-f]{4,39}")
|
|
38
|
+
POLL_SECONDS = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Proc(NamedTuple):
|
|
42
|
+
rc: int
|
|
43
|
+
out: str
|
|
44
|
+
err: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Runner = Callable[[list[str]], Proc]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Clock:
|
|
51
|
+
def now(self) -> float:
|
|
52
|
+
return time.monotonic()
|
|
53
|
+
|
|
54
|
+
def sleep(self, seconds: float) -> None:
|
|
55
|
+
time.sleep(seconds)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def real_runner(cwd: Path) -> Runner:
|
|
59
|
+
def run(cmd: list[str]) -> Proc:
|
|
60
|
+
try:
|
|
61
|
+
done = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
62
|
+
except FileNotFoundError as exc:
|
|
63
|
+
return Proc(127, "", str(exc))
|
|
64
|
+
return Proc(done.returncode, done.stdout, done.stderr)
|
|
65
|
+
return run
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Profile:
|
|
70
|
+
found: bool = False
|
|
71
|
+
branch: str = "main"
|
|
72
|
+
required_jobs: list[str] = field(default_factory=list)
|
|
73
|
+
deploy_job: str = "deploy_prod"
|
|
74
|
+
push_run_wait_minutes: float = 10
|
|
75
|
+
run_wait_minutes: float = 60
|
|
76
|
+
version_url: str | None = None
|
|
77
|
+
version_field: str = "sha"
|
|
78
|
+
health_url: str | None = None
|
|
79
|
+
auth: str = "none"
|
|
80
|
+
timeout_seconds: int = 60
|
|
81
|
+
background: dict | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_KEY_TYPES: dict[str, type | tuple[type, ...]] = {
|
|
85
|
+
"branch": str, "required_jobs": list, "deploy_job": str, "push_run_wait_minutes": (int, float),
|
|
86
|
+
"run_wait_minutes": (int, float), "version_url": str, "version_field": str, "health_url": str, "auth": str,
|
|
87
|
+
"timeout_seconds": int,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_profile(repo: Path, path: Path | None = None) -> Profile:
|
|
92
|
+
"""Read `.caddis/prod-check.toml`. No file is a valid, empty profile. A bad file raises ValueError."""
|
|
93
|
+
path = path or Path(repo) / ".caddis" / "prod-check.toml"
|
|
94
|
+
if not path.is_file():
|
|
95
|
+
return Profile()
|
|
96
|
+
try:
|
|
97
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
99
|
+
raise ValueError(f"{path}: {exc}") from exc
|
|
100
|
+
ci, prod = data.get("ci", {}), data.get("prod", {})
|
|
101
|
+
background = data.get("background")
|
|
102
|
+
if not isinstance(ci, dict) or not isinstance(prod, dict) or not isinstance(background, (dict, type(None))):
|
|
103
|
+
raise ValueError(f"{path}: [ci], [prod] and [background] must be tables")
|
|
104
|
+
profile = Profile(found=True)
|
|
105
|
+
for key, value in {**ci, **prod}.items():
|
|
106
|
+
expected = _KEY_TYPES.get(key)
|
|
107
|
+
if expected is None:
|
|
108
|
+
raise ValueError(f"{path}: unknown key {key!r}")
|
|
109
|
+
if isinstance(value, bool) or not isinstance(value, expected):
|
|
110
|
+
raise ValueError(f"{path}: {key} has the wrong type")
|
|
111
|
+
if key == "required_jobs" and not all(isinstance(job, str) for job in value):
|
|
112
|
+
raise ValueError(f"{path}: required_jobs must be a list of job names")
|
|
113
|
+
setattr(profile, key, value)
|
|
114
|
+
if profile.auth not in ("none", "ntlm"):
|
|
115
|
+
raise ValueError(f"{path}: auth must be 'none' or 'ntlm'")
|
|
116
|
+
if background is not None:
|
|
117
|
+
need = ("url", "list_key", "name_key", "attempt_key", "failure_key")
|
|
118
|
+
missing = [key for key in need if not isinstance(background.get(key), str)]
|
|
119
|
+
if missing:
|
|
120
|
+
raise ValueError(f"{path}: [background] needs {', '.join(missing)}")
|
|
121
|
+
profile.background = background
|
|
122
|
+
return profile
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Context:
|
|
127
|
+
run: Runner
|
|
128
|
+
clock: Clock
|
|
129
|
+
profile: Profile
|
|
130
|
+
repo: Path
|
|
131
|
+
gh_repo: str | None = None
|
|
132
|
+
dry_run: bool = False
|
|
133
|
+
out: Callable[[str], None] = print
|
|
134
|
+
|
|
135
|
+
def gh(self, *args: str) -> Proc:
|
|
136
|
+
cmd = ["gh", *args]
|
|
137
|
+
if self.gh_repo and args and args[0] in ("pr", "run"):
|
|
138
|
+
cmd += ["-R", self.gh_repo]
|
|
139
|
+
return self.run(cmd)
|
|
140
|
+
|
|
141
|
+
def gh_json(self, *args: str) -> object:
|
|
142
|
+
proc = self.gh(*args)
|
|
143
|
+
if proc.rc != 0:
|
|
144
|
+
raise RuntimeError(f"gh {' '.join(args[:3])} failed: {(proc.err or proc.out).strip()[:300]}")
|
|
145
|
+
return json.loads(proc.out)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_sha(ctx: Context, sha: str) -> tuple[int, str]:
|
|
149
|
+
"""Return (exit code, full SHA). A full SHA is used as given; a short one is resolved with git."""
|
|
150
|
+
sha = sha.strip().lower()
|
|
151
|
+
if FULL_SHA.fullmatch(sha):
|
|
152
|
+
return caddis_exit.CLEAN, sha
|
|
153
|
+
if not SHORT_SHA.fullmatch(sha):
|
|
154
|
+
ctx.out(f"FAIL sha {sha!r} is not a commit SHA")
|
|
155
|
+
return caddis_exit.ERROR, ""
|
|
156
|
+
for attempt in range(2):
|
|
157
|
+
proc = ctx.run(["git", "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"])
|
|
158
|
+
if proc.rc == 0 and FULL_SHA.fullmatch(proc.out.strip()):
|
|
159
|
+
return caddis_exit.CLEAN, proc.out.strip()
|
|
160
|
+
if "ambiguous" in proc.err:
|
|
161
|
+
ctx.out(f"FAIL short sha {sha} is ambiguous; give more characters")
|
|
162
|
+
return caddis_exit.ERROR, ""
|
|
163
|
+
if attempt == 0:
|
|
164
|
+
ctx.run(["git", "fetch", "-q", "origin"])
|
|
165
|
+
ctx.out(f"FAIL sha {sha} not found, also after git fetch")
|
|
166
|
+
return caddis_exit.ERROR, ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def push_runs(ctx: Context, limit: int = 20) -> list[dict]:
|
|
170
|
+
return ctx.gh_json("run", "list", "--branch", ctx.profile.branch, "--event", "push", "--limit", str(limit),
|
|
171
|
+
"--json", "databaseId,headSha,status,conclusion") # type: ignore[return-value]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def find_push_run(ctx: Context, sha: str) -> dict | None:
|
|
175
|
+
"""Wait up to the profile's minutes for a push run of `sha` to appear."""
|
|
176
|
+
deadline = ctx.clock.now() + ctx.profile.push_run_wait_minutes * 60
|
|
177
|
+
while True:
|
|
178
|
+
run = next((r for r in push_runs(ctx) if r.get("headSha") == sha), None)
|
|
179
|
+
if run or ctx.dry_run or ctx.clock.now() >= deadline:
|
|
180
|
+
return run
|
|
181
|
+
ctx.clock.sleep(15)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def wait_run(ctx: Context, run_id: int) -> dict | None:
|
|
185
|
+
"""Return the completed run view (status, conclusion, jobs), or None when it does not complete in time."""
|
|
186
|
+
deadline = ctx.clock.now() + ctx.profile.run_wait_minutes * 60
|
|
187
|
+
while True:
|
|
188
|
+
view = ctx.gh_json("run", "view", str(run_id), "--json", "status,conclusion,jobs,headSha")
|
|
189
|
+
if view.get("status") == "completed" or ctx.dry_run: # type: ignore[union-attr]
|
|
190
|
+
return view # type: ignore[return-value]
|
|
191
|
+
if ctx.clock.now() >= deadline:
|
|
192
|
+
return None
|
|
193
|
+
ctx.clock.sleep(POLL_SECONDS)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _jobs(view: dict) -> dict[str, str]:
|
|
197
|
+
return {j.get("name", ""): (j.get("conclusion") or j.get("status") or "") for j in view.get("jobs", [])}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _deploy_done(view: dict, deploy_job: str) -> datetime | None:
|
|
201
|
+
for job in view.get("jobs", []):
|
|
202
|
+
if job.get("name") == deploy_job and job.get("completedAt"):
|
|
203
|
+
return _utc(job["completedAt"])
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _utc(text: str) -> datetime:
|
|
208
|
+
when = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
209
|
+
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def check_ci(ctx: Context, sha: str, docs_only: bool) -> tuple[list[str], dict | None]:
|
|
213
|
+
"""Items 1 and 2. Return (failed item labels, completed run view)."""
|
|
214
|
+
run = find_push_run(ctx, sha)
|
|
215
|
+
if not run:
|
|
216
|
+
ctx.out(f"FAIL 1 no push run for {sha} on {ctx.profile.branch} after "
|
|
217
|
+
f"{ctx.profile.push_run_wait_minutes:g} min")
|
|
218
|
+
return ["1 push run"], None
|
|
219
|
+
if ctx.dry_run and run.get("status") != "completed":
|
|
220
|
+
ctx.out(f"INFO 1 push run {run['databaseId']} is {run.get('status')}; would wait for it")
|
|
221
|
+
return [], None
|
|
222
|
+
view = wait_run(ctx, run["databaseId"])
|
|
223
|
+
if view is None:
|
|
224
|
+
ctx.out(f"FAIL 1 push run {run['databaseId']} did not complete in {ctx.profile.run_wait_minutes:g} min")
|
|
225
|
+
return ["1 push run"], None
|
|
226
|
+
conclusion = view.get("conclusion") or ""
|
|
227
|
+
if conclusion in ("cancelled", ""):
|
|
228
|
+
ctx.out(f"FAIL 1 push run {run['databaseId']} conclusion {conclusion or 'none'}")
|
|
229
|
+
return ["1 push run"], view
|
|
230
|
+
ctx.out(f"PASS 1 push run {run['databaseId']} completed ({conclusion})")
|
|
231
|
+
jobs = _jobs(view)
|
|
232
|
+
ctx.out("jobs: " + ", ".join(f"{name}={state}" for name, state in jobs.items()))
|
|
233
|
+
deploy = ctx.profile.deploy_job
|
|
234
|
+
if docs_only:
|
|
235
|
+
if jobs.get(deploy) == "skipped" and conclusion == "success":
|
|
236
|
+
ctx.out(f"PASS 2 docs-only: {deploy} skipped, run conclusion success")
|
|
237
|
+
return [], view
|
|
238
|
+
if conclusion != "success":
|
|
239
|
+
ctx.out(f"FAIL 2 docs-only merge, but the run concluded {conclusion}")
|
|
240
|
+
return ["2 jobs"], view
|
|
241
|
+
ctx.out(f"FAIL 2 docs-only merge, but {deploy} = {jobs.get(deploy, 'absent')}; run the full prod check")
|
|
242
|
+
return ["2 jobs"], view
|
|
243
|
+
if not ctx.profile.found:
|
|
244
|
+
if conclusion == "success":
|
|
245
|
+
ctx.out("PASS 2 run conclusion success (no profile: required jobs unknown)")
|
|
246
|
+
return [], view
|
|
247
|
+
ctx.out(f"FAIL 2 run conclusion {conclusion}")
|
|
248
|
+
return ["2 jobs"], view
|
|
249
|
+
bad = {name: jobs.get(name, "absent") for name in ctx.profile.required_jobs if jobs.get(name) != "success"}
|
|
250
|
+
if deploy and jobs.get(deploy) != "success":
|
|
251
|
+
# A code merge that did not deploy fails, also when the job is not listed as required.
|
|
252
|
+
# A repo with no deploy job sets `deploy_job = ""` in its profile.
|
|
253
|
+
bad[deploy] = jobs.get(deploy, "absent")
|
|
254
|
+
if bad:
|
|
255
|
+
skipped = " (skipped without --docs-only)" if bad.get(deploy) == "skipped" else ""
|
|
256
|
+
ctx.out("FAIL 2 " + ", ".join(f"{n}={c}" for n, c in bad.items()) + skipped)
|
|
257
|
+
return ["2 jobs"], view
|
|
258
|
+
ctx.out(f"PASS 2 {', '.join(ctx.profile.required_jobs) or 'no required jobs'} success")
|
|
259
|
+
return [], view
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def curl(ctx: Context, url: str) -> tuple[int, object]:
|
|
263
|
+
"""GET `url`. Return (HTTP code, parsed JSON or None)."""
|
|
264
|
+
auth = ["--ntlm", "-u", ":"] if ctx.profile.auth == "ntlm" else []
|
|
265
|
+
proc = ctx.run(["curl", "-s", "-m", str(ctx.profile.timeout_seconds), *auth, "-w", "\n%{http_code}", url])
|
|
266
|
+
body, _, code = proc.out.rpartition("\n")
|
|
267
|
+
try:
|
|
268
|
+
status = int(code.strip())
|
|
269
|
+
except ValueError:
|
|
270
|
+
status = 0
|
|
271
|
+
try:
|
|
272
|
+
return status, json.loads(body)
|
|
273
|
+
except ValueError:
|
|
274
|
+
return status, None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _false_leaves(value: object, path: str = "") -> list[str]:
|
|
278
|
+
"""Paths whose leaf value is not exactly True. An empty object counts as not healthy."""
|
|
279
|
+
if isinstance(value, dict):
|
|
280
|
+
if not value:
|
|
281
|
+
return [path or "(empty)"]
|
|
282
|
+
return [p for key, sub in value.items() for p in _false_leaves(sub, f"{path}.{key}" if path else str(key))]
|
|
283
|
+
return [] if value is True else [f"{path}={value!r}"]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_version(ctx: Context, sha: str) -> list[str]:
|
|
287
|
+
status, body = curl(ctx, ctx.profile.version_url or "")
|
|
288
|
+
found = body.get(ctx.profile.version_field) if isinstance(body, dict) else None
|
|
289
|
+
if status == 200 and isinstance(found, str) and found:
|
|
290
|
+
if found == sha:
|
|
291
|
+
ctx.out(f"PASS 3 version sha {found}")
|
|
292
|
+
return []
|
|
293
|
+
if ctx.run(["git", "merge-base", "--is-ancestor", sha, found]).rc == 0:
|
|
294
|
+
ctx.out(f"PASS 3 version sha {found} contains {sha}")
|
|
295
|
+
return []
|
|
296
|
+
ctx.out(f"FAIL 3 version sha {found!r} (HTTP {status}) is not {sha} and does not contain it")
|
|
297
|
+
return ["3 version"]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_health(ctx: Context) -> list[str]:
|
|
301
|
+
status, body = curl(ctx, ctx.profile.health_url or "")
|
|
302
|
+
bad = _false_leaves(body) if isinstance(body, dict) else ["body is not a JSON object"]
|
|
303
|
+
if status == 200 and not bad:
|
|
304
|
+
ctx.out(f"PASS 4 health {json.dumps(body)}")
|
|
305
|
+
return []
|
|
306
|
+
ctx.out(f"FAIL 4 health HTTP {status}; not true: {', '.join(bad) or '-'}")
|
|
307
|
+
return ["4 health"]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def check_background(ctx: Context, since: datetime | None) -> list[str]:
|
|
311
|
+
spec = ctx.profile.background or {}
|
|
312
|
+
since = since or datetime.now(timezone.utc)
|
|
313
|
+
deadline = ctx.clock.now() + float(spec.get("wait_minutes", 15)) * 60
|
|
314
|
+
while True:
|
|
315
|
+
status, body = curl(ctx, spec["url"])
|
|
316
|
+
jobs = body.get(spec["list_key"]) if isinstance(body, dict) and status == 200 else None
|
|
317
|
+
if not isinstance(jobs, list) or not jobs:
|
|
318
|
+
ctx.out(f"FAIL 5 background list {spec['list_key']!r} missing or empty (HTTP {status})")
|
|
319
|
+
return ["5 background"]
|
|
320
|
+
failed = [(j.get(spec["name_key"]), j.get(spec["failure_key"])) for j in jobs if j.get(spec["failure_key"])]
|
|
321
|
+
late = [j.get(spec["name_key"]) for j in jobs
|
|
322
|
+
if not j.get(spec["attempt_key"]) or _utc(str(j[spec["attempt_key"]])) < since]
|
|
323
|
+
if failed:
|
|
324
|
+
ctx.out(f"FAIL 5 failed background jobs: {failed}")
|
|
325
|
+
return ["5 background"]
|
|
326
|
+
if not late:
|
|
327
|
+
ctx.out(f"PASS 5 {len(jobs)} background jobs attempted after {since:%H:%M:%S} UTC, no failures")
|
|
328
|
+
return []
|
|
329
|
+
if ctx.clock.now() >= deadline:
|
|
330
|
+
ctx.out(f"FAIL 5 not attempted since the deploy: {late}")
|
|
331
|
+
return ["5 background"]
|
|
332
|
+
ctx.clock.sleep(60)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def check(ctx: Context, sha: str, docs_only: bool = False) -> int:
|
|
336
|
+
code, full = resolve_sha(ctx, sha)
|
|
337
|
+
if code != caddis_exit.CLEAN:
|
|
338
|
+
return code
|
|
339
|
+
if not ctx.profile.found:
|
|
340
|
+
ctx.out("no profile .caddis/prod-check.toml: checking items 1 and 2 only")
|
|
341
|
+
try:
|
|
342
|
+
fails, view = check_ci(ctx, full, docs_only)
|
|
343
|
+
if not fails and not docs_only and ctx.profile.found:
|
|
344
|
+
if ctx.dry_run:
|
|
345
|
+
ctx.out("would check items 3 to 5 against prod (version, health, background jobs)")
|
|
346
|
+
else:
|
|
347
|
+
if ctx.profile.version_url:
|
|
348
|
+
fails += check_version(ctx, full)
|
|
349
|
+
if ctx.profile.health_url:
|
|
350
|
+
fails += check_health(ctx)
|
|
351
|
+
if ctx.profile.background:
|
|
352
|
+
fails += check_background(ctx, _deploy_done(view or {}, ctx.profile.deploy_job))
|
|
353
|
+
except (RuntimeError, ValueError, KeyError) as exc:
|
|
354
|
+
ctx.out(f"FAIL could not read CI state: {exc}")
|
|
355
|
+
fails = ["CI state"]
|
|
356
|
+
ctx.out("RESULT PASS" if not fails else "RESULT FAIL " + ", ".join(fails))
|
|
357
|
+
return caddis_exit.CLEAN if not fails else caddis_exit.BLOCKED
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def main(argv: list[str] | None = None) -> int:
|
|
361
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
362
|
+
parser.add_argument("sha")
|
|
363
|
+
parser.add_argument("--repo", type=Path, default=Path("."))
|
|
364
|
+
parser.add_argument("--gh-repo", help="OWNER/NAME when the repo's remote is not the one to read")
|
|
365
|
+
parser.add_argument("--profile", type=Path, help="default: <repo>/.caddis/prod-check.toml")
|
|
366
|
+
parser.add_argument("--docs-only", action="store_true", help="the merge changed docs only: expect no deploy")
|
|
367
|
+
parser.add_argument("--dry-run", action="store_true", help="read CI state only; never call prod")
|
|
368
|
+
args = parser.parse_args(argv)
|
|
369
|
+
try:
|
|
370
|
+
profile = load_profile(args.repo, args.profile)
|
|
371
|
+
except ValueError as exc:
|
|
372
|
+
print(f"bad profile: {exc}", file=sys.stderr)
|
|
373
|
+
return caddis_exit.NOT_RUN
|
|
374
|
+
ctx = Context(run=real_runner(args.repo), clock=Clock(), profile=profile, repo=args.repo,
|
|
375
|
+
gh_repo=args.gh_repo, dry_run=args.dry_run)
|
|
376
|
+
return check(ctx, args.sha, docs_only=args.docs_only)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
sys.exit(main())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""caddis_redact - keep provider keys out of caddis logs and captured output.
|
|
3
|
+
|
|
4
|
+
Why: on 10 Oct 2026 a review launcher session printed the GLM API key into its local session log while
|
|
5
|
+
debugging (caddis-next proposal, issue 4); the owner had to rotate the key. caddis captures model and
|
|
6
|
+
launcher output into review files, lane packets and printed summaries. Every such text goes through
|
|
7
|
+
`redact()` (or `redact_obj()` for a packet) first.
|
|
8
|
+
|
|
9
|
+
Secrets are the VALUES (never the names) of:
|
|
10
|
+
- every `NAME=value` line in the keys file whose NAME looks secret (contains KEY, TOKEN, SECRET, PASS,
|
|
11
|
+
AUTH or CREDENTIAL): `$CADDIS_KEYS_FILE` if set (with `~` expanded, as oss_model and oss_review do),
|
|
12
|
+
else `~/.caddis/keys.env`; optional `export `, quotes stripped the way the key consumers strip them;
|
|
13
|
+
- the same kind of variables in the environment (`REVIEW_API_KEY`, `OSS_API_KEY`, `<P>_API_KEY`, which
|
|
14
|
+
the consumers prefer over the file).
|
|
15
|
+
Values shorter than 8 characters are ignored. Each value is also masked in its JSON-escaped form, so a key
|
|
16
|
+
with `\\`, `"` or `<>&` (Go-style `\\u003c`) does not survive inside raw JSON output.
|
|
17
|
+
|
|
18
|
+
Limit: only whole, exact copies are masked. A key split across lines, cut short (`sk-abc...xyz`) or
|
|
19
|
+
re-encoded (base64) passes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
MIN_SECRET_LEN = 8
|
|
30
|
+
MASK = "***"
|
|
31
|
+
_SENSITIVE_NAME = re.compile(r"KEY|TOKEN|SECRET|PASS|AUTH|CREDENTIAL", re.I)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def keys_file(home: Path) -> Path:
|
|
35
|
+
override = os.environ.get("CADDIS_KEYS_FILE")
|
|
36
|
+
return Path(override).expanduser() if override else home / ".caddis" / "keys.env"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _forms(value: str) -> set[str]:
|
|
40
|
+
"""The value and the forms it takes inside raw JSON output (standard and Go-style escapes)."""
|
|
41
|
+
forms = {value}
|
|
42
|
+
escaped = json.dumps(value)[1:-1]
|
|
43
|
+
forms.add(escaped)
|
|
44
|
+
forms.add(escaped.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026"))
|
|
45
|
+
return forms
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_secrets(home: Path) -> list[str]:
|
|
49
|
+
"""The secret values in every form to mask, longest first (so a key that contains another is masked whole)."""
|
|
50
|
+
values: set[str] = set()
|
|
51
|
+
try:
|
|
52
|
+
text = keys_file(home).read_text(encoding="utf-8-sig", errors="replace")
|
|
53
|
+
except OSError:
|
|
54
|
+
text = ""
|
|
55
|
+
for raw in text.splitlines():
|
|
56
|
+
line = raw.strip()
|
|
57
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
58
|
+
continue
|
|
59
|
+
if line.startswith("export "):
|
|
60
|
+
line = line[len("export "):].lstrip()
|
|
61
|
+
name, value = line.split("=", 1)
|
|
62
|
+
if not _SENSITIVE_NAME.search(name):
|
|
63
|
+
continue
|
|
64
|
+
value = value.strip()
|
|
65
|
+
# The consumers (oss_model, oss_review) use .strip('"').strip("'"); mask that value AND the
|
|
66
|
+
# balanced-quote value, so an unbalanced quote cannot leave the key that is really used unmasked.
|
|
67
|
+
for candidate in {value.strip('"').strip("'"),
|
|
68
|
+
value[1:-1] if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"" else value}:
|
|
69
|
+
if len(candidate) >= MIN_SECRET_LEN:
|
|
70
|
+
values.add(candidate)
|
|
71
|
+
for name, value in os.environ.items():
|
|
72
|
+
if (_SENSITIVE_NAME.search(name) and len(value) >= MIN_SECRET_LEN and name not in ("CADDIS_KEYS_FILE",)
|
|
73
|
+
and not name.startswith("GIT_CONFIG_")): # GIT_CONFIG_KEY_<n> holds a setting NAME, not a secret
|
|
74
|
+
values.add(value)
|
|
75
|
+
forms: set[str] = set()
|
|
76
|
+
for value in values:
|
|
77
|
+
forms |= _forms(value)
|
|
78
|
+
return sorted(forms, key=len, reverse=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def redact(text: str, secrets: list[str]) -> str:
|
|
82
|
+
"""`text` with every secret form replaced by ***."""
|
|
83
|
+
if not text:
|
|
84
|
+
return text
|
|
85
|
+
for secret in secrets:
|
|
86
|
+
if secret in text:
|
|
87
|
+
text = text.replace(secret, MASK)
|
|
88
|
+
return text
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def redact_obj(obj, secrets: list[str]):
|
|
92
|
+
"""A copy of a JSON-able packet (dict/list/str) with every string redacted."""
|
|
93
|
+
if isinstance(obj, str):
|
|
94
|
+
return redact(obj, secrets)
|
|
95
|
+
if isinstance(obj, dict):
|
|
96
|
+
return {k: redact_obj(v, secrets) for k, v in obj.items()}
|
|
97
|
+
if isinstance(obj, list):
|
|
98
|
+
return [redact_obj(v, secrets) for v in obj]
|
|
99
|
+
return obj
|
|
@@ -124,8 +124,8 @@ def fetch_relay(repo: Path, workstream: str, *, replace: bool = False) -> str:
|
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
def _headless() -> bool:
|
|
127
|
-
"""No human is watching: the caddis launchers
|
|
128
|
-
return
|
|
127
|
+
"""No human is watching: the caddis launchers set CADDIS_HEADLESS."""
|
|
128
|
+
return bool(os.environ.get("CADDIS_HEADLESS"))
|
|
129
129
|
|
|
130
130
|
|
|
131
131
|
def main(argv: list[str] | None = None) -> int:
|