diff --git a/.config/tend.yaml b/.config/tend.yaml index 5f951f1b2..c711e81e2 100644 --- a/.config/tend.yaml +++ b/.config/tend.yaml @@ -21,7 +21,7 @@ workflows: # GitHub only ever delays a scheduled run, so the default 09:17 slot # has always started before the reset. cron: "17 15 * * 0" - ci-fix: + ci-failures: watched_workflows: - CI - Argos diff --git a/.github/workflows/tend-ci-failures.yaml b/.github/workflows/tend-ci-failures.yaml new file mode 100644 index 000000000..0a15d2823 --- /dev/null +++ b/.github/workflows/tend-ci-failures.yaml @@ -0,0 +1,282 @@ +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init +# +# Do not edit this file directly — it will be overwritten on regeneration. +# To change this workflow, edit .config/tend.yaml. To change what the agent +# does, edit the relevant skill (for example, `running-tend`) in this repo's +# .claude/skills/ directory, or open an issue at +# https://github.com/max-sixty/tend/issues for changes that need to +# happen upstream in the tend-ci-runner plugin. + +name: tend-ci-failures +# Turns a watched workflow's verdict on a watched branch into its CI failure +# report, with no agent: a failure files the report or comments on it and +# dispatches the report's triage, and a pass closes it. `ci_failure_report.py` +# documents the report, and why it writes with the job's own token rather than +# the bot's. +on: + workflow_run: + workflows: ["CI", "Argos"] + types: [completed] + branches: ["main"] + +jobs: + report: + # Only a verdict reaches the group below. On a busy branch most completions + # are runs a newer commit cancelled, and each would otherwise take the + # lane's single pending slot from a verdict waiting in it. + # + # Only this repository's own branches, too. A fork PR opened from the + # fork's `main` runs with `head_branch` set to `main`, which passes the + # branch filter; reporting it would file and close another lane's report, + # with a title and logs the fork's author controls. + if: >- + github.repository_owner == 'diffplug' && vars.TEND_ENABLED != 'false' + && github.event.workflow_run.head_repository.full_name == github.repository + && ( + github.event.workflow_run.conclusion == 'success' + || github.event.workflow_run.conclusion == 'failure' + || github.event.workflow_run.conclusion == 'timed_out' + || github.event.workflow_run.conclusion == 'startup_failure') + concurrency: + # One writer per report, so one lane's verdicts apply in turn and never + # file two reports. A newer pending verdict replaces an older one, which + # it supersedes anyway. + group: ${{ github.workflow }}-${{ github.event.workflow_run.name }}-${{ github.event.workflow_run.event }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + runs-on: ubuntu-24.04 + # `actions: write` is what the triage dispatch needs; the job holds no + # secret, and runs only the pinned script below. + permissions: + actions: write + issues: write + steps: + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + id: tend_uv + env: + UV_NO_MODIFY_PATH: "1" + with: + version: "0.13.5" + checksum: ${{ runner.arch == 'ARM64' && '4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403' || 'f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f' }} + enable-cache: false + ignore-empty-workdir: true + - name: File, update or close the CI failure report + env: + GH_TOKEN: ${{ github.token }} + TRIAGE_WORKFLOW: "tend-triage.yaml" + TEND_UV: ${{ steps.tend_uv.outputs.uv-path }} + run: | + "$TEND_UV" run --script - <<'TEND_PY' + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + """File, update or close the CI failure report a finished watched run calls for. + + `tend-ci-failures` runs this, with no agent, when a watched workflow finishes + on a watched branch of this repository with a verdict. A report is one issue + per workflow, branch and triggering event, the unit a run's verdict speaks + for: a scheduled suite can fail on tests that push runs never reach. + + - A failed run files the report, or comments on the one already open, and + dispatches `tend-triage` for it. + - A passing run closes the open report. The cause issues filed under it stay + as they are: a pass doesn't show that a flaky cause is gone, and a cause's + fix may still be in flight, so each cause's own triage closes it. + - A run that a newer finished run of the same lane supersedes changes nothing. + Runs can finish out of order, and the newer verdict is the lane's state. + + The job's own `GITHUB_TOKEN` writes, as `github-actions[bot]`, rather than the + bot's token, so the bot is notified of the report and of each later comment; + GitHub notifies nobody of their own activity. `GITHUB_TOKEN` starts no workflow + by opening an issue, but it may dispatch one, and the dispatched triage run is + the fast path. The notification is the fallback, for a dispatch that failed or + a run the rate limit refused: the run marks it read only once it succeeds, and + the notifications poll re-dispatches whatever is left unread. + + Inputs (env): ``GH_TOKEN``, ``GITHUB_REPOSITORY``, ``GITHUB_EVENT_PATH`` (a + `workflow_run` payload), and ``TRIAGE_WORKFLOW``, the triage workflow's file, + empty where triage is not generated. + """ + + from __future__ import annotations + + import json + import os + import subprocess + import sys + from pathlib import Path + from typing import Any + from urllib.parse import urlencode + + LABEL = "tend-ci-failure" + LABEL_DESCRIPTION = "A watched CI workflow is failing; Tend tracks its causes here" + LABEL_COLOR = "b60205" + REPORTER = "github-actions[bot]" + # The verdicts that say something about the lane. The generated job's `if:` + # admits only these, so a cancelled run never takes the lane's pending slot + # from a verdict; everything but `success` reaching here is a failure. + VERDICTS = ("success", "failure", "timed_out", "startup_failure") + # How far back the newer-run check looks. A lane finishing more runs than this + # while this one waited for its job is a lane whose newest run reports itself. + RECENT_RUNS = 30 + + + def api(path: str, *, method: str = "GET", body: dict | None = None) -> Any: + """One `gh api` call; a failure raises, and fails the job with gh's error.""" + args = ["gh", "api", "--method", method, path] + if body is not None: + args += ["--input", "-"] + # `gh` colorizes a piped response when the job's environment forces color; + # `CLICOLOR_FORCE=0` outranks a forced value where `NO_COLOR` alone loses. + env = {**os.environ, "NO_COLOR": "1", "CLICOLOR_FORCE": "0"} + result = subprocess.run( + args, + input=json.dumps(body) if body is not None else None, + capture_output=True, + text=True, + env=env, + check=False, + ) + if result.returncode: + sys.stderr.write(result.stderr) + raise subprocess.CalledProcessError( + result.returncode, args, result.stdout, result.stderr + ) + return json.loads(result.stdout) if result.stdout.strip() else None + + + def paginated(path: str) -> list[dict[str, Any]]: + """Every item of a listing, page by page.""" + items: list[dict[str, Any]] = [] + page = 1 + while True: + batch = api(f"{path}&page={page}") + items += batch + if len(batch) < 100: + return items + page += 1 + + + def title(run: dict[str, Any]) -> str: + return f"`{run['name']}` failing on `{run['head_branch']}` ({run['event']})" + + + def run_link(run: dict[str, Any]) -> str: + return f"[run {run['id']}]({run['html_url']}) on `{run['head_sha'][:12]}`" + + + def superseded(repo: str, run: dict[str, Any]) -> bool: + """Whether a newer run of the same lane has already finished with a verdict.""" + query = urlencode( + { + "branch": run["head_branch"], + "event": run["event"], + "status": "completed", + "per_page": RECENT_RUNS, + } + ) + listing = api(f"repos/{repo}/actions/workflows/{run['workflow_id']}/runs?{query}") + return any( + other["id"] > run["id"] and other.get("conclusion") in VERDICTS + for other in listing["workflow_runs"] + ) + + + def open_report(repo: str, run: dict[str, Any]) -> int | None: + """The lane's open report: the lowest open issue by `github-actions[bot]` + under the label and the lane's title. Any workflow's `GITHUB_TOKEN` writes + as that account, so this matches what no person and not the bot filed.""" + issues = paginated(f"repos/{repo}/issues?labels={LABEL}&state=open&per_page=100") + numbers = sorted( + issue["number"] + for issue in issues + if "pull_request" not in issue + and issue["user"]["login"] == REPORTER + and issue["title"] == title(run) + ) + return numbers[0] if numbers else None + + + def comment(repo: str, number: int, body: str) -> None: + api(f"repos/{repo}/issues/{number}/comments", method="POST", body={"body": body}) + + + def file_report(repo: str, run: dict[str, Any]) -> int: + # Fails once the label exists, which is every run after the first. + try: + api( + f"repos/{repo}/labels", + method="POST", + body={ + "name": LABEL, + "description": LABEL_DESCRIPTION, + "color": LABEL_COLOR, + }, + ) + except subprocess.CalledProcessError: + pass + body = ( + f"`{run['name']}` failed on `{run['head_branch']}`: {run_link(run)}, " + f"triggered by `{run['event']}`.\n\n" + f"This issue tracks `{run['name']}`'s `{run['event']}` runs on " + f"`{run['head_branch']}` until one passes. Each later failed run adds a " + "comment, and the next passing run closes this issue. Tend triages each " + "failure, filing every distinct cause as a sub-issue and working it " + "there.\n" + ) + issue = api( + f"repos/{repo}/issues", + method="POST", + body={"title": title(run), "body": body, "labels": [LABEL]}, + ) + return issue["number"] + + + def dispatch_triage(repo: str, run: dict[str, Any], number: int, ref: str) -> None: + """Start the report's triage; a failure leaves it to the notification.""" + workflow = os.environ.get("TRIAGE_WORKFLOW", "") + if not workflow: + return + try: + api( + f"repos/{repo}/actions/workflows/{workflow}/dispatches", + method="POST", + body={ + "ref": ref, + "inputs": {"issue": str(number), "title": title(run), "report": "true"}, + }, + ) + except subprocess.CalledProcessError: + print( + f"::warning::could not dispatch triage for #{number}; the " + "notifications poll picks it up" + ) + + + def main() -> int: + repo = os.environ["GITHUB_REPOSITORY"] + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + run = event["workflow_run"] + if superseded(repo, run): + print(f"A newer run of this lane has finished; run {run['id']} changes nothing") + return 0 + number = open_report(repo, run) + if run["conclusion"] != "success": + if number is None: + number = file_report(repo, run) + else: + comment(repo, number, f"Failed again: {run_link(run)}.") + dispatch_triage(repo, run, number, event["repository"]["default_branch"]) + elif number is not None: + comment(repo, number, f"{run_link(run)} passed. Closing.") + api( + f"repos/{repo}/issues/{number}", + method="PATCH", + body={"state": "closed", "state_reason": "completed"}, + ) + return 0 + + + if __name__ == "__main__": + raise SystemExit(main()) + TEND_PY diff --git a/.github/workflows/tend-ci-fix.yaml b/.github/workflows/tend-ci-fix.yaml deleted file mode 100644 index e7b2f8d1f..000000000 --- a/.github/workflows/tend-ci-fix.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init -# -# Do not edit this file directly — it will be overwritten on regeneration. -# To change this workflow, edit .config/tend.yaml. To change what the agent -# does, edit the relevant skill (for example, `running-tend`) in this repo's -# .claude/skills/ directory, or open an issue at -# https://github.com/max-sixty/tend/issues for changes that need to -# happen upstream in the tend-ci-runner plugin. - -name: tend-ci-fix -on: - workflow_run: - workflows: ["CI", "Argos"] - types: [completed] - branches: ["main"] - -jobs: - fix-ci: - # By default, only failed runs start a ci-fix session. - if: github.repository_owner == 'diffplug' && vars.TEND_ENABLED != 'false' && 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 and the run's event are in the key too: a red - # `publish-site` must not starve behind a stream of red `ci`, and a - # pending scheduled failure must not be replaced by the next push - # failure on the same branch. Never cancel — a running session may - # already have pushed a branch or opened a PR. Default queue depth is - # right: within each group, the newest failure replaces the pending one. - group: ${{ github.workflow }}-${{ github.event.workflow_run.name }}-${{ github.event.workflow_run.event }}-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: false - runs-on: ubuntu-24.04 - environment: - name: tend - deployment: false - permissions: - contents: write - pull-requests: write - actions: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - fetch-depth: 0 - token: ${{ secrets.TEND_BOT_TOKEN }} - - - run: node scripts/setup-git.mjs - name: Define the markdown merge driver - - - uses: max-sixty/tend/claude@0.3.14 - with: - github_token: ${{ secrets.TEND_BOT_TOKEN }} - merge: restricted - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - bot_name: dormouse-bot - model: opus - checkout_mode: base - base_branch: ${{ github.event.repository.default_branch }} - prompt: |2 - /tend-ci-runner:ci-fix ${{ github.event.workflow_run.id }} - - Run URL: ${{ github.event.workflow_run.html_url }} - - Commit: ${{ github.event.workflow_run.head_sha }} - - Commit message: ${{ github.event.workflow_run.head_commit.message }} diff --git a/.github/workflows/tend-mention-relay.yaml b/.github/workflows/tend-mention-relay.yaml index 231a0d092..aae8f0412 100644 --- a/.github/workflows/tend-mention-relay.yaml +++ b/.github/workflows/tend-mention-relay.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To change this workflow, edit .config/tend.yaml. To change what the agent diff --git a/.github/workflows/tend-mention.yaml b/.github/workflows/tend-mention.yaml index 891b372d5..0b7783c70 100644 --- a/.github/workflows/tend-mention.yaml +++ b/.github/workflows/tend-mention.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To change this workflow, edit .config/tend.yaml. To change what the agent @@ -27,21 +27,15 @@ on: jobs: verify: - # Skip comments on the issues tend files about its own health: the action - # auto-comments on those when a run fails or is refused, and without this - # guard those comments re-trigger tend-mention, producing a - # self-sustaining ~1 run/minute loop until the underlying condition - # clears. The prompt's self-loop guard can't help here because the model - # never executes — the action fails before Claude starts. A relayed review - # enters as `repository_dispatch` and is judged in the check step below, - # against the record the API holds. + # The check step rejects the bot's own comments before handle starts, + # including health-report comments from a failed preflight. Human replies + # on those same issues still go through engagement verification. if: | vars.TEND_ENABLED != 'false' && ( github.event_name == 'repository_dispatch' || (github.event_name == 'issues' && contains(github.event.issue.body, '@dormouse-bot')) || - (github.event_name == 'issue_comment' && - contains(github.event.issue.labels.*.name, 'tend-outage') == false && contains(github.event.issue.labels.*.name, 'tend-rate-limit') == false)) + github.event_name == 'issue_comment') runs-on: ubuntu-24.04 environment: name: tend @@ -59,8 +53,8 @@ jobs: env: UV_NO_MODIFY_PATH: "1" with: - version: "0.12.23" - checksum: ${{ runner.arch == 'ARM64' && '6524bd338177ed50d035d39354e12545e993bbeba2ecbddf0480c5b3a81d313f' || '9167d72b3319674b6303c4cbe071854bba13ebdf3d76b1a7cbdc175471fb66d6' }} + version: "0.13.5" + checksum: ${{ runner.arch == 'ARM64' && '4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403' || 'f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f' }} enable-cache: false ignore-empty-workdir: true - name: Verify bot engagement @@ -346,7 +340,7 @@ jobs: - run: node scripts/setup-git.mjs name: Define the markdown merge driver - - uses: max-sixty/tend/claude@0.3.14 + - uses: max-sixty/tend/claude@0.3.16 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} merge: restricted diff --git a/.github/workflows/tend-nightly.yaml b/.github/workflows/tend-nightly.yaml index 9aa89ab49..2e5ea2f87 100644 --- a/.github/workflows/tend-nightly.yaml +++ b/.github/workflows/tend-nightly.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To change this workflow, edit .config/tend.yaml. To change what the agent @@ -35,7 +35,7 @@ jobs: - run: node scripts/setup-git.mjs name: Define the markdown merge driver - - uses: max-sixty/tend/claude@0.3.14 + - uses: max-sixty/tend/claude@0.3.16 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} merge: restricted diff --git a/.github/workflows/tend-notifications.yaml b/.github/workflows/tend-notifications.yaml index 0a1907391..506af5511 100644 --- a/.github/workflows/tend-notifications.yaml +++ b/.github/workflows/tend-notifications.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To change this workflow, edit .config/tend.yaml. To change what the agent @@ -34,8 +34,8 @@ jobs: env: UV_NO_MODIFY_PATH: "1" with: - version: "0.12.23" - checksum: ${{ runner.arch == 'ARM64' && '6524bd338177ed50d035d39354e12545e993bbeba2ecbddf0480c5b3a81d313f' || '9167d72b3319674b6303c4cbe071854bba13ebdf3d76b1a7cbdc175471fb66d6' }} + version: "0.13.5" + checksum: ${{ runner.arch == 'ARM64' && '4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403' || 'f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f' }} enable-cache: false ignore-empty-workdir: true - name: Check for unread notifications and conflicted PRs @@ -234,7 +234,7 @@ jobs: - run: node scripts/setup-git.mjs name: Define the markdown merge driver if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' - - uses: max-sixty/tend/claude@0.3.14 + - uses: max-sixty/tend/claude@0.3.16 if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} diff --git a/.github/workflows/tend-review-runs.yaml b/.github/workflows/tend-review-runs.yaml index 79ca58a8b..e57ed3643 100644 --- a/.github/workflows/tend-review-runs.yaml +++ b/.github/workflows/tend-review-runs.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To change this workflow, edit .config/tend.yaml. To change what the agent @@ -35,7 +35,7 @@ jobs: - run: node scripts/setup-git.mjs name: Define the markdown merge driver - - uses: max-sixty/tend/claude@0.3.14 + - uses: max-sixty/tend/claude@0.3.16 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} merge: restricted diff --git a/.github/workflows/tend-review.yaml b/.github/workflows/tend-review.yaml index 3be218ed6..2bf89de9a 100644 --- a/.github/workflows/tend-review.yaml +++ b/.github/workflows/tend-review.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To change this workflow, edit .config/tend.yaml. To change what the agent @@ -53,7 +53,7 @@ jobs: - run: node scripts/setup-git.mjs name: Define the markdown merge driver - - uses: max-sixty/tend/claude@0.3.14 + - uses: max-sixty/tend/claude@0.3.16 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} merge: restricted diff --git a/.github/workflows/tend-triage.yaml b/.github/workflows/tend-triage.yaml index 233a64aa1..6e71ddcea 100644 --- a/.github/workflows/tend-triage.yaml +++ b/.github/workflows/tend-triage.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To change this workflow, edit .config/tend.yaml. To change what the agent @@ -8,17 +8,44 @@ # happen upstream in the tend-ci-runner plugin. name: tend-triage +# A dispatched run would otherwise be named for the workflow. Named for the +# issue, like an opened one, the notifications poll can see it holds that +# issue. +run-name: ${{ inputs.title || github.event.issue.title }} on: issues: types: [opened] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.issue.number }} - cancel-in-progress: true + # A CI failure report, dispatched by tend-ci-failures for each failed run, + # and an issue a later run hands back, such as a stalled cause issue. + # GitHub creates the run even when a `GITHUB_TOKEN` dispatched it, and it + # carries the default branch, which the environment admits. + workflow_dispatch: + inputs: + issue: + description: The issue to triage + type: number + required: true + title: + description: The issue's title, which names the run + type: string + default: "" + report: + description: Whether the issue is a CI failure report + type: boolean + default: false jobs: triage: if: github.repository_owner == 'diffplug' && vars.TEND_ENABLED != 'false' && contains(github.event.issue.labels.*.name, 'tend-outage') == false && contains(github.event.issue.labels.*.name, 'tend-rate-limit') == false + # Reports share one group, so no two report sessions file the same cause + # at once. Each finished failure dispatches one, and the newest pending one + # replaces an older: it reads every run its report holds since the bot's + # last word, and a replaced one's report keeps its notification. Every + # other issue is a group of its own. Never cancelled: a session may already + # have filed an issue or opened a PR. + concurrency: + group: ${{ inputs.report && 'tend-triage-ci-reports' || format('{0}-{1}', github.workflow, github.event.issue.number || inputs.issue) }} + cancel-in-progress: false runs-on: ubuntu-24.04 environment: name: tend @@ -35,7 +62,7 @@ jobs: || echo "::warning::could not add the eyes reaction" env: REPO: ${{ github.repository }} - TARGET: issues/${{ github.event.issue.number }} + TARGET: issues/${{ github.event.issue.number || inputs.issue }} GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -47,7 +74,7 @@ jobs: - run: node scripts/setup-git.mjs name: Define the markdown merge driver - - uses: max-sixty/tend/claude@0.3.14 + - uses: max-sixty/tend/claude@0.3.16 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} merge: restricted @@ -58,7 +85,7 @@ jobs: checkout_mode: base base_branch: ${{ github.event.repository.default_branch }} prompt: |2 - /tend-ci-runner:triage ${{ github.event.issue.number }} + /tend-ci-runner:triage ${{ github.event.issue.number || inputs.issue }} - name: Remove the eyes reaction if: always() @@ -72,6 +99,6 @@ jobs: fi env: REPO: ${{ github.repository }} - TARGET: issues/${{ github.event.issue.number }} + TARGET: issues/${{ github.event.issue.number || inputs.issue }} BOT_NAME: dormouse-bot GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} diff --git a/.github/workflows/tend-weekly.yaml b/.github/workflows/tend-weekly.yaml index 1aaadeb2f..c5ce79e17 100644 --- a/.github/workflows/tend-weekly.yaml +++ b/.github/workflows/tend-weekly.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.3.14. Regenerate with: uvx tend@latest init +# Generated by tend 0.3.16. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To change this workflow, edit .config/tend.yaml. To change what the agent @@ -35,7 +35,7 @@ jobs: - run: node scripts/setup-git.mjs name: Define the markdown merge driver - - uses: max-sixty/tend/claude@0.3.14 + - uses: max-sixty/tend/claude@0.3.16 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} merge: restricted diff --git a/.github/zizmor.yml b/.github/zizmor.yml index a2237b7df..5722ed347 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -17,12 +17,11 @@ rules: # `pull_request_target` rule (docs/specs/security-ci.md -> "GitHub # Actions Policies"); "Automated Maintainer (tend)" bounds them. - tend-review.yaml - - tend-ci-fix.yaml + - tend-ci-failures.yaml artipacked: ignore: # tend checks out with `TEND_BOT_TOKEN` so the agent can push; the # files are regenerated nightly and cannot carry inline ignores. - - tend-ci-fix.yaml - tend-mention.yaml - tend-nightly.yaml - tend-notifications.yaml diff --git a/docs/specs/security-ci.md b/docs/specs/security-ci.md index ef1dc551e..a5fce6df8 100644 --- a/docs/specs/security-ci.md +++ b/docs/specs/security-ci.md @@ -99,7 +99,7 @@ The diff window's evasions are `docs/specs/security.md` -> "Known gaps". - **FAIL IF** `S60` any `tend-*.yaml` pins `max-sixty/tend` below `0.1.19`, the release that pins instruction files by glob at any depth (rationale). Pinned by `scripts/workflow-lint.mjs`. - **FAIL IF** `S61` the pinned `max-sixty/tend/claude` starts the agent on a workflow's fork-PR checkout before reverting the project-instruction paths, or hands the agent's environment, disk, or `.git/config` the real `TEND_BOT_TOKEN` or an Anthropic credential; or a `tend-*.yaml` sets workflow- or job-level `env:`, which the harness forwards to the agent, or writes a secret to `$GITHUB_ENV`. Read the action at the tag the workflows pin: `claude/action.yaml` and the shared steps it runs for checkout ordering, instruction pinning, and sandbox setup (rationale). A fork PR the agent checks out itself is `docs/specs/security.md` -> "Known gaps". The `env:` and `$GITHUB_ENV` halves pinned by `scripts/workflow-lint.mjs`. - **FAIL IF** `S62` any `tend-*.yaml` references `max-sixty/tend/claude` unpinned (e.g. `@main`, no version). Every other action reference is "GitHub Actions Policies"'s. Pinned by `scripts/workflow-lint.mjs`. -- **FAIL IF** `S63` any job in an agent-managed workflow has **effective** `GITHUB_TOKEN` permissions beyond `contents: write`, `pull-requests: write`, `issues: write`, `actions: read`, or any `read` permission, or runs `anthropics/claude-code-action` without `github_token`, which hands its agent the Claude GitHub App's token. Effective, not declared: apply job permissions over workflow permissions over the repository default; omitted scopes in an explicit block become `none` (rationale). Pinned by `scripts/workflow-lint.mjs`. +- **FAIL IF** `S63` any job in an agent-managed workflow has **effective** `GITHUB_TOKEN` permissions beyond `contents: write`, `pull-requests: write`, `issues: write`, `actions: read`, or any `read` permission, except `actions: write` on the agentless `report` job of `tend-ci-failures.yaml`, which dispatches `tend-triage.yaml`; or runs `anthropics/claude-code-action` without `github_token`, which hands its agent the Claude GitHub App's token. Effective, not declared: apply job permissions over workflow permissions over the repository default; omitted scopes in an explicit block become `none` (rationale). Pinned by `scripts/workflow-lint.mjs`. - **FAIL IF** `S64` `default_workflow_permissions` for this repository is not `read`, or `can_approve_pull_request_reviews` is not `false` (`gh api repos/diffplug/dormouse/actions/permissions/workflow`) — the backstop for every permission bullet in this spec (rationale). Pinned by `scripts/github-state-check.mjs`. Source of truth: `packageRules` in `.github/renovate.json`; `.github/workflows/workflow-audit.yaml`. diff --git a/docs/specs/security-ci.rationale.md b/docs/specs/security-ci.rationale.md index 248416b1a..2102815f0 100644 --- a/docs/specs/security-ci.rationale.md +++ b/docs/specs/security-ci.rationale.md @@ -18,7 +18,7 @@ The October 2026 audit read tend 0.3.5's `claude/action.yaml`, `restore-sensitive-config.sh`, and instruction-pinning helper. Trusted outer steps receive credentials for preflight and proxy setup; the boundary is their absence from the sandbox agent, rather than an assertion that no trusted runner process outside the proxy ever holds them. The generated reaction/pre-check steps also receive the bot credential without launching an agent. -**How every other generated workflow picks its subjects.** An event payload names the PR, issue, or comment (`tend-review`, `tend-triage`, `tend-mention`, `tend-ci-fix`), or a scheduled sweep works a fixed list — recent commits, dependency PRs, last night's runs. +**How every other generated workflow picks its subjects.** An event payload names the PR, issue, or comment (`tend-review`, `tend-triage`, `tend-mention`), or a scheduled sweep works a fixed list — recent commits, dependency PRs, last night's runs. **Evidence that the subscription PUT has taken effect.** Asking as the bot (`gh api repos/diffplug/dormouse/subscription` → `subscribed: true`), corroborated without the bot credential through the public `GET /repos/diffplug/dormouse/subscribers` listing. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index fa95dd77a..846c0ba1d 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -25,7 +25,7 @@ "docs/specs/remote-security-model.md": 5700, "docs/specs/reopen.md": 1200, "docs/specs/security-audit.md": 5400, - "docs/specs/security-ci.md": 3400, + "docs/specs/security-ci.md": 3450, "docs/specs/security-hosted.md": 3150, "docs/specs/security-local.md": 6300, "docs/specs/security-remote.md": 7200, diff --git a/scripts/workflow-lint.mjs b/scripts/workflow-lint.mjs index 8602ca9d0..7d505eb4c 100644 --- a/scripts/workflow-lint.mjs +++ b/scripts/workflow-lint.mjs @@ -127,7 +127,10 @@ export function check(inputs) { const declared = job?.permissions !== undefined ? job.permissions : doc.permissions; const held = declared === undefined ? [] : writes(declared); if (agent) { - const extra = held.filter((s) => !AGENT_WRITES.includes(s)); + // The CI failure reporter runs no agent and reads no secret; its + // `actions: write` dispatches `tend-triage.yaml` for a failed run. + const allowed = file === 'tend-ci-failures.yaml' && jobId === 'report' ? [...AGENT_WRITES, 'actions'] : AGENT_WRITES; + const extra = held.filter((s) => !allowed.includes(s)); if (extra.length) fail(TEND, `${file} job \`${jobId}\` holds write on ${extra.join(', ')}`); } else { const allowed = file === 'release.yml' && jobId === 'security-audit' ? [...RELEASE_WRITES, 'actions'] @@ -190,6 +193,18 @@ export function check(inputs) { if (!ephemeral) fail(VSCODE, 'release.yml `build-standalone` no longer generates an ephemeral Tauri updater key'); } + // tend-ci-failures.yaml `report`: its `actions: write` exception holds only while the job runs + // no action but uv's setup, reads no secret, and dispatches `tend-triage.yaml`. + const report = docs['tend-ci-failures.yaml']?.jobs?.report; + if (report) { + const actions = (report.steps ?? []).filter((s) => s.uses && !s.uses.startsWith('astral-sh/setup-uv@')); + if (actions.length) fail(TEND, `tend-ci-failures.yaml \`report\` runs \`${actions.map((s) => s.uses).join('`, `')}\` with its \`actions: write\``); + const secrets = secretsOf(docs['tend-ci-failures.yaml'], report); + if (secrets.size) fail(TEND, `tend-ci-failures.yaml \`report\` reads ${[...secrets].map((s) => `\`${s}\``).join(', ')}`); + const targets = (report.steps ?? []).filter((s) => s.env?.TRIAGE_WORKFLOW !== undefined).map((s) => s.env.TRIAGE_WORKFLOW); + if (targets.some((t) => t !== 'tend-triage.yaml')) fail(TEND, `tend-ci-failures.yaml \`report\` dispatches \`${targets.join('`, `')}\`, not \`tend-triage.yaml\``); + } + // hosted-production.yml `tag`: the minted token is the only credential, scoped to contents on this repository. const tag = docs['hosted-production.yml']?.jobs?.tag; const mint = tag?.steps?.find((s) => s.uses?.startsWith('actions/create-github-app-token@')); diff --git a/scripts/workflow-lint.test.mjs b/scripts/workflow-lint.test.mjs index 79d46fbc7..e6bf0407a 100644 --- a/scripts/workflow-lint.test.mjs +++ b/scripts/workflow-lint.test.mjs @@ -42,6 +42,11 @@ const cases = [ ['issues: write on another job of the red-team issue workflow', edit('red-team-issue.yaml', ' open:\n', ' other:\n'), 'red-team-issue.yaml job `other` holds write on issues'], ['issues: write on the release red-team note', edit('release.yml', ' contents: read\n issues: read', ' contents: read\n issues: write'), 'release.yml job `red-team-note` holds write on issues'], ['an agent-managed job with actions: write', edit('tend-review.yaml', ' actions: read\n', ' actions: write\n'), 'tend-review.yaml job `review` holds write on actions'], + ['actions: write on another job of the CI failure reporter', edit('tend-ci-failures.yaml', ' report:\n', ' other:\n'), 'tend-ci-failures.yaml job `other` holds write on actions'], + ['the CI failure reporter checks out', edit('tend-ci-failures.yaml', ' steps:\n', ` steps:\n - uses: ${CHECKOUT}\n`), 'tend-ci-failures.yaml `report` runs `actions/checkout@'], + ['the CI failure reporter reads a secret', edit('tend-ci-failures.yaml', 'GH_TOKEN: ${{ github.token }}', 'GH_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}'), 'tend-ci-failures.yaml `report` reads `TEND_BOT_TOKEN`'], + ['the CI failure reporter dispatches another workflow', edit('tend-ci-failures.yaml', 'TRIAGE_WORKFLOW: "tend-triage.yaml"', 'TRIAGE_WORKFLOW: "release.yml"'), 'dispatches `release.yml`, not `tend-triage.yaml`'], + ['the CI failure reporter writes checks', edit('tend-ci-failures.yaml', ' actions: write\n issues: write', ' actions: write\n checks: write\n issues: write'), 'tend-ci-failures.yaml job `report` holds write on checks'], ['an agent-managed job with checks: write', edit('security-audit.yaml', ' actions: read\n issues: write', ' actions: read\n checks: write\n issues: write'), 'security-audit.yaml job `audit` holds write on checks'], ['an agent-managed job with id-token: write', edit('security-audit.yaml', ' actions: read\n issues: write', ' actions: read\n issues: write\n id-token: write'), 'security-audit.yaml job `audit` holds write on id-token'], ['the audit agent on the Claude App token', edit('security-audit.yaml', ' github_token: ${{ github.token }}\n', ''), 'job `audit` runs `anthropics/claude-code-action` without `github_token`'],