diff --git a/CLAUDE.md b/CLAUDE.md index 461cd790..7911cac9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 7610ccbd..25211300 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. @@ -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: diff --git a/docs/tend.example.yaml b/docs/tend.example.yaml index 78f7b20c..a11ae8b1 100644 --- a/docs/tend.example.yaml +++ b/docs/tend.example.yaml @@ -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` @@ -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 diff --git a/generator/src/tend/cli.py b/generator/src/tend/cli.py index 86504f04..6aa1ff6d 100644 --- a/generator/src/tend/cli.py +++ b/generator/src/tend/cli.py @@ -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. @@ -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: @@ -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 "" @@ -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) diff --git a/generator/src/tend/config.py b/generator/src/tend/config.py index 0ca6b927..81e0ab63 100644 --- a/generator/src/tend/config.py +++ b/generator/src/tend/config.py @@ -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", @@ -31,6 +54,7 @@ } KNOWN_TOP_LEVEL = { "bot_name", + "enabled", "memory_gist", "harness", "model", @@ -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 @@ -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( @@ -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) @@ -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, ) diff --git a/generator/src/tend/templates/check-enabled.rb b/generator/src/tend/templates/check-enabled.rb new file mode 100644 index 00000000..a3a17e22 --- /dev/null +++ b/generator/src/tend/templates/check-enabled.rb @@ -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 + +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 diff --git a/generator/src/tend/templates/ci-fix.yaml.j2 b/generator/src/tend/templates/ci-fix.yaml.j2 index 16364f2b..b2fa0e37 100644 --- a/generator/src/tend/templates/ci-fix.yaml.j2 +++ b/generator/src/tend/templates/ci-fix.yaml.j2 @@ -20,6 +20,7 @@ jobs: permissions: <> steps: -<> +<> +<> <> -<> +<> diff --git a/generator/src/tend/templates/macros.yaml.j2 b/generator/src/tend/templates/macros.yaml.j2 index 92cecbf5..9f3dfd21 100644 --- a/generator/src/tend/templates/macros.yaml.j2 +++ b/generator/src/tend/templates/macros.yaml.j2 @@ -54,6 +54,33 @@ <> {%- 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/<>{% if ref %}?ref=<>{% endif %}" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'<<'>>'RUBY' >> "$GITHUB_OUTPUT" +<> + 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 @@ -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 %} +<> +{% else %} if: always() +{% endif %} run: | <> {%- endif %} diff --git a/generator/src/tend/templates/mention.yaml.j2 b/generator/src/tend/templates/mention.yaml.j2 index 384db6b7..51cbb8d4 100644 --- a/generator/src/tend/templates/mention.yaml.j2 +++ b/generator/src/tend/templates/mention.yaml.j2 @@ -106,10 +106,12 @@ jobs: permissions: contents: write steps: +<> # Identifiers only: `verify` re-reads the review or comment from the # API, so the words the bot weighs and acts on are the ones GitHub # holds, and a forged dispatch faces the same scrutiny as a relayed one. - name: Re-enter on an admitted ref + if: <> env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -136,14 +138,18 @@ jobs: <>) runs-on: ubuntu-24.04 <> + permissions: + contents: read outputs: should_run: ${{ steps.check.outputs.should_run }} reason: ${{ steps.check.outputs.reason }} url: ${{ steps.check.outputs.url }} ts: ${{ steps.check.outputs.ts }} steps: +<> - name: Verify bot engagement id: check + if: <> run: | <> env: @@ -172,6 +178,7 @@ jobs: permissions: <> steps: +<> # Both halves of the reaction belong to this job, so the eyes can only # go on once the job that takes them off has started. Put them in # `verify` and `handle` respectively and the routine burst case strands @@ -183,14 +190,15 @@ jobs: # step verified belongs to this PR; a review *submission* has no single # comment to react to, so it gets no eyes — same as when the events # arrived directly. The job's own `if` already carries `should_run`. -<> +<> -<> +<> <> - name: Check out PR branch if: | - (github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || - github.event_name == 'repository_dispatch' + steps.tend_enabled.outputs.enabled == 'true' && + ((github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || + github.event_name == 'repository_dispatch') run: | PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state') if [ "$PR_STATE" = "OPEN" ]; then @@ -204,6 +212,7 @@ jobs: - name: Compute queue delay id: delay + if: <> run: | if [ -z "$EVENT_TS" ]; then echo "seconds=" >> "$GITHUB_OUTPUT" @@ -216,6 +225,6 @@ jobs: # the API record — the dispatch payload never carries one to spoof. EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }} -<><> +<><> -<> +<> diff --git a/generator/src/tend/templates/notifications.yaml.j2 b/generator/src/tend/templates/notifications.yaml.j2 index f8275efc..ac95d10c 100644 --- a/generator/src/tend/templates/notifications.yaml.j2 +++ b/generator/src/tend/templates/notifications.yaml.j2 @@ -15,12 +15,14 @@ jobs: permissions: <> steps: +<> - name: Check for unread notifications and conflicted PRs id: check + if: <> env: GITHUB_TOKEN: ${{ secrets.<> }} run: | <> -<> -<><> +<> +<><> diff --git a/generator/src/tend/templates/review.yaml.j2 b/generator/src/tend/templates/review.yaml.j2 index adaa5b97..bd18f599 100644 --- a/generator/src/tend/templates/review.yaml.j2 +++ b/generator/src/tend/templates/review.yaml.j2 @@ -24,13 +24,14 @@ jobs: permissions: <> steps: -<> +<> +<> {% if has_setup %} # Two checkouts: `setup:` runs against the base tree, and the PR's own # tree lands after it. Setup executes as the runner user, outside the # containment the harness builds for the contributor's code. -<> +<> <> {% endif %} # GitHub only materializes refs/pull/N/merge for mergeable PRs — on @@ -40,6 +41,7 @@ jobs: # tree. - name: Resolve PR checkout ref id: pr_ref + if: <> env: GITHUB_TOKEN: ${{ secrets.<> }} PR: ${{ github.event.pull_request.number }} @@ -50,8 +52,8 @@ jobs: echo "ref=refs/pull/$PR/head" >> "$GITHUB_OUTPUT" echo "::notice::refs/pull/$PR/merge unavailable (likely merge conflict); falling back to /head" fi -<> +<> -<><> +<><> -<> +<> diff --git a/generator/src/tend/templates/scheduled.yaml.j2 b/generator/src/tend/templates/scheduled.yaml.j2 index f5b1a908..fa64b327 100644 --- a/generator/src/tend/templates/scheduled.yaml.j2 +++ b/generator/src/tend/templates/scheduled.yaml.j2 @@ -12,6 +12,7 @@ jobs: permissions: <> steps: -<> +<> +<> <> -<> +<> diff --git a/generator/src/tend/templates/triage.yaml.j2 b/generator/src/tend/templates/triage.yaml.j2 index 857100e0..fe8ac3e1 100644 --- a/generator/src/tend/templates/triage.yaml.j2 +++ b/generator/src/tend/templates/triage.yaml.j2 @@ -16,10 +16,11 @@ jobs: permissions: <> steps: -<> +<> +<> -<> +<> <> -<> +<> -<> +<> diff --git a/generator/src/tend/workflows.py b/generator/src/tend/workflows.py index e73f7ba9..a2799992 100644 --- a/generator/src/tend/workflows.py +++ b/generator/src/tend/workflows.py @@ -130,6 +130,17 @@ def _indent_block(text: str, width: int) -> str: BOOKKEEPING_LABELS = ("tend-outage", "tend-rate-limit") _JINJA.globals["bookkeeping_labels"] = BOOKKEEPING_LABELS +# Every operational job evaluates the current config on the repository's +# default branch before it checks out code or reads an operational secret. The +# Contents API defaults to that branch when `ref` is omitted. +TEND_ENABLED_CONDITION = "steps.tend_enabled.outputs.enabled == 'true'" +_JINJA.globals["tend_enabled_condition"] = TEND_ENABLED_CONDITION +_JINJA.globals["check_enabled_script"] = ( + (importlib.resources.files("tend") / "templates" / "check-enabled.rb") + .read_text(encoding="utf-8") + .rstrip("\n") +) + # Register every macro defined in `macros.yaml.j2` as a Jinja global so # workflow templates can call `agent_step(...)` directly, without an @@ -267,7 +278,7 @@ def generate_review(cfg: Config) -> GeneratedWorkflow: content = _REVIEW_TMPL.render( cfg=eff, - setup=_setup_yaml(eff), + setup=_setup_yaml(eff, condition=TEND_ENABLED_CONDITION), local_actions=_restore_local_actions_run(eff), prompt=prompt, ) @@ -291,7 +302,7 @@ def generate_mention(cfg: Config) -> GeneratedWorkflow: content = _MENTION_TMPL.render( cfg=eff, - setup=_setup_yaml(eff), + setup=_setup_yaml(eff, condition=TEND_ENABLED_CONDITION), local_actions=_restore_local_actions_run(eff), check_script=check_script.rstrip("\n"), ) @@ -313,7 +324,11 @@ def generate_triage(cfg: Config) -> GeneratedWorkflow: "{issue_number}", "${{ github.event.issue.number }}" ) - content = _TRIAGE_TMPL.render(cfg=eff, setup=_setup_yaml(eff), prompt=prompt) + content = _TRIAGE_TMPL.render( + cfg=eff, + setup=_setup_yaml(eff, condition=TEND_ENABLED_CONDITION), + prompt=prompt, + ) return GeneratedWorkflow(filename="tend-triage.yaml", content=content) @@ -345,7 +360,7 @@ def generate_ci_fix(cfg: Config) -> GeneratedWorkflow: cfg=eff, watched=watched, branches=branches, - setup=_setup_yaml(eff), + setup=_setup_yaml(eff, condition=TEND_ENABLED_CONDITION), prompt=prompt, ) return GeneratedWorkflow(filename="tend-ci-fix.yaml", content=content) @@ -378,7 +393,7 @@ def _generate_scheduled(cfg: Config, name: str) -> GeneratedWorkflow: cfg=eff, name=name, cron=cron, - setup=_setup_yaml(eff), + setup=_setup_yaml(eff, condition=TEND_ENABLED_CONDITION), prompt=prompt, ) return GeneratedWorkflow(filename=f"tend-{name}.yaml", content=content) @@ -413,7 +428,10 @@ def generate_notifications(cfg: Config) -> GeneratedWorkflow: cfg=eff, cron=cron, skip_condition=skip_condition, - setup=_setup_yaml(eff, condition=skip_condition), + setup=_setup_yaml( + eff, + condition=f"({TEND_ENABLED_CONDITION}) && ({skip_condition})", + ), prompt=prompt, check_script=check_script.rstrip("\n"), ) @@ -497,6 +515,10 @@ def generate_install_test(cfg: Config) -> GeneratedWorkflow: verifies them. Harness auth is exercised end-to-end by `tend-review` on the first post-merge PR. """ + # The config is introduced by the install PR itself, so this one-shot job + # reads the PR head rather than the default branch used by operational jobs. + install_ref = "${{ github.event.pull_request.head.sha }}" + enabled_check = _MACROS.module.check_tend_enabled(cfg, install_ref) content = f"""\ {HEADER} name: tend-install-test @@ -516,9 +538,13 @@ def generate_install_test(cfg: Config) -> GeneratedWorkflow: permissions: contents: read steps: +{enabled_check} - uses: actions/checkout@v7 + if: {TEND_ENABLED_CONDITION} - uses: astral-sh/setup-uv@v10.0.1 + if: {TEND_ENABLED_CONDITION} - name: Verify generator output matches committed files + if: {TEND_ENABLED_CONDITION} env: GH_TOKEN: ${{{{ github.token }}}} run: | diff --git a/generator/tests/_regtest_outputs/test_generate.test_extras_apply_path_regtest.out b/generator/tests/_regtest_outputs/test_generate.test_extras_apply_path_regtest.out index 2a59d3d4..f2c5bc07 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_extras_apply_path_regtest.out +++ b/generator/tests/_regtest_outputs/test_generate.test_extras_apply_path_regtest.out @@ -32,7 +32,75 @@ jobs: issues: write packages: read steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -48,6 +116,7 @@ jobs: # tree. - name: Resolve PR checkout ref id: pr_ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} PR: ${{ github.event.pull_request.number }} @@ -59,6 +128,7 @@ jobs: echo "::notice::refs/pull/$PR/merge unavailable (likely merge conflict); falling back to /head" fi - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: ${{ steps.pr_ref.outputs.ref }} allow-unsafe-pr-checkout: true @@ -67,6 +137,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -77,7 +148,9 @@ jobs: /tend-ci-runner:review ${{ github.event.pull_request.number }} - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-ci-fix.yaml].out b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-ci-fix.yaml].out index e76fa9b2..023292f2 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-ci-fix.yaml].out +++ b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-ci-fix.yaml].out @@ -25,7 +25,75 @@ jobs: pull-requests: write actions: read steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -33,6 +101,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-nightly.yaml].out b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-nightly.yaml].out index e4724e66..420f4666 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-nightly.yaml].out +++ b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-nightly.yaml].out @@ -25,7 +25,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -33,6 +101,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-notifications.yaml].out b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-notifications.yaml].out index 0079457e..6db51fe7 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-notifications.yaml].out +++ b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-notifications.yaml].out @@ -28,8 +28,76 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Check for unread notifications and conflicted PRs id: check + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} run: | @@ -114,14 +182,14 @@ jobs: fi - uses: actions/checkout@v7 - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') with: ref: main fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-review-runs.yaml].out b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-review-runs.yaml].out index 6272ef75..6bacbdb0 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-review-runs.yaml].out +++ b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-review-runs.yaml].out @@ -25,7 +25,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -33,6 +101,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-triage.yaml].out b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-triage.yaml].out index df509580..4888c016 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-triage.yaml].out +++ b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-triage.yaml].out @@ -28,7 +28,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -38,6 +106,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -45,6 +114,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -55,7 +125,9 @@ jobs: /tend-ci-runner:triage ${{ github.event.issue.number }} - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-weekly.yaml].out b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-weekly.yaml].out index e511389d..948c68cd 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-weekly.yaml].out +++ b/generator/tests/_regtest_outputs/test_generate.test_fork_guard_rendered_shape_regtest[tend-weekly.yaml].out @@ -25,7 +25,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -33,6 +101,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_install_test_workflow_regtest[claude].out b/generator/tests/_regtest_outputs/test_generate.test_install_test_workflow_regtest[claude].out index 7d85227b..932ddc50 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_install_test_workflow_regtest[claude].out +++ b/generator/tests/_regtest_outputs/test_generate.test_install_test_workflow_regtest[claude].out @@ -23,9 +23,79 @@ jobs: permissions: contents: read steps: + - 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/.config/tend.yaml?ref=${{ github.event.pull_request.head.sha }}" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' - uses: astral-sh/setup-uv@v10.0.1 + if: steps.tend_enabled.outputs.enabled == 'true' - name: Verify generator output matches committed files + if: steps.tend_enabled.outputs.enabled == 'true' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/generator/tests/_regtest_outputs/test_generate.test_install_test_workflow_regtest[codex].out b/generator/tests/_regtest_outputs/test_generate.test_install_test_workflow_regtest[codex].out index 7d85227b..932ddc50 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_install_test_workflow_regtest[codex].out +++ b/generator/tests/_regtest_outputs/test_generate.test_install_test_workflow_regtest[codex].out @@ -23,9 +23,79 @@ jobs: permissions: contents: read steps: + - 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/.config/tend.yaml?ref=${{ github.event.pull_request.head.sha }}" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' - uses: astral-sh/setup-uv@v10.0.1 + if: steps.tend_enabled.outputs.enabled == 'true' - name: Verify generator output matches committed files + if: steps.tend_enabled.outputs.enabled == 'true' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/generator/tests/_regtest_outputs/test_generate.test_sandbox_levers_regtest.out b/generator/tests/_regtest_outputs/test_generate.test_sandbox_levers_regtest.out index d61d46db..8cbe0f82 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_sandbox_levers_regtest.out +++ b/generator/tests/_regtest_outputs/test_generate.test_sandbox_levers_regtest.out @@ -76,10 +76,78 @@ jobs: permissions: contents: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Identifiers only: `verify` re-reads the review or comment from the # API, so the words the bot weighs and acts on are the ones GitHub # holds, and a forged dispatch faces the same scrutiny as a relayed one. - name: Re-enter on an admitted ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -108,14 +176,84 @@ jobs: environment: name: tend deployment: false + permissions: + contents: read outputs: should_run: ${{ steps.check.outputs.should_run }} reason: ${{ steps.check.outputs.reason }} url: ${{ steps.check.outputs.url }} ts: ${{ steps.check.outputs.ts }} steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Verify bot engagement id: check + if: steps.tend_enabled.outputs.enabled == 'true' run: | # shellcheck shell=bash # Pre-check for tend-mention: decide whether the mention is addressed to the @@ -357,6 +495,73 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Both halves of the reaction belong to this job, so the eyes can only # go on once the job that takes them off has started. Put them in # `verify` and `handle` respectively and the routine burst case strands @@ -370,9 +575,9 @@ jobs: # arrived directly. The job's own `if` already carries `should_run`. - name: React with eyes if: | - ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + (steps.tend_enabled.outputs.enabled == 'true') && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -384,6 +589,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: fetch-depth: 0 fetch-tags: true @@ -391,8 +597,9 @@ jobs: - name: Check out PR branch if: | - (github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || - github.event_name == 'repository_dispatch' + steps.tend_enabled.outputs.enabled == 'true' && + ((github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || + github.event_name == 'repository_dispatch') run: | PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state') if [ "$PR_STATE" = "OPEN" ]; then @@ -406,6 +613,7 @@ jobs: - name: Compute queue delay id: delay + if: steps.tend_enabled.outputs.enabled == 'true' run: | if [ -z "$EVENT_TS" ]; then echo "seconds=" >> "$GITHUB_OUTPUT" @@ -419,6 +627,7 @@ jobs: EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -459,9 +668,10 @@ jobs: - name: Remove the eyes reaction if: | always() - && ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + && (steps.tend_enabled.outputs.enabled == 'true') + && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[ci-fix].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[ci-fix].out index 256a3388..17a63e22 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[ci-fix].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[ci-fix].out @@ -25,7 +25,75 @@ jobs: pull-requests: write actions: read steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -33,6 +101,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/codex@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[mention].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[mention].out index 91dd0a47..4b7a31af 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[mention].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[mention].out @@ -76,10 +76,78 @@ jobs: permissions: contents: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Identifiers only: `verify` re-reads the review or comment from the # API, so the words the bot weighs and acts on are the ones GitHub # holds, and a forged dispatch faces the same scrutiny as a relayed one. - name: Re-enter on an admitted ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -108,14 +176,84 @@ jobs: environment: name: tend deployment: false + permissions: + contents: read outputs: should_run: ${{ steps.check.outputs.should_run }} reason: ${{ steps.check.outputs.reason }} url: ${{ steps.check.outputs.url }} ts: ${{ steps.check.outputs.ts }} steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Verify bot engagement id: check + if: steps.tend_enabled.outputs.enabled == 'true' run: | # shellcheck shell=bash # Pre-check for tend-mention: decide whether the mention is addressed to the @@ -357,6 +495,73 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Both halves of the reaction belong to this job, so the eyes can only # go on once the job that takes them off has started. Put them in # `verify` and `handle` respectively and the routine burst case strands @@ -370,9 +575,9 @@ jobs: # arrived directly. The job's own `if` already carries `should_run`. - name: React with eyes if: | - ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + (steps.tend_enabled.outputs.enabled == 'true') && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -384,6 +589,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: fetch-depth: 0 fetch-tags: true @@ -391,8 +597,9 @@ jobs: - name: Check out PR branch if: | - (github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || - github.event_name == 'repository_dispatch' + steps.tend_enabled.outputs.enabled == 'true' && + ((github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || + github.event_name == 'repository_dispatch') run: | PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state') if [ "$PR_STATE" = "OPEN" ]; then @@ -406,6 +613,7 @@ jobs: - name: Compute queue delay id: delay + if: steps.tend_enabled.outputs.enabled == 'true' run: | if [ -z "$EVENT_TS" ]; then echo "seconds=" >> "$GITHUB_OUTPUT" @@ -419,6 +627,7 @@ jobs: EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }} - uses: max-sixty/tend/codex@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} @@ -449,9 +658,10 @@ jobs: - name: Remove the eyes reaction if: | always() - && ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + && (steps.tend_enabled.outputs.enabled == 'true') + && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[nightly].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[nightly].out index b283dd6d..8482009f 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[nightly].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[nightly].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,6 +100,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/codex@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[notifications].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[notifications].out index 436759b7..f86d5d0e 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[notifications].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[notifications].out @@ -27,8 +27,76 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Check for unread notifications and conflicted PRs id: check + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} run: | @@ -113,14 +181,14 @@ jobs: fi - uses: actions/checkout@v7 - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') with: ref: main fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/codex@X.Y.Z - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') with: github_token: ${{ secrets.TEND_BOT_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[review-runs].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[review-runs].out index 3a0bafd4..41754418 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[review-runs].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[review-runs].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,6 +100,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/codex@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[review].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[review].out index 6ea3d16f..9bbce0cd 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[review].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[review].out @@ -31,7 +31,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -47,6 +115,7 @@ jobs: # tree. - name: Resolve PR checkout ref id: pr_ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} PR: ${{ github.event.pull_request.number }} @@ -58,6 +127,7 @@ jobs: echo "::notice::refs/pull/$PR/merge unavailable (likely merge conflict); falling back to /head" fi - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: ${{ steps.pr_ref.outputs.ref }} allow-unsafe-pr-checkout: true @@ -66,6 +136,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/codex@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} @@ -75,7 +146,9 @@ jobs: $review ${{ github.event.pull_request.number }} - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[triage].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[triage].out index c6c9242e..e907f2b4 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[triage].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[triage].out @@ -28,7 +28,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -38,6 +106,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -45,6 +114,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/codex@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} @@ -54,7 +124,9 @@ jobs: $triage ${{ github.event.issue.number }} - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[weekly].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[weekly].out index 0ef7c8c2..19eb238d 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[weekly].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_codex_regtest[weekly].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,6 +100,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/codex@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[ci-fix].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[ci-fix].out index 6ce5bfd6..1b181ac9 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[ci-fix].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[ci-fix].out @@ -25,7 +25,75 @@ jobs: pull-requests: write actions: read steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -33,6 +101,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[mention].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[mention].out index 985595d5..466e319a 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[mention].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[mention].out @@ -76,10 +76,78 @@ jobs: permissions: contents: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Identifiers only: `verify` re-reads the review or comment from the # API, so the words the bot weighs and acts on are the ones GitHub # holds, and a forged dispatch faces the same scrutiny as a relayed one. - name: Re-enter on an admitted ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -108,14 +176,84 @@ jobs: environment: name: tend deployment: false + permissions: + contents: read outputs: should_run: ${{ steps.check.outputs.should_run }} reason: ${{ steps.check.outputs.reason }} url: ${{ steps.check.outputs.url }} ts: ${{ steps.check.outputs.ts }} steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Verify bot engagement id: check + if: steps.tend_enabled.outputs.enabled == 'true' run: | # shellcheck shell=bash # Pre-check for tend-mention: decide whether the mention is addressed to the @@ -357,6 +495,73 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Both halves of the reaction belong to this job, so the eyes can only # go on once the job that takes them off has started. Put them in # `verify` and `handle` respectively and the routine burst case strands @@ -370,9 +575,9 @@ jobs: # arrived directly. The job's own `if` already carries `should_run`. - name: React with eyes if: | - ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + (steps.tend_enabled.outputs.enabled == 'true') && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -384,6 +589,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: fetch-depth: 0 fetch-tags: true @@ -391,8 +597,9 @@ jobs: - name: Check out PR branch if: | - (github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || - github.event_name == 'repository_dispatch' + steps.tend_enabled.outputs.enabled == 'true' && + ((github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || + github.event_name == 'repository_dispatch') run: | PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state') if [ "$PR_STATE" = "OPEN" ]; then @@ -406,6 +613,7 @@ jobs: - name: Compute queue delay id: delay + if: steps.tend_enabled.outputs.enabled == 'true' run: | if [ -z "$EVENT_TS" ]; then echo "seconds=" >> "$GITHUB_OUTPUT" @@ -419,6 +627,7 @@ jobs: EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -450,9 +659,10 @@ jobs: - name: Remove the eyes reaction if: | always() - && ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + && (steps.tend_enabled.outputs.enabled == 'true') + && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[nightly].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[nightly].out index 389b7ac5..f0e069fb 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[nightly].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[nightly].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,6 +100,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[notifications].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[notifications].out index af7f1494..4a06c3c3 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[notifications].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[notifications].out @@ -27,8 +27,76 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Check for unread notifications and conflicted PRs id: check + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} run: | @@ -113,14 +181,14 @@ jobs: fi - uses: actions/checkout@v7 - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') with: ref: main fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[review-runs].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[review-runs].out index 5fa6b483..70b9de5c 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[review-runs].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[review-runs].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,6 +100,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[review].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[review].out index 6a560220..f5f4020c 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[review].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[review].out @@ -31,7 +31,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -47,6 +115,7 @@ jobs: # tree. - name: Resolve PR checkout ref id: pr_ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} PR: ${{ github.event.pull_request.number }} @@ -58,6 +127,7 @@ jobs: echo "::notice::refs/pull/$PR/merge unavailable (likely merge conflict); falling back to /head" fi - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: ${{ steps.pr_ref.outputs.ref }} allow-unsafe-pr-checkout: true @@ -66,6 +136,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -76,7 +147,9 @@ jobs: /tend-ci-runner:review ${{ github.event.pull_request.number }} - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[triage].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[triage].out index ed651d65..6d737b4d 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[triage].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[triage].out @@ -28,7 +28,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -38,6 +106,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -45,6 +114,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -55,7 +125,9 @@ jobs: /tend-ci-runner:triage ${{ github.event.issue.number }} - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[weekly].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[weekly].out index 5b95150c..396b6ce6 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[weekly].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_minimal_regtest[weekly].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,6 +100,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_local_setup_regtest[mention].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_local_setup_regtest[mention].out index db20d7fb..5a0d2a02 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_local_setup_regtest[mention].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_local_setup_regtest[mention].out @@ -76,10 +76,78 @@ jobs: permissions: contents: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Identifiers only: `verify` re-reads the review or comment from the # API, so the words the bot weighs and acts on are the ones GitHub # holds, and a forged dispatch faces the same scrutiny as a relayed one. - name: Re-enter on an admitted ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -108,14 +176,84 @@ jobs: environment: name: tend deployment: false + permissions: + contents: read outputs: should_run: ${{ steps.check.outputs.should_run }} reason: ${{ steps.check.outputs.reason }} url: ${{ steps.check.outputs.url }} ts: ${{ steps.check.outputs.ts }} steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Verify bot engagement id: check + if: steps.tend_enabled.outputs.enabled == 'true' run: | # shellcheck shell=bash # Pre-check for tend-mention: decide whether the mention is addressed to the @@ -357,6 +495,73 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Both halves of the reaction belong to this job, so the eyes can only # go on once the job that takes them off has started. Put them in # `verify` and `handle` respectively and the routine burst case strands @@ -370,9 +575,9 @@ jobs: # arrived directly. The job's own `if` already carries `should_run`. - name: React with eyes if: | - ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + (steps.tend_enabled.outputs.enabled == 'true') && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -384,17 +589,20 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - uses: ./.github/actions/tend-setup + if: steps.tend_enabled.outputs.enabled == 'true' - name: Check out PR branch if: | - (github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || - github.event_name == 'repository_dispatch' + steps.tend_enabled.outputs.enabled == 'true' && + ((github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || + github.event_name == 'repository_dispatch') run: | PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state') if [ "$PR_STATE" = "OPEN" ]; then @@ -408,6 +616,7 @@ jobs: - name: Compute queue delay id: delay + if: steps.tend_enabled.outputs.enabled == 'true' run: | if [ -z "$EVENT_TS" ]; then echo "seconds=" >> "$GITHUB_OUTPUT" @@ -421,6 +630,7 @@ jobs: EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -450,7 +660,9 @@ jobs: }} - name: Restore local setup actions for POST cleanup - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | dir=.github/actions/tend-setup git checkout "$GITHUB_SHA" -- "$dir" || @@ -459,9 +671,10 @@ jobs: - name: Remove the eyes reaction if: | always() - && ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + && (steps.tend_enabled.outputs.enabled == 'true') + && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_local_setup_regtest[review].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_local_setup_regtest[review].out index 416044c1..ff4b93fc 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_local_setup_regtest[review].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_local_setup_regtest[review].out @@ -31,7 +31,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -44,12 +112,14 @@ jobs: # tree lands after it. Setup executes as the runner user, outside the # containment the harness builds for the contributor's code. - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - uses: ./.github/actions/tend-setup + if: steps.tend_enabled.outputs.enabled == 'true' # GitHub only materializes refs/pull/N/merge for mergeable PRs ? on # conflicting PRs it 404s and every downstream step cascades as skipped. @@ -58,6 +128,7 @@ jobs: # tree. - name: Resolve PR checkout ref id: pr_ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} PR: ${{ github.event.pull_request.number }} @@ -69,6 +140,7 @@ jobs: echo "::notice::refs/pull/$PR/merge unavailable (likely merge conflict); falling back to /head" fi - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: ${{ steps.pr_ref.outputs.ref }} allow-unsafe-pr-checkout: true @@ -78,6 +150,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -88,14 +161,18 @@ jobs: /tend-ci-runner:review ${{ github.event.pull_request.number }} - name: Restore local setup actions for POST cleanup - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | dir=.github/actions/tend-setup git checkout "$GITHUB_SHA" -- "$dir" || echo "::warning::could not restore $dir from $GITHUB_SHA; POST cleanup of the local action may fail" - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[ci-fix].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[ci-fix].out index f8c850e3..de0f75f5 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[ci-fix].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[ci-fix].out @@ -25,7 +25,75 @@ jobs: pull-requests: write actions: read steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -33,8 +101,10 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: astral-sh/setup-uv@v6 + if: steps.tend_enabled.outputs.enabled == 'true' - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[mention].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[mention].out index 4290ec8f..84d5090e 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[mention].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[mention].out @@ -76,10 +76,78 @@ jobs: permissions: contents: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Identifiers only: `verify` re-reads the review or comment from the # API, so the words the bot weighs and acts on are the ones GitHub # holds, and a forged dispatch faces the same scrutiny as a relayed one. - name: Re-enter on an admitted ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -108,14 +176,84 @@ jobs: environment: name: tend deployment: false + permissions: + contents: read outputs: should_run: ${{ steps.check.outputs.should_run }} reason: ${{ steps.check.outputs.reason }} url: ${{ steps.check.outputs.url }} ts: ${{ steps.check.outputs.ts }} steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Verify bot engagement id: check + if: steps.tend_enabled.outputs.enabled == 'true' run: | # shellcheck shell=bash # Pre-check for tend-mention: decide whether the mention is addressed to the @@ -357,6 +495,73 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY # Both halves of the reaction belong to this job, so the eyes can only # go on once the job that takes them off has started. Put them in # `verify` and `handle` respectively and the routine burst case strands @@ -370,9 +575,9 @@ jobs: # arrived directly. The job's own `if` already carries `should_run`. - name: React with eyes if: | - ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + (steps.tend_enabled.outputs.enabled == 'true') && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -384,17 +589,20 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - uses: astral-sh/setup-uv@v6 + if: steps.tend_enabled.outputs.enabled == 'true' - name: Check out PR branch if: | - (github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || - github.event_name == 'repository_dispatch' + steps.tend_enabled.outputs.enabled == 'true' && + ((github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || + github.event_name == 'repository_dispatch') run: | PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state') if [ "$PR_STATE" = "OPEN" ]; then @@ -408,6 +616,7 @@ jobs: - name: Compute queue delay id: delay + if: steps.tend_enabled.outputs.enabled == 'true' run: | if [ -z "$EVENT_TS" ]; then echo "seconds=" >> "$GITHUB_OUTPUT" @@ -421,6 +630,7 @@ jobs: EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -452,9 +662,10 @@ jobs: - name: Remove the eyes reaction if: | always() - && ((github.event.comment && contains(github.event.comment.body, '@test-bot')) + && (steps.tend_enabled.outputs.enabled == 'true') + && (((github.event.comment && contains(github.event.comment.body, '@test-bot')) || (github.event.client_payload.kind == 'pull_request_review_comment' - && needs.verify.outputs.reason == 'mention')) + && needs.verify.outputs.reason == 'mention'))) run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[nightly].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[nightly].out index be4a26af..30ee81f7 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[nightly].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[nightly].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,8 +100,10 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: astral-sh/setup-uv@v6 + if: steps.tend_enabled.outputs.enabled == 'true' - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[notifications].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[notifications].out index 7a7e302b..1c51b9ef 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[notifications].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[notifications].out @@ -27,8 +27,76 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: Check for unread notifications and conflicted PRs id: check + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} run: | @@ -113,7 +181,7 @@ jobs: fi - uses: actions/checkout@v7 - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') with: ref: main fetch-depth: 0 @@ -121,9 +189,9 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: astral-sh/setup-uv@v6 - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') - uses: max-sixty/tend/claude@X.Y.Z - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[review-runs].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[review-runs].out index 1d5799b5..dfb663b9 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[review-runs].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[review-runs].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,8 +100,10 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: astral-sh/setup-uv@v6 + if: steps.tend_enabled.outputs.enabled == 'true' - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[review].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[review].out index fad8dace..819192ec 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[review].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[review].out @@ -31,7 +31,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -44,12 +112,14 @@ jobs: # tree lands after it. Setup executes as the runner user, outside the # containment the harness builds for the contributor's code. - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - uses: astral-sh/setup-uv@v6 + if: steps.tend_enabled.outputs.enabled == 'true' # GitHub only materializes refs/pull/N/merge for mergeable PRs ? on # conflicting PRs it 404s and every downstream step cascades as skipped. @@ -58,6 +128,7 @@ jobs: # tree. - name: Resolve PR checkout ref id: pr_ref + if: steps.tend_enabled.outputs.enabled == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} PR: ${{ github.event.pull_request.number }} @@ -69,6 +140,7 @@ jobs: echo "::notice::refs/pull/$PR/merge unavailable (likely merge conflict); falling back to /head" fi - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: ${{ steps.pr_ref.outputs.ref }} allow-unsafe-pr-checkout: true @@ -78,6 +150,7 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -88,7 +161,9 @@ jobs: /tend-ci-runner:review ${{ github.event.pull_request.number }} - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[triage].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[triage].out index 3dfdab9f..8801b710 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[triage].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[triage].out @@ -28,7 +28,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - name: React with eyes + if: steps.tend_enabled.outputs.enabled == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -38,6 +106,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -45,8 +114,10 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: astral-sh/setup-uv@v6 + if: steps.tend_enabled.outputs.enabled == 'true' - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -57,7 +128,9 @@ jobs: /tend-ci-runner:triage ${{ github.event.issue.number }} - name: Remove the eyes reaction - if: always() + if: | + always() + && (steps.tend_enabled.outputs.enabled == 'true') run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[weekly].out b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[weekly].out index af67c276..c93a4d1d 100644 --- a/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[weekly].out +++ b/generator/tests/_regtest_outputs/test_generate.test_workflow_with_setup_regtest[weekly].out @@ -24,7 +24,75 @@ jobs: actions: read issues: write steps: + - 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/.config/tend.yaml" \ + > "$RUNNER_TEMP/tend.yaml" + ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" + # 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 + + 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 + RUBY - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 @@ -32,8 +100,10 @@ jobs: token: ${{ secrets.TEND_BOT_TOKEN }} - uses: astral-sh/setup-uv@v6 + if: steps.tend_enabled.outputs.enabled == 'true' - uses: max-sixty/tend/claude@X.Y.Z + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/generator/tests/test_checks.py b/generator/tests/test_checks.py index 65c4a33d..7e08f174 100644 --- a/generator/tests/test_checks.py +++ b/generator/tests/test_checks.py @@ -1521,6 +1521,21 @@ def test_cli_check_all_pass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> assert "PASS" in result.output +def test_cli_check_reports_disabled_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write_config(tmp_path, "bot_name: test-bot\nenabled: false\n") + monkeypatch.chdir(tmp_path) + + with patch("tend.cli.run_all_checks", return_value=[]): + result = CliRunner().invoke(main, ["check"]) + + assert result.exit_code == 0 + assert ( + "Tend is disabled in config; new operational jobs will skip." in result.output + ) + + def test_cli_check_failure_exits_1( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/generator/tests/test_config_edge_cases.py b/generator/tests/test_config_edge_cases.py index cb8d71d5..d386cb90 100644 --- a/generator/tests/test_config_edge_cases.py +++ b/generator/tests/test_config_edge_cases.py @@ -43,6 +43,7 @@ def test_bot_name_only(tmp_path: Path) -> None: path = _write_config(tmp_path, "bot_name: my-bot") cfg = Config.load(path) assert cfg.bot_name == "my-bot" + assert cfg.enabled is True assert cfg.model == "opus" assert cfg.protected_branches == [] assert cfg.setup == [] @@ -51,6 +52,23 @@ def test_bot_name_only(tmp_path: Path) -> None: assert cfg.memory_gist is False +@pytest.mark.parametrize("value", ["yes", '"false"', "1", "null", "{}"]) +def test_enabled_requires_a_boolean(tmp_path: Path, value: str) -> None: + path = _write_config(tmp_path, f"bot_name: my-bot\nenabled: {value}\n") + with pytest.raises(ClickException, match="enabled must be true or false"): + Config.load(path) + + +def test_yaml_merge_keys_are_rejected(tmp_path: Path) -> None: + path = _write_config( + tmp_path, + "defaults: &defaults\n enabled: false\n<<: *defaults\nbot_name: my-bot\n", + ) + + with pytest.raises(ClickException, match="YAML merge keys"): + Config.load(path) + + @pytest.mark.parametrize("value", ["yes", "1", "{}"]) def test_memory_gist_requires_a_boolean(tmp_path: Path, value: str) -> None: path = _write_config(tmp_path, f"bot_name: my-bot\nmemory_gist: {value}\n") diff --git a/generator/tests/test_generate.py b/generator/tests/test_generate.py index ea9c42f0..8da55022 100644 --- a/generator/tests/test_generate.py +++ b/generator/tests/test_generate.py @@ -5,6 +5,7 @@ import importlib.resources import json import re +import subprocess from pathlib import Path from textwrap import dedent @@ -32,6 +33,14 @@ from tests import ACTION_VERSION, agent_prompt from tests import _yaml as yaml +CHECK_ENABLED = ( + Path(__file__).resolve().parents[1] + / "src" + / "tend" + / "templates" + / "check-enabled.rb" +) + def _minimal_config(tmp_path: Path, extra: str = "") -> Path: cfg = tmp_path / ".config" / "tend.yaml" @@ -80,6 +89,129 @@ def test_disabled_workflow_not_generated(tmp_path: Path) -> None: assert len(workflows) == 6 +def test_top_level_disable_keeps_workflows_installed_and_gates_every_job( + tmp_path: Path, +) -> None: + """The runtime switch can turn itself back on without regeneration.""" + cfg = Config.load( + _minimal_config( + tmp_path, + dedent("""\ + enabled: false + workflows: + ci-fix: + watched_workflows: ["ci"] + """), + ) + ) + + assert cfg.enabled is False + workflows = generate_all(cfg, with_install_test=True) + assert len(workflows) == len(STANDARD_WORKFLOWS) + 1 + + condition = "steps.tend_enabled.outputs.enabled == 'true'" + for workflow in workflows: + jobs = yaml.safe_load(workflow.content)["jobs"] + for job_name, job in jobs.items(): + steps = job["steps"] + gate = steps[0] + assert gate["id"] == "tend_enabled", (workflow.filename, job_name) + assert f"contents/{cfg.config_path}" in gate["run"] + assert "secrets." not in str(gate) + if workflow.filename == "tend-install-test.yaml": + assert "?ref=${{ github.event.pull_request.head.sha }}" in gate["run"] + for step in steps[1:]: + assert condition in str(step.get("if", "")), ( + workflow.filename, + job_name, + step, + ) + + +@pytest.mark.parametrize( + ("config", "enabled"), + [ + ("bot_name: bot\n", "true"), + ("bot_name: bot\nenabled: true\n", "true"), + ("bot_name: bot\nenabled: false\n", "false"), + ("\ufeffbot_name: bot\nenabled: false\n", "false"), + ], +) +def test_runtime_enabled_check(tmp_path: Path, config: str, enabled: str) -> None: + path = tmp_path / "tend.yaml" + path.write_text(config) + + result = subprocess.run( + ["ruby", str(CHECK_ENABLED), str(path)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == f"enabled={enabled}\n" + assert ("Tend disabled" in result.stderr) is (enabled == "false") + + +@pytest.mark.parametrize( + "config", + [ + "bot_name: bot\nenabled: no\n", + 'bot_name: bot\nenabled: "false"\n', + "bot_name: bot\nenabled:\n", + "bot_name: bot\nenabled: {}\n", + "bot_name: bot\nenabled: true\nenabled: false\n", + ], +) +def test_runtime_enabled_check_rejects_non_boolean_values( + tmp_path: Path, config: str +) -> None: + path = tmp_path / "tend.yaml" + path.write_text(config) + + result = subprocess.run( + ["ruby", str(CHECK_ENABLED), str(path)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "enabled must" in result.stderr + + +def test_runtime_enabled_check_rejects_multiple_documents(tmp_path: Path) -> None: + path = tmp_path / "tend.yaml" + path.write_text("bot_name: bot\n---\nenabled: false\n") + + result = subprocess.run( + ["ruby", str(CHECK_ENABLED), str(path)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "exactly one YAML document" in result.stderr + + +def test_runtime_enabled_check_rejects_yaml_merge_keys(tmp_path: Path) -> None: + path = tmp_path / "tend.yaml" + path.write_text( + "defaults: &defaults\n enabled: false\n<<: *defaults\nbot_name: bot\n" + ) + + result = subprocess.run( + ["ruby", str(CHECK_ENABLED), str(path)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "YAML merge keys" in result.stderr + + def test_setup_steps_rendered(tmp_path: Path) -> None: extra = dedent("""\ setup: @@ -142,7 +274,8 @@ def test_local_setup_action_restored_for_post_cleanup( chain walks. `always()` covers a failed session so cleanup still sees the version the - runner loaded.""" + runner loaded. The runtime switch still applies because a disabled job + never loaded the local action.""" extra = "setup:\n - uses: ./.github/actions/tend-setup\n" cfg = Config.load(_minimal_config(tmp_path, extra)) steps = yaml.safe_load(GENERATORS[name](cfg).content)["jobs"][job]["steps"] @@ -154,7 +287,9 @@ def test_local_setup_action_restored_for_post_cleanup( assert condition.startswith("always()"), ( f"{name}: the restore has to run even when the session fails" ) - assert condition == "always()", f"{name}: restore has an extra gate" + assert condition.strip() == ( + "always()\n&& (steps.tend_enabled.outputs.enabled == 'true')" + ), f"{name}: restore has an unexpected gate" switch_idx = next(i for i, s in enumerate(steps) if switch(s)) # type: ignore[operator] assert idx > switch_idx, f"{name}: the restore has to follow the tree switch" @@ -431,9 +566,10 @@ def test_setup_step_user_if_narrows_notifications_guard( if s.get("run") == "./flaky.sh" ) assert step["if"] == ( + "((steps.tend_enabled.outputs.enabled == 'true') && " "(steps.check.outputs.count != '0' || " "steps.check.outputs.conflict_count != '0' || " - "github.event_name == 'workflow_dispatch') && (runner.os == 'Linux')" + "github.event_name == 'workflow_dispatch')) && (runner.os == 'Linux')" ) @@ -885,17 +1021,17 @@ def test_issue_and_pr_acknowledged_with_eyes(tmp_path: Path) -> None: workflows = {wf.filename: wf for wf in generate_all(cfg)} triage = yaml.safe_load(workflows["tend-triage.yaml"].content) - first = triage["jobs"]["triage"]["steps"][0] - assert "content=eyes" in first["run"] - assert first["env"]["TARGET"] == "issues/${{ github.event.issue.number }}" - # Every issues:opened event the job-level `if` admits is the bot's to take. - assert "if" not in first + react = _eyes_steps(triage["jobs"]["triage"]["steps"])[0] + assert react["env"]["TARGET"] == "issues/${{ github.event.issue.number }}" + # Every enabled issues:opened event the job-level `if` admits is the bot's + # to take. + assert react["if"] == "steps.tend_enabled.outputs.enabled == 'true'" review = yaml.safe_load(workflows["tend-review.yaml"].content) react = _eyes_steps(review["jobs"]["review"]["steps"])[0] assert react["env"]["TARGET"] == "issues/${{ github.event.pull_request.number }}" - # Every admitted review run boots a session, so each one gets a reaction. - assert "if" not in react + # Every enabled review run boots a session, so each one gets a reaction. + assert react["if"] == "steps.tend_enabled.outputs.enabled == 'true'" @pytest.mark.parametrize( diff --git a/generator/tests/test_integration.py b/generator/tests/test_integration.py index a6c75acd..b96dd325 100644 --- a/generator/tests/test_integration.py +++ b/generator/tests/test_integration.py @@ -419,7 +419,25 @@ def test_init_custom_config_path( assert result.exit_code == 0 for path in _workflow_dir(tmp_path).glob("tend-*.yaml"): - assert "custom-bot" in path.read_text(), f"{path.name} missing custom bot name" + content = path.read_text() + assert "custom-bot" in content, f"{path.name} missing custom bot name" + if path.name != "tend-install-test.yaml": + assert "contents/custom/my-tend.yaml" in content + + +def test_init_rejects_a_config_outside_the_repository( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + custom = tmp_path / "tend.yaml" + custom.write_text("bot_name: custom-bot") + monkeypatch.chdir(repo) + + result = CliRunner().invoke(main, ["init", "-c", str(custom)]) + + assert result.exit_code == 1 + assert "Config must be inside the repository" in result.output # --------------------------------------------------------------------------- @@ -717,15 +735,16 @@ def test_init_notifications_has_precheck( } steps = data["jobs"]["notifications"]["steps"] - # First step is the pre-check - check_step = steps[0] + assert steps[0]["id"] == "tend_enabled" + check_index = next(i for i, step in enumerate(steps) if step.get("id") == "check") + check_step = steps[check_index] assert check_step["id"] == "check" assert "--paginate --slurp" in check_step["run"] assert "subscription" in check_step["run"] assert "notifications/threads/" not in check_step["run"] - # All subsequent steps are gated on the check output - for step in steps[1:]: + # Everything after the notification check is gated on its output. + for step in steps[check_index + 1 :]: assert "if" in step, ( f"step {step.get('uses', step.get('name'))} missing if guard" ) @@ -754,7 +773,11 @@ def test_notifications_precheck_tolerates_transient_non_json( data = yaml.safe_load( (_workflow_dir(tmp_path) / "tend-notifications.yaml").read_text() ) - script = data["jobs"]["notifications"]["steps"][0]["run"] + script = next( + step["run"] + for step in data["jobs"]["notifications"]["steps"] + if step.get("id") == "check" + ) # Fake `gh` accepts the idempotent repository-watch write, then mimics a # transient notifications blip: the endpoint returns a 200 with an HTML @@ -908,6 +931,7 @@ def test_init_removes_stale_files_when_no_workflows_enabled( result = _run_init() assert result.exit_code == 0 assert not stale.exists() + assert "No workflows generated from config." in result.output assert "Removed 1 stale" in result.output @@ -957,6 +981,11 @@ def test_install_test_workflow_shape( assert "head.repo.full_name == github.repository" in job["if"] assert job["permissions"] == {"contents": "read"} assert "secrets." not in content + steps = job["steps"] + assert steps[0]["id"] == "tend_enabled" + assert "?ref=${{ github.event.pull_request.head.sha }}" in steps[0]["run"] + for step in steps[1:]: + assert step["if"] == "steps.tend_enabled.outputs.enabled == 'true'" # Generator-drift step regenerates with the same flag to keep output stable. # Version is pinned from the committed header (not `@latest`) so a release diff --git a/plugins/install-tend/skills/install-tend/references/tend.example.yaml b/plugins/install-tend/skills/install-tend/references/tend.example.yaml index 78f7b20c..a11ae8b1 100644 --- a/plugins/install-tend/skills/install-tend/references/tend.example.yaml +++ b/plugins/install-tend/skills/install-tend/references/tend.example.yaml @@ -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` @@ -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