tend 0.0.1__tar.gz

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.
tend-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: tend
3
+ Version: 0.0.1
4
+ Summary: Claude-powered CI for GitHub repos
5
+ Project-URL: homepage, https://github.com/max-sixty/tend
6
+ Author-email: Maximilian Roos <m@maxroos.com>
7
+ License-Expression: MIT
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: click>=8.0
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tend"
7
+ version = "0.0.1"
8
+ description = "Claude-powered CI for GitHub repos"
9
+ license = "MIT"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Maximilian Roos", email = "m@maxroos.com" }]
12
+ classifiers = [
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ ]
20
+ dependencies = ["click>=8.0"]
21
+
22
+ [project.scripts]
23
+ tend = "continuous.cli:main"
24
+
25
+ [project.urls]
26
+ homepage = "https://github.com/max-sixty/tend"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/continuous"]
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest>=9.0.2", "pyyaml>=6.0.3"]
File without changes
@@ -0,0 +1,136 @@
1
+ """Security checks for continuous setup.
2
+
3
+ Verifies the repository has the security prerequisites described in
4
+ docs/security-model.md: branch protection on the default branch, bot
5
+ permission level, and required secrets.
6
+
7
+ Uses the `gh` CLI for GitHub API access. Checks degrade gracefully when
8
+ gh is unavailable or the token lacks permission.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shutil
15
+ import subprocess
16
+ from dataclasses import dataclass
17
+
18
+ from continuous.config import Config
19
+
20
+
21
+ @dataclass
22
+ class CheckResult:
23
+ name: str
24
+ passed: bool | None # None = skipped/error
25
+ message: str
26
+
27
+
28
+ def _gh(*args: str) -> subprocess.CompletedProcess[str] | None:
29
+ """Run a gh CLI command. Returns None if gh is not installed."""
30
+ gh = shutil.which("gh")
31
+ if not gh:
32
+ return None
33
+ try:
34
+ return subprocess.run(
35
+ [gh, *args], capture_output=True, text=True, timeout=30
36
+ )
37
+ except subprocess.TimeoutExpired:
38
+ return None
39
+
40
+
41
+ def detect_repo() -> str | None:
42
+ """Detect owner/repo from the gh CLI context."""
43
+ result = _gh("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner")
44
+ if result and result.returncode == 0:
45
+ repo = result.stdout.strip()
46
+ return repo or None
47
+ return None
48
+
49
+
50
+ def check_branch_protection(repo: str, branch: str) -> CheckResult:
51
+ """Check if the default branch is protected."""
52
+ result = _gh("api", f"repos/{repo}/branches/{branch}", "--jq", ".protected")
53
+ if result is None:
54
+ return CheckResult("branch-protection", None, "gh CLI not found")
55
+ if result.returncode != 0:
56
+ return CheckResult("branch-protection", None, f"API error: {result.stderr.strip()}")
57
+
58
+ if result.stdout.strip() == "true":
59
+ return CheckResult("branch-protection", True, f"Default branch '{branch}' is protected")
60
+ return CheckResult(
61
+ "branch-protection",
62
+ False,
63
+ f"Default branch '{branch}' is NOT protected. "
64
+ "The bot must not be able to merge PRs — this is the primary security boundary. "
65
+ "Add a branch protection rule or ruleset. See docs/security-model.md.",
66
+ )
67
+
68
+
69
+ def check_bot_permission(repo: str, bot_name: str) -> CheckResult:
70
+ """Check the bot's permission level (should be write, not admin)."""
71
+ result = _gh("api", f"repos/{repo}/collaborators/{bot_name}/permission", "--jq", ".permission")
72
+ if result is None:
73
+ return CheckResult("bot-permission", None, "gh CLI not found")
74
+ if result.returncode != 0:
75
+ stderr = result.stderr.strip()
76
+ if "Not Found" in stderr or "404" in stderr:
77
+ return CheckResult(
78
+ "bot-permission", None,
79
+ f"Bot '{bot_name}' not found as a collaborator — check the bot_name in config",
80
+ )
81
+ return CheckResult("bot-permission", None, "Could not check (may require admin access to read)")
82
+
83
+ perm = result.stdout.strip()
84
+ if perm == "admin":
85
+ return CheckResult(
86
+ "bot-permission",
87
+ False,
88
+ f"Bot '{bot_name}' has admin permission — it can bypass branch protection. "
89
+ "Downgrade to write access.",
90
+ )
91
+ return CheckResult("bot-permission", True, f"Bot '{bot_name}' has '{perm}' permission")
92
+
93
+
94
+ def check_secrets(repo: str, expected: list[str]) -> CheckResult:
95
+ """Check that required secrets exist in the repository."""
96
+ result = _gh("api", f"repos/{repo}/actions/secrets", "--jq", "[.secrets[].name]")
97
+ if result is None:
98
+ return CheckResult("secrets", None, "gh CLI not found")
99
+ if result.returncode != 0:
100
+ return CheckResult("secrets", None, "Could not list secrets (may require admin access)")
101
+
102
+ try:
103
+ secret_names = json.loads(result.stdout)
104
+ except json.JSONDecodeError:
105
+ return CheckResult("secrets", None, "Could not parse secrets response")
106
+
107
+ missing = [s for s in expected if s not in secret_names]
108
+ if missing:
109
+ return CheckResult(
110
+ "secrets",
111
+ False,
112
+ f"Missing secrets: {', '.join(missing)}. "
113
+ "Add them in repo Settings > Secrets and variables > Actions.",
114
+ )
115
+ return CheckResult("secrets", True, f"Required secrets present: {', '.join(expected)}")
116
+
117
+
118
+ def run_all_checks(cfg: Config, repo: str | None = None) -> list[CheckResult]:
119
+ """Run all security checks. Auto-detects repo if not provided."""
120
+ if shutil.which("gh") is None:
121
+ return [CheckResult("prerequisites", None, "gh CLI not found — install it to run security checks")]
122
+
123
+ if repo is None:
124
+ repo = detect_repo()
125
+ if repo is None:
126
+ return [CheckResult(
127
+ "prerequisites",
128
+ None,
129
+ "Could not detect repository. Run from a git repo with a GitHub remote, or pass --repo.",
130
+ )]
131
+
132
+ return [
133
+ check_branch_protection(repo, cfg.default_branch),
134
+ check_bot_permission(repo, cfg.bot_name),
135
+ check_secrets(repo, [cfg.bot_token_secret, cfg.claude_token_secret]),
136
+ ]
@@ -0,0 +1,75 @@
1
+ """CLI for generating continuous workflow files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from continuous.checks import CheckResult, run_all_checks
10
+ from continuous.config import Config
11
+ from continuous.workflows import generate_all
12
+
13
+
14
+ def _print_check_results(results: list[CheckResult]) -> None:
15
+ """Print check results with pass/fail/skip indicators."""
16
+ for r in results:
17
+ if r.passed is True:
18
+ icon = click.style("PASS", fg="green")
19
+ elif r.passed is False:
20
+ icon = click.style("FAIL", fg="red")
21
+ else:
22
+ icon = click.style("SKIP", fg="yellow")
23
+ click.echo(f" {icon} {r.name} — {r.message}")
24
+
25
+
26
+ @click.group()
27
+ def main() -> None:
28
+ """Generate Claude-powered CI workflows from .config/continuous.toml."""
29
+
30
+
31
+ @main.command()
32
+ @click.option("--config", "-c", "config_path", type=click.Path(exists=True, path_type=Path), default=None)
33
+ @click.option("--dry-run", is_flag=True, help="Print generated files without writing")
34
+ def init(config_path: Path | None, dry_run: bool) -> None:
35
+ """Generate workflow files from config. Idempotent — always overwrites."""
36
+ cfg = Config.load(config_path)
37
+ outdir = Path(".github/workflows")
38
+
39
+ workflows = generate_all(cfg)
40
+ if not workflows:
41
+ click.echo("No workflows enabled in config.")
42
+ return
43
+
44
+ if not dry_run:
45
+ outdir.mkdir(parents=True, exist_ok=True)
46
+
47
+ for wf in workflows:
48
+ path = outdir / wf.filename
49
+ if dry_run:
50
+ click.echo(f"--- {wf.filename} ---")
51
+ click.echo(wf.content)
52
+ continue
53
+
54
+ path.write_text(wf.content)
55
+ click.echo(f" wrote {path}")
56
+
57
+ if not dry_run:
58
+ click.echo(f"\nGenerated {len(workflows)} workflow files.")
59
+ click.echo("Run `continuous check` to verify security prerequisites.")
60
+
61
+
62
+ @main.command()
63
+ @click.option("--config", "-c", "config_path", type=click.Path(exists=True, path_type=Path), default=None)
64
+ @click.option("--repo", "-r", help="GitHub repo (owner/name). Auto-detected if omitted.")
65
+ def check(config_path: Path | None, repo: str | None) -> None:
66
+ """Verify security prerequisites (branch protection, bot access, secrets)."""
67
+ cfg = Config.load(config_path)
68
+ results = run_all_checks(cfg, repo)
69
+
70
+ click.echo("Security checks:")
71
+ _print_check_results(results)
72
+
73
+ failures = [r for r in results if r.passed is False]
74
+ if failures:
75
+ raise SystemExit(1)
@@ -0,0 +1,108 @@
1
+ """Read and validate .config/continuous.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ KNOWN_WORKFLOWS = {"review", "mention", "triage", "ci-fix", "nightly", "renovate"}
13
+ KNOWN_TOP_LEVEL = {"bot_name", "default_branch", "secrets", "setup", "workflows"}
14
+ _GITHUB_USERNAME = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$")
15
+
16
+
17
+ @dataclass
18
+ class SetupStep:
19
+ """A single project setup step — either a `uses:` action or a `run:` command."""
20
+
21
+ uses: str = ""
22
+ run: str = ""
23
+
24
+
25
+ @dataclass
26
+ class WorkflowConfig:
27
+ enabled: bool = True
28
+ prompt: str = ""
29
+ cron: str = ""
30
+ watched_workflows: list[str] | None = None
31
+
32
+
33
+ @dataclass
34
+ class Config:
35
+ bot_name: str
36
+ default_branch: str
37
+ bot_token_secret: str
38
+ claude_token_secret: str
39
+ setup: list[SetupStep]
40
+ workflows: dict[str, WorkflowConfig]
41
+
42
+ @classmethod
43
+ def load(cls, path: Path | None = None) -> Config:
44
+ if path is None:
45
+ path = Path(".config/continuous.toml")
46
+ if not path.exists():
47
+ raise click.ClickException(f"Config not found: {path}")
48
+ with path.open("rb") as f:
49
+ raw = tomllib.load(f)
50
+
51
+ if "bot_name" not in raw:
52
+ raise click.ClickException("Missing required field: bot_name")
53
+
54
+ bot_name = raw["bot_name"]
55
+ if not bot_name:
56
+ raise click.ClickException("bot_name must not be empty")
57
+ if not _GITHUB_USERNAME.match(bot_name):
58
+ raise click.ClickException(
59
+ f"bot_name '{bot_name}' is not a valid GitHub username "
60
+ "(only letters, digits, and hyphens)"
61
+ )
62
+
63
+ default_branch = raw.get("default_branch", "main")
64
+ if not default_branch:
65
+ raise click.ClickException("default_branch must not be empty")
66
+
67
+ unknown = set(raw.keys()) - KNOWN_TOP_LEVEL
68
+ for key in sorted(unknown):
69
+ click.echo(f"Warning: unknown config key '{key}'", err=True)
70
+
71
+ secrets = raw.get("secrets", {})
72
+
73
+ setup: list[SetupStep] = []
74
+ setup_raw = raw.get("setup", {})
75
+ for action in setup_raw.get("uses", []):
76
+ setup.append(SetupStep(uses=action))
77
+ for cmd in setup_raw.get("run", []):
78
+ setup.append(SetupStep(run=cmd))
79
+
80
+ workflows: dict[str, WorkflowConfig] = {}
81
+ for name, wf_raw in raw.get("workflows", {}).items():
82
+ if name not in KNOWN_WORKFLOWS:
83
+ click.echo(f"Warning: unknown workflow '{name}' in config (known: {', '.join(sorted(KNOWN_WORKFLOWS))})", err=True)
84
+ if isinstance(wf_raw, dict):
85
+ watched = wf_raw.get("watched_workflows")
86
+ if watched is not None and len(watched) == 0 and name == "ci-fix":
87
+ raise click.ClickException(
88
+ "watched_workflows = [] is invalid for ci-fix — "
89
+ "workflow_run requires at least one workflow name. "
90
+ "Disable ci-fix with enabled = false instead."
91
+ )
92
+ workflows[name] = WorkflowConfig(
93
+ enabled=wf_raw.get("enabled", True),
94
+ prompt=wf_raw.get("prompt", ""),
95
+ cron=wf_raw.get("cron", ""),
96
+ watched_workflows=watched,
97
+ )
98
+ else:
99
+ workflows[name] = WorkflowConfig(enabled=bool(wf_raw))
100
+
101
+ return cls(
102
+ bot_name=bot_name,
103
+ default_branch=default_branch,
104
+ bot_token_secret=secrets.get("bot_token", "BOT_TOKEN"),
105
+ claude_token_secret=secrets.get("claude_token", "CLAUDE_CODE_OAUTH_TOKEN"),
106
+ setup=setup,
107
+ workflows=workflows,
108
+ )