Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,17 @@ Four pieces:
the adopter's `.github/workflows/` from `.config/tend.yaml`. Picks the
right action ref and secret names per `harness`. Generation is
idempotent — running `init` again overwrites all files from the
current config. When review is enabled, it also merges the
current config. When the review workflow is generated, it also merges the
`concurrency.queue` ignore into the adopter-owned
`.github/actionlint.yaml` (see "Concurrency and filtering").
4. **Config** (`.config/tend.yaml`) — inputs to the generator. Overrides
from defaults only. `harness: claude | codex` selects the harness
(default `claude`). A per-workflow `harness:` override (and matching
`model:`) lets an adopter trial a different harness on one workflow at a
time. All workflows are enabled by default.
time. All workflows are generated by default. A per-workflow
`enabled: false` omits that workflow on regeneration; top-level
`enabled: false` leaves the workflows installed and pauses new jobs at
runtime.

Generated workflows are standalone — full `steps:` jobs, not
`workflow_call`. The generator owns the entire file. Project setup (build
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ reaction comes off when the session ends.
Scheduled workflows also support manual dispatch for testing. GitHub runs
`schedule` triggers on a best-effort basis and drops ticks under load, so
the intervals above are the requested cadence rather than a guarantee —
observed gaps between runs are routinely longer. All are enabled by
observed gaps between runs are routinely longer. All are generated by
default except **ci-fix**, which requires `watched_workflows` to be
configured. Any can be disabled:
configured. Any can be omitted on the next regeneration:

```yaml
workflows:
Expand All @@ -106,7 +106,7 @@ for the configured harness, pinned to the released generator version
(`max-sixty/tend/claude@X.Y.Z` for Claude, `max-sixty/tend/codex@X.Y.Z` for Codex).
The nightly regen restamps a newer tag when a new tend version ships.

When review is enabled, `init` also merges one ignore into
When the review workflow is generated, `init` also merges one ignore into
`.github/actionlint.yaml`: the workflow's `concurrency.queue` is valid GitHub
syntax that actionlint's schema rejects. The ignore applies only to generated
workflows and preserves the rest of the adopter-owned config.
Expand Down Expand Up @@ -186,11 +186,18 @@ Claude; `harness: codex` selects OpenAI Codex (see
```yaml
bot_name: my-project-bot

# Optional runtime switch — every new job skips before checkout or setup
# enabled: false

# Optional — defaults to "claude"
# harness: codex
# effort: medium # codex only: low | medium | high | xhigh
```

Top-level `enabled: false` pauses tend from the default branch without removing
its workflows. Setting it back to `true` (or removing it) resumes new jobs
without regeneration.

The secrets, stored in the repo's `tend` environment (install-tend creates
it; `tend check` verifies it), depend on the harness:

Expand Down
14 changes: 12 additions & 2 deletions docs/tend.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@

bot_name: my-project-bot

# ## Runtime switch
#
# Every operational job reads this value from the repository's default branch
# before checkout, setup, reactions, or the agent action. `false` leaves the
# generated workflows installed, so changing it back to `true` (or removing
# the key) resumes new jobs without regeneration. A job already running is not
# interrupted.
#
# enabled: false

# ## Harness
#
# Which agent runtime to use. Defaults to "claude" (the official `claude`
Expand Down Expand Up @@ -267,10 +277,10 @@ bot_name: my-project-bot

# ## Workflows
#
# All workflows are enabled by default except ci-fix (requires
# All workflows are generated by default except ci-fix (requires
# `watched_workflows`). Every workflow accepts these options:
#
# - `enabled` (bool) — disable with `enabled: false`
# - `enabled` (bool) — omit this workflow on the next regeneration
# - `prompt` (string) — override the default skill invocation. May span lines.
# Three workflows substitute one placeholder into it: `{pr_number}` (review),
# `{issue_number}` (triage), `{run_id}` (ci-fix). Everything else is passed to
Expand Down
19 changes: 17 additions & 2 deletions generator/src/tend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ def _detect_default_branch_local() -> str:
return "main"


def _runtime_config_path(path: Path) -> str:
"""Return the config's repository-relative path for runtime checks."""
try:
return path.resolve().relative_to(Path.cwd().resolve()).as_posix()
except ValueError as error:
raise click.ClickException(
f"Config must be inside the repository so workflows can read it: {path}"
) from error


def _update_actionlint_config(dry_run: bool) -> None:
"""Ensure `.github/actionlint.yaml` ignores the `concurrency.queue` schema
false positive, so an adopter's workflow lint stays green on regen.
Expand Down Expand Up @@ -142,6 +152,9 @@ def init(config_path: Path | None, dry_run: bool, with_install_test: bool) -> No
preview_path = Path(tmp) / "tend.yaml"
preview_path.write_text(preview_yaml, encoding="utf-8")
cfg = Config.load(preview_path)
cfg.config_path = _runtime_config_path(
config_path if config_path is not None else Path(".config/tend.yaml")
)
cfg.default_branch = _detect_default_branch_local()
cfg.repo_owner = detect_canonical_owner() or ""
if not cfg.repo_owner:
Expand Down Expand Up @@ -197,7 +210,7 @@ def init(config_path: Path | None, dry_run: bool, with_install_test: bool) -> No

if not workflows:
suffix = f" Removed {removed} stale tend-*.yaml file(s)." if removed else ""
click.echo(f"No workflows enabled in config.{suffix}")
click.echo(f"No workflows generated from config.{suffix}")
return

suffix = f" ({removed} removed)" if removed else ""
Expand All @@ -220,8 +233,10 @@ def init(config_path: Path | None, dry_run: bool, with_install_test: bool) -> No
def check(config_path: Path | None, repo: str | None, fix: bool) -> None:
"""Verify security prerequisites (branch protection, bot access, credentials)."""
cfg = Config.load(config_path)
results = run_all_checks(cfg, repo)
if not cfg.enabled:
click.echo("Tend is disabled in config; new operational jobs will skip.")

results = run_all_checks(cfg, repo)
click.echo("Security checks:")
_print_check_results(results)

Expand Down
39 changes: 37 additions & 2 deletions generator/src/tend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,34 @@

import click
from ruamel.yaml import YAML
from ruamel.yaml.nodes import MappingNode, Node, SequenceNode

# ruamel.yaml parses YAML 1.2 by default, which fixes PyYAML's `on:` → True
# trap and the Norway problem (yes/no/on/off coerced to bool).
_YAML = YAML(typ="safe", pure=True)


def _has_yaml_merge_key(node: Node | None, seen: set[int] | None = None) -> bool:
"""Return whether a parsed YAML tree contains a `<<` merge key."""
if node is None:
return False
if seen is None:
seen = set()
if id(node) in seen:
return False
seen.add(id(node))

if isinstance(node, MappingNode):
for key, value in node.value:
if key.tag == "tag:yaml.org,2002:merge":
return True
if _has_yaml_merge_key(key, seen) or _has_yaml_merge_key(value, seen):
return True
elif isinstance(node, SequenceNode):
return any(_has_yaml_merge_key(value, seen) for value in node.value)
return False


STANDARD_WORKFLOWS = {
"review",
"mention",
Expand All @@ -31,6 +54,7 @@
}
KNOWN_TOP_LEVEL = {
"bot_name",
"enabled",
"memory_gist",
"harness",
"model",
Expand Down Expand Up @@ -190,6 +214,10 @@ class Config:
effort: str
setup: list[SetupStep]
workflows: dict[str, WorkflowConfig]
# Runtime kill switch. Generated workflows stay installed and read this
# value from the default branch at the start of every operational job.
enabled: bool = True
config_path: str = ".config/tend.yaml"
# Owner of the repo where workflows will run. Used to gate jobs that fail
# noisily on forks (no access to bot/Claude secrets). Not user-configurable;
# cli.init populates this via `gh repo view` so fork-based maintainer
Expand Down Expand Up @@ -242,8 +270,10 @@ def load(cls, path: Path | None = None) -> Config:
"and regenerates workflows in one step)."
)
raise click.ClickException(f"Config not found: {path}")
with path.open(encoding="utf-8") as f:
raw = _YAML.load(f) or {}
text = path.read_text(encoding="utf-8")
if _has_yaml_merge_key(_YAML.compose(text)):
raise click.ClickException("YAML merge keys (<<) are not supported")
raw = _YAML.load(text) or {}

if not isinstance(raw, dict):
raise click.ClickException(
Expand Down Expand Up @@ -292,6 +322,10 @@ def load(cls, path: Path | None = None) -> Config:
if not isinstance(memory_gist, bool):
raise click.ClickException("memory_gist must be true or false")

enabled = raw.get("enabled", True)
if not isinstance(enabled, bool):
raise click.ClickException("enabled must be true or false")

unknown = set(raw.keys()) - KNOWN_TOP_LEVEL
for key in sorted(unknown):
click.echo(f"Warning: unknown config key '{key}'", err=True)
Expand Down Expand Up @@ -662,6 +696,7 @@ def load(cls, path: Path | None = None) -> Config:
sandbox_env=sandbox_env,
sandbox_setup=sandbox_setup,
memory_gist=memory_gist,
enabled=enabled,
workflows=workflows,
allowed_repo_secrets=allowed,
)
Expand Down
56 changes: 56 additions & 0 deletions generator/src/tend/templates/check-enabled.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"

path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end

mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end

def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end

if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end

matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
Comment thread
tend-agent marked this conversation as resolved.

value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end

puts "enabled=#{enabled}"

unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
5 changes: 3 additions & 2 deletions generator/src/tend/templates/ci-fix.yaml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
permissions:
<<permissions(issues=False)>>
steps:
<<checkout(cfg, ref=cfg.default_branch)>>
<<check_tend_enabled(cfg)>>
<<checkout(cfg, ref=cfg.default_branch, if_condition=tend_enabled_condition)>>
<<setup>>
<<agent_step(cfg, block_input('prompt', full_prompt))>>
<<agent_step(cfg, block_input('prompt', full_prompt), if_condition=tend_enabled_condition)>>
33 changes: 32 additions & 1 deletion generator/src/tend/templates/macros.yaml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,33 @@
<<prompt_body>>
{%- endmacro %}

{# Read the runtime kill switch from the repository's default branch. This is
the first step of every operational job, before checkout, setup, reactions,
or the agent action. The Contents API defaults to the default branch when no
ref is supplied, so a PR cannot disable its own review from its head tree.
The one-shot install test passes its PR head explicitly because the config
does not exist on the default branch until that PR lands.

Ruby and its YAML parser ship on GitHub's pinned ubuntu-24.04 runner. The
parser lives in its own source file so its behavior can be tested directly;
it is inlined here because the gate runs before checkout. A missing or
unreadable config, invalid YAML document, or invalid `enabled` value fails
the step and therefore the job. #}
{% macro check_tend_enabled(cfg, ref='') %}
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/<<cfg.config_path|urlencode>>{% if ref %}?ref=<<ref>>{% endif %}" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'<<'>>'RUBY' >> "$GITHUB_OUTPUT"
<<check_enabled_script|indent(10, first=True)>>
RUBY
{%- endmacro %}

{# Environment block at column 4. The environment is a secret scope, not a
deploy target: its branch policy is what stops a workflow pushed to a
feature branch reading the operational secrets (see TEND_ENVIRONMENT in
Expand Down Expand Up @@ -244,12 +271,16 @@
away.

#}
{% macro restore_local_actions(run_body) %}
{% macro restore_local_actions(run_body, if_condition='') %}
{% if run_body %}


- name: Restore local setup actions for POST cleanup
{% if if_condition %}
<<step_if("always()\n&& (" ~ if_condition ~ ")")>>
{% else %}
if: always()
{% endif %}
run: |
<<run_body|indent(10, first=True)>>
{%- endif %}
Expand Down
Loading
Loading