From 229754b6a4b6ef2c020d6bc80b2eba475d5859e6 Mon Sep 17 00:00:00 2001 From: Filip Masar Date: Mon, 17 Aug 2026 09:20:26 +0200 Subject: [PATCH 1/7] feat: add claude-md-pr reusable workflow Keeps CLAUDE.md up to date inside the PR that made it wrong, instead of opening a separate PR after merge that nobody merges. Refs apify/integrations-team#98 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-md-pr.yml | 414 +++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 .github/workflows/claude-md-pr.yml diff --git a/.github/workflows/claude-md-pr.yml b/.github/workflows/claude-md-pr.yml new file mode 100644 index 0000000..3b9c2d8 --- /dev/null +++ b/.github/workflows/claude-md-pr.yml @@ -0,0 +1,414 @@ +name: Keep CLAUDE.md accurate (reusable) + +# Keeps CLAUDE.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 must grant `contents: write` and +# `pull-requests: write` — a reusable workflow can only narrow the caller's permissions, never +# widen them, so with the default read-only token every run fails at the commit step: +# +# on: +# pull_request: +# # ready_for_review matters: this workflow 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-claude-md: +# uses: apify/workflows/.github/workflows/claude-md-pr.yml@main +# secrets: +# ANTHROPIC_API_KEY: ${{ secrets.YOUR_ANTHROPIC_API_KEY }} +# +# The repo also needs CLAUDE.md to be the real file in its root — if AGENTS.md holds the content +# today, swap them and symlink AGENTS.md -> CLAUDE.md. The workflow skips with a warning +# otherwise, because claude-code-action's pre-agent reset only protects the path CLAUDE.md. +# +# This is deliberately a separate file from claude-md-maintenance.yml, which callers trigger on +# push to main and which opens its own PR. Every caller stub pins @main, so editing that file in +# place would flip every onboarded repo at once. + +on: + workflow_call: + secrets: + ANTHROPIC_API_KEY: + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + update: + name: Update CLAUDE.md + # Skipped before a runner starts: callers wired to the wrong event, drafts, forks (we can't + # push to their branch), PRs opened by any bot, and runs our own commit started. That last one + # is the cheapest loop breaker there is, and it is also load-bearing: claude-code-action + # refuses to run at all when a bot started the workflow ("Workflow initiated by non-human + # actor"), so without it an approved run over our own commit fails the job. + # + # The event check is first because `github.event` here is the *caller's* event. It is not + # aimed at push or workflow_dispatch — those carry no pull_request payload, so the fork check + # below already fails them closed. It is aimed at the events that DO populate + # github.event.pull_request: pull_request_target and pull_request_review*, each of which + # satisfies every other clause. Note also that 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: claude-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 }} + # Shallow. We only read the tip commit's author here. The base commit gets fetched + # later, in the step that needs it. + fetch-depth: 2 + token: ${{ github.token }} + + - name: Gate + id: gate + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + # The checkout is by branch name, so the tip can already be newer than the commit this run + # was started for. Judging it would measure 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 + + # From the API, not `gh pr diff`: gh parses the raw diff, and GitHub truncates that on + # a big PR. A truncated list reads as "nothing relevant changed" and we skip silently. + # This endpoint has its own cap of 3000 files that --paginate does not lift, so the + # problem is moved out of reach rather than solved. + files="$(gh api "repos/$GH_REPO/pulls/$PR/files" --paginate --jq '.[].filename')" + echo "Changed files:"; echo "$files" | sed 's/^/ /' + + # We only handle a real CLAUDE.md in the repo root. Some repos make it a symlink to + # AGENTS.md, or a one-line pointer at it. Editing through either writes a file this + # workflow never commits, so the run would cost a full agent call and change nothing. + if [ -L CLAUDE.md ]; then + echo "::warning::CLAUDE.md is a symlink. This workflow needs CLAUDE.md to be the real" + echo "::warning::file and AGENTS.md to be the symlink. Skipping." + echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + if [ -f CLAUDE.md ] && [ "$(wc -c < CLAUDE.md)" -lt 64 ] && [ "$(head -c 1 CLAUDE.md)" = "@" ]; then + echo "::warning::CLAUDE.md points at another file instead of holding the doc. Skipping." + echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + # A repo that was never onboarded usually keeps the doc in AGENTS.md with no CLAUDE.md at + # all. The prompt below tells the agent to create a missing CLAUDE.md, so without this we + # would invent a second, divergent instruction file and commit it to someone else's PR. + # Only when AGENTS.md is a real file: with neither file present, creating one is the point. + if [ ! -e CLAUDE.md ] && [ -f AGENTS.md ] && [ ! -L AGENTS.md ]; then + echo "::warning::AGENTS.md holds the doc and there is no CLAUDE.md. Swap them during" + echo "::warning::onboarding — CLAUDE.md real, AGENTS.md a symlink to it. Skipping." + echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + # An ignored CLAUDE.md is absent from the checkout, so the agent creates one and the + # `git add` in prep then refuses the path and exits 1 — the only hard job failure in this + # file. Skip loudly instead. Tracked files are unaffected: check-ignore consults the index, + # so a CLAUDE.md that is committed and also listed in .gitignore reports as not ignored. + if git check-ignore -q CLAUDE.md; then + echo "::warning::CLAUDE.md is listed in .gitignore. Skipping." + echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + + # Stop if we made the newest commit, or we'd react to our own commit and never stop. + # Newest commit only, so an earlier doc commit doesn't disable this for later pushes. + # %an is the author: our commits are made through the API, which sets the committer to + # GitHub, so %cn would never match. + 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 + + # If a human commit in this PR edited CLAUDE.md, leave the file alone for the rest of it. + # claude-code-action resets CLAUDE.md to the base version before the agent starts, so the + # agent never sees their edit and committing its output would throw that edit away. Our + # own doc commits are excluded, so one of them doesn't disable this for the whole PR. + if grep -qxF 'CLAUDE.md' <<<"$files"; then # in the diff? if not, nobody touched it + # Assigned first, then iterated. `for x in $(cmd)` does not trip `set -e`, so a failed + # API call would give an empty list, skip the whole check and let us overwrite them. + # Merge commits are skipped: a merge of the base branch lists every file that changed + # there, so one that touched CLAUDE.md would disable this for the rest of the PR. + shas="$(gh api "repos/$GH_REPO/pulls/$PR/commits" --paginate \ + --jq '.[] | select(.commit.author.name != "github-actions[bot]") + | select((.parents | length) < 2) | .sha')" + for sha in $shas; do + # Matched inside jq, not `| grep -q`: grep exits on the first match, gh gets SIGPIPE, + # and pipefail turns that into a false "nobody touched it". + info="$(gh api "repos/$GH_REPO/commits/$sha" \ + --jq '[([.files[]?.filename] | index("CLAUDE.md") != null), (.files | length)] | @tsv')" + read -r touched count <<<"$info" + # The API caps .files at 300. At the cap we cannot prove they left it alone, so + # assume they didn't. + if [ "$touched" = "true" ] || [ "$count" -ge 300 ]; then + echo "Commit $sha (human) modifies CLAUDE.md -> leaving the file to its author." + echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + done + fi + + # Deliberately no path filtering, here or as a `paths:` trigger filter. 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 skipped tests, lockfiles + # and boilerplate to save agent calls, and it hid real staleness: a new top-level `tests/` + # directory belongs in Repository structure, and a `.prettierrc` can contradict a + # documented convention. The agent's "NOT stale" rules do the job instead, and keep it + # affordable. If the spend ever bites, lockfiles alone is the one filter worth re-adding — + # a real dependency change always touches a manifest too. + echo "run=true" >> "$GITHUB_OUTPUT" + + - name: Update CLAUDE.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 `CLAUDE.md` inaccurate, fix `CLAUDE.md` on + disk. You do NOT commit, push, or comment — later steps do that. Editing nothing is a valid and + common outcome. + + ## Steps + 1. Read `CLAUDE.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 CLAUDE.md` commit of your own. 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, `CLAUDE.md` states something **wrong, missing, or + misleading** for someone working in this repo. + 4. If yes, edit `CLAUDE.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 `CLAUDE.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 `CLAUDE.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 `CLAUDE.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. + - **If `CLAUDE.md` does not exist, create it**, using only the sections that apply to this repo: + `# `, then `## Project purpose`, `## Repository structure`, + `## Technology stack`, `## Build, test & run`, `## Conventions`, + `## Notes for AI assistants`. Document only what you have actually read. + - **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 `CLAUDE.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. + - Touch `CLAUDE.md` and `/tmp/summary.md` only. Not `AGENTS.md` (a later step manages the symlink), + 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: + # Through env, not pasted into the script: a branch name can contain a quote. + BASE_REF: ${{ github.event.pull_request.base.ref }} + AGENT_OUTCOME: ${{ steps.agent.outcome }} + run: | + set -euo pipefail + + # The agent's reasoning goes to the run summary either way, so a run that changed nothing + # can still be checked. + { + echo "## CLAUDE.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 + + # Three outcomes below: the agent asked for no change (never commit), it re-derived what + # the branch already has (nothing to do), or it made a real change (commit). + # + # Telling them apart means comparing against what the agent was HANDED, which is the base + # branch TIP — claude-code-action resets the file with + # `git checkout origin/ -- CLAUDE.md` before the agent starts. Three traps + # here, each one a bug we shipped: comparing against HEAD reverts an earlier doc commit on + # this branch and comments that it updated the file; comparing against `base.sha` uses a + # snapshot taken when the event fired, which goes stale the moment anything lands on the + # base branch; and a bare `git fetch origin ` can write FETCH_HEAD and nothing else, + # because actions/checkout narrows remote.origin.fetch to the head branch — which drops us + # into the HEAD fallback this comparison exists to avoid. + git fetch --quiet --depth=1 origin \ + "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" 2>/dev/null || true + + # `rev-parse --verify --quiet`, because plain rev-parse on a path that isn't in the tree + # still echoes ":CLAUDE.md" to stdout while exiting 128. + if git rev-parse --verify --quiet "origin/$BASE_REF^{commit}" >/dev/null; then + handed="$(git rev-parse --verify --quiet "origin/$BASE_REF:CLAUDE.md" || echo absent)" + else + # Don't fall through to "absent": that reads as "the base had no CLAUDE.md" and would + # commit anything. + echo "::warning::Could not resolve origin/$BASE_REF. Falling back to comparing against" + echo "::warning::HEAD, which cannot distinguish an agent edit from the reset." + handed="$(git rev-parse --verify --quiet 'HEAD:CLAUDE.md' || echo absent)" + fi + + # Stage first, then read the index: that blob is exactly what would be committed, with + # any .gitattributes filters applied. -f and not -L, because a symlink stages as the + # target's path string rather than any content, which compares against nothing useful. + if [ -f CLAUDE.md ] && [ ! -L CLAUDE.md ]; then + git add -- CLAUDE.md + now="$(git rev-parse :CLAUDE.md)" + else + now=absent + fi + echo "CLAUDE.md handed to the agent: $handed" + echo "CLAUDE.md now: $now" + + # The agent is told to delete stale claims, not the file. If the file is gone, that is a + # mistake, and committing it would delete the repo's CLAUDE.md under a "docs: update" + # message. + if [ "$now" = absent ] && [ "$handed" != absent ]; then + echo "::error::CLAUDE.md is missing after the agent ran. Not committing a deletion." + echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + + # Comparing blobs rather than running `git diff` also covers a brand new file, which is + # untracked and so invisible to diff. + if [ "$now" = "$handed" ]; then + echo "The agent left CLAUDE.md exactly as it was handed -> nothing to commit." + if [ -n "$(git status --porcelain -- CLAUDE.md)" ]; then + # The agent wanted no change, but the file on disk differs from HEAD. Two causes: an + # earlier doc commit of ours on this branch, or CLAUDE.md having moved on the base + # branch since it was cut. Committing would overwrite one of them with the other, and + # the blobs cannot say which is wanted. + echo "::warning::CLAUDE.md differs from HEAD only because of the pre-agent reset, and" + echo "::warning::the agent asked for no change, so nothing is being committed. Either" + echo "::warning::this branch has an earlier doc commit, or the base branch's CLAUDE.md" + echo "::warning::changed after this branch was cut — merge the base branch in to see" + echo "::warning::which, and drop the earlier doc commit if this PR made it stale." + fi + echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + + # The agent edited, but the result is what the branch already has: after a doc commit it + # gets the base file and usually writes the same fix again. + if [ -z "$(git status --porcelain -- CLAUDE.md)" ]; then + echo "The agent re-derived CLAUDE.md identically to what is already on this branch." + echo "Nothing to commit. Reasoning is in the run summary." + echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + + # AGENTS.md is a symlink to CLAUDE.md, created once per repo during onboarding. We only + # warn, never touch it: a signed commit can't carry a symlink, because the GraphQL API + # stores file contents with no mode. + if [ ! -e AGENTS.md ]; then + echo "::warning::No AGENTS.md. Create it once during onboarding: ln -s CLAUDE.md AGENTS.md" + elif [ ! -L AGENTS.md ]; then + echo "::warning::AGENTS.md is a regular file, not a symlink — leaving it for a human to merge." + fi + + git diff --cached --stat -- CLAUDE.md + echo "commit=true" >> "$GITHUB_OUTPUT" + + - name: Commit CLAUDE.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 CLAUDE.md for this PR' + add: CLAUDE.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 "### CLAUDE.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 From 239b141855851f352dcdb52a9df5de2e1f39484e Mon Sep 17 00:00:00 2001 From: Filip Masar Date: Mon, 17 Aug 2026 14:38:59 +0200 Subject: [PATCH 2/7] feat: make AGENTS.md the file this workflow maintains AGENTS.md now holds the doc and CLAUDE.md is a one-line `@AGENTS.md` pointer at it. This is the right way round: claude-code-action deletes and re-checks-out its SENSITIVE_PATHS from the base branch before the agent starts, and CLAUDE.md is on that list while AGENTS.md is not. So the agent now reads the branch's own version of the file it is judging. The gate hard-fails a repo that isn't laid out this way, printing the fix, instead of skipping with a warning. A skip is indistinguishable from "found nothing to fix", which is how a repo silently stops being maintained. Two things the old layout forced, both now gone: - The base-branch blob comparison in prep. With AGENTS.md never reset, `git status` answers "did the agent change anything" directly. - The per-commit API loop that bailed out when a human had edited the doc in the PR. It existed because the reset hid their edit from the agent, so committing reverted their work. The agent now sees it and edits on top, which is correct. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-md-pr.yml | 279 +++++++++++------------------ 1 file changed, 108 insertions(+), 171 deletions(-) diff --git a/.github/workflows/claude-md-pr.yml b/.github/workflows/claude-md-pr.yml index 3b9c2d8..bdefbdc 100644 --- a/.github/workflows/claude-md-pr.yml +++ b/.github/workflows/claude-md-pr.yml @@ -1,6 +1,6 @@ -name: Keep CLAUDE.md accurate (reusable) +name: Keep AGENTS.md accurate (reusable) -# Keeps CLAUDE.md up to date. If a PR makes it wrong, the bot commits the fix to that PR's +# 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 @@ -21,18 +21,19 @@ name: Keep CLAUDE.md accurate (reusable) # pull-requests: write # # jobs: -# update-claude-md: +# update-agents-md: # uses: apify/workflows/.github/workflows/claude-md-pr.yml@main # secrets: # ANTHROPIC_API_KEY: ${{ secrets.YOUR_ANTHROPIC_API_KEY }} # -# The repo also needs CLAUDE.md to be the real file in its root — if AGENTS.md holds the content -# today, swap them and symlink AGENTS.md -> CLAUDE.md. The workflow skips with a warning -# otherwise, because claude-code-action's pre-agent reset only protects the path CLAUDE.md. +# The repo must also use the standard layout: AGENTS.md is the real file holding the doc, and +# CLAUDE.md is a regular file whose first line is `@AGENTS.md`. A repo that doesn't FAILS the run, +# with the fix in the error message — see the Gate step for why a failure and not a skip. # -# This is deliberately a separate file from claude-md-maintenance.yml, which callers trigger on -# push to main and which opens its own PR. Every caller stub pins @main, so editing that file in -# place would flip every onboarded repo at once. +# The filename still says claude-md because every onboarded caller stub pins +# `claude-md-pr.yml@main`, so renaming it would break all of them at once. Same reason this is a +# separate file from claude-md-maintenance.yml, which callers trigger on push to main and which +# opens its own PR. on: workflow_call: @@ -46,7 +47,7 @@ permissions: jobs: update: - name: Update CLAUDE.md + name: Update AGENTS.md # Skipped before a runner starts: callers wired to the wrong event, drafts, forks (we can't # push to their branch), PRs opened by any bot, and runs our own commit started. That last one # is the cheapest loop breaker there is, and it is also load-bearing: claude-code-action @@ -101,43 +102,57 @@ jobs: echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 fi - # From the API, not `gh pr diff`: gh parses the raw diff, and GitHub truncates that on - # a big PR. A truncated list reads as "nothing relevant changed" and we skip silently. - # This endpoint has its own cap of 3000 files that --paginate does not lift, so the - # problem is moved out of reach rather than solved. - files="$(gh api "repos/$GH_REPO/pulls/$PR/files" --paginate --jq '.[].filename')" - echo "Changed files:"; echo "$files" | sed 's/^/ /' - - # We only handle a real CLAUDE.md in the repo root. Some repos make it a symlink to - # AGENTS.md, or a one-line pointer at it. Editing through either writes a file this - # workflow never commits, so the run would cost a full agent call and change nothing. - if [ -L CLAUDE.md ]; then - echo "::warning::CLAUDE.md is a symlink. This workflow needs CLAUDE.md to be the real" - echo "::warning::file and AGENTS.md to be the symlink. Skipping." - echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - if [ -f CLAUDE.md ] && [ "$(wc -c < CLAUDE.md)" -lt 64 ] && [ "$(head -c 1 CLAUDE.md)" = "@" ]; then - echo "::warning::CLAUDE.md points at another file instead of holding the doc. Skipping." - echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 + # The required layout: AGENTS.md is the real file holding the doc, CLAUDE.md is a regular + # file whose first line is `@AGENTS.md`. Anything else fails the run. + # + # A failure, not a skip, and deliberately so. A skipped run is indistinguishable from a + # run that found nothing to fix, so a repo with the wrong layout would quietly never be + # maintained — which is the exact failure mode of the post-merge workflow this replaces. + # A red check is noticed and fixed once, during migration or onboarding, and then never + # fires again. This is also why the gate doesn't try to repair the layout itself: moving a + # repo's instruction file is a reviewable decision, not something to smuggle into an + # unrelated PR. + # + # Why the doc lives in AGENTS.md and not CLAUDE.md: before the agent starts, + # claude-code-action deletes its SENSITIVE_PATHS and checks them out again from the base + # branch (src/github/operations/restore-config.ts). CLAUDE.md is on that list; AGENTS.md is + # not. So with the doc in AGENTS.md the agent reads the branch's own version of the file it + # is judging, and a human's in-PR edits to it survive. With the doc in CLAUDE.md every run + # was instead handed the base version, which is what the base-branch blob comparison this + # file used to carry existed to work around. The CLAUDE.md pointer is reset too, but it is + # one unchanging line, so resetting it is a no-op — and Claude Code follows the `@` import, + # so the agent still gets the doc as its own instructions. + layout_ok=true + if [ ! -f AGENTS.md ] || [ -L AGENTS.md ]; then + echo "::error::AGENTS.md must be a regular file in the repo root, holding the doc itself." + layout_ok=false + elif git check-ignore -q AGENTS.md; then + # check-ignore consults the index, so a tracked AGENTS.md that is also listed in + # .gitignore 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 - # A repo that was never onboarded usually keeps the doc in AGENTS.md with no CLAUDE.md at - # all. The prompt below tells the agent to create a missing CLAUDE.md, so without this we - # would invent a second, divergent instruction file and commit it to someone else's PR. - # Only when AGENTS.md is a real file: with neither file present, creating one is the point. - if [ ! -e CLAUDE.md ] && [ -f AGENTS.md ] && [ ! -L AGENTS.md ]; then - echo "::warning::AGENTS.md holds the doc and there is no CLAUDE.md. Swap them during" - echo "::warning::onboarding — CLAUDE.md real, AGENTS.md a symlink to it. Skipping." - echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 + # 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 - # An ignored CLAUDE.md is absent from the checkout, so the agent creates one and the - # `git add` in prep then refuses the path and exits 1 — the only hard job failure in this - # file. Skip loudly instead. Tracked files are unaffected: check-ignore consults the index, - # so a CLAUDE.md that is committed and also listed in .gitignore reports as not ignored. - if git check-ignore -q CLAUDE.md; then - echo "::warning::CLAUDE.md is listed in .gitignore. Skipping." - echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 + if [ "$layout_ok" != true ]; then + echo "::error::Move the doc into AGENTS.md if it isn't there already, then run:" + echo "::error:: printf '@AGENTS.md\\n' > CLAUDE.md && git add AGENTS.md CLAUDE.md" + exit 1 fi + # Logged, not judged: nothing below decides anything from this list, so a truncated one + # costs a confusing log and nothing more. From the API rather than `gh pr diff` because + # gh parses the raw diff, which GitHub truncates on a big PR. + # `|| true`, so a transient API error costs a log line and not the run. + echo "Changed files:" + gh api "repos/$GH_REPO/pulls/$PR/files" --paginate --jq '.[].filename' \ + | sed 's/^/ /' || true + # Stop if we made the newest commit, or we'd react to our own commit and never stop. # Newest commit only, so an earlier doc commit doesn't disable this for later pushes. # %an is the author: our commits are made through the API, which sets the committer to @@ -147,33 +162,13 @@ jobs: echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 fi - # If a human commit in this PR edited CLAUDE.md, leave the file alone for the rest of it. - # claude-code-action resets CLAUDE.md to the base version before the agent starts, so the - # agent never sees their edit and committing its output would throw that edit away. Our - # own doc commits are excluded, so one of them doesn't disable this for the whole PR. - if grep -qxF 'CLAUDE.md' <<<"$files"; then # in the diff? if not, nobody touched it - # Assigned first, then iterated. `for x in $(cmd)` does not trip `set -e`, so a failed - # API call would give an empty list, skip the whole check and let us overwrite them. - # Merge commits are skipped: a merge of the base branch lists every file that changed - # there, so one that touched CLAUDE.md would disable this for the rest of the PR. - shas="$(gh api "repos/$GH_REPO/pulls/$PR/commits" --paginate \ - --jq '.[] | select(.commit.author.name != "github-actions[bot]") - | select((.parents | length) < 2) | .sha')" - for sha in $shas; do - # Matched inside jq, not `| grep -q`: grep exits on the first match, gh gets SIGPIPE, - # and pipefail turns that into a false "nobody touched it". - info="$(gh api "repos/$GH_REPO/commits/$sha" \ - --jq '[([.files[]?.filename] | index("CLAUDE.md") != null), (.files | length)] | @tsv')" - read -r touched count <<<"$info" - # The API caps .files at 300. At the cap we cannot prove they left it alone, so - # assume they didn't. - if [ "$touched" = "true" ] || [ "$count" -ge 300 ]; then - echo "Commit $sha (human) modifies CLAUDE.md -> leaving the file to its author." - echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - done - fi - + # There is deliberately no "a human edited the doc in this PR, so leave it alone" check + # here any more. It existed because the pre-agent reset meant the agent never saw an + # in-PR edit to CLAUDE.md, so committing its output silently reverted the author's work. + # AGENTS.md is not reset, so the agent now reads the author's edit and judges the file as + # it actually stands — editing on top of it is correct rather than destructive. Dropping + # the check also removes one API call per run plus one per commit in the PR. + # # Deliberately no path filtering, here or as a `paths:` trigger filter. 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 skipped tests, lockfiles @@ -184,7 +179,7 @@ jobs: # a real dependency change always touches a manifest too. echo "run=true" >> "$GITHUB_OUTPUT" - - name: Update CLAUDE.md if this PR made it wrong + - name: Update AGENTS.md if this PR made it wrong id: agent if: steps.gate.outputs.run == 'true' uses: anthropics/claude-code-action@v1 @@ -196,37 +191,40 @@ jobs: 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 `CLAUDE.md` inaccurate, fix `CLAUDE.md` on + ${{ 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 `CLAUDE.md` in the repo root. + 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 CLAUDE.md` commit of your own. 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, `CLAUDE.md` states something **wrong, missing, or + `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 `CLAUDE.md`. If no, edit nothing. + 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 `CLAUDE.md` contradicts + - 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 `CLAUDE.md` already covers at the right level of abstraction + - 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 `CLAUDE.md` that becomes wrong, it is not stale: change + 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 @@ -235,17 +233,14 @@ jobs: - **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. - - **If `CLAUDE.md` does not exist, create it**, using only the sections that apply to this repo: - `# `, then `## Project purpose`, `## Repository structure`, - `## Technology stack`, `## Build, test & run`, `## Conventions`, - `## Notes for AI assistants`. Document only what you have actually read. - **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 `CLAUDE.md` is a deliberate, reviewable cleanup of its own; smuggling it into an + 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. - - Touch `CLAUDE.md` and `/tmp/summary.md` only. Not `AGENTS.md` (a later step manages the symlink), - not any other file. + - **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 @@ -278,8 +273,6 @@ jobs: # The step bails below without committing. if: always() && steps.gate.outputs.run == 'true' env: - # Through env, not pasted into the script: a branch name can contain a quote. - BASE_REF: ${{ github.event.pull_request.base.ref }} AGENT_OUTCOME: ${{ steps.agent.outcome }} run: | set -euo pipefail @@ -287,7 +280,7 @@ jobs: # The agent's reasoning goes to the run summary either way, so a run that changed nothing # can still be checked. { - echo "## CLAUDE.md" + 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" @@ -298,92 +291,34 @@ jobs: echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0 fi - # Three outcomes below: the agent asked for no change (never commit), it re-derived what - # the branch already has (nothing to do), or it made a real change (commit). + # `git status` is the entire comparison, because AGENTS.md is not one of + # claude-code-action's SENSITIVE_PATHS: nothing reset it before the agent started, so what + # the agent was handed is exactly what this branch has. A dirty file means the agent + # changed something; a clean one means it didn't. # - # Telling them apart means comparing against what the agent was HANDED, which is the base - # branch TIP — claude-code-action resets the file with - # `git checkout origin/ -- CLAUDE.md` before the agent starts. Three traps - # here, each one a bug we shipped: comparing against HEAD reverts an earlier doc commit on - # this branch and comments that it updated the file; comparing against `base.sha` uses a - # snapshot taken when the event fired, which goes stale the moment anything lands on the - # base branch; and a bare `git fetch origin ` can write FETCH_HEAD and nothing else, - # because actions/checkout narrows remote.origin.fetch to the head branch — which drops us - # into the HEAD fallback this comparison exists to avoid. - git fetch --quiet --depth=1 origin \ - "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" 2>/dev/null || true - - # `rev-parse --verify --quiet`, because plain rev-parse on a path that isn't in the tree - # still echoes ":CLAUDE.md" to stdout while exiting 128. - if git rev-parse --verify --quiet "origin/$BASE_REF^{commit}" >/dev/null; then - handed="$(git rev-parse --verify --quiet "origin/$BASE_REF:CLAUDE.md" || echo absent)" - else - # Don't fall through to "absent": that reads as "the base had no CLAUDE.md" and would - # commit anything. - echo "::warning::Could not resolve origin/$BASE_REF. Falling back to comparing against" - echo "::warning::HEAD, which cannot distinguish an agent edit from the reset." - handed="$(git rev-parse --verify --quiet 'HEAD:CLAUDE.md' || echo absent)" - fi - - # Stage first, then read the index: that blob is exactly what would be committed, with - # any .gitattributes filters applied. -f and not -L, because a symlink stages as the - # target's path string rather than any content, which compares against nothing useful. - if [ -f CLAUDE.md ] && [ ! -L CLAUDE.md ]; then - git add -- CLAUDE.md - now="$(git rev-parse :CLAUDE.md)" - else - now=absent - fi - echo "CLAUDE.md handed to the agent: $handed" - echo "CLAUDE.md now: $now" - - # The agent is told to delete stale claims, not the file. If the file is gone, that is a - # mistake, and committing it would delete the repo's CLAUDE.md under a "docs: update" - # message. - if [ "$now" = absent ] && [ "$handed" != absent ]; then - echo "::error::CLAUDE.md is missing after the agent ran. Not committing a deletion." + # This used to be forty lines of fetching the base branch and comparing blobs, because + # the doc lived in CLAUDE.md, which IS reset — so "differs from HEAD" could mean either an + # agent edit or the reset undoing one of our own earlier commits, and telling those apart + # needed the base tip. Moving the doc to AGENTS.md deleted the whole problem. If the doc + # ever moves back, that comparison has to come back with it. + if [ ! -f AGENTS.md ] || [ -L AGENTS.md ]; then + # The agent is told to delete stale claims, not the file. Committing this would remove + # the repo's AGENTS.md under a "docs: update" message. + echo "::error::AGENTS.md is missing or no longer a regular file after the agent ran." + echo "::error::Not committing that." echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0 fi - # Comparing blobs rather than running `git diff` also covers a brand new file, which is - # untracked and so invisible to diff. - if [ "$now" = "$handed" ]; then - echo "The agent left CLAUDE.md exactly as it was handed -> nothing to commit." - if [ -n "$(git status --porcelain -- CLAUDE.md)" ]; then - # The agent wanted no change, but the file on disk differs from HEAD. Two causes: an - # earlier doc commit of ours on this branch, or CLAUDE.md having moved on the base - # branch since it was cut. Committing would overwrite one of them with the other, and - # the blobs cannot say which is wanted. - echo "::warning::CLAUDE.md differs from HEAD only because of the pre-agent reset, and" - echo "::warning::the agent asked for no change, so nothing is being committed. Either" - echo "::warning::this branch has an earlier doc commit, or the base branch's CLAUDE.md" - echo "::warning::changed after this branch was cut — merge the base branch in to see" - echo "::warning::which, and drop the earlier doc commit if this PR made it stale." - 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 - # The agent edited, but the result is what the branch already has: after a doc commit it - # gets the base file and usually writes the same fix again. - if [ -z "$(git status --porcelain -- CLAUDE.md)" ]; then - echo "The agent re-derived CLAUDE.md identically to what is already on this branch." - echo "Nothing to commit. Reasoning is in the run summary." - echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0 - fi - - # AGENTS.md is a symlink to CLAUDE.md, created once per repo during onboarding. We only - # warn, never touch it: a signed commit can't carry a symlink, because the GraphQL API - # stores file contents with no mode. - if [ ! -e AGENTS.md ]; then - echo "::warning::No AGENTS.md. Create it once during onboarding: ln -s CLAUDE.md AGENTS.md" - elif [ ! -L AGENTS.md ]; then - echo "::warning::AGENTS.md is a regular file, not a symlink — leaving it for a human to merge." - fi - - git diff --cached --stat -- CLAUDE.md + git diff --stat -- AGENTS.md echo "commit=true" >> "$GITHUB_OUTPUT" - - name: Commit CLAUDE.md + - 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. @@ -392,8 +327,10 @@ jobs: uses: apify/actions/signed-commit@v1.4.0 with: github-token: ${{ github.token }} - message: 'docs: update CLAUDE.md for this PR' - add: CLAUDE.md + 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. @@ -404,7 +341,7 @@ jobs: run: | set -euo pipefail { - echo "### CLAUDE.md updated" + 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 From ef8d4555086dec07024ff2e8b8d0f81c797626bd Mon Sep 17 00:00:00 2001 From: Filip Masar Date: Mon, 17 Aug 2026 14:48:31 +0200 Subject: [PATCH 3/7] fix: drop what the AGENTS.md flip made redundant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate no longer lists the PR's changed files. Nothing judged that list any more, and the agent's own `gh pr diff` is already in the log, so it was an API call per run for a duplicate. The gate now needs no token and touches no network — every decision it makes reads the checkout on disk. fetch-depth drops to 1. Nothing reads a parent commit: the gate checks the tip's author and the only comparison left is the working tree against HEAD. The post-agent guard also refuses an empty AGENTS.md, not just a missing one. An Edit/Write agent cannot unlink a file but can truncate one, and that would have been committed as a "docs: update" that wipes the repo's doc. Comments that described the previous version of this unmerged file are rewritten as what a future reader needs: what to add back, and when. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-md-pr.yml | 53 ++++++++++++------------------ 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/.github/workflows/claude-md-pr.yml b/.github/workflows/claude-md-pr.yml index bdefbdc..d470a95 100644 --- a/.github/workflows/claude-md-pr.yml +++ b/.github/workflows/claude-md-pr.yml @@ -79,17 +79,16 @@ jobs: with: # The head branch, not the merge ref, because we push back to it. ref: ${{ github.event.pull_request.head.ref }} - # Shallow. We only read the tip commit's author here. The base commit gets fetched - # later, in the step that needs it. - fetch-depth: 2 + # Nothing here reads a parent commit: the gate checks the tip's author, and the only + # comparison left is the working tree against HEAD. claude-code-action fetches the base + # branch itself for its own config reset. + fetch-depth: 1 token: ${{ github.token }} - name: Gate id: gate + # No token and no network: every decision below reads the checkout on disk. env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail @@ -145,14 +144,6 @@ jobs: exit 1 fi - # Logged, not judged: nothing below decides anything from this list, so a truncated one - # costs a confusing log and nothing more. From the API rather than `gh pr diff` because - # gh parses the raw diff, which GitHub truncates on a big PR. - # `|| true`, so a transient API error costs a log line and not the run. - echo "Changed files:" - gh api "repos/$GH_REPO/pulls/$PR/files" --paginate --jq '.[].filename' \ - | sed 's/^/ /' || true - # Stop if we made the newest commit, or we'd react to our own commit and never stop. # Newest commit only, so an earlier doc commit doesn't disable this for later pushes. # %an is the author: our commits are made through the API, which sets the committer to @@ -162,12 +153,11 @@ jobs: echo "run=false" >> "$GITHUB_OUTPUT"; exit 0 fi - # There is deliberately no "a human edited the doc in this PR, so leave it alone" check - # here any more. It existed because the pre-agent reset meant the agent never saw an - # in-PR edit to CLAUDE.md, so committing its output silently reverted the author's work. - # AGENTS.md is not reset, so the agent now reads the author's edit and judges the file as - # it actually stands — editing on top of it is correct rather than destructive. Dropping - # the check also removes one API call per run plus one per commit in the PR. + # Deliberately nothing here that bails out because a human edited AGENTS.md in this PR. + # That check is only needed when the agent cannot see their edit, which is true of a file + # on claude-code-action's SENSITIVE_PATHS and false of AGENTS.md: the agent reads the edit + # and judges the file as it actually stands, so editing on top of it is correct rather + # than destructive. Add it back only alongside a doc that IS reset. # # Deliberately no path filtering, here or as a `paths:` trigger filter. Deciding relevance # from a filename fails silently — the run that should have fired doesn't, which looks @@ -291,21 +281,20 @@ jobs: echo "commit=false" >> "$GITHUB_OUTPUT"; exit 0 fi - # `git status` is the entire comparison, because AGENTS.md is not one of + # `git status` is the entire comparison, and only because AGENTS.md is not one of # claude-code-action's SENSITIVE_PATHS: nothing reset it before the agent started, so what # the agent was handed is exactly what this branch has. A dirty file means the agent - # changed something; a clean one means it didn't. + # changed something. Move the doc onto a path that IS reset and this stops working — a + # dirty file would then mean either an agent edit or the reset undoing one of our own + # earlier commits, which takes a blob comparison against the base branch TIP (not + # `base.sha`, which is an event snapshot and goes stale) to tell apart. # - # This used to be forty lines of fetching the base branch and comparing blobs, because - # the doc lived in CLAUDE.md, which IS reset — so "differs from HEAD" could mean either an - # agent edit or the reset undoing one of our own earlier commits, and telling those apart - # needed the base tip. Moving the doc to AGENTS.md deleted the whole problem. If the doc - # ever moves back, that comparison has to come back with it. - if [ ! -f AGENTS.md ] || [ -L AGENTS.md ]; then - # The agent is told to delete stale claims, not the file. Committing this would remove - # the repo's AGENTS.md under a "docs: update" message. - echo "::error::AGENTS.md is missing or no longer a regular file after the agent ran." - echo "::error::Not committing that." + # The agent is told to delete stale claims, not the file, and its tools can't unlink one + # anyway — but an empty or vanished file would be committed as a "docs: update" that wipes + # the repo's doc, so it is worth one cheap line to refuse. + 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 From 8237496d77896196ac1d6a78d888457c9c76fc64 Mon Sep 17 00:00:00 2001 From: Filip Masar Date: Mon, 17 Aug 2026 15:10:10 +0200 Subject: [PATCH 4/7] Fail the gate on an empty AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A present-but-empty AGENTS.md passed the layout check. The agent is forbidden to create the file, so it would have had no claims to check and would report "nothing to do" on a repo with no doc at all — silence that means the opposite of what it looks like. Same -s predicate as the post-agent guard already uses. Found by lab/test-layout-gate.sh in apify/claude-md-lab, which covers 17 layout shapes locally. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-md-pr.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-md-pr.yml b/.github/workflows/claude-md-pr.yml index d470a95..db451f1 100644 --- a/.github/workflows/claude-md-pr.yml +++ b/.github/workflows/claude-md-pr.yml @@ -122,8 +122,13 @@ jobs: # one unchanging line, so resetting it is a no-op — and Claude Code follows the `@` import, # so the agent still gets the doc as its own instructions. layout_ok=true - if [ ! -f AGENTS.md ] || [ -L AGENTS.md ]; then - echo "::error::AGENTS.md must be a regular file in the repo root, holding the doc itself." + # -s as well as -f: an empty AGENTS.md is a broken layout, not a doc with nothing in it + # yet. The agent is forbidden to create the file, so it would have no claims to check and + # nothing to correct, and the run would report "nothing to do" on a repo that has no doc + # at all — a silence that means the opposite of what it looks like. Same predicate as the + # post-agent guard below, which refuses to commit a file the agent emptied. + 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 AGENTS.md that is also listed in From 481158061f124eb7e7c438f14231f53f60131225 Mon Sep 17 00:00:00 2001 From: Filip Masar Date: Tue, 18 Aug 2026 09:05:12 +0200 Subject: [PATCH 5/7] fix: don't tell a repo to write the pointer through a symlink The layout error told the reader to run `printf '@AGENTS.md\n' > CLAUDE.md`. Where CLAUDE.md is a symlink to AGENTS.md the redirect follows the link and truncates the doc to that single line -- so the gate would fail the run, hand back an instruction, and the instruction would destroy the file it was meant to move. apify-core and apify/activepieces are both in exactly that shape, and apify-hubspot-integration and apify-integrations-backend are in the inverse one, with AGENTS.md as the link. The message now states the required end state, gives the command for the common case, and names the two shapes where a shortcut loses work: a symlink must be removed rather than written through, and two real files have diverged and need a human merge. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-md-pr.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-md-pr.yml b/.github/workflows/claude-md-pr.yml index db451f1..ca80095 100644 --- a/.github/workflows/claude-md-pr.yml +++ b/.github/workflows/claude-md-pr.yml @@ -110,7 +110,10 @@ jobs: # A red check is noticed and fixed once, during migration or onboarding, and then never # fires again. This is also why the gate doesn't try to repair the layout itself: moving a # repo's instruction file is a reviewable decision, not something to smuggle into an - # unrelated PR. + # unrelated PR — and the repair is not even always the same command. Repos in the wild have + # CLAUDE.md as a symlink to AGENTS.md, AGENTS.md as a symlink to CLAUDE.md, both paths as + # real files that have diverged, or neither present. A writer that guessed wrong here 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 checks them out again from the base @@ -144,8 +147,15 @@ jobs: layout_ok=false fi if [ "$layout_ok" != true ]; then - echo "::error::Move the doc into AGENTS.md if it isn't there already, then run:" - echo "::error:: printf '@AGENTS.md\\n' > CLAUDE.md && git add AGENTS.md CLAUDE.md" + 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 From d7535d15584ae6cb3be2329b43e4d9c6260f16c3 Mon Sep 17 00:00:00 2001 From: Filip Masar Date: Tue, 18 Aug 2026 09:46:01 +0200 Subject: [PATCH 6/7] refactor: name the workflow after AGENTS.md, the doc it maintains claude-md-pr.yml -> agents-md-maintenance.yml. The doc has lived in AGENTS.md since the reset problem in claude-code-action made CLAUDE.md unusable as the source of truth, so the old name pointed at the pointer file rather than the document. Free to do now and not later: the workflow is not on main yet, so nothing pins claude-md-pr.yml@main and the rename breaks no callers. The comment claiming a rename would break every stub was written for the state after rollout; it now says so explicitly, and warns off renaming once stubs exist. claude-md-maintenance.yml keeps its name. It is the superseded post-merge workflow that already exists in the target repos, and onboarding deletes it by that exact filename. Co-Authored-By: Claude Opus 5 (1M context) --- ...claude-md-pr.yml => agents-md-maintenance.yml} | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) rename .github/workflows/{claude-md-pr.yml => agents-md-maintenance.yml} (96%) diff --git a/.github/workflows/claude-md-pr.yml b/.github/workflows/agents-md-maintenance.yml similarity index 96% rename from .github/workflows/claude-md-pr.yml rename to .github/workflows/agents-md-maintenance.yml index ca80095..d77acf3 100644 --- a/.github/workflows/claude-md-pr.yml +++ b/.github/workflows/agents-md-maintenance.yml @@ -22,7 +22,7 @@ name: Keep AGENTS.md accurate (reusable) # # jobs: # update-agents-md: -# uses: apify/workflows/.github/workflows/claude-md-pr.yml@main +# uses: apify/workflows/.github/workflows/agents-md-maintenance.yml@main # secrets: # ANTHROPIC_API_KEY: ${{ secrets.YOUR_ANTHROPIC_API_KEY }} # @@ -30,10 +30,13 @@ name: Keep AGENTS.md accurate (reusable) # CLAUDE.md is a regular file whose first line is `@AGENTS.md`. A repo that doesn't FAILS the run, # with the fix in the error message — see the Gate step for why a failure and not a skip. # -# The filename still says claude-md because every onboarded caller stub pins -# `claude-md-pr.yml@main`, so renaming it would break all of them at once. Same reason this is a -# separate file from claude-md-maintenance.yml, which callers trigger on push to main and which -# opens its own PR. +# Do not rename this file once repos are onboarded: every caller stub pins +# `agents-md-maintenance.yml@main` by path, so a rename breaks all of them at once and the failure +# surfaces as a red check on somebody else's unrelated PR. +# +# It is a separate file from claude-md-maintenance.yml, which it supersedes. That one runs on push to +# main and opens a standalone PR nobody merges; this one corrects the doc inside the PR that made it +# wrong. Onboarding deletes the old file in the same commit, so no repo runs both. on: workflow_call: @@ -69,7 +72,7 @@ jobs: # 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: claude-md-${{ github.event.pull_request.number }} + group: agents-md-${{ github.event.pull_request.number }} cancel-in-progress: true runs-on: ubuntu-latest timeout-minutes: 15 From 76d15e7b74aba45c60f2285dacb904a3d4af7d47 Mon Sep 17 00:00:00 2001 From: Filip Masar Date: Tue, 18 Aug 2026 11:38:03 +0200 Subject: [PATCH 7/7] docs: tighten the commentary, no behaviour change 135 comment lines out of 358 was more than this needed. Cuts the verbose ones and drops two that said nothing the code or the error messages next to them did not already say: - the gate's opening restatement of the required layout, which the two ::error:: echoes immediately below it state twice more - "the agent's reasoning goes to the run summary either way", which the step's own always() comment three lines up already explains Everything load-bearing stays, because each of these was written down after something went wrong: `'' == false` comparing equal on a payload-less event, %an rather than %cn on API commits, check-ignore consulting the index, tr -d '\r' on a CRLF checkout, concurrency on the job rather than the workflow, and why the doc must live in AGENTS.md and not on claude-code-action's SENSITIVE_PATHS. The two "deliberately nothing here" blocks stay for the same reason: they document code that is absent on purpose, which is the only kind of decision a comment can carry at all. Comment-only. Verified by stripping comment lines from both versions and diffing: every remaining line is byte-identical, and the agent prompt -- whose markdown ## headings a naive comment filter would have eaten -- is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/agents-md-maintenance.yml | 145 ++++++++------------ 1 file changed, 59 insertions(+), 86 deletions(-) diff --git a/.github/workflows/agents-md-maintenance.yml b/.github/workflows/agents-md-maintenance.yml index d77acf3..bf0ee63 100644 --- a/.github/workflows/agents-md-maintenance.yml +++ b/.github/workflows/agents-md-maintenance.yml @@ -6,14 +6,14 @@ name: Keep AGENTS.md accurate (reusable) # 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 must grant `contents: write` and +# 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 with the default read-only token every run fails at the commit step: +# widen them, so a read-only token fails every run at the commit step: # # on: # pull_request: -# # ready_for_review matters: this workflow skips drafts, and no later event arrives -# # when a draft is marked ready. +# # 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: @@ -26,17 +26,12 @@ name: Keep AGENTS.md accurate (reusable) # secrets: # ANTHROPIC_API_KEY: ${{ secrets.YOUR_ANTHROPIC_API_KEY }} # -# The repo must also use the standard layout: AGENTS.md is the real file holding the doc, and -# CLAUDE.md is a regular file whose first line is `@AGENTS.md`. A repo that doesn't FAILS the run, -# with the fix in the error message — see the Gate step for why a failure and not a skip. +# 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 -# `agents-md-maintenance.yml@main` by path, so a rename breaks all of them at once and the failure -# surfaces as a red check on somebody else's unrelated PR. -# -# It is a separate file from claude-md-maintenance.yml, which it supersedes. That one runs on push to -# main and opens a standalone PR nobody merges; this one corrects the doc inside the PR that made it -# wrong. Onboarding deletes the old file in the same commit, so no repo runs both. +# 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: @@ -51,17 +46,14 @@ permissions: jobs: update: name: Update AGENTS.md - # Skipped before a runner starts: callers wired to the wrong event, drafts, forks (we can't - # push to their branch), PRs opened by any bot, and runs our own commit started. That last one - # is the cheapest loop breaker there is, and it is also load-bearing: claude-code-action - # refuses to run at all when a bot started the workflow ("Workflow initiated by non-human - # actor"), so without it an approved run over our own commit fails the job. + # 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* event. It is not - # aimed at push or workflow_dispatch — those carry no pull_request payload, so the fork check - # below already fails them closed. It is aimed at the events that DO populate - # github.event.pull_request: pull_request_target and pull_request_review*, each of which - # satisfies every other clause. Note also that on a payload-less event the draft check alone + # 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' && @@ -82,9 +74,8 @@ jobs: with: # The head branch, not the merge ref, because we push back to it. ref: ${{ github.event.pull_request.head.ref }} - # Nothing here reads a parent commit: the gate checks the tip's author, and the only - # comparison left is the working tree against HEAD. claude-code-action fetches the base - # branch itself for its own config reset. + # 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 }} @@ -96,50 +87,41 @@ jobs: run: | set -euo pipefail - # The checkout is by branch name, so the tip can already be newer than the commit this run - # was started for. Judging it would measure code the event never described, and that push - # has a run of its own. + # 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 - # The required layout: AGENTS.md is the real file holding the doc, CLAUDE.md is a regular - # file whose first line is `@AGENTS.md`. Anything else fails the run. - # - # A failure, not a skip, and deliberately so. A skipped run is indistinguishable from a - # run that found nothing to fix, so a repo with the wrong layout would quietly never be - # maintained — which is the exact failure mode of the post-merge workflow this replaces. - # A red check is noticed and fixed once, during migration or onboarding, and then never - # fires again. This is also why the gate doesn't try to repair the layout itself: moving a + # 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 repair is not even always the same command. Repos in the wild have - # CLAUDE.md as a symlink to AGENTS.md, AGENTS.md as a symlink to CLAUDE.md, both paths as - # real files that have diverged, or neither present. A writer that guessed wrong here would - # truncate the doc through a symlink instead of moving it. + # 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 checks them out again from the base - # branch (src/github/operations/restore-config.ts). CLAUDE.md is on that list; AGENTS.md is - # not. So with the doc in AGENTS.md the agent reads the branch's own version of the file it - # is judging, and a human's in-PR edits to it survive. With the doc in CLAUDE.md every run - # was instead handed the base version, which is what the base-branch blob comparison this - # file used to carry existed to work around. The CLAUDE.md pointer is reset too, but it is - # one unchanging line, so resetting it is a no-op — and Claude Code follows the `@` import, - # so the agent still gets the doc as its own instructions. + # 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 with nothing in it - # yet. The agent is forbidden to create the file, so it would have no claims to check and - # nothing to correct, and the run would report "nothing to do" on a repo that has no doc - # at all — a silence that means the opposite of what it looks like. Same predicate as the - # post-agent guard below, which refuses to commit a file the agent emptied. + # -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 AGENTS.md that is also listed in - # .gitignore correctly reports as not ignored. This only catches the untracked case, - # where the file is absent from the checkout and could never be committed. + # 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 @@ -162,29 +144,24 @@ jobs: exit 1 fi - # Stop if we made the newest commit, or we'd react to our own commit and never stop. - # Newest commit only, so an earlier doc commit doesn't disable this for later pushes. - # %an is the author: our commits are made through the API, which sets the committer to - # GitHub, so %cn would never match. + # 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 here that bails out because a human edited AGENTS.md in this PR. - # That check is only needed when the agent cannot see their edit, which is true of a file - # on claude-code-action's SENSITIVE_PATHS and false of AGENTS.md: the agent reads the edit - # and judges the file as it actually stands, so editing on top of it is correct rather - # than destructive. Add it back only alongside a doc that IS reset. + # 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 filter. 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 skipped tests, lockfiles - # and boilerplate to save agent calls, and it hid real staleness: a new top-level `tests/` - # directory belongs in Repository structure, and a `.prettierrc` can contradict a - # documented convention. The agent's "NOT stale" rules do the job instead, and keep it - # affordable. If the spend ever bites, lockfiles alone is the one filter worth re-adding — - # a real dependency change always touches a manifest too. + # 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 @@ -285,8 +262,6 @@ jobs: run: | set -euo pipefail - # The agent's reasoning goes to the run summary either way, so a run that changed nothing - # can still be checked. { echo "## AGENTS.md" echo @@ -300,16 +275,14 @@ jobs: 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 before the agent started, so what - # the agent was handed is exactly what this branch has. A dirty file means the agent - # changed something. Move the doc onto a path that IS reset and this stops working — a - # dirty file would then mean either an agent edit or the reset undoing one of our own - # earlier commits, which takes a blob comparison against the base branch TIP (not - # `base.sha`, which is an event snapshot and goes stale) to tell apart. + # 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, and its tools can't unlink one - # anyway — but an empty or vanished file would be committed as a "docs: update" that wipes - # the repo's doc, so it is worth one cheap line to refuse. + # 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."