Skip to content
331 changes: 331 additions & 0 deletions .github/workflows/agents-md-maintenance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
name: Keep AGENTS.md accurate (reusable)

# Keeps AGENTS.md up to date. If a PR makes it wrong, the bot commits the fix to that PR's
# branch and comments what it changed.
#
# Order: shell gate -> agent edits the file -> commit -> comment. The agent only reads and
# writes files, so when something breaks you know which step did it.
#
# The caller must trigger this on `pull_request` and grant `contents: write` and
# `pull-requests: write` — a reusable workflow can only narrow the caller's permissions, never
# widen them, so a read-only token fails every run at the commit step:
#
# on:
# pull_request:
# # ready_for_review matters: this skips drafts, and no later event arrives when a draft
# # is marked ready.
# types: [opened, synchronize, reopened, ready_for_review]
#
# permissions:
# contents: write
# pull-requests: write
#
# jobs:
# update-agents-md:
# uses: apify/workflows/.github/workflows/agents-md-maintenance.yml@main
# secrets:
# ANTHROPIC_API_KEY: ${{ secrets.YOUR_ANTHROPIC_API_KEY }}
#
# The repo must also use the standard layout: AGENTS.md holds the doc, CLAUDE.md is a regular file
# whose first line is `@AGENTS.md`. Anything else FAILS the run — see the Gate step for why.
#
# Do not rename this file once repos are onboarded: every caller stub pins it by path, so a rename
# breaks all of them at once, as a red check on somebody else's unrelated PR. It supersedes
# claude-md-maintenance.yml, which onboarding deletes from each repo in the same commit.

on:
workflow_call:
secrets:
ANTHROPIC_API_KEY:
required: true

permissions:
contents: write
pull-requests: write

jobs:
update:
name: Update AGENTS.md
# Skipped before a runner starts: wrong caller event, drafts, forks (we can't push to their
# branch), PRs opened by any bot, and runs our own commit started. That last is the cheapest
# loop breaker there is, and load-bearing: claude-code-action refuses to run at all when a bot
# started the workflow ("Workflow initiated by non-human actor").
#
# The event check is first because `github.event` here is the *caller's*. It targets the events
# that DO populate github.event.pull_request — pull_request_target, pull_request_review* —
# each of which satisfies every other clause. On a payload-less event the draft check alone
# would pass, since `'' == false` compares equal in GitHub expressions.
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
!endsWith(github.event.pull_request.user.login, '[bot]') &&
github.actor != 'github-actions[bot]'
# On the job, not the workflow. A workflow-level group is claimed before the `if` above is
# evaluated, so a run we are about to skip still cancels the run examining the newest commit.
concurrency:
group: agents-md-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout the PR branch
uses: actions/checkout@v6
with:
# The head branch, not the merge ref, because we push back to it.
ref: ${{ github.event.pull_request.head.ref }}
# Nothing reads a parent commit: the gate checks the tip's author, and the only other
# comparison is the working tree against HEAD.
fetch-depth: 1
token: ${{ github.token }}

- name: Gate
id: gate
# No token and no network: every decision below reads the checkout on disk.
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail

# Checkout is by branch name, so the tip may already be newer than the commit this run
# was started for — judging it measures code the event never described, and that push has
# a run of its own.
if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then
echo "Branch moved on since this run started -> leaving it to the newer run."
echo "run=false" >> "$GITHUB_OUTPUT"; exit 0
fi

# A failure, not a skip, deliberately: a skipped run is indistinguishable from one that
# found nothing to fix, so a wrongly laid-out repo would quietly never be maintained —
# the exact failure mode of the post-merge workflow this replaces. A red check is fixed
# once, at onboarding, and never fires again. It doesn't self-repair either: moving a
# repo's instruction file is a reviewable decision, not something to smuggle into an
# unrelated PR, and the fix isn't even one command. Repos in the wild have CLAUDE.md
# symlinked to AGENTS.md, the reverse, both as diverged real files, or neither — a writer
# that guessed wrong would truncate the doc through a symlink instead of moving it.
#
# Why the doc lives in AGENTS.md and not CLAUDE.md: before the agent starts,
# claude-code-action deletes its SENSITIVE_PATHS and restores them from the base branch
# (src/github/operations/restore-config.ts). CLAUDE.md is on that list, AGENTS.md is not,
# so the agent reads the branch's own version of the file it is judging and a human's
# in-PR edits survive. The pointer is reset too, but it is one unchanging line, and Claude
# Code follows the `@` import, so the agent still gets the doc as its instructions.
layout_ok=true
# -s as well as -f: an empty AGENTS.md is a broken layout, not a doc not written yet. The
# agent may not create the file, so the run would report "nothing to do" on a repo with no
# doc at all — a silence meaning the opposite of what it looks like. Same predicate as the
# post-agent guard below.
if [ ! -f AGENTS.md ] || [ -L AGENTS.md ] || [ ! -s AGENTS.md ]; then
echo "::error::AGENTS.md must be a regular, non-empty file in the repo root, holding the doc."
layout_ok=false
elif git check-ignore -q AGENTS.md; then
# check-ignore consults the index, so a tracked-but-gitignored AGENTS.md correctly
# reports as not ignored. This only catches the untracked case, where the file is
# absent from the checkout and could never be committed.
echo "::error::AGENTS.md is listed in .gitignore, so it could never be committed."
layout_ok=false
fi
# tr -d '\r', or a CRLF checkout fails this on an invisible difference.
if [ ! -f CLAUDE.md ] || [ -L CLAUDE.md ] \
|| [ "$(head -n 1 CLAUDE.md | tr -d '\r')" != "@AGENTS.md" ]; then
echo "::error::CLAUDE.md must be a regular file whose first line is exactly '@AGENTS.md'."
layout_ok=false
fi
if [ "$layout_ok" != true ]; then
echo "::error::Required layout: AGENTS.md a regular file holding the doc, and CLAUDE.md a"
echo "::error::regular file whose first line is exactly '@AGENTS.md'."
echo "::error::With the doc still in CLAUDE.md and neither path a symlink:"
echo "::error:: git mv CLAUDE.md AGENTS.md && printf '@AGENTS.md\\n' > CLAUDE.md"
echo "::error::If either path IS a symlink, delete it before writing its replacement."
echo "::error::Redirecting into a symlink writes through to the file at the other end and"
echo "::error::truncates it, so the one command above would destroy the doc it is moving."
echo "::error::If both paths are real files they have diverged: merge them into AGENTS.md"
echo "::error::by hand first, because every shortcut here silently drops one of the two."
exit 1
fi

# Stop if we made the newest commit, or we'd react to our own and never stop. Newest only,
# so an earlier doc commit doesn't disable this for later pushes. %an, not %cn: our commits
# go through the API, which sets the committer to GitHub.
if [ "$(git log -1 --format='%an')" = "github-actions[bot]" ]; then
echo "Head commit is our own doc commit -> nothing to do."
echo "run=false" >> "$GITHUB_OUTPUT"; exit 0
fi

# Deliberately nothing that bails out because a human edited AGENTS.md in this PR. That
# check is only needed when the agent cannot see their edit — true of a reset
# SENSITIVE_PATHS file, false of AGENTS.md. Add it back only alongside a doc that IS reset.
#
# Deliberately no path filtering, here or as a `paths:` trigger. Deciding relevance from a
# filename fails silently: the run that should have fired doesn't, which looks exactly
# like one that found nothing. An earlier blocklist here hid real staleness — a new
# top-level `tests/` belongs in Repository structure, and a `.prettierrc` can contradict a
# documented convention. The agent's "NOT stale" rules do the job instead. If the spend
# ever bites, lockfiles alone is the one filter worth re-adding.
echo "run=true" >> "$GITHUB_OUTPUT"

- name: Update AGENTS.md if this PR made it wrong
id: agent
if: steps.gate.outputs.run == 'true'
uses: anthropics/claude-code-action@v1
env:
GH_TOKEN: ${{ github.token }}
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ github.token }}
show_full_output: true
prompt: |
You are in a GitHub Actions workflow on pull request #${{ github.event.pull_request.number }} in
${{ github.repository }}. Your job: if this PR makes `AGENTS.md` inaccurate, fix `AGENTS.md` on
disk. You do NOT commit, push, or comment — later steps do that. Editing nothing is a valid and
common outcome.

`AGENTS.md` in the repo root is the real instruction file. `CLAUDE.md` is a one-line
`@AGENTS.md` pointer at it and must be left exactly as it is.

## Steps
1. Read `AGENTS.md` in the repo root.
2. Read the diff: `gh pr diff ${{ github.event.pull_request.number }}`. Where the diff is
ambiguous, read the changed files themselves. Never infer from filenames.
The diff is cumulative for the whole PR, and it may already contain an earlier
`docs: update AGENTS.md` commit of your own, or an edit the PR author made by hand.
Judge the file **as it is on disk now** against the code **as it is now** — an earlier
pass having fixed something does not mean a later push didn't break it again.
3. Decide whether, once this PR merges, `AGENTS.md` states something **wrong, missing, or
misleading** for someone working in this repo.
4. If yes, edit `AGENTS.md`. If no, edit nothing.
5. **Always** write `/tmp/summary.md` explaining your conclusion, either way. Silence with no
record is not acceptable: someone must be able to audit a "nothing to do" afterwards.

## Stale means a concrete factual mismatch
- a documented command, script, or path that no longer exists or was renamed
- a new entry point, top-level directory, or dependency that changes how the project is built or run
- a convention this PR establishes or abandons that `AGENTS.md` contradicts
- a documented feature this PR removes

## NOT stale — be strict, because you are editing someone's branch uninvited
- pure refactors, internal renames, formatting
- new tests, fixtures, or CI tweaks that don't change how a developer works
- additions `AGENTS.md` already covers at the right level of abstraction
- anything where you would be rewording rather than correcting

If you cannot name the specific line in `AGENTS.md` that becomes wrong, it is not stale: change
nothing, and say so in the summary file.

## Editing rules
- **Surgical.** Change only what this PR made wrong. Leave every accurate section byte-for-byte
alone. Do not reformat, reorder, or reword for taste.
- **Deleting is editing too.** If this PR removes a documented command, path, or feature, remove
the claim. Do not leave a corrected-but-still-wrong sentence behind, and do not describe
something as removed — just stop describing it.
- **Keep it under 200 lines by not adding bulk — never by deleting content this PR did not make
wrong.** If the file is already over the limit, note that in the summary and leave it. Trimming a
bloated `AGENTS.md` is a deliberate, reviewable cleanup of its own; smuggling it into an
unrelated PR is how a one-line rename turns into a 186-line deletion nobody asked for.
- Never document something you have not read.
- **Never create `AGENTS.md`.** It is guaranteed to exist — the workflow fails the run before
reaching you if it doesn't. If you cannot read it, stop and say so in the summary.
- Touch `AGENTS.md` and `/tmp/summary.md` only. Not `CLAUDE.md`, not any other file.

## /tmp/summary.md
Markdown bullets, max 5. Put paths and commands in backticks; never wrap a whole bullet in
backticks.

Describe **what you changed**, not the state you left behind. "Removed the appendix" is a
disclosure; "the appendix is absent" reads as though it was never there and hides your own edit.
If your edit deletes more than it adds, say so in the first bullet and say why.

If you edited the file, one bullet per correction, each naming the claim that broke and the change
that broke it:

- `pnpm build` no longer exists — `scripts/build.sh` was renamed to `scripts/compile.sh`
- new `src/api/` entry point was missing from Repository structure

If you edited nothing, say what you checked and why each documented claim still holds. Name the
claims — "nothing was stale" alone is not auditable:

- `src/index.js` as the only entry point still holds; this PR adds no new entry point
- `scripts/build.sh` untouched, so the Build section is still correct
- the only near-miss: `.github/workflows/` is described as plural and there is now one file —
a rewording, not a correction, so left alone
claude_args: |
--max-turns 30
--allowedTools "Read,Glob,Grep,Edit,Write,Bash(gh pr diff:*),Bash(gh pr view:*)"

- name: Decide whether there is anything to commit
id: prep
# always(), so a failed or cancelled agent still gets its reasoning into the run summary.
# The step bails below without committing.
if: always() && steps.gate.outputs.run == 'true'
env:
AGENT_OUTCOME: ${{ steps.agent.outcome }}
run: |
set -euo pipefail

{
echo "## AGENTS.md"
echo
cat /tmp/summary.md 2>/dev/null || echo "_The agent left no summary. Treat its silence with suspicion._"
} >> "$GITHUB_STEP_SUMMARY"

if [ "$AGENT_OUTCOME" != "success" ]; then
echo "::warning::The agent step ended as '$AGENT_OUTCOME'. Whatever is on disk may be"
echo "::warning::half-written, so there is nothing safe to commit."
echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0
fi

# `git status` is the entire comparison, and only because AGENTS.md is not one of
# claude-code-action's SENSITIVE_PATHS: nothing reset it, so what the agent was handed is
# what this branch has, and a dirty file means the agent changed something. Move the doc
# onto a path that IS reset and this stops working — telling an agent edit from the reset
# undoing an earlier commit of ours then needs a blob comparison against the base branch
# TIP (not `base.sha`, an event snapshot that goes stale).
#
# The agent is told to delete stale claims, not the file, but an empty or vanished
# AGENTS.md would commit as a "docs: update" that wipes the repo's doc.
if [ ! -f AGENTS.md ] || [ -L AGENTS.md ] || [ ! -s AGENTS.md ]; then
echo "::error::AGENTS.md is missing, empty, or no longer a regular file after the agent"
echo "::error::ran. Not committing that."
echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0
fi

if [ -z "$(git status --porcelain -- AGENTS.md)" ]; then
echo "The agent left AGENTS.md unchanged -> nothing to commit."
echo "Reasoning is in the run summary."
echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0
fi

git diff --stat -- AGENTS.md
echo "commit=true" >> "$GITHUB_OUTPUT"

- name: Commit AGENTS.md
id: signed
if: steps.prep.outputs.commit == 'true'
# Commits through the GraphQL API, so GitHub signs it and sets the author from the token.
# The gate trusts that author name, and on an ordinary commit anyone with push access can
# set it to whatever they like.
uses: apify/actions/signed-commit@v1.4.0
with:
github-token: ${{ github.token }}
message: 'docs: update AGENTS.md for this PR'
# Only this path. claude-code-action leaves CLAUDE.md reset to the base version and
# unstaged in the working tree, so `add: .` would commit that revert alongside our edit.
add: AGENTS.md

- name: Comment on the PR
# The action's own `committed` output, so the comment can't claim a commit that isn't there.
if: steps.signed.outputs.committed == 'true'
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
{
echo "### AGENTS.md updated"
echo
if [ -f /tmp/summary.md ]; then cat /tmp/summary.md; else echo "See the commit for what changed."; fi
echo
echo "Committed to this branch, so it merges with your code. Review it like any other commit —"
echo "if it's wrong, edit or drop it."
} > /tmp/body.md
gh pr comment "$PR" --body-file /tmp/body.md
Loading