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 +15 -0
- tend-0.0.1/pyproject.toml +32 -0
- tend-0.0.1/src/continuous/__init__.py +0 -0
- tend-0.0.1/src/continuous/checks.py +136 -0
- tend-0.0.1/src/continuous/cli.py +75 -0
- tend-0.0.1/src/continuous/config.py +108 -0
- tend-0.0.1/src/continuous/workflows.py +529 -0
- tend-0.0.1/tests/__init__.py +0 -0
- tend-0.0.1/tests/test_checks.py +247 -0
- tend-0.0.1/tests/test_config_edge_cases.py +403 -0
- tend-0.0.1/tests/test_generate.py +172 -0
- tend-0.0.1/uv.lock +154 -0
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
|
+
)
|