Tend is an autonomous CI maintainer for GitHub repos: it reviews PRs, triages
issues, and fixes CI, powered by Claude or Codex. This repo ships the generator
(uvx tend@latest) that stamps each adopter's workflow files, plus the plugins
and composite actions those workflows run.
No backward compatibility. When a config format or API changes, cut over completely — old formats should fail with a clear error, not silently parse.
wt test # run pytest in generator/
uvx tend@latest init # regenerate workflows from .config/tend.yaml
uvx tend@latest init --dry-run # preview without writing
uvx tend@latest check # verify branch protection, secrets, bot access
pre-commit run --all-files # lint: ruff, typos, actionlint, uv-lockFour pieces:
-
Plugins —
install-tend(user-facing setup) andtend-ci-runner(CI skills). Both ship from the same marketplace. -
Composite action(s) — the stable interface, pinned to an immutable release tag (
max-sixty/tend/<harness>@X.Y.Z, the generator's own version — no floatingv1). Every harness lives under a harness-named path; there is no bare-root default. One per harness:max-sixty/tend/claude@X.Y.Z(Claude) — runs the officialclaudebinary headless (claude -p) as a non-sudo sandbox user behind the credential-injecting proxy; completion is the process exit code. Inputs inclaude/action.yaml.max-sixty/tend/codex@X.Y.Z(Codex) — installs@openai/codexand shells out tocodex exec. Skills are staged on disk and anAGENTS.mdin$CODEX_HOMEteaches Codex to resolve/tend-ci-runner:NAMEslash commands. Inputs incodex/action.yaml. Shares the cross-harness preflight/teardown scripts undershared/steps/.
All actions resolve the bot's numeric ID at runtime, run security and rate-limit preflight, and upload session logs. The actions don't know or care about triggers, checkout, or project setup.
Removed:
claude-interactive, a PTY-supervised variant of the same binary that existed only to dodge the 2026-06-15 Agent SDK metering (which coveredclaude -pbut not interactive sessions). Anthropic paused that change and the default harness now runs the binary rather than the SDK, so nothing selected it. Restore from036f9c4if the metering resumes. -
Generator (
uvx tend@latest init) — stamps workflow files into the adopter's.github/workflows/from.config/tend.yaml. Picks the right action ref and secret names perharness. Generation is idempotent — runninginitagain overwrites all files from the current config. -
Config (
.config/tend.yaml) — inputs to the generator. Overrides from defaults only.harness: claude | codexselects the harness (defaultclaude). A per-workflowharness:override (and matchingmodel:) lets an adopter trial a different harness on one workflow at a time. All workflows are enabled by default.
Generated workflows are standalone — full steps: jobs, not
workflow_call. The generator owns the entire file. Project setup (build
tools, caches, env vars) is defined in the setup: section of the config
and rendered into each workflow.
tend/
├── .claude-plugin/
│ └── marketplace.json # Claude Code marketplace — lists both plugins
├── .agents/plugins/
│ └── marketplace.json # Codex marketplace — lists tend-ci-runner
├── plugins/
│ ├── install-tend/ # User-facing plugin (setup skill)
│ └── tend-ci-runner/ # CI plugin (review, triage, ci-fix, etc.)
│ ├── .codex-plugin/ # Codex plugin manifest
│ └── scripts/ # Helper scripts (survey, run listing)
├── claude/
│ └── action.yaml # Claude harness composite action (default, headless)
├── codex/
│ ├── action.yaml # Codex harness composite action
│ └── agents-tail.md # AGENTS.md appendix for Codex
├── shared/
│ ├── steps/ # Shared composite-action step bodies (bash scripts)
│ └── system-prompt.md # Harness-neutral system prompt base
├── proxy/ # Credential-injection proxy (setup-sandbox.sh, addon)
├── generator/ # Python package (uvx tend@latest), uv_build backend
│ ├── src/tend/
│ │ ├── config.py # Reads .config/tend.yaml
│ │ ├── workflows.py # Generates workflow YAML
│ │ ├── checks.py # Security checks (branch protection, secrets)
│ │ └── cli.py # Click CLI (init, check)
│ └── tests/
├── site/ # Astro marketing site (tend-src.com)
├── worker/ # Cloudflare Worker — serves the 2 site data streams
├── data/ # consumers.json — Worker's input (refreshed weekly)
└── docs/
└── security-model.md
The tend-*.yaml files in .github/workflows/ are generated by uvx tend@latest init
from .config/tend.yaml. Edit the generator or config, not the workflow files
directly.
The generator is a Python package under generator/ — uses the uv_build
backend, requires Python 3.11+. Runtime dependencies: click, ruamel.yaml.
Dev dependencies: pytest, pytest-regtest.
Consuming repos regenerate their tend-*.yaml workflows nightly (tend itself
included — it dogfoods its own workflows). Changes to the generator do not
require manual regeneration in downstream repos.
Tend's own tend-*.yaml workflows track the latest published release. They
update each night via uvx tend@latest init. Updating earlier to the latest
release (e.g., during a release commit) is fine. Never regenerate them with
the in-tree generator: the action ref is pinned to the generator's own
version (max-sixty/tend/<harness>@X.Y.Z), so an unreleased in-tree version
stamps a tag that does not exist yet, and the workflow's uses: fails to resolve.
Between a generator commit and the next release the committed workflows lag
the in-tree generator; that is expected, and the gap closes at the next
release (which tags X.Y.Z before regenerating, so the pin always resolves).
| Aspect | Owner | Lives in |
|---|---|---|
Trigger events (on:) |
Generator | generated workflow |
Filter conditions (if:) |
Generator | generated workflow |
| Engagement verification (mention) | Generator | generated workflow |
| Concurrency groups | Generator | generated workflow |
| Permissions | Generator | generated workflow |
| Checkout | Generator | generated workflow |
| Composite action call | Generator | generated workflow |
| Project setup (build tools, cache) | Adopter | setup: in .config/tend.yaml |
| Bot identity, auth config | Adopter | .config/tend.yaml |
| Skills (generic) | Tend | tend plugin (marketplace) |
| Skills (project-specific) | Adopter | .claude/skills/ in their repo |
Adopters extend generated workflows via YAML keys that the generator
merges into the rendered YAML using RFC 7396 (JSON Merge Patch — mappings
deep-merge, scalars and lists replace, null deletes):
workflows:
review:
workflow_extra:
env:
MY_VAR: hello
jobs:
review:
timeout-minutes: 240
runs-on: ubuntu-22.04-largeYAML has a native null literal, so RFC 7396's null-deletes works
directly — e.g. drop the cron from a scheduled workflow while keeping
workflow_dispatch:
workflows:
nightly:
workflow_extra:
on:
schedule: nullWorkflow-level (workflow_extra) and job-level (jobs.<name>) overrides
are supported; step-level is not — the setup: mechanism handles step
injection. No allowlist of override keys; unknown job names produce a
warning.
When overrides are present, the generator renders the base template, parses it, merges the overrides, and re-serializes. Output YAML formatting differs slightly from the base template (block-style lists) but is functionally identical.
Each adopter creates a GitHub bot account and a classic PAT (public_repo
for public repos, repo for private) plus workflow, notifications,
write:discussion, gist, user. The PAT and a Claude OAuth token are
stored as secrets in the repo's tend GitHub Environment, whose deployment
branch policy admits only the branches tend check confirmed the bot
cannot write — the default branch and any protected_branches that exist
and are protected. A workflow the bot pushes to any other ref is refused
them before its first step. The gist scope supports bot-owned secret gists
used by review-reviewers as a per-month structured evidence store
(avoids the 65 KB comment-body limit). The user scope lets install-tend
set the bot's profile bio (PATCH /user) so the account's authorization
stance is discoverable on the bot's user page.
Classic PATs are all-or-nothing — public_repo grants full write to every
public repo the user can access. Fine-grained PATs allow per-category
scoping but don't support outside collaborators (GitHub roadmap
#601, not shipped).
Current privilege model: write + branch protection + environment gate.
The bot has write access; a merge restriction (ruleset or branch
protection) is the primary security boundary — without it the bot can merge
its own PRs — and the tend environment keeps the operational secrets out
of any run the bot can cause on its own. tend check verifies both are
configured correctly, and --fix creates either. See
docs/security-model.md for the full threat model. Alternative models
(GitHub App, triage+fork) are in TODO.md.
The environment half is still being adopted: a repo is protected only once
its secrets are in the environment and the repo-level copies are deleted —
per-repo work TODO.md tracks. Until then it runs with its old exposure.
This repo's own workflows name it too, including the hand-maintained ones the generator never touches.
Events pass through three layers before the bot does work:
- GHA
if:conditions — evaluated by Actions before the job starts. A false condition skips the job entirely (never enters the concurrency group, never queues). - Custom
should_runlogic (mention only) — a lightweight verify job checks engagement before the expensive handle job runs. - Concurrency groups — at most one running job per group.
Concurrency groups:
| Workflow | Group key | Cancel-in-progress |
|---|---|---|
| review | workflow-PR# |
yes — new push invalidates a review |
| mention/relay | none | stateless — secretless job that re-posts review events as a repository_dispatch |
| mention/verify | none | stateless |
| mention/handle | workflow-handle-issue#|PR# |
no — each mention runs to completion |
| triage | workflow-issue# |
yes — latest comment wins |
| ci-fix / nightly / weekly | none | rare overlap or cron-serialized |
Fork guard. Workflows whose triggers can fire from a fork's own
Actions (schedule, workflow_dispatch, workflow_run, issues) carry
if: github.repository_owner == '<owner>' so a fork that's enabled
Actions but doesn't have the bot/Claude secrets no-ops cleanly. The
canonical owner is detected at init time (via gh repo view, walking
source.owner.login if the local repo is itself a fork) and pinned in
the generated workflow. tend-review uses pull_request_target (base
repo only) and tend-mention's review-event paths already filter forks
via head.repo.full_name == github.repository, so neither needs the
guard.
GHA queue depth = 1. With cancel-in-progress: false (mention/handle),
when a third job arrives while one runs and one queues, the pending job is
replaced. Mitigation lives in the skill prompts: dedup if the bot already
responded to the triggering comment; self-heal earlier comments without a
bot reply (oldest first). The workflow injects the queue-to-run time delta
(seconds between event timestamp and job start) into the prompt — over
~40 s indicates the job was queued behind another run, making conversation
drift more likely.
Bundled skills in plugins/tend-ci-runner/skills/ supply defaults. Consumer
repos overlay them at .claude/skills/running-tend/SKILL.md; where the two
conflict, the overlay wins.
When writing a bundled skill, keep the content universal — it applies to
every consumer. Repo-specific policy, taste, or convention (PR title
formats, label names, branch routing) belongs in an overlay. Tend has its
own overlay at .claude/skills/running-tend/SKILL.md — use it for guidance
that only applies to developing tend itself.
When adding to or editing files in plugins/tend-ci-runner/skills/ or
.claude/skills/:
- Be brief. Skills are loaded into every relevant session — extra prose is overhead. Lead with the rule or recipe; cut motivation, anecdotes, and historical context unless required to apply the rule.
- No specific past-run references. Don't link GitHub Actions runs, cite session IDs, or quote durations from individual incidents. They age into trivia and aren't useful when the skill is reused. State the structural rule without the run links.
- No specific past-case references. Don't cite individual past PRs, issues, or commits as supporting precedent for a rule (e.g. "[#123] tried X and was closed"). They age into trivia, accumulate as the rule is re-tested, and create read-time work. State the structural rule (what shape is accepted, what shape is not) without naming the cases that produced it.
- Date-stamp only when the value depends on time. A baseline number expected to drift can be dated; a one-shot incident citation should not be.
- Prefer recipe over narrative. A code block plus a one-sentence framing beats a multi-paragraph explanation.
- Examples over templates; open frames over closed menus. For text the agent produces (comments, PR bodies, summaries), give an example labeled as such rather than verbatim wording to paste — canned phrasing reproduced literally reads awkwardly off-situation. For choices the agent makes (when to respond, which cases to check), frame the goal and give examples rather than a list it will read as exhaustive. Reserve mandatory exact wording for fragile mechanics: dedup keys, commands, API formats.
Tend's workflows invoke the agent through the harness-specific composite
action (max-sixty/tend/claude@X.Y.Z for Claude, max-sixty/tend/codex@X.Y.Z
for Codex). When adding new capability, split work along this line:
- The agent drives diagnostics and remediation. Once the action is
running, put logic into the relevant skill (or a script the skill calls —
see
plugins/tend-ci-runner/scripts/). The agent handles edge cases, interprets output, and writes clearer messages than shell. - Actions gate whether the agent runs at all. Agent invocations cost
tokens; gating them in YAML is cheap. Pre-check steps that early-exit
the job (e.g.
tend-notifications's "Check for unread notifications") save an entire agent run when there's nothing to do.
Don't build deterministic YAML steps for work that happens inside an agent run. Extend the skill instead.
For live experiments against real GitHub behavior (environments, branch
protection, workflow triggers, secret release), tend-agent/tend-integration
is a persistent public repo we own, admin via the tend-agent account
(gh auth token --user tend-agent). Its main is branch-protected with
enforce_admins: false, so the owner can push directly and reset in place.
The weekly integration test (.claude/skills/running-tend/references/integration-test.md)
also drives it, so clean up any probe artifacts (extra workflows, branches,
environments, dummy secrets) when done.
site/ is the Astro marketing site (tend-src.com); worker/ is the
Cloudflare Worker that serves its two data streams from data/consumers.json.
The site's dev server starts automatically per worktree via a wt post-start
hook (.config/wt.toml) on a deterministic port derived from the branch name.
Get the URL with wt list statusline --format json | jq -r '.[].url'; logs
land in .git/wt/logs/. Don't run npm run dev; it duplicates the running
server on a different port.