From d2eda841a81811a02b5625f421eb0edb48d97b9e Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:39:19 +0000 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20update=20tend=20workflows=20(0.1.2?= =?UTF-8?q?4=20=E2=86=92=200.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tend-ci-fix.yaml | 82 ++- .github/workflows/tend-mention.yaml | 600 ++++++++++++++-------- .github/workflows/tend-nightly.yaml | 73 ++- .github/workflows/tend-notifications.yaml | 341 +++++++++--- .github/workflows/tend-review-runs.yaml | 73 ++- .github/workflows/tend-review.yaml | 79 ++- .github/workflows/tend-triage.yaml | 78 ++- .github/workflows/tend-weekly.yaml | 73 ++- 8 files changed, 1096 insertions(+), 303 deletions(-) diff --git a/.github/workflows/tend-ci-fix.yaml b/.github/workflows/tend-ci-fix.yaml index 7cfeea7ab..ccee84ef9 100644 --- a/.github/workflows/tend-ci-fix.yaml +++ b/.github/workflows/tend-ci-fix.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init +# Generated by tend 0.2.0. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -16,6 +16,15 @@ on: jobs: fix-ci: if: github.repository_owner == 'diffplug' && github.event.workflow_run.conclusion == 'failure' + concurrency: + # A red branch fails every push that follows, each on its own commit, so + # one session per branch — not per commit — is what collapses the burst. + # The watched workflow is in the key too: a red `publish-site` must not + # starve behind a stream of red `ci`. Never cancel — a running session + # may already have pushed a branch or opened a PR. Default queue depth + # is right: the newest failure replaces the pending one. + group: ${{ github.workflow }}-${{ github.event.workflow_run.name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false runs-on: ubuntu-24.04 environment: name: tend @@ -25,14 +34,83 @@ 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 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - - uses: max-sixty/tend/claude@0.1.24 + - uses: max-sixty/tend/claude@0.2.0 + 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/.github/workflows/tend-mention.yaml b/.github/workflows/tend-mention.yaml index b1153281b..ebba0f5b3 100644 --- a/.github/workflows/tend-mention.yaml +++ b/.github/workflows/tend-mention.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init +# Generated by tend 0.2.0. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -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,224 +176,272 @@ 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 + - uses: astral-sh/setup-uv@v10.0.1 + if: steps.tend_enabled.outputs.enabled == 'true' + with: + version: "0.12.10" + ignore-empty-workdir: true - 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 - # bot — by name or by engagement — and so whether the agent boots at all. - # - # Inlined into the generated workflow (adopter repos have no copy of this - # file), so it stays self-contained: env in, GITHUB_OUTPUT out. - # - # env: BOT_NAME, EVENT_NAME, COMMENT_BODY, COMMENT_AUTHOR, COMMENT_AUTHOR_TYPE, - # ISSUE_BODY, ISSUE_OR_PR_NUMBER, ISSUE_AUTHOR, PR_URL, PAYLOAD_KIND, - # PAYLOAD_PR, PAYLOAD_ID, GITHUB_REPOSITORY, GITHUB_OUTPUT, GITHUB_TOKEN - # out: should_run, reason, url, ts - - # A relayed review event arrives as identifiers only ({kind, pr, id}): resolve - # them against the API before judging anything, so the words weighed below are - # the ones GitHub holds rather than whatever the payload carried. Any - # write-scoped actor can POST a dispatch, so a forged payload faces the same - # checks a real event does — and fetching by PR and id binds the two, so a - # payload pairing a real review with some other PR dies here instead of - # steering the handle job. The ids are spliced into API paths here and into the - # prompt later, so reject anything but digits at this edge. - KIND="$EVENT_NAME" - if [ "$EVENT_NAME" = "repository_dispatch" ]; then - KIND="$PAYLOAD_KIND" - if ! [[ "$PAYLOAD_PR" =~ ^[0-9]+$ && "$PAYLOAD_ID" =~ ^[0-9]+$ ]]; then - echo "malformed dispatch payload — skipping" - echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - case "$KIND" in - pull_request_review) - if ! REVIEW=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR/reviews/$PAYLOAD_ID"); then - echo "review $PAYLOAD_ID not found on PR $PAYLOAD_PR — skipping" - echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - REVIEW_AUTHOR=$(echo "$REVIEW" | jq -r '.user.login') - # REST reports the state uppercase (a webhook payload's is lowercase); - # normalize so the terminal-approval gate below reads one shape. - REVIEW_STATE=$(echo "$REVIEW" | jq -r '.state | ascii_downcase') - COMMENT_BODY=$(echo "$REVIEW" | jq -r '.body // ""') - echo "url=$(echo "$REVIEW" | jq -r '.html_url')" >> "$GITHUB_OUTPUT" - # A review without `submitted_at` (a PENDING one, which only a forged - # dispatch can name) would otherwise write the string `null`, which - # handle's `date -d` rejects — failing the job red where the empty-value - # guard would have skipped it. - echo "ts=$(echo "$REVIEW" | jq -r '.submitted_at // empty')" >> "$GITHUB_OUTPUT" - ;; - pull_request_review_comment) - if ! COMMENT=$(gh api "repos/$GITHUB_REPOSITORY/pulls/comments/$PAYLOAD_ID"); then - echo "comment $PAYLOAD_ID not found — skipping" - echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - # Comments fetch by id alone, so bind the PR explicitly. - if [ "$(echo "$COMMENT" | jq -r '.pull_request_url')" != "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR" ]; then - echo "comment $PAYLOAD_ID does not belong to PR $PAYLOAD_PR — skipping" - echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - COMMENT_AUTHOR=$(echo "$COMMENT" | jq -r '.user.login') - COMMENT_BODY=$(echo "$COMMENT" | jq -r '.body // ""') - echo "url=$(echo "$COMMENT" | jq -r '.html_url')" >> "$GITHUB_OUTPUT" - echo "ts=$(echo "$COMMENT" | jq -r '.updated_at')" >> "$GITHUB_OUTPUT" - ;; - *) - echo "unknown dispatch kind '$KIND' — skipping" - echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 - ;; - esac - fi + uv run --script - <<'TEND_PY' + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + """Decide whether a mention event should start an agent session.""" - # Mentions always run - if [ "$KIND" = "issues" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - exit 0 - fi + from __future__ import annotations - # The bot's own comments never summon it (its PAT-based User account is - # invisible to the Bot-type skip below). Placed *before* the mention check, - # since a bot comment can quote a prior @-mention. Comments only: the bot's - # review *submissions* are judged with the review kind below — a review carries - # reviewer-role signal a comment can't. - if { [ "$KIND" = "issue_comment" ] || [ "$KIND" = "pull_request_review_comment" ]; } \ - && [ "$COMMENT_AUTHOR" = "$BOT_NAME" ]; then - echo "should_run=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + import json + import os + import subprocess + import sys + from pathlib import Path + from typing import Any - if [ -n "$COMMENT_BODY" ] && printf '%s\n' "$COMMENT_BODY" | grep -qF "@$BOT_NAME"; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - echo "reason=mention" >> "$GITHUB_OUTPUT" - exit 0 - fi - # Other bots' undirected comments (deploy notifications, CI status) summon by - # mention only, never by the engagement heuristics below — which would boot a - # no-op session per notification, twice when the source bot edits its comment. - if [ "$KIND" = "issue_comment" ] && [ "$COMMENT_AUTHOR_TYPE" = "Bot" ]; then - echo "should_run=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + def gh(*args: str, quiet: bool = False) -> str: + result = subprocess.run(["gh", *args], capture_output=True, text=True, check=False) + if result.returncode: + if result.stderr and not quiet: + sys.stderr.write(result.stderr) + raise subprocess.CalledProcessError( + result.returncode, result.args, result.stdout, result.stderr + ) + return result.stdout - # A review's record includes review.body (checked above) but NOT the bodies of - # the inline comments attached to the review. Fetch them so a first-contact - # @-mention inside an inline comment is detected on PRs where the bot has no - # prior engagement. One object per line, so `--paginate` concatenates pages - # instead of reducing within one. Keep the `{body, in_reply_to_id}` - # construction: `in_reply_to_id` is an *optional* property, absent rather than - # null on a fresh comment, and building the object normalizes absent to null so - # the `== null` select below counts both shapes. A bare `.in_reply_to_id` - # stream would emit nothing for a fresh comment and count every review as - # reply-only. - if [ "$KIND" = "pull_request_review" ]; then - INLINE=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR/reviews/$PAYLOAD_ID/comments" \ - --jq '.[] | {body, in_reply_to_id}') - - if printf '%s\n' "$INLINE" | jq -r '.body' | grep -qF "@$BOT_NAME"; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - echo "reason=mention" >> "$GITHUB_OUTPUT" - exit 0 - fi - - FRESH_INLINE=$(printf '%s\n' "$INLINE" | jq -s '[.[] | select(.in_reply_to_id == null)] | length') - - # A contentless approval — no body, no inline comments — asks for nothing, - # whoever submitted it, and the bot cannot merge on its own. Without this it - # reads as engagement below and boots a session whose only outcome is a - # silent exit. `approved` and `$INLINE` empty are both load-bearing: a bare - # COMMENTED review is how GitHub wraps a human's inline reply (not terminal), - # and an approval whose nits live inline is a request to the PR's author — on - # a bot-authored PR, a role the bot has to act in. - if [ "$REVIEW_STATE" = "approved" ] \ - && [ -z "$COMMENT_BODY" ] \ - && [ -z "$INLINE" ]; then - echo "should_run=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - fi - # Non-mention: check bot engagement - if [ "$KIND" = "issue_comment" ]; then - ISSUE_NUMBER="$ISSUE_OR_PR_NUMBER" - - if [ -z "$PR_URL" ]; then - if [ "$ISSUE_AUTHOR" = "$BOT_NAME" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0 - fi - if printf '%s\n' "$ISSUE_BODY" | grep -qF "@$BOT_NAME"; then - echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0 - fi - # Don't reduce inside jq: `gh api --paginate` applies `--jq` once per page, - # so `| length` emits one count per page rather than one overall. Past 100 - # comments the variable holds e.g. `100\n7`, a numeric test on it errors - # with `integer expression expected`, and the failed test falls through to - # should_run=false — the bot goes quiet on its most-engaged threads. - # Capture the per-element stream and test it for emptiness — the bare - # substitution also keeps a failing `gh api` fatal under GHA's default - # `bash -e`. - BOT_COMMENTS=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \ - --jq ".[] | select(.user.login == \"$BOT_NAME\") | .id") - if [ -n "$BOT_COMMENTS" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0 - fi - echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - - PR_NUMBER="$ISSUE_NUMBER" - else - PR_NUMBER="$PAYLOAD_PR" - fi + def gh_json(*args: str, quiet: bool = False) -> Any: + return json.loads(gh(*args, quiet=quiet)) - PR_AUTHOR=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login') - - # The bot's own review summons a session in exactly one shape: the reviewer - # role handing work to the author role — fresh content (a body, or an inline - # comment that isn't a reply) on a PR the bot authored. On another author's PR - # the review session already did whatever the review warranted; an empty-body - # reply-only review is the synthetic container GitHub wraps around an inline - # reply — the same comment the self-comment skip above drops on its other event - # path. Either would otherwise read as engagement below (the BOT_REVIEWS - # heuristic counts this very review) and boot a session that exits silently. - # Sits *after* the inline @-mention scan, so an explicit summons the bot quotes - # still wins. - if [ "$KIND" = "pull_request_review" ] && [ "$REVIEW_AUTHOR" = "$BOT_NAME" ]; then - if [ "$PR_AUTHOR" = "$BOT_NAME" ] \ - && { [ -n "$COMMENT_BODY" ] || [ "$FRESH_INLINE" -gt 0 ]; }; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0 - fi - echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - if [ "$PR_AUTHOR" = "$BOT_NAME" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0 - fi + def gh_paginated(path: str) -> list[dict[str, Any]]: + text = gh("api", "--paginate", path) + decoder = json.JSONDecoder() + position = 0 + items: list[dict[str, Any]] = [] + while position < len(text): + while position < len(text) and text[position].isspace(): + position += 1 + if position == len(text): + break + page, position = decoder.raw_decode(text, position) + if not isinstance(page, list): + raise TypeError("paginated GitHub response was not an array") + items.extend(page) + return items - # Captured, not counted — see the note on the issue-comment lookup above. - BOT_REVIEWS=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \ - --jq ".[] | select(.user.login == \"$BOT_NAME\") | .id") - if [ -n "$BOT_REVIEWS" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0 - fi - BOT_COMMENTS=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ - --jq ".[] | select(.user.login == \"$BOT_NAME\") | .id") - if [ -n "$BOT_COMMENTS" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0 - fi + def actor_login(actor: object) -> str: + """Return a GitHub actor login, including for deleted-account records.""" + if not isinstance(actor, dict): + return "" + return str(actor.get("login") or "") + + + def output(name: str, value: str | bool) -> None: + rendered = str(value).lower() if isinstance(value, bool) else value + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: + stream.write(f"{name}={rendered}\n") + + + def verdict(should_run: bool, reason: str = "") -> int: + output("should_run", should_run) + if reason: + output("reason", reason) + return 0 + + + def main() -> int: + env = os.environ + bot = env.get("BOT_NAME", "") + repo = env.get("GITHUB_REPOSITORY", "") + kind = env.get("EVENT_NAME", "") + comment_body = env.get("COMMENT_BODY", "") + comment_author = env.get("COMMENT_AUTHOR", "") + review_author = "" + review_state = "" + inline: list[dict[str, Any]] = [] + fresh_inline = 0 + + if kind == "repository_dispatch": + kind = env.get("PAYLOAD_KIND", "") + pr = env.get("PAYLOAD_PR", "") + item_id = env.get("PAYLOAD_ID", "") + if not pr.isdigit() or not item_id.isdigit(): + print("malformed dispatch payload — skipping") + return verdict(False) + if kind == "pull_request_review": + try: + review = gh_json( + "api", f"repos/{repo}/pulls/{pr}/reviews/{item_id}", quiet=True + ) + except (subprocess.CalledProcessError, json.JSONDecodeError): + print(f"review {item_id} not found on PR {pr} — skipping") + return verdict(False) + review_author = actor_login(review.get("user")) + review_state = str(review["state"]).lower() + comment_body = review.get("body") or "" + output("url", review["html_url"]) + output("ts", review.get("submitted_at") or "") + elif kind == "pull_request_review_comment": + try: + comment = gh_json( + "api", f"repos/{repo}/pulls/comments/{item_id}", quiet=True + ) + except (subprocess.CalledProcessError, json.JSONDecodeError): + print(f"comment {item_id} not found — skipping") + return verdict(False) + expected_pr = f"https://api.github.com/repos/{repo}/pulls/{pr}" + if comment.get("pull_request_url") != expected_pr: + print(f"comment {item_id} does not belong to PR {pr} — skipping") + return verdict(False) + comment_author = actor_login(comment.get("user")) + comment_body = comment.get("body") or "" + output("url", comment["html_url"]) + output("ts", comment["updated_at"]) + else: + print(f"unknown dispatch kind '{kind}' — skipping") + return verdict(False) + + if kind == "issues": + return verdict(True) + + if ( + kind in {"issue_comment", "pull_request_review_comment"} + and comment_author == bot + ): + return verdict(False) + if comment_body and f"@{bot}" in comment_body: + return verdict(True, "mention") + if kind == "issue_comment" and env.get("COMMENT_AUTHOR_TYPE") == "Bot": + return verdict(False) - echo "should_run=false" >> "$GITHUB_OUTPUT" + if kind == "pull_request_review": + inline = gh_paginated( + f"repos/{repo}/pulls/{env.get('PAYLOAD_PR', '')}/reviews/" + f"{env.get('PAYLOAD_ID', '')}/comments" + ) + if any(f"@{bot}" in (comment.get("body") or "") for comment in inline): + return verdict(True, "mention") + fresh_inline = sum(comment.get("in_reply_to_id") is None for comment in inline) + if review_state == "approved" and not comment_body and not inline: + return verdict(False) + + if kind == "issue_comment": + issue_number = env.get("ISSUE_OR_PR_NUMBER", "") + if not env.get("PR_URL"): + if env.get("ISSUE_AUTHOR") == bot or f"@{bot}" in env.get("ISSUE_BODY", ""): + return verdict(True) + comments = gh_paginated(f"repos/{repo}/issues/{issue_number}/comments") + return verdict( + any(actor_login(comment.get("user")) == bot for comment in comments) + ) + pr_number = issue_number + else: + pr_number = env.get("PAYLOAD_PR", "") + + pr = gh_json("pr", "view", pr_number, "--repo", repo, "--json", "author") + pr_author = actor_login(pr.get("author")) + if kind == "pull_request_review" and review_author == bot: + if pr_author == bot and (comment_body or fresh_inline > 0): + return verdict(True, "participation") + return verdict(False) + if pr_author == bot: + return verdict(True, "participation") + + reviews = gh_paginated(f"repos/{repo}/pulls/{pr_number}/reviews") + if any(actor_login(review.get("user")) == bot for review in reviews): + return verdict(True, "participation") + comments = gh_paginated(f"repos/{repo}/issues/{pr_number}/comments") + if any(actor_login(comment.get("user")) == bot for comment in comments): + return verdict(True, "participation") + return verdict(False) + + + if __name__ == "__main__": + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as error: + raise SystemExit(error.returncode or 1) from None + TEND_PY env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} BOT_NAME: dormouse-bot @@ -357,6 +473,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 +553,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, '@dormouse-bot')) + (steps.tend_enabled.outputs.enabled == 'true') && (((github.event.comment && contains(github.event.comment.body, '@dormouse-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 +567,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 +575,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 +591,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" @@ -418,7 +604,8 @@ 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 }} - - uses: max-sixty/tend/claude@0.1.24 + - uses: max-sixty/tend/claude@0.2.0 + 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 +637,10 @@ jobs: - name: Remove the eyes reaction if: | always() - && ((github.event.comment && contains(github.event.comment.body, '@dormouse-bot')) + && (steps.tend_enabled.outputs.enabled == 'true') + && (((github.event.comment && contains(github.event.comment.body, '@dormouse-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/.github/workflows/tend-nightly.yaml b/.github/workflows/tend-nightly.yaml index fb417a3c8..c8a5ad7c6 100644 --- a/.github/workflows/tend-nightly.yaml +++ b/.github/workflows/tend-nightly.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init +# Generated by tend 0.2.0. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -25,14 +25,83 @@ 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 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - - uses: max-sixty/tend/claude@0.1.24 + - uses: max-sixty/tend/claude@0.2.0 + 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/.github/workflows/tend-notifications.yaml b/.github/workflows/tend-notifications.yaml index b2b30b2e9..403a95e8c 100644 --- a/.github/workflows/tend-notifications.yaml +++ b/.github/workflows/tend-notifications.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init +# Generated by tend 0.2.0. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -28,100 +28,275 @@ 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: astral-sh/setup-uv@v10.0.1 + if: steps.tend_enabled.outputs.enabled == 'true' + with: + version: "0.12.10" + ignore-empty-workdir: true - 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: | - # shellcheck shell=bash - # Establish the repository's frequent maintenance queue and decide whether the - # agent needs to boot. Inlined into the generated workflow: env in, - # GITHUB_OUTPUT out. - # - # env: GITHUB_REPOSITORY, GITHUB_OUTPUT, GITHUB_TOKEN - - # Activity newer than this belongs to an event workflow that may still be - # running. The same cutoff is passed to the agent and, once every older item has - # a semantic outcome, to GitHub's repository-level mark-read endpoint. Newer - # activity therefore cannot be acknowledged by this run. - CUTOFF=$(date -u -d '10 minutes ago' +%Y-%m-%dT%H:%M:%SZ) - echo "cutoff=$CUTOFF" >> "$GITHUB_OUTPUT" - - # Watching makes a new issue or PR visible before the bot has participated in - # its thread. The installer sets this too; every poll repeats the idempotent PUT - # so a later settings change is repaired without additional state. - gh api "repos/$GITHUB_REPOSITORY/subscription" -X PUT \ - -F subscribed=true -F ignored=false --silent \ - || echo "::warning::could not enable repository watching; retrying next cycle" - - # Capture every unread page at the cutoff. GitHub occasionally returns an HTML - # error page even with a successful status, so validate the slurped page shape. - # A failed fetch leaves the queue untouched for the next scheduled cycle. - ENDPOINT="notifications?before=$CUTOFF&per_page=100" - if PAGES=$(gh api "$ENDPOINT" --paginate --slurp 2>/dev/null) \ - && NOTIFS=$(echo "$PAGES" | jq -ce \ - 'if type == "array" and all(.[]; type == "array") then add // [] else error("invalid pages") end'); then - COUNT=$(echo "$NOTIFS" | jq 'length') - else - COUNT=0 - echo "::warning::notifications fetch failed; queue left for the next cycle" - fi - - echo "count=$COUNT" >> "$GITHUB_OUTPUT" - - # GitHub computes mergeability lazily after the base moves. UNKNOWN therefore - # means "worth a synchronous local test", not "clean". This is only a cheap - # boot gate; the agent test-merges every candidate before changing a branch. - # Read the newest comments: a deferral is normally the PR's latest activity. - # An older marker can waste boots, but the resolver paginates before acting. - # shellcheck disable=SC2016 # $q is a GraphQL variable, not a shell variable. - if BOT_LOGIN=$(gh api user --jq .login 2>/dev/null) \ - && PRS=$(gh api graphql -f query=' - query($q: String!) { - search(query: $q, type: ISSUE, first: 100) { - nodes { ... on PullRequest { - mergeable headRefOid - comments(last: 100) { nodes { author { login } body } } - } } - } - }' -f q="repo:$GITHUB_REPOSITORY author:$BOT_LOGIN is:pr is:open" \ - --jq '.data.search.nodes' 2>/dev/null) \ - && CONFLICT_COUNT=$(jq -er --arg bot "$BOT_LOGIN" ' - [.[] - | select(.mergeable != "MERGEABLE") - | . as $pr - | "" as $marker - | select(any($pr.comments.nodes[]?; - .author.login == $bot - and (((.body // "") | sub("\\s+$"; "") | split("\n") | last) == $marker)) - | not)] - | length' <<<"$PRS"); then - : - else - CONFLICT_COUNT=0 - echo "::warning::bot PR conflict scan failed; retrying next cycle" - fi - - echo "conflict_count=$CONFLICT_COUNT" >> "$GITHUB_OUTPUT" - - if [ "$COUNT" = "0" ] && [ "$CONFLICT_COUNT" = "0" ]; then - echo "No notification or conflict work — skipping" - else - [ "$COUNT" = "0" ] || \ - echo "$COUNT notification task(s) — proceeding" - [ "$CONFLICT_COUNT" = "0" ] || \ - echo "$CONFLICT_COUNT possible conflicted bot PR(s) — proceeding" - fi + uv run --script - <<'TEND_PY' + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + """Decide whether the notifications workflow has work for an agent.""" + + from __future__ import annotations + + import json + import os + import subprocess + import sys + from datetime import UTC, datetime, timedelta + from pathlib import Path + from typing import Any + + GRAPHQL_QUERY = """ + query($q: String!) { + search(query: $q, type: ISSUE, first: 100) { + nodes { ... on PullRequest { + mergeable headRefOid + comments(last: 100) { nodes { author { login } body } } + } } + } + } + """ + + + def _gh(*args: str, quiet: bool = False) -> str: + result = subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + env=os.environ.copy(), + check=False, + ) + if result.returncode: + if result.stderr and not quiet: + sys.stderr.write(result.stderr) + raise subprocess.CalledProcessError( + result.returncode, result.args, result.stdout, result.stderr + ) + return result.stdout + + + def _json(*args: str, quiet: bool = False) -> Any: + return json.loads(_gh(*args, quiet=quiet)) + + + def _paginated(path: str) -> list[Any]: + text = _gh("api", path, "--paginate", quiet=True) + decoder = json.JSONDecoder() + pages: list[Any] = [] + position = 0 + saw_page = False + while position < len(text): + while position < len(text) and text[position].isspace(): + position += 1 + if position == len(text): + break + page, position = decoder.raw_decode(text, position) + saw_page = True + if not isinstance(page, list): + raise TypeError("paginated GitHub response was not an array") + pages.extend(page) + if not saw_page: + raise ValueError("paginated GitHub response was empty") + return pages + + + def _output(name: str, value: str | int) -> None: + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: + stream.write(f"{name}={value}\n") + + + def _notifications(cutoff: str) -> int: + try: + return len(_paginated(f"notifications?before={cutoff}&per_page=100")) + except ( + json.JSONDecodeError, + subprocess.CalledProcessError, + TypeError, + ValueError, + ): + print("::warning::notifications fetch failed; queue left for the next cycle") + return 0 + + + def _actor_login(actor: object) -> str: + if not isinstance(actor, dict): + return "" + return str(actor.get("login") or "") + + + def _is_deferred(pr: dict[str, Any], bot: str) -> bool: + marker = f"" + comments = pr.get("comments") + nodes = comments.get("nodes", []) if isinstance(comments, dict) else [] + return any( + _actor_login(comment.get("author")) == bot + and str(comment.get("body") or "").rstrip().split("\n")[-1] == marker + for comment in nodes + if isinstance(comment, dict) + ) + + + def _conflicts(repo: str) -> int: + try: + bot = _gh("api", "user", "--jq", ".login", quiet=True).strip() + if not bot: + raise ValueError("authenticated GitHub login was empty") + response = _json( + "api", + "graphql", + "-f", + f"query={GRAPHQL_QUERY}", + "-f", + f"q=repo:{repo} author:{bot} is:pr is:open", + quiet=True, + ) + nodes = response["data"]["search"]["nodes"] + if not isinstance(nodes, list): + raise TypeError("GraphQL search nodes were not an array") + return sum( + pr.get("mergeable") != "MERGEABLE" and not _is_deferred(pr, bot) + for pr in nodes + if isinstance(pr, dict) + ) + except ( + json.JSONDecodeError, + KeyError, + subprocess.CalledProcessError, + TypeError, + ValueError, + ): + print("::warning::bot PR conflict scan failed; retrying next cycle") + return 0 + + + def main(*, now: datetime | None = None) -> int: + repo = os.environ["GITHUB_REPOSITORY"] + cutoff = ((now or datetime.now(UTC)) - timedelta(minutes=10)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + _output("cutoff", cutoff) + + try: + _gh( + "api", + f"repos/{repo}/subscription", + "-X", + "PUT", + "-F", + "subscribed=true", + "-F", + "ignored=false", + "--silent", + quiet=True, + ) + except subprocess.CalledProcessError: + print("::warning::could not enable repository watching; retrying next cycle") + + count = _notifications(cutoff) + _output("count", count) + conflict_count = _conflicts(repo) + _output("conflict_count", conflict_count) + + if count == 0 and conflict_count == 0: + print("No notification or conflict work — skipping") + else: + if count: + print(f"{count} notification task(s) — proceeding") + if conflict_count: + print(f"{conflict_count} possible conflicted bot PR(s) — proceeding") + return 0 + + + if __name__ == "__main__": + raise SystemExit(main()) + TEND_PY - 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@0.1.24 - if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' + - uses: max-sixty/tend/claude@0.2.0 + 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/.github/workflows/tend-review-runs.yaml b/.github/workflows/tend-review-runs.yaml index 31a7553b4..c5d94599b 100644 --- a/.github/workflows/tend-review-runs.yaml +++ b/.github/workflows/tend-review-runs.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init +# Generated by tend 0.2.0. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -25,14 +25,83 @@ 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 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - - uses: max-sixty/tend/claude@0.1.24 + - uses: max-sixty/tend/claude@0.2.0 + 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/.github/workflows/tend-review.yaml b/.github/workflows/tend-review.yaml index d8e81e357..0259b40b0 100644 --- a/.github/workflows/tend-review.yaml +++ b/.github/workflows/tend-review.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init +# Generated by tend 0.2.0. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -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 @@ -65,7 +135,8 @@ jobs: fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - - uses: max-sixty/tend/claude@0.1.24 + - uses: max-sixty/tend/claude@0.2.0 + 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/.github/workflows/tend-triage.yaml b/.github/workflows/tend-triage.yaml index 3c8e83535..3f9de51fb 100644 --- a/.github/workflows/tend-triage.yaml +++ b/.github/workflows/tend-triage.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init +# Generated by tend 0.2.0. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -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,13 +106,15 @@ jobs: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@v7 + if: steps.tend_enabled.outputs.enabled == 'true' with: ref: main fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - - uses: max-sixty/tend/claude@0.1.24 + - uses: max-sixty/tend/claude@0.2.0 + 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/.github/workflows/tend-weekly.yaml b/.github/workflows/tend-weekly.yaml index 79e499eda..4cfc85c09 100644 --- a/.github/workflows/tend-weekly.yaml +++ b/.github/workflows/tend-weekly.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init +# Generated by tend 0.2.0. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -25,14 +25,83 @@ 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 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - - uses: max-sixty/tend/claude@0.1.24 + - uses: max-sixty/tend/claude@0.2.0 + if: steps.tend_enabled.outputs.enabled == 'true' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} From 596b1880bb990619e48001e10a4769b3d6950192 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:52:28 +0000 Subject: [PATCH 2/2] Record setup-uv as a second upstream publisher in security-ci spec tend 0.2.0 adds astral-sh/setup-uv@v10.0.1 to tend-mention's verify job and tend-notifications' check job, immediately before run: steps whose env carries TEND_BOT_TOKEN. "Upstream compromise" described the mutable tag residual as tend's action alone, so name the second publisher and its broader trust; the rationale explains why the existing acceptance reasoning covers only half of it. Also refresh two stale version references in the rationale: the checked-in workflows are at 0.2.0, and the generator link now points at 0.2.0, whose init still writes with Path.write_text following symlinks. --- docs/specs/security-ci.md | 2 +- docs/specs/security-ci.rationale.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/security-ci.md b/docs/specs/security-ci.md index 872c23ae5..27ce1af7c 100644 --- a/docs/specs/security-ci.md +++ b/docs/specs/security-ci.md @@ -40,7 +40,7 @@ This repository runs the [tend](https://github.com/max-sixty/tend) agent harness **Org-level secrets.** An org secret shared with this repo is reachable by any workflow the bot can author, exactly like a repo-level one, and does not appear in this repo's own secret listing — `gh api repos/diffplug/dormouse/actions/organization-secrets` is the check. **None are visible here today** (rationale); **must re-evaluate and name any that becomes visible before accepting it**, and the `FAIL IF` below admits none. -**Upstream compromise.** Every generated workflow references tend's action as `max-sixty/tend/claude@` — a **tag**, not a commit SHA, and mutable by whoever owns that repository, so upstream can change what our workflows execute with no commit landing here and `workflow-audit.yaml` seeing a byte-identical file. **A real residual, accepted** (rationale). **The version pin bounds *deliberate* upgrades, not a hostile upstream**; `uvx tend@latest` runs only at install and during nightly regen, so a compromise of that path affects the next re-run, not the in-flight workflows. +**Upstream compromise.** Every generated workflow references tend's action as `max-sixty/tend/claude@` — a **tag**, not a commit SHA, and mutable by whoever owns that repository, so upstream can change what our workflows execute with no commit landing here and `workflow-audit.yaml` seeing a byte-identical file. **A real residual, accepted** (rationale). **The version pin bounds *deliberate* upgrades, not a hostile upstream**; `uvx tend@latest` runs only at install and during nightly regen, so a compromise of that path affects the next re-run, not the in-flight workflows. **A second publisher now sits in the same position**: `tend-mention`'s `verify` and `tend-notifications`' `check` run `astral-sh/setup-uv@`, whose `uv` then interprets a `run:` step holding `TEND_BOT_TOKEN` — a broader trust than tend's, **accepted on the same generated-file grounds** (rationale). **Audit visibility.** `.github/workflows/workflow-audit.yaml` walks nightly every commit touching `.github/workflows/`, `.config/tend.yaml`, `.github/audit/`, or `.vscode/` since its previous successful run — **across all branches, not just `main`**, so a workflow pushed to a feature branch is seen even though it never opens a PR. **This enumeration and the job's `WINDOW` must name the same paths** (rationale). It reports the *unexplained*, classifying out two routine sources on independently checked provenance and content: diff --git a/docs/specs/security-ci.rationale.md b/docs/specs/security-ci.rationale.md index 01e4ce91f..4d8cc925c 100644 --- a/docs/specs/security-ci.rationale.md +++ b/docs/specs/security-ci.rationale.md @@ -10,7 +10,7 @@ **Why instruction files are a class of their own.** They are not read as data the way a diff is; Claude Code loads them as authoritative guidance, which is what makes a fork PR's copy of them a different class of input from the fork's code. -**The `0.1.18` gap, reported from this audit and now fixed.** At the previously pinned `0.1.18` the revert list was a flat, root-relative `SENSITIVE` array naming `CLAUDE.md` but no `AGENTS.md` at all — and this repo keeps its instructions in `AGENTS.md` with `CLAUDE.md` as a one-line `@AGENTS.md` pointer, so the control reverted a pointer and left the content it pointed at attacker-controlled. The fix ([max-sixty/tend#1005](https://github.com/max-sixty/tend/pull/1005), merged 2026-08-22, released in `0.1.19` on 2026-08-26) replaces that list with pathspec globs — `':(glob)**/AGENTS.md'`, `':(glob)**/CLAUDE.md'`, `':(glob)**/.claude/**'` — which `restore-sensitive-config.sh` passes to `pin_to_base`, covering every depth rather than a hand-enumerated set of root paths. The checked-in workflows use `0.1.24` as inspected in September 2026; `0.1.19` remains the minimum security floor. +**The `0.1.18` gap, reported from this audit and now fixed.** At the previously pinned `0.1.18` the revert list was a flat, root-relative `SENSITIVE` array naming `CLAUDE.md` but no `AGENTS.md` at all — and this repo keeps its instructions in `AGENTS.md` with `CLAUDE.md` as a one-line `@AGENTS.md` pointer, so the control reverted a pointer and left the content it pointed at attacker-controlled. The fix ([max-sixty/tend#1005](https://github.com/max-sixty/tend/pull/1005), merged 2026-08-22, released in `0.1.19` on 2026-08-26) replaces that list with pathspec globs — `':(glob)**/AGENTS.md'`, `':(glob)**/CLAUDE.md'`, `':(glob)**/.claude/**'` — which `restore-sensitive-config.sh` passes to `pin_to_base`, covering every depth rather than a hand-enumerated set of root paths. The checked-in workflows use `0.2.0` as inspected in September 2026; `0.1.19` remains the minimum security floor. **The local remedy if it ever regresses.** The nightly regen overwrites the *workflow*, not this repository's instruction files, so moving the instruction body into `CLAUDE.md` and dropping the pointer would close it with no upstream dependency, at the cost of the filename convention other agent harnesses read. @@ -26,7 +26,7 @@ **The org secrets that no longer need accepting.** `BUILDCACHE_USER` and `NEXUS_USER` were org-wide shares — visible to every `diffplug` repository, not grants made to this one — and were previously accepted on the grounds that they are usernames rather than the paired credentials. They have since been narrowed to `selected` visibility over the repositories that actually consume them, which excludes this one, so the acceptance no longer has to be made. Every `diffplug` org secret is now `selected`, and none lists `diffplug/dormouse`. -**Why the mutable upstream tag is accepted.** The file is generated — a hand-edited SHA is overwritten by the next nightly regen, so pinning locally is not durable — and the trust it represents is the same trust the harness already has: tend runs the agent that holds `TEND_BOT_TOKEN` either way. +**Why the mutable upstream tag is accepted.** The file is generated — a hand-edited SHA is overwritten by the next nightly regen, so pinning locally is not durable — and the trust it represents is the same trust the harness already has: tend runs the agent that holds `TEND_BOT_TOKEN` either way. `astral-sh/setup-uv`, added to `tend-mention` and `tend-notifications` in `0.2.0`, is a publisher this repository had not otherwise trusted with the PAT, so only the first clause carries it: the reference is generated, and a hand-edited SHA would not survive the next regen. `workflow-audit.yaml` sees the tag *change* when a regeneration commit lands, but not the tag *moving* upstream — exactly as for tend's own action. **What unites the four `WINDOW` paths.** Each executes from a branch nobody reviewed — a workflow on a bot push, a `folderOpen` task on checkout, a prompt that decides what the nightly audit even looks at. `.config/tend.yaml` is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. The two enumerations have to agree because a path added to one without the other leaves a reader checking the `FAIL IF` against a paragraph that contradicts it. @@ -34,7 +34,7 @@ **Why the tend-regeneration classifier refuses a commit that also edits `.config/tend.yaml`.** The config's values land verbatim in the generated YAML: such a commit would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. -**Why regeneration materializes only its inputs.** In [tend 0.1.24's generator](https://github.com/max-sixty/tend/blob/0.1.24/generator/src/tend/cli.py), `init` writes workflows and `.github/actionlint.yaml` with `Path.write_text`, following symlinks. The former full worktree let an audited commit redirect those writes outside the checkout. Materializing only regular config/workflow blobs also excludes attacker-controlled ignore rules that could hide unexpected generated files from `git status`. The regression tests exercise both failures against the shipped classifier. +**Why regeneration materializes only its inputs.** In [tend 0.2.0's generator](https://github.com/max-sixty/tend/blob/0.2.0/generator/src/tend/cli.py), `init` writes workflows and `.github/actionlint.yaml` with `Path.write_text`, following symlinks. The former full worktree let an audited commit redirect those writes outside the checkout. Materializing only regular config/workflow blobs also excludes attacker-controlled ignore rules that could hide unexpected generated files from `git status`. The regression tests exercise both failures against the shipped classifier. **Why merged commits are still reported.** Review is not proof — the social-engineering path ends in an admin merge.