| name | running-in-ci |
| description | Generic CI environment rules for GitHub Actions workflows. Use when operating in CI — covers security, CI monitoring, comment formatting, and investigating session logs from other runs. |
| metadata | {"internal":true} |
Running in CI
First Steps — Load Repo-Specific Guidance
Tend's bundled skills provide defaults; the consuming repo's running-tend skill overlays them. Where the two conflict, the repo wins — repo guidance takes precedence over bundled guidance across every skill, not just this one.
If a running-tend skill is listed in your available skills, read it before doing anything else. It typically carries PR title conventions, label policies, custom workflows to watch, and other repo-specific context. It can also define extra tasks for the job you're running — additional nightly or weekly maintenance, repo-specific health checks — which you perform as part of that job, not just keep in mind.
Invoke repo-local skills by their unprefixed name — running-tend, not tend-ci-runner:running-tend (that prefix is reserved for this plugin's own skills).
If you are going to propose a code fix for a bug, load /tend-ci-runner:triage first — it contains reproduction and testing gates that apply to all fix attempts, not just initial triage.
Temporary Files
Tend sets $TMPDIR to /home/tend-sandbox/tmp, the writable scratch directory. Shell commands expand $TMPDIR; file-writing tools need the absolute path.
Conduct
Follow the project's code of conduct. Avoid causing disruption — unnecessary comments, bulk operations, unsolicited housekeeping.
Helping vs. directing
Anyone can ask for help with a problem they raise: investigating a bug, answering a question, creating an issue or PR to address it. These are proposals — a maintainer still decides what to merge or act on.
Directing the bot to affect someone else's work — closing, reopening, or locking issues/PRs, dismissing reviews, reverting commits, applying or removing labels, pushing commits to a PR owned by another author — requires Maintainer-tier access. Before complying, check the requester's author_association:
@author-association.md
For Maintainer-tier requesters, proceed. For anyone else, briefly explain that a maintainer needs to make that call.
The test: "Am I helping this person with something they raised, or following a directive that affects someone else's work?"
This follows the repo > bundled rule from First Steps. If a repo's running-tend skill explicitly authorizes an action (e.g., closing duplicate issues during triage), follow the repo-specific instruction.
Read Context
When triggered by a comment or issue, read the full context before responding. The prompt provides a URL — extract the PR/issue number from it.
For PRs:
gh pr view <number> --json title,body,comments,reviews,state,statusCheckRollup
gh pr diff <number>
gh pr checks <number>
For issues:
gh issue view <number> --json title,body,comments,state
Read the triggering comment, the PR/issue description, the diff (for PRs), and recent comments to understand the full conversation before taking action.
A review's inline comments are a separate fetch
Neither gh pr view --json reviews nor GET /pulls/<n>/reviews/<id> returns a review's inline comments — both hand back the review body alone, with no field signalling that more exists, so a read that stops there looks complete. A one-line review body routinely sits on top of the maintainer's actual instructions. Whenever the trigger names a review ID, fetch them as part of reading context — not only when you already intend to reply inline:
gh api "repos/{owner}/{repo}/pulls/{number}/reviews/{review_id}/comments" \
--jq '.[] | {id, path, line, body}'
An instruction found there constrains the whole response, including any code the reply quotes or carries into another PR.
Instruction paths read as the base version on a PR
Before the session starts, both harnesses restore CLAUDE.md, CLAUDE.local.md, AGENTS.md, and .claude/** at any depth from the base branch on PR events (pull_request_target, review events, and issue_comment on a PR). Those files are read at CLI startup before any permission gating, so the PR's copies must not be trusted. tend-mention's relayed repository_dispatch carries no PR payload and restores nothing. The restore touches the worktree only; the index and HEAD keep the PR's version. So on a PR that legitimately edits these paths:
- The working tree holds the base content — grepping it reports the PR's additions as absent, and the repo-local skills loaded into this session are the base versions too. Read the PR's version with
git show HEAD:<path> before making any claim about what these files contain.
git status shows a modification nobody made and git diff shows the PR's edit as deletions. Where the pin ran, that is the restore, not a contributor mistake — nothing to report or revert. On an unpinned event it is a real modification, worth reading.
- Never stage one of these paths from the PR checkout —
git add <path>, git add -A, and git commit -a all copy the worktree over the index, committing the base version back over the PR's own edit. Commit them from a $TMPDIR worktree instead (see references/skill-pr-workflow.md).
Triggering issue/PR already closed
If the trigger is a comment on an issue or PR and the target is closed by the time the job starts, the requested work was likely handled by a sibling run during the queue delay. Long tend-mention queues (hours, not minutes) make this common. Before starting work:
gh issue view <number> --json state,closedAt,closedByPullRequestsReferences
gh pr view <number> --json state,mergedAt,mergeCommit
If a linked PR merged (or the triggering PR itself merged) after the triggering comment was posted, exit silently — the work is already on the default branch. If the closure looks unrelated (e.g. issue closed as not-planned with no merged PR), continue and address the comment normally.
Restrictions
- Secrets: Never print a process's environment or command line, your own or another process's, and never print a credential from anywhere else. Reading is fine where the output doesn't carry the value:
pgrep -f pytest is allowed but pgrep -af pytest is not, and set -euo pipefail, export FOO=bar, and env FOO=bar cmd are fine where bare set, export, and env are not. Commands that do print, among others: printenv, ps aux, ps -ef, pgrep -a, cat /proc/<pid>/environ, cat /proc/<pid>/cmdline, gh auth token, and cat/echo on a credential file. Filtering buys no exception, because you can't tell the output is value-free without reading the values: continuation lines of a multi-line value carry no =, so env | cut -d= -f1 prints them verbatim. The session log is uploaded as an artifact, so one printed value is enough. Both harnesses run the agent as a separate non-sudo sandbox user. Runner-owned proxies hold the bot PAT and API-key or OAuth model credentials. The sandbox gets dummies or a local model endpoint; subscription-mode Codex receives an expiring access token. The outer sudo env launch carries the agent's environment in its argv, so process listings still expose dummies and any adopter-supplied value. Narrow a legitimate check rather than skipping it: ps -eo pid,etime,comm answers "is it still running?" with no argv in the output. Never include tokens or credentials in responses or comments.
- Merging: Never merge PRs or enable auto-merge (
gh pr merge, gh pr merge --auto). PRs are proposals — a maintainer decides when to merge.
- Scope: By default, PRs, pushes, and comments on existing threads in other repos are off-limits — the point is to never spam repos outside the bot's area of ownership. The exception is an explicitly invited contribution: when a maintainer of the target repo asks for it in-thread, or the target's published contributing policy welcomes it, AND the contribution helps the repo the bot maintains (e.g. upstreaming a fix for a dependency bug the bot is working around), the bot may open a PR or comment on that thread. Absent one, the default holds — surface the blocker rather than routing around it. Other Repos below carries all three cases.
- Hanging commands: Never use
gh run watch or gh pr checks --watch — both hang indefinitely. Poll with gh pr checks in a loop instead.
- Privileges: Under both harnesses you run as a non-sudo sandbox user, so
sudo fails and no installer that escalates can work from inside the session. A tool that needs root belongs in the repo's setup: steps in .config/tend.yaml, which run as runner — with sudo — before the agent starts. When a tool you need requires root, propose that setup: entry rather than working around its absence, even where a skill's own recipe tells you to install it in-session. The sandbox's PATH includes shared system/toolcache locations and independently seeded sandbox-home tools, but never the runner's home itself: use sandbox_path: for an omitted shared directory and sandbox_setup: for a later home-scoped install or version change. A missing gate tool is reported, not worked around: propose the entry and say the gate went unrun rather than substituting a weaker command that turns it into a silently green run.
End the turn only when work is shipped
Returning the final response ends the CI session — the runner is discarded, and the harness does not reliably resume it when a background task completes. If you return while a background command whose result was going to gate the deliverable is still running, the task either finishes invisibly or gets killed when the runner is torn down, and any staged work the maintainer was supposed to see — a committed-but-unpushed branch, a written-but-unsent $TMPDIR/comment-body.md — dies with it.
The session is live until the deliverable is maintainer-visible: pushed, posted, or opened. Local-only state — a commit nobody else can see, a comment body never sent — does not count and is not recoverable on a follow-up.
Corollary: don't background anything whose output gates the deliverable. If a full test suite or comprehensive lint needs to run before push, run it synchronously and accept the time cost; if it's too slow for the session budget, push first and let CI re-run it. A session that shipped a partial result is recoverable; a session that ended mid-wait with the deliverable on a local branch is not. A targeted compile plus the tests directly exercising the change is enough local confidence to ship — leave the comprehensive matrix to CI.
A pushed fix isn't done until its required checks are terminal — see CI Monitoring.
Your closing summary is the session's only durable record of what happened, and it is read later as if it were current. Re-check any state claim in it against the live PR or issue as you write it, and prefer claims about what you did over claims about a state you don't control — "pushed the fix as <sha>, and its checks went green at that head" stays true, while "the PR is open and awaiting a maintainer" is falsified the moment a sibling session or a maintainer closes it.
Weighing a Fix
The maintainer's order of value: outward correctness first — what the bot posts, approves, merges, closes — then simple machinery, and efficiency a distant third. Complexity spent preventing a wrong outward action is well spent. Complexity spent saving compute is not, whether the compute is the bot's own sessions (a no-op run, a duplicated survey) or the repo's CI runner time (a slow job, a hang that a rerun clears): the waste costs cents, while the added gate, retry wrapper, or cache is maintained forever and fails in ways of its own.
So a change whose only benefit is saved compute clears a higher bar than a correctness fix, on two counts:
- Evidence. The waste has recurred across days — observed, not projected.
- Remedy. Use one existing knob in one place, remove machinery, or add a one-line condition. Judge the whole change: repeated settings across workflows, jobs, platforms, or call sites are a configuration scheme, even when they use the same knob or value.
When either bar fails, don't make the change: note what the waste costs where the maintainer will see it and move on.
Scripts over prose recipes
When a skill's code block needs edge-case handling or grows past a couple of dozen lines, put the logic in a tested script and leave the skill a one-line invocation with the intent: for bundled skills plugins/tend-ci-runner/scripts/ (exercised by the generator test suite), for a repo overlay a scripts/ directory beside the skill. A prose recipe gets no shellcheck and no tests; every session re-derives its correctness.
Filing Issues in This Repo
An issue here is not a note to a maintainer — where tend-triage is enabled (the default), it fires on issues and does the work. Filing one for a fix you have already scoped hands your own analysis to a second agent run, which re-derives it from your issue body and opens the PR minutes later at full session cost, on a thread nobody needed.
So if you can open the PR in this run, open the PR. Reserve an issue for what you genuinely can't finish here: a problem too large or ambiguous to fix, one that needs a maintainer decision, or one whose verification is out of reach from CI. Bookkeeping issues are a separate case, not this trade-off: ci-fix's transient-diagnosis tracker carries tend-outage, which the generated tend-triage and tend-mention if: skip, so no conversion run fires.
This governs your own repo only; filing into another repo follows the section below.
Other Repos
Default: don't act in another repo unsolicited. File an issue in the current repo asking permission to file in the target; on maintainer approval, file there. references/other-repos.md carries the rest: the standing exception an overlay can grant for agent-equipped targets, what an issue body must contain, when an invitation makes a PR or comment in the target legitimate, and what to do when a scope rule is the only thing between you and the right move.
PR Creation
When asked to create a PR, use gh pr create directly.
Before creating a branch or PR, check for existing work:
gh pr list --state open --limit 200 --json number,title,headRefName --jq '.[] | "#\(.number) [\(.headRefName)]: \(.title)"'
git branch -r --list 'origin/fix/*'
Open PRs compete for one maintainer's attention. A self-initiated improvement — a sweep finding, a skill or workflow refinement nobody asked for — draws on a budget: when the bot already has five or more PRs open (gh pr list --state open --author "@me"), open one only for a wrong outward action (see Weighing a Fix), and hold the rest until the queue drains, recorded where the maintainer will see it (the evidence store, or a line on the triggering thread) rather than as an issue (Filing Issues in This Repo explains why not). The budget never holds work someone asked for, a fix a user is waiting on (a red default branch, a triaged bug), or the scheduled maintenance a skill itself instructs (a workflow regeneration, a pinned-version bump, a data refresh). Base every PR on the default branch; never stack one on an unmerged bot branch, which puts the same change through review once per link in the chain.
Write PR titles, issue titles, and commit subjects in plain, literal language that a reader can understand without the body. Name the concrete component and behavior changed while keeping any prefix the repository requires. Put the explanation in the body. The test: someone who has not read the diff can say what changed. Example: Stop worker retries after cancellation.
The titles that fail it read as figures rather than descriptions — a metaphor, a subject withheld for effect, a phrase that only lands once you already know the bug. Rewrite to the literal statement: Press again for the tab the driver lost, not the one Chromium never made → Retry the click when the browser driver never reports the opened tab.
Describe the current PR for a maintainer deciding whether to merge it. Follow Reader-facing prose under Comment Formatting and synthesize across commits and review rounds.
If an existing PR addresses the same problem, work on that PR instead.
Configure git identity before the first commit
Runners don't always pre-seed a git identity, and a fresh git worktree never inherits one. Without it git commit fails with Author identity unknown, the branch gets pushed with no commit, and gh pr create then fails with No commits between main and <branch>. Set it once before your first commit — --global covers the main checkout and every $TMPDIR worktree in one shot, and it's idempotent, so re-running is safe:
BOT_LOGIN=$(gh api user --jq '.login')
BOT_ID=$(gh api user --jq '.id')
git config --global user.name "$BOT_LOGIN"
git config --global user.email "${BOT_ID}+${BOT_LOGIN}@users.noreply.github.com"
The noreply form (<id>+<login>@users.noreply.github.com) keeps commits attributed to the bot account and passes verified-email push rules.
Dedup recheck immediately before gh pr create
A separate mention on a different issue/PR can trigger a concurrent run asking for the same fix. Those runs are not serialized — each has its own concurrency group — so both may read an empty gh pr list at session start and then each open their own PR minutes later, producing near-duplicates. A long workflow queue (tend-mention can wait hours) also lets a sibling run open and merge a PR before this run starts — already-merged duplicates need to be in scope too. Re-run the check as the last step before gh pr create, with --state all so closed and merged siblings show up:
BOT_LOGIN=$(gh api user --jq '.login')
gh pr list --state all --author "$BOT_LOGIN" --limit 200 \
--json number,title,state,mergedAt,headRefName,createdAt
When the trigger is an issue/PR comment, also search for sibling PRs that reference that issue number — a merged PR's title or body often cites the issue (Fixes #123, #123 in title) even when the branch name diverged:
gh pr list --state all --search "author:$BOT_LOGIN <issue-number>" \
--json number,title,state,mergedAt
Compare by title keywords and the files the new PR would modify — two concurrent fixes for the same bug typically pick different branch names, so a branch-name match is not sufficient. If a sibling bot PR overlaps in scope — whether open, closed, or already merged — do not create: post a comment on the triggering thread linking the existing PR and exit.
A fix may have landed directly on the default branch while you worked. Fetch it immediately before creating the PR:
DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')
git fetch origin "$DEFAULT_BRANCH"
git log --oneline "HEAD..origin/$DEFAULT_BRANCH"
Inspect overlapping commits and reproduce the problem on the fetched default branch. If it is fixed, don't create the PR; comment on the triggering thread when it needs a response.
Fetch the prior rejection before re-deriving a fix
A change a maintainer already turned down leaves its verdict in two places the checks above don't fetch: the closed PR that carried it, and the comments on the issue tracking it. Search by the symbol or path the change would edit — a finding re-derived from the code has no issue number, and an attempt predating the tracking issue cites none either — then read the closed hits and the issue bodies, not just their titles.
Search, don't scan. A recency-ordered listing ages a rejection out in bot-throughput time: at a few PRs a day, any --limit drops it within weeks, and raising the cap only moves that boundary. A symbol match stays small however many PRs have landed since.
BOT_LOGIN=$(gh api user --jq '.login')
gh pr list --state all --search "author:$BOT_LOGIN <symbol>" --limit 100 --json number,title,state,closedAt
gh pr view <n> --json comments,reviews --jq '[.comments[].body, .reviews[].body]'
gh issue view <n> --json body,comments --jq '[.body, .comments[].body]'
What you find governs: a PR closed on the code leaves the fix available to redo, while one closed on the approach means the semantics are still an open maintainer question — add findings to that thread rather than opening a second implementation of it.
Pushing to PR Branches
Always use git push without specifying a remote — gh pr checkout configures tracking to the correct remote, including for fork PRs. Specifying origin explicitly can push to the wrong place.
If pushing fails (fork PR with edits disabled), fall back to posting code snippets in a comment. Don't reference commit SHAs from temporary branches — post code inline.
Batch the push — every push costs a reviewer round
tend-review triggers on synchronize under a per-PR concurrency group with queue: max and without cancel-in-progress: pending PR events within GitHub's queue limit wait while a review session runs. A push that lands mid-review is folded into the review that session posts, anchored at the live head; the queued run then boots, finds that head already reviewed, and finishes without posting. Nothing within that limit is killed or replaced, but every push still costs a session, and one that lands after the review posts costs a full review.
- Commit everything before
gh pr create. Changelog entries, test pins, and formatting fixups belong in the initial push, not a follow-up thirty seconds later.
- Make the commits, then push once — not a push after each commit. Amends and rebases count: a force-push fires
synchronize too.
A follow-up push that acts on information the session didn't have at push time — review feedback, a red check — earns its round. What's wasteful is splitting work you already have into several pushes.
Re-check PR state before pushing a follow-up commit
Any wait that lets time pass — a CI poll, coverage fetch, sleep, background task — also gives a maintainer time to merge or close the PR. After waiting:
STATE=$(gh pr view <N> --json state --jq '.state')
[ "$STATE" = "OPEN" ] || { echo "PR #<N> is $STATE — skipping push"; exit 0; }
If the PR is merged, the work is superseded. Comment if a real gap remains; do not push to the now-orphan branch. After merge, gh pr view <N> --json headRefOid returns the SHA at merge time and never advances — polling it for a new push is a guaranteed deadlock.
Re-check the head SHA before the expensive verify, not just before the push
A PR another tend session opened keeps that session alive polling its checks, and closing a red gate is exactly the follow-up it stays alive for — so a sibling commit can land on the branch while you edit it. Find that out at git push and the suite you just ran was scoped against a stale head, so the whole verify cycle is paid again after the rebase. Record the head before editing and re-check it immediately before each expensive step — full test suite, coverage or snapshot regeneration, a long build:
HEAD_OID=$(gh pr view <N> --json headRefOid --jq '.headRefOid')
read -r NOW_OID NOW_STATE < <(gh pr view <N> --json headRefOid,state --jq '"\(.headRefOid) \(.state)"')
[ "$NOW_OID" = "$HEAD_OID" ] && [ "$NOW_STATE" = "OPEN" ] \
|| echo "sibling pushed or PR closed — fetch and re-scope before verifying"
state rides along on the same call because a merged or closed PR freezes headRefOid at its merge-time value (see above) — the OID comparison alone passes and the expensive step runs on work that is already superseded. On a non-OPEN state, stop per the subsection above rather than re-scoping.
If it moved, git fetch and read the new commits before verifying: drop whatever the sibling already landed, rebase what's left, and verify once against the new head. Expect the overlap rather than treating it as a surprise — a reviewer and a coverage gate reading the same new code ask for the same missing test. The runs API can't substitute for this check: a schedule or repository_dispatch run reports head_branch: main, not the branch it is editing, so a live sibling is invisible there.
Merging Upstream into PR Branches
When merging the default branch into a PR branch, never use --allow-unrelated-histories: if git merge fails because no merge base exists, the checkout is broken (usually shallow — re-checkout with fetch-depth: 0), and forcing the merge creates add/add conflicts in every file. If the merge fails because untracked files would be overwritten, stash them (git stash --include-untracked, merge, git stash pop) rather than deleting them.
Dismiss a standing bot approval the moment you conclude the PR shouldn't merge
A bot APPROVED keeps deciding the PR until a dismissal or a CHANGES_REQUESTED replaces it. A later COMMENT doesn't, and neither does the event that actually invalidated it: another PR merging and superseding this one, a dependency bump that turns this PR into a downgrade, an approach the thread has since rejected. None of those touch the approved PR, so no review round fires and the approval stands indefinitely — with nothing between it and a merge once the branch stops conflicting. Whichever session reaches the conclusion is the one that has to clear it, whether or not this session posts anything.