@caddis/cli 0.4.36 → 0.4.38
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/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +0 -171
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +0 -15
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +0 -14
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +0 -15
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -21,9 +21,6 @@ that hard-fails when it cannot read its inputs breaks every consumer the day it
|
|
|
21
21
|
first thing anyone does with a gate that cries wolf is switch it off. Only a genuine, positively
|
|
22
22
|
identified violation is non-zero.
|
|
23
23
|
|
|
24
|
-
One exception: `deploy-boundary` guards the production boundary and FAILS CLOSED. When it
|
|
25
|
-
cannot tell, it exits 3 ("could not run" on the caddis scale), never 0.
|
|
26
|
-
|
|
27
24
|
EXIT CODES
|
|
28
25
|
0 proceed
|
|
29
26
|
1 blocked — a real violation; the caller must stop
|
|
@@ -651,166 +648,6 @@ def gate_docs_check(root: Path) -> int:
|
|
|
651
648
|
return EXIT_BLOCKED if failures else EXIT_OK
|
|
652
649
|
|
|
653
650
|
|
|
654
|
-
# ── gate: deploy-boundary ───────────────────────────────────────────────────
|
|
655
|
-
# The owner's production boundary: an agent never merges into a repo whose default branch deploys
|
|
656
|
-
# on every push. /ship, /ship-merge and /pr-merge run this before any merge or push to the default
|
|
657
|
-
# branch, and hand the merge to the owner on anything but exit 0.
|
|
658
|
-
#
|
|
659
|
-
# FAILS CLOSED — the one deliberate exception to DEGRADE OPEN above. "Cannot tell" is exit 3,
|
|
660
|
-
# never 0, because a false "clean" here is a production deploy nobody approved. Any positive
|
|
661
|
-
# signal wins over every negative one: config cannot switch the check off.
|
|
662
|
-
EXIT_CANNOT_TELL = 3
|
|
663
|
-
|
|
664
|
-
_WORKFLOW_DIRS = (".github/workflows", ".gitea/workflows", ".forgejo/workflows")
|
|
665
|
-
|
|
666
|
-
# An inherited GIT_DIR (from a hook or wrapper) would make `git -C <repo>` read another repository.
|
|
667
|
-
_GIT_REPO_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY",
|
|
668
|
-
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE")
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def _git_out(repo: Path, *args: str) -> str | None:
|
|
672
|
-
"""stdout of a git command in `repo`, or None when it fails."""
|
|
673
|
-
env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_ENV}
|
|
674
|
-
try:
|
|
675
|
-
r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True,
|
|
676
|
-
encoding="utf-8", errors="replace", timeout=30, env=env)
|
|
677
|
-
except (OSError, subprocess.SubprocessError):
|
|
678
|
-
return None
|
|
679
|
-
return r.stdout if r.returncode == 0 else None
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
def _ref_exists(repo: Path, ref: str) -> bool:
|
|
683
|
-
return _git_out(repo, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}") is not None
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
def _default_branch_refs(repo: Path) -> list[str]:
|
|
687
|
-
"""Every copy of the default branch: each remote's and the local branch of that name.
|
|
688
|
-
|
|
689
|
-
The name comes from any remote's recorded HEAD; with none recorded, `main` and `master` stand
|
|
690
|
-
in. Reading every copy is deliberate: a positive signal on any of them blocks.
|
|
691
|
-
"""
|
|
692
|
-
remotes = (_git_out(repo, "remote") or "").split()
|
|
693
|
-
names: set[str] = set()
|
|
694
|
-
for remote in remotes:
|
|
695
|
-
target = (_git_out(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD") or "").strip()
|
|
696
|
-
if target.startswith(f"refs/remotes/{remote}/"):
|
|
697
|
-
names.add(target[len(f"refs/remotes/{remote}/"):])
|
|
698
|
-
names = names or {"main", "master"}
|
|
699
|
-
refs = [f"refs/remotes/{r}/{n}" for r in remotes for n in sorted(names)]
|
|
700
|
-
refs += [f"refs/heads/{n}" for n in sorted(names)]
|
|
701
|
-
return [r for r in refs if _ref_exists(repo, r)]
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
def _config_production_on_main(text: str) -> bool | None:
|
|
705
|
-
"""True/False from `[deploy] production_on_main`; None when absent. Raises on a bad file."""
|
|
706
|
-
import tomllib
|
|
707
|
-
deploy = tomllib.loads(text).get("deploy", {})
|
|
708
|
-
if not isinstance(deploy, dict):
|
|
709
|
-
raise ValueError("[deploy] is not a table")
|
|
710
|
-
value = deploy.get("production_on_main")
|
|
711
|
-
if value is not None and not isinstance(value, bool):
|
|
712
|
-
raise ValueError(f"production_on_main is {value!r}, not true or false")
|
|
713
|
-
return value
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
# A deploy is often not named "deploy". Letters-only boundaries on prod, so `deploy_prod` matches
|
|
717
|
-
# and `product` or `reproduce` do not.
|
|
718
|
-
_DEPLOY_WORD = re.compile(r"deploy|release|publish|(?<![a-z])prod(?:uction)?(?![a-z])", re.I)
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
def _workflow_deploys(text: str) -> bool:
|
|
722
|
-
"""True when the workflow's name, a job id or a job name looks like a deploy, or a job sets a
|
|
723
|
-
GitHub `environment:` (the forge's own marker for a deployment). Raises on a bad file.
|
|
724
|
-
|
|
725
|
-
The caller also checks the file name: a forge shows the file path as the workflow's name when
|
|
726
|
-
`name:` is unset.
|
|
727
|
-
"""
|
|
728
|
-
import yaml
|
|
729
|
-
doc = yaml.safe_load(text)
|
|
730
|
-
if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict):
|
|
731
|
-
raise ValueError("not a workflow (no jobs mapping)")
|
|
732
|
-
labels = [doc.get("name")]
|
|
733
|
-
for job_id, job in doc["jobs"].items():
|
|
734
|
-
labels.append(job_id)
|
|
735
|
-
if isinstance(job, dict):
|
|
736
|
-
if job.get("environment"):
|
|
737
|
-
return True
|
|
738
|
-
labels.append(job.get("name"))
|
|
739
|
-
return any(_DEPLOY_WORD.search(str(label)) for label in labels if label is not None)
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
def gate_deploy_boundary(repo: Path) -> int:
|
|
743
|
-
"""EXIT_BLOCKED when a push to the default branch deploys to production, EXIT_OK when it
|
|
744
|
-
positively does not, EXIT_CANNOT_TELL otherwise."""
|
|
745
|
-
blocked: list[str] = []
|
|
746
|
-
unknown: list[str] = []
|
|
747
|
-
|
|
748
|
-
top = _git_out(repo, "rev-parse", "--show-toplevel") if repo.is_dir() else None
|
|
749
|
-
root = Path(top.strip()) if top else repo
|
|
750
|
-
refs = _default_branch_refs(root) if top else []
|
|
751
|
-
if not repo.is_dir():
|
|
752
|
-
unknown.append(f"{repo} is not a directory")
|
|
753
|
-
elif not top:
|
|
754
|
-
unknown.append(f"{repo} is not a git repository")
|
|
755
|
-
elif not refs:
|
|
756
|
-
unknown.append("cannot find the default branch (no remote HEAD, no main or master)")
|
|
757
|
-
# HEAD is what is about to be merged or pushed: a deploy it adds lands on the default branch.
|
|
758
|
-
# It only ever adds signals; the default branch is still read, so deleting there hides nothing.
|
|
759
|
-
sources = refs + (["HEAD"] if top and _ref_exists(root, "HEAD") else [])
|
|
760
|
-
|
|
761
|
-
# Config: the working tree and every source. Only `true` counts; config cannot switch it off.
|
|
762
|
-
configs: list[tuple[str, str | None]] = []
|
|
763
|
-
cfg = root / ".caddis" / "config.toml"
|
|
764
|
-
if cfg.is_file():
|
|
765
|
-
configs.append(("working tree", None))
|
|
766
|
-
for ref in sources:
|
|
767
|
-
text = _git_out(root, "show", f"{ref}:.caddis/config.toml")
|
|
768
|
-
if text is not None:
|
|
769
|
-
configs.append((ref, text))
|
|
770
|
-
for where, text in configs:
|
|
771
|
-
try:
|
|
772
|
-
if _config_production_on_main(cfg.read_text(encoding="utf-8") if text is None else text):
|
|
773
|
-
blocked.append(f".caddis/config.toml ({where}) sets production_on_main = true")
|
|
774
|
-
except Exception as exc:
|
|
775
|
-
unknown.append(f".caddis/config.toml ({where}) unreadable: {exc}")
|
|
776
|
-
|
|
777
|
-
# Workflows: read from committed trees, never the working tree.
|
|
778
|
-
for ref in sources:
|
|
779
|
-
listing = _git_out(root, "ls-tree", "-r", "-z", "--name-only", ref, "--", *_WORKFLOW_DIRS)
|
|
780
|
-
if listing is None:
|
|
781
|
-
unknown.append(f"cannot list workflows on {ref}")
|
|
782
|
-
continue
|
|
783
|
-
for path in listing.split("\0"):
|
|
784
|
-
# Forges run only the files directly inside the workflows directory.
|
|
785
|
-
if path.rsplit("/", 1)[0] not in _WORKFLOW_DIRS:
|
|
786
|
-
continue
|
|
787
|
-
if not path.lower().endswith((".yml", ".yaml")):
|
|
788
|
-
continue
|
|
789
|
-
text = _git_out(root, "show", f"{ref}:{path}")
|
|
790
|
-
try:
|
|
791
|
-
if text is None:
|
|
792
|
-
raise OSError("git show failed")
|
|
793
|
-
if _DEPLOY_WORD.search(path.rsplit("/", 1)[1]) or _workflow_deploys(text):
|
|
794
|
-
blocked.append(f"{path} on {ref} deploys")
|
|
795
|
-
except Exception as exc:
|
|
796
|
-
unknown.append(f"{path} on {ref} unreadable: {type(exc).__name__}: {exc}")
|
|
797
|
-
|
|
798
|
-
if blocked:
|
|
799
|
-
print("[gate] deploy-boundary: BLOCKED — a push to the default branch deploys to production")
|
|
800
|
-
for reason in dict.fromkeys(blocked):
|
|
801
|
-
print(f" {reason}")
|
|
802
|
-
print(" Do not merge. Hand the merge to the owner with the PR link.")
|
|
803
|
-
return EXIT_BLOCKED
|
|
804
|
-
if unknown:
|
|
805
|
-
print("[gate] deploy-boundary: CANNOT TELL — treat as production")
|
|
806
|
-
for reason in dict.fromkeys(unknown):
|
|
807
|
-
print(f" {reason}")
|
|
808
|
-
print(" Do not merge. Hand the merge to the owner with the PR link.")
|
|
809
|
-
return EXIT_CANNOT_TELL
|
|
810
|
-
print(f"[gate] deploy-boundary: clean — no deploy signal found on {', '.join(sources)}")
|
|
811
|
-
return EXIT_OK
|
|
812
|
-
|
|
813
|
-
|
|
814
651
|
def main(argv: list[str] | None = None) -> int:
|
|
815
652
|
ap = argparse.ArgumentParser(description="caddis machine gates")
|
|
816
653
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
@@ -838,17 +675,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
838
675
|
help="block a doc longer than this (relays: 4000); default: no cap")
|
|
839
676
|
dc = sub.add_parser("docs-check")
|
|
840
677
|
dc.add_argument("--root", default=".")
|
|
841
|
-
db = sub.add_parser("deploy-boundary")
|
|
842
|
-
db.add_argument("--repo", default=".")
|
|
843
678
|
a = ap.parse_args(argv)
|
|
844
679
|
|
|
845
680
|
plan = Path(getattr(a, "plan", "") or ".")
|
|
846
|
-
if a.cmd == "deploy-boundary":
|
|
847
|
-
try:
|
|
848
|
-
return gate_deploy_boundary(Path(a.repo).resolve())
|
|
849
|
-
except Exception as exc:
|
|
850
|
-
sys.stderr.write(f"[caddis-gate] deploy-boundary cannot evaluate: {type(exc).__name__}: {exc}\n")
|
|
851
|
-
return EXIT_CANNOT_TELL # never degrade open across the production boundary
|
|
852
681
|
if a.cmd == "docs-check":
|
|
853
682
|
try:
|
|
854
683
|
return gate_docs_check(Path(a.root).resolve())
|
|
@@ -179,7 +179,7 @@ step goes through GitHub. What changes:
|
|
|
179
179
|
|
|
180
180
|
## Rules
|
|
181
181
|
- This command never merges into the default branch. The feature branch goes there later through
|
|
182
|
-
`/caddis:ship-pr
|
|
182
|
+
`/caddis:ship-pr`.
|
|
183
183
|
- One phase, one prompt, one branch, one PR.
|
|
184
184
|
- The orchestrator reads every diff. A green CI run proves only that the tests pass, not that the
|
|
185
185
|
phase did what its Design says.
|
|
@@ -104,20 +104,6 @@ Watch the PR's checks job-by-job until completion:
|
|
|
104
104
|
- If any check fails (red check): report the failure, classify the cause, and **stop before merging**.
|
|
105
105
|
Do not merge a failing PR. Apply the minimum source fix and re-push.
|
|
106
106
|
|
|
107
|
-
## Step 6b — Deploy boundary (before the merge; nothing skips it)
|
|
108
|
-
|
|
109
|
-
The PR exists and is green. Run this before the merge:
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" deploy-boundary --repo .
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
Exit **0** — go on to Step 7. Exit **1** (a push to the default branch deploys to production), exit
|
|
116
|
-
**3** (it cannot tell), or any other non-zero code — **stop. Do not merge.** Hand the merge to the
|
|
117
|
-
owner with the PR link; skip Steps 7–9. Show the gate's output and end the command. This holds
|
|
118
|
-
whatever confirmation was given: `--yes`, a confirm flag, "just merge it" or an earlier approval
|
|
119
|
-
cannot bypass it.
|
|
120
|
-
|
|
121
107
|
## Step 7 — Merge to default branch
|
|
122
108
|
|
|
123
109
|
Once all required checks are **green**, there are no merge conflicts, and review requirements are satisfied:
|
|
@@ -173,7 +159,6 @@ Cleanup: remote branch deleted / local branch deleted / backup ref dropped
|
|
|
173
159
|
## Rules
|
|
174
160
|
|
|
175
161
|
- Never merge a PR whose required checks are red or absent.
|
|
176
|
-
- Never merge unless `deploy-boundary` exited 0 on this run. No confirmation overrides it.
|
|
177
162
|
- Never delete a feature branch before its merge is confirmed and any deploy validated.
|
|
178
163
|
- Never `git add -A` without reviewing `git status` first (stage named files only).
|
|
179
164
|
- Never force-push without a `backup/*-preship` recovery ref; force pushes are `--force-with-lease` only.
|
|
@@ -64,19 +64,6 @@ over 400 changed lines. No judgement in any of those — deliberately, because j
|
|
|
64
64
|
what failed.
|
|
65
65
|
<!-- /shared:cross-review-trigger -->
|
|
66
66
|
|
|
67
|
-
**2c. DEPLOY BOUNDARY** — before the push; nothing skips it:
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" deploy-boundary --repo .
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Exit **0** (no deploy signal found) — push. Exit **1** (a push to the default branch deploys to
|
|
74
|
-
production), exit **3** (it cannot tell), or any other non-zero code — **stop. Do not merge or push to
|
|
75
|
-
the default branch.** The commit stays local. If it sits on the default branch, move it to a feature
|
|
76
|
-
branch first (`git switch -c <feature-branch>`). Then open a PR with `/ship-pr` and hand the merge
|
|
77
|
-
to the owner with the PR link. Show the gate's output and end the command. This holds whatever confirmation was given:
|
|
78
|
-
`--yes`, a confirm flag, "just merge it" or an earlier approval cannot bypass it.
|
|
79
|
-
|
|
80
67
|
**3. PUSH** — to the repo's default branch (confirm the branch first; don't assume `main`):
|
|
81
68
|
```
|
|
82
69
|
git push origin <branch>
|
|
@@ -132,7 +119,6 @@ Release tag: <tag> → <sha> | n/a
|
|
|
132
119
|
|
|
133
120
|
## Rules
|
|
134
121
|
- Never push before preflight gates are green.
|
|
135
|
-
- Never push to the default branch unless `deploy-boundary` exited 0 on this run.
|
|
136
122
|
- Never use `git add -A` without reviewing `git status` first.
|
|
137
123
|
- Do not edit a workflow file (`.gitea/workflows/` or `.github/workflows/`) to make a failing gate
|
|
138
124
|
pass — fix the source.
|
|
@@ -28,20 +28,6 @@ Resolve the target PR from `$ARGUMENTS`, else the current branch's open PR. No o
|
|
|
28
28
|
that and point at `/ship-pr`. Read `AGENTS.md` + the workflow file(s) for the repo's real deploy
|
|
29
29
|
job names and default branch — never assume.
|
|
30
30
|
|
|
31
|
-
## Step 0b — Deploy boundary (before the confirm; nothing skips it)
|
|
32
|
-
|
|
33
|
-
Run it before asking for any confirmation, in a checkout of the PR's own repository. If the
|
|
34
|
-
PR belongs to another repository, treat that as exit **3**.
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" deploy-boundary --repo .
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Exit **0** — go on to Step 1. Exit **1** (a push to the default branch deploys to production), exit
|
|
41
|
-
**3** (it cannot tell), or any other non-zero code — **stop. Do not merge.** Hand the merge to the
|
|
42
|
-
owner with the PR link. Show the gate's output and end the command. This holds whatever confirmation
|
|
43
|
-
was given: `--yes`, a confirm flag, "just merge it" or an earlier approval cannot bypass it.
|
|
44
|
-
|
|
45
31
|
## Step 1 — Refuse unless green + mergeable + reviewed
|
|
46
32
|
|
|
47
33
|
All of, per the repo's own rules:
|
|
@@ -126,7 +112,6 @@ Cleanup: remote branch ✗ deleted / local ✗ deleted / backup ref droppe
|
|
|
126
112
|
|
|
127
113
|
## Rules
|
|
128
114
|
- Never merge without: green required checks **and** the explicit human deploy-confirm.
|
|
129
|
-
- Never merge unless `deploy-boundary` exited 0 on this run. No confirmation overrides it.
|
|
130
115
|
- Never delete a branch before its deploy is validated green.
|
|
131
116
|
- Never `git add -A` without reviewing `git status`; never edit a workflow file to make a gate pass.
|
|
132
117
|
- Local-only lane: no PR, no merge — refuse and point at `/ship`.
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED